d3ro-voice/apps/mobile-rn/__tests__/content-report-service.test.ts
2026-08-29 18:33:45 +09:00

151 lines
5 KiB
TypeScript

import {
ContentReportError,
submitAiContentReport,
} from '../src/features/reporting/content-report-service'
const originalFetch = global.fetch
const GENERATION_ID = '11111111-1111-4111-8111-111111111111'
const IDEMPOTENCY_KEY = '22222222-2222-4222-8222-222222222222'
const REPORT_ID = '33333333-3333-4333-8333-333333333333'
function response(status: number, body: unknown): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as unknown as Response
}
afterEach(() => {
global.fetch = originalFetch
jest.restoreAllMocks()
})
describe('content report service', () => {
it('sends the exact authenticated Talk report contract and validates the receipt', async () => {
global.fetch = jest.fn().mockResolvedValue(response(201, {
reportId: REPORT_ID,
status: 'submitted',
idempotent: false,
createdAt: '2026-08-24T08:00:00.000Z',
}))
await expect(submitAiContentReport({
accessToken: ' user-token ',
idempotencyKey: IDEMPOTENCY_KEY,
generationId: GENERATION_ID,
sourceType: 'talk_response',
reason: 'privacy',
comment: ' exposed a name ',
snapshot: ' AI output ',
})).resolves.toEqual({
reportId: REPORT_ID,
status: 'submitted',
idempotent: false,
createdAt: '2026-08-24T08:00:00.000Z',
})
expect(global.fetch).toHaveBeenCalledTimes(1)
const [url, request] = (global.fetch as jest.Mock).mock.calls[0]
expect(url).toMatch(/\/functions\/v1\/content-report$/)
expect(request.headers).toMatchObject({
Authorization: 'Bearer user-token',
'Content-Type': 'application/json',
'Idempotency-Key': IDEMPOTENCY_KEY,
})
expect(JSON.parse(request.body)).toEqual({
kind: 'ai_output',
source: { type: 'talk_response', generationId: GENERATION_ID },
reason: 'privacy',
comment: 'exposed a name',
snapshot: 'AI output',
})
})
it('sends an owned generated meeting document through the same report boundary', async () => {
global.fetch = jest.fn().mockResolvedValue(response(201, {
reportId: REPORT_ID,
status: 'submitted',
idempotent: false,
createdAt: '2026-08-29T08:00:00.000Z',
}))
await submitAiContentReport({
accessToken: 'user-token',
idempotencyKey: IDEMPOTENCY_KEY,
generationId: GENERATION_ID,
sourceType: 'meeting_document',
reason: 'misinformation',
snapshot: 'Generated meeting document excerpt',
})
const [, request] = (global.fetch as jest.Mock).mock.calls[0]
expect(JSON.parse(request.body)).toEqual({
kind: 'ai_output',
source: { type: 'meeting_document', generationId: GENERATION_ID },
reason: 'misinformation',
snapshot: 'Generated meeting document excerpt',
})
})
it('rejects invalid or oversized input before any network request', async () => {
global.fetch = jest.fn()
await expect(submitAiContentReport({
accessToken: 'token',
idempotencyKey: 'not-a-uuid',
generationId: GENERATION_ID,
sourceType: 'talk_response',
reason: 'other',
snapshot: 'output',
})).rejects.toBeInstanceOf(ContentReportError)
await expect(submitAiContentReport({
accessToken: 'token',
idempotencyKey: IDEMPOTENCY_KEY,
generationId: GENERATION_ID,
sourceType: 'talk_response',
reason: 'other',
snapshot: 'x'.repeat(4_001),
})).rejects.toMatchObject({ code: 'INVALID_REQUEST', retryable: false })
expect(global.fetch).not.toHaveBeenCalled()
})
it.each([
[401, { error: 'unauthorized' }, 'AUTH_REQUIRED', false],
[404, { error: 'source_not_found' }, 'SOURCE_NOT_FOUND', false],
[409, { error: 'source_already_reported' }, 'ALREADY_REPORTED', false],
[409, { error: 'idempotency_conflict' }, 'IDEMPOTENCY_CONFLICT', false],
[429, { error: 'report_rate_limited' }, 'RATE_LIMITED', true],
[503, { error: 'server_error' }, 'SERVER_ERROR', true],
] as const)('maps HTTP %s without leaking raw server text', async (status, body, code, retryable) => {
global.fetch = jest.fn().mockResolvedValue(response(status, body))
await expect(submitAiContentReport({
accessToken: 'token',
idempotencyKey: IDEMPOTENCY_KEY,
generationId: GENERATION_ID,
sourceType: 'talk_response',
reason: 'harmful',
snapshot: 'output',
})).rejects.toMatchObject({ code, retryable, status })
})
it('fails closed when the server returns an unverified success shape', async () => {
global.fetch = jest.fn().mockResolvedValue(response(201, {
reportId: 'raw-secret-instead-of-uuid',
status: 'submitted',
idempotent: false,
createdAt: 'not-a-date',
}))
await expect(submitAiContentReport({
accessToken: 'token',
idempotencyKey: IDEMPOTENCY_KEY,
generationId: GENERATION_ID,
sourceType: 'talk_response',
reason: 'spam',
snapshot: 'output',
})).rejects.toMatchObject({ code: 'INVALID_RESPONSE', retryable: true })
})
})