feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,363 @@
import {
AdminServiceError,
changeAdminUserRole,
createAdminSubscription,
currentAdminRole,
getAdminPaymentHistory,
getAdminUser,
listAdminAudit,
listAdminSubscriptions,
listAdminUsers,
updateAdminSubscription,
} from '../src/features/admin/admin-service'
const ACCESS_TOKEN = 'authenticated-admin-access-token'
const USER_ID = '11111111-1111-4111-8111-111111111111'
const SUBSCRIPTION_ID = '22222222-2222-4222-8222-222222222222'
const ADMIN_ID = '33333333-3333-4333-8333-333333333333'
const IDEMPOTENCY_KEY = '44444444-4444-4444-8444-444444444444'
const NOW = '2026-08-21T00:00:00.000Z'
function response(status: number, body: unknown): Response {
return {
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(body),
} as Response
}
function subscription(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: SUBSCRIPTION_ID,
user_id: USER_ID,
tier: 'pro',
status: 'active',
provider: 'google_play',
payment_provider: 'google_play',
current_period_start: NOW,
current_period_end: '2026-09-21T00:00:00.000Z',
overage_credits: 12,
admin_note: null,
cancel_at: null,
created_at: NOW,
updated_at: NOW,
...overrides,
}
}
describe('mobile admin service', () => {
afterEach(() => {
jest.restoreAllMocks()
jest.useRealTimers()
})
test('derives only explicit privileged roles for local navigation gating', () => {
expect(currentAdminRole({ role: 'manager' })).toBe('manager')
expect(currentAdminRole({ role: 'admin' })).toBe('admin')
expect(currentAdminRole({ role: 'super_admin' })).toBe('super_admin')
expect(currentAdminRole({ role: 'user' })).toBeNull()
expect(currentAdminRole({ role: 'ADMIN' })).toBeNull()
expect(currentAdminRole(null)).toBeNull()
})
test('strictly normalizes a paginated user response', async () => {
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue(response(200, {
profiles: [{
id: USER_ID,
name: '테스트 사용자',
avatar_url: null,
tier: 'free',
role: 'user',
created_at: NOW,
}],
total: 1,
page: 1,
limit: 20,
}))
await expect(listAdminUsers({
accessToken: ACCESS_TOKEN,
page: 1,
limit: 20,
search: '테스트',
role: 'user',
})).resolves.toEqual({
entries: [{
id: USER_ID,
name: '테스트 사용자',
avatarUrl: null,
tier: 'free',
role: 'user',
createdAt: NOW,
}],
total: 1,
page: 1,
limit: 20,
})
const [url, request] = fetchMock.mock.calls[0]
expect(String(url)).toContain('admin-users?page=1&limit=20&search=')
expect((request?.headers as Record<string, string>).Authorization).toBe(`Bearer ${ACCESS_TOKEN}`)
})
test('rejects unknown response fields instead of rendering untrusted data', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(200, {
profiles: [{
id: USER_ID,
name: null,
avatar_url: null,
tier: 'free',
role: 'user',
created_at: NOW,
raw_token: 'must-not-be-accepted',
}],
total: 1,
page: 1,
limit: 20,
}))
await expect(listAdminUsers({ accessToken: ACCESS_TOKEN }))
.rejects.toMatchObject({ code: 'server', status: 502 })
})
test('validates user, account, and subscription ownership as one detail response', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(200, {
profile: {
id: USER_ID,
name: 'User',
avatar_url: null,
locale: 'ko',
tier: 'pro',
role: 'user',
created_at: NOW,
updated_at: NOW,
},
subscription: subscription(),
account: {
id: USER_ID,
email: 'user@example.com',
createdAt: NOW,
lastSignInAt: NOW,
},
}))
const detail = await getAdminUser(ACCESS_TOKEN, USER_ID)
expect(detail.profile.id).toBe(USER_ID)
expect(detail.account.email).toBe('user@example.com')
expect(detail.subscription?.paymentProvider).toBe('google_play')
})
test('maps current-role rejection to a stable forbidden error', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(403, {
error: 'admin_forbidden',
internal_detail: 'must not surface',
}))
await expect(listAdminSubscriptions({ accessToken: ACCESS_TOKEN }))
.rejects.toEqual(new AdminServiceError('forbidden', 403, false))
})
test('sends role changes with a UUID idempotency key and validates the replay result', async () => {
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue(response(200, {
success: true,
userId: USER_ID,
newRole: 'manager',
}))
await expect(changeAdminUserRole({
accessToken: ACCESS_TOKEN,
idempotencyKey: IDEMPOTENCY_KEY,
userId: USER_ID,
newRole: 'manager',
memo: 'Support escalation approved',
})).resolves.toEqual({ userId: USER_ID, newRole: 'manager' })
const [, request] = fetchMock.mock.calls[0]
expect(request?.method).toBe('PATCH')
expect((request?.headers as Record<string, string>)['idempotency-key']).toBe(IDEMPOTENCY_KEY)
expect(JSON.parse(String(request?.body))).toEqual({
userId: USER_ID,
newRole: 'manager',
memo: 'Support escalation approved',
})
})
test('does not retry or reinterpret an idempotency conflict', async () => {
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue(response(409, {
error: 'idempotency_conflict',
}))
await expect(changeAdminUserRole({
accessToken: ACCESS_TOKEN,
idempotencyKey: IDEMPOTENCY_KEY,
userId: USER_ID,
newRole: 'manager',
memo: 'Support escalation approved',
})).rejects.toMatchObject({ code: 'conflict', retryable: false })
expect(fetchMock).toHaveBeenCalledTimes(1)
})
test('validates subscription list joins without exposing extra profile fields', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(200, {
subscriptions: [{
...subscription(),
profiles: { name: 'User', avatar_url: null },
}],
total: 1,
page: 1,
limit: 20,
}))
const page = await listAdminSubscriptions({
accessToken: ACCESS_TOKEN,
tier: 'pro',
status: 'active',
})
expect(page.entries[0]).toMatchObject({ userId: USER_ID, profileName: 'User' })
})
test('creates and updates subscriptions only after strict RPC response validation', async () => {
const fetchMock = jest.spyOn(global, 'fetch')
.mockResolvedValueOnce(response(201, { success: true, subscription: subscription() }))
.mockResolvedValueOnce(response(200, {
success: true,
subscription: subscription({ tier: 'pro_plus', overage_credits: 200 }),
}))
await expect(createAdminSubscription({
accessToken: ACCESS_TOKEN,
idempotencyKey: IDEMPOTENCY_KEY,
userId: USER_ID,
mutation: { tier: 'pro', status: 'active', memo: 'Manual entitlement recovery' },
})).resolves.toMatchObject({ userId: USER_ID, tier: 'pro' })
await expect(updateAdminSubscription({
accessToken: ACCESS_TOKEN,
idempotencyKey: '55555555-5555-4555-8555-555555555555',
userId: USER_ID,
mutation: {
tier: 'pro_plus',
overageCredits: 200,
memo: 'Approved tier correction',
},
})).resolves.toMatchObject({ userId: USER_ID, tier: 'pro_plus', overageCredits: 200 })
expect(fetchMock).toHaveBeenCalledTimes(2)
})
test('normalizes audit rows and discards before/after snapshots from the mobile contract', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(200, {
logs: [{
id: 12,
admin_id: ADMIN_ID,
action: 'subscription.update',
target_type: 'subscription',
target_id: USER_ID,
before_data: { tier: 'free' },
after_data: { tier: 'pro' },
memo: 'Approved correction',
created_at: NOW,
admin_name: 'Operator',
}],
total: 1,
page: 1,
limit: 20,
}))
const page = await listAdminAudit({ accessToken: ACCESS_TOKEN })
expect(page.entries).toEqual([{
id: 12,
adminId: ADMIN_ID,
adminName: 'Operator',
action: 'subscription.update',
targetType: 'subscription',
targetId: USER_ID,
memo: 'Approved correction',
createdAt: NOW,
}])
expect(page.entries[0]).not.toHaveProperty('before_data')
expect(page.entries[0]).not.toHaveProperty('after_data')
})
test('accepts only redacted payment history fields', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(200, {
subscription: subscription(),
auditLogs: [{
id: 13,
admin_id: ADMIN_ID,
action: 'subscription.update',
target_type: 'subscription',
target_id: USER_ID,
memo: null,
created_at: NOW,
}],
providerEvents: [{
id: 'event-ledger-id',
provider: 'google_play',
event_type: 'subscription.renewed',
event_created_at: NOW,
disposition: 'applied',
received_at: NOW,
processed_at: NOW,
}],
providerOperations: [{
id: 'operation-ledger-id',
provider: 'google_play',
operation_type: 'verify',
requested_tier: 'pro',
state: 'succeeded',
error_code: null,
expires_at: '2026-08-22T00:00:00.000Z',
created_at: NOW,
updated_at: NOW,
}],
liveProviderHistoryAvailable: false,
}))
const history = await getAdminPaymentHistory(ACCESS_TOKEN, USER_ID)
expect(history.providerEvents).toEqual([{
provider: 'google_play',
eventType: 'subscription.renewed',
eventCreatedAt: NOW,
disposition: 'applied',
receivedAt: NOW,
processedAt: NOW,
}])
expect(history.providerOperations[0]).not.toHaveProperty('id')
expect(history.liveProviderHistoryAvailable).toBe(false)
})
test('fails closed when payment responses contain raw provider payloads', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(200, {
subscription: null,
auditLogs: [],
providerEvents: [{
id: 'event-ledger-id',
provider: 'google_play',
event_type: 'renewed',
event_created_at: NOW,
disposition: 'applied',
received_at: NOW,
processed_at: NOW,
raw_payload: { purchaseToken: 'secret' },
}],
providerOperations: [],
liveProviderHistoryAvailable: false,
}))
await expect(getAdminPaymentHistory(ACCESS_TOKEN, USER_ID))
.rejects.toMatchObject({ code: 'server', status: 502 })
})
test('aborts caller-cancelled reads without converting them into retryable timeouts', async () => {
jest.spyOn(global, 'fetch').mockImplementation(async (_url, init) => (
await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
})
))
const controller = new AbortController()
const request = listAdminUsers({ accessToken: ACCESS_TOKEN, signal: controller.signal })
controller.abort()
await expect(request).rejects.toMatchObject({ code: 'network', retryable: false })
})
})