feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
363
apps/mobile-rn/__tests__/admin-service.test.ts
Normal file
363
apps/mobile-rn/__tests__/admin-service.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue