151 lines
5 KiB
TypeScript
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 })
|
|
})
|
|
})
|