302 lines
11 KiB
TypeScript
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())
|
|
})
|
|
})
|