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 = {}): Record { 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).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)['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((_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 }) }) })