d3ro-voice/apps/mobile-rn/__tests__/admin-screens.test.tsx
2026-08-29 18:33:45 +09:00

302 lines
11 KiB
TypeScript

import React from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
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'
let mockUserRole: 'user' | 'manager' | 'admin' | 'super_admin' = 'manager'
const mockNavigate = jest.fn()
const mockGoBack = jest.fn()
const mockListUsers = jest.fn()
const mockListSubscriptions = jest.fn()
const mockListAudit = jest.fn()
const mockGetUser = jest.fn()
const mockGetPaymentHistory = jest.fn()
const mockChangeRole = jest.fn()
const mockCreateSubscription = jest.fn()
const mockUpdateSubscription = jest.fn()
const mockDeleteSubscription = jest.fn()
const mockRequireToken = jest.fn()
jest.mock('@react-navigation/native', () => {
const ReactValue = require('react') as typeof React
return {
useFocusEffect: (callback: () => void | (() => void)) => {
ReactValue.useEffect(callback, [callback])
},
}
})
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
}))
jest.mock('@d3ro/i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => params === undefined
? key
: `${key}:${JSON.stringify(params)}`,
formatDate: () => '2026-08-21',
}),
}))
jest.mock('../src/lib/auth-context', () => ({
useAuth: () => ({
user: {
id: ADMIN_ID,
email: 'admin@example.com',
app_metadata: { role: mockUserRole },
},
}),
}))
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: () => ({
palette: {
bg: { app: '#000', card: '#181818', inset: '#202020' },
text: { primary: '#fff', muted: '#999' },
border: { default: '#444' },
accent: { main: '#7cf', dim: '#234' },
tag: { red: '#f66', green: '#6f6', orange: '#fa6', purple: '#b7f' },
},
}),
}))
jest.mock('../src/theme/themed-components', () => {
const { Pressable, Text, View } = require('react-native') as typeof import('react-native')
return {
ThemeText: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
<Text {...props}>{children}</Text>
),
ThemeCard: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
<View {...props}>{children}</View>
),
ThemeButton: ({
label,
onPress,
disabled,
...props
}: {
label: string
onPress?: () => void
disabled?: boolean
[key: string]: unknown
}) => (
<Pressable {...props} disabled={disabled} onPress={onPress}>
<Text>{label}</Text>
</Pressable>
),
}
})
jest.mock('../src/lib/random-id', () => ({ createUuidV4: () => IDEMPOTENCY_KEY }))
jest.mock('../src/features/admin/admin-session', () => ({
requireAdminAccessToken: (...args: unknown[]) => mockRequireToken(...args),
}))
jest.mock('../src/features/admin/admin-service', () => {
class MockAdminServiceError extends Error {
code: string
status: number | null
retryable: boolean
constructor(code: string, status: number | null, retryable: boolean) {
super(code)
this.code = code
this.status = status
this.retryable = retryable
}
}
return {
ADMIN_ROLES: ['user', 'manager', 'admin', 'super_admin'],
ADMIN_TIERS: ['free', 'pro', 'pro_plus'],
ADMIN_SUBSCRIPTION_STATUSES: ['active', 'canceled', 'past_due', 'expired'],
AdminServiceError: MockAdminServiceError,
currentAdminRole: (metadata: { role?: string } | null | undefined) => (
metadata?.role === 'manager' || metadata?.role === 'admin' || metadata?.role === 'super_admin'
? metadata.role
: null
),
listAdminUsers: (...args: unknown[]) => mockListUsers(...args),
listAdminSubscriptions: (...args: unknown[]) => mockListSubscriptions(...args),
listAdminAudit: (...args: unknown[]) => mockListAudit(...args),
getAdminUser: (...args: unknown[]) => mockGetUser(...args),
getAdminPaymentHistory: (...args: unknown[]) => mockGetPaymentHistory(...args),
changeAdminUserRole: (...args: unknown[]) => mockChangeRole(...args),
createAdminSubscription: (...args: unknown[]) => mockCreateSubscription(...args),
updateAdminSubscription: (...args: unknown[]) => mockUpdateSubscription(...args),
deleteAdminSubscription: (...args: unknown[]) => mockDeleteSubscription(...args),
}
})
import AdminScreen from '../src/screens/AdminScreen'
import AdminUserDetailScreen from '../src/screens/AdminUserDetailScreen'
const profile = {
id: USER_ID,
name: 'Target user',
avatarUrl: null,
locale: 'ko',
tier: 'pro' as const,
role: 'user' as const,
createdAt: NOW,
updatedAt: NOW,
}
const subscription = {
id: SUBSCRIPTION_ID,
userId: USER_ID,
tier: 'pro' as const,
status: 'active' as const,
provider: 'admin',
paymentProvider: 'admin',
currentPeriodStart: NOW,
currentPeriodEnd: '2026-09-21T00:00:00.000Z',
overageCredits: 2,
adminNote: null,
cancelAt: null,
createdAt: NOW,
updatedAt: NOW,
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
}
describe('mobile administrator screens', () => {
beforeEach(() => {
mockUserRole = 'manager'
jest.clearAllMocks()
mockRequireToken.mockResolvedValue('authenticated-admin-access-token')
mockListUsers.mockResolvedValue({ entries: [profile], total: 1, page: 1, limit: 20 })
mockListSubscriptions.mockResolvedValue({ entries: [subscription], total: 1, page: 1, limit: 20 })
mockListAudit.mockResolvedValue({ entries: [], total: 0, page: 1, limit: 20 })
mockGetUser.mockResolvedValue({
profile,
subscription,
account: { id: USER_ID, email: 'target@example.com', createdAt: NOW, lastSignInAt: NOW },
})
mockGetPaymentHistory.mockResolvedValue({
subscription,
auditLogs: [],
providerEvents: [],
providerOperations: [],
liveProviderHistoryAvailable: false,
})
mockUpdateSubscription.mockResolvedValue(subscription)
})
it('fails closed before any request for a non-administrator session', async () => {
mockUserRole = 'user'
let renderer: ReactTestRenderer
await act(async () => {
renderer = create(<AdminScreen navigation={{ navigate: mockNavigate, goBack: mockGoBack }} />)
})
await flush()
expect(renderer!.root.findByProps({ testID: 'admin-forbidden' })).toBeTruthy()
expect(mockRequireToken).not.toHaveBeenCalled()
expect(mockListUsers).not.toHaveBeenCalled()
act(() => renderer!.unmount())
})
it('loads real server users and opens only the selected user detail', async () => {
let renderer: ReactTestRenderer
await act(async () => {
renderer = create(<AdminScreen navigation={{ navigate: mockNavigate, goBack: mockGoBack }} />)
})
await flush()
expect(mockRequireToken).toHaveBeenCalledWith(ADMIN_ID)
expect(mockListUsers).toHaveBeenCalledWith(expect.objectContaining({ page: 1, limit: 20 }))
const resultsScroll = renderer!.root.find(
(node) => node.props.keyboardShouldPersistTaps === 'handled',
)
expect(resultsScroll.props.keyboardDismissMode).toBe('on-drag')
const row = renderer!.root.findByProps({ testID: `admin-user-${USER_ID}` })
act(() => row.props.onPress())
expect(mockNavigate).toHaveBeenCalledWith('AdminUserDetail', { userId: USER_ID })
act(() => renderer!.unmount())
})
it('purges previously rendered administrator data when the server revokes access', async () => {
const { AdminServiceError } = jest.requireMock('../src/features/admin/admin-service')
let renderer: ReactTestRenderer
await act(async () => {
renderer = create(<AdminScreen navigation={{ navigate: mockNavigate, goBack: mockGoBack }} />)
})
await flush()
expect(renderer!.root.findByProps({ testID: `admin-user-${USER_ID}` })).toBeTruthy()
mockListUsers.mockRejectedValueOnce(new AdminServiceError('forbidden', 403, false))
const scroll = renderer!.root.find((node) => node.props.refreshControl !== undefined)
await act(async () => {
scroll.props.refreshControl.props.onRefresh()
await Promise.resolve()
await Promise.resolve()
})
expect(renderer!.root.findByProps({ testID: 'admin-error' })).toBeTruthy()
expect(() => renderer!.root.findByProps({ testID: `admin-user-${USER_ID}` })).toThrow()
act(() => renderer!.unmount())
})
it('lets a manager update an existing subscription but not change roles or delete it', async () => {
let renderer: ReactTestRenderer
await act(async () => {
renderer = create(<AdminUserDetailScreen route={{ params: { userId: USER_ID } }} navigation={{ goBack: mockGoBack }} />)
})
await flush()
expect(() => renderer!.root.findByProps({ testID: 'admin-change-role' })).toThrow()
expect(() => renderer!.root.findByProps({ testID: 'admin-delete-subscription' })).toThrow()
const edit = renderer!.root.findByProps({ testID: 'admin-edit-subscription' })
act(() => edit.props.onPress())
const memo = renderer!.root.findByProps({ testID: 'admin-memo-input' })
act(() => memo.props.onChangeText('verified customer request'))
const submit = renderer!.root.findByProps({ testID: 'admin-mutation-submit' })
await act(async () => {
submit.props.onPress()
await Promise.resolve()
await Promise.resolve()
})
expect(mockUpdateSubscription).toHaveBeenCalledWith(expect.objectContaining({
accessToken: 'authenticated-admin-access-token',
idempotencyKey: IDEMPOTENCY_KEY,
userId: USER_ID,
mutation: expect.objectContaining({ memo: 'verified customer request' }),
}))
act(() => renderer!.unmount())
})
it('locks input after an uncertain failure and retries the exact idempotency key', async () => {
mockUserRole = 'admin'
mockUpdateSubscription
.mockRejectedValueOnce(new (jest.requireMock('../src/features/admin/admin-service').AdminServiceError)('timeout', null, true))
.mockResolvedValueOnce(subscription)
let renderer: ReactTestRenderer
await act(async () => {
renderer = create(<AdminUserDetailScreen route={{ params: { userId: USER_ID } }} navigation={{ goBack: mockGoBack }} />)
})
await flush()
act(() => renderer!.root.findByProps({ testID: 'admin-edit-subscription' }).props.onPress())
act(() => renderer!.root.findByProps({ testID: 'admin-memo-input' }).props.onChangeText('network response lost'))
await act(async () => {
renderer!.root.findByProps({ testID: 'admin-mutation-submit' }).props.onPress()
await Promise.resolve()
await Promise.resolve()
})
expect(renderer!.root.findByProps({ testID: 'admin-memo-input' }).props.editable).toBe(false)
expect(renderer!.root.findByProps({ testID: 'admin-mutation-error' })).toBeTruthy()
await act(async () => {
renderer!.root.findByProps({ testID: 'admin-mutation-submit' }).props.onPress()
await Promise.resolve()
await Promise.resolve()
})
expect(mockUpdateSubscription).toHaveBeenCalledTimes(2)
expect(mockUpdateSubscription.mock.calls[0][0]).toEqual(mockUpdateSubscription.mock.calls[1][0])
act(() => renderer!.unmount())
})
})