jest.mock('../src/lib/supabase', () => ({ supabase: { rpc: jest.fn(), from: jest.fn(), channel: jest.fn(), removeChannel: jest.fn(), }, })) import AsyncStorage from '@react-native-async-storage/async-storage' import type { MeetingDocument } from '@d3ro/api-client' import { generationKeyDisposition, type GenerationAttemptOutcome, type GenerationKeyDisposition, } from '../src/features/templates/generation-key-policy' import { getOrCreateGenerationIdempotencyKey } from '../src/features/templates/generation-idempotency' import { generateMeetingDocumentOnce, generationAttemptOutcome, TemplateServiceError, type GenerateMeetingDocumentOnceDeps, } from '../src/features/templates' import type { GenerateMeetingDocumentOptions } from '../src/features/templates' const USER_ID = '11111111-1111-4111-8111-111111111111' const MEETING_ID = '33333333-3333-4333-8333-333333333333' const TEMPLATE_ID = '44444444-4444-4444-8444-444444444444' const DOCUMENT_ID = '55555555-5555-4555-8555-555555555555' const ACCESS_TOKEN = 'access-token-with-enough-length' function response(status: number, errorCode: string | null): GenerationAttemptOutcome { return { kind: 'server-response', status, errorCode } } describe('generationKeyDisposition', () => { const table: Array<[string, GenerationAttemptOutcome, GenerationKeyDisposition]> = [ ['success', { kind: 'succeeded' }, 'discard'], ['idempotent 200 replay', response(200, null), 'discard'], ['no response (network loss / abort / local pre-flight)', { kind: 'no-response' }, 'retain'], ['400 idempotency conflict or invalid request', response(400, 'invalid_request'), 'discard'], ['401 session expired before claim', response(401, 'unauthorized'), 'retain'], ['403 forbidden', response(403, 'forbidden'), 'discard'], ['404 not found', response(404, 'not_found'), 'discard'], ['409 generation_failed', response(409, 'generation_failed'), 'discard'], ['409 generation_in_progress', response(409, 'generation_in_progress'), 'retain'], ['409 without body', response(409, null), 'retain'], ['429 quota exceeded', response(429, 'quota_exceeded'), 'discard'], ['500 commit_failed (row marked failed)', response(500, 'commit_failed'), 'discard'], ['500 internal_error (state unknown)', response(500, 'internal_error'), 'retain'], ['502 provider_request_failed', response(502, 'provider_request_failed'), 'discard'], ['502 provider_invalid_response', response(502, 'provider_invalid_response'), 'discard'], ['504 provider_timeout', response(504, 'provider_timeout'), 'discard'], ['504 gateway timeout without function body', response(504, null), 'retain'], ['503 unknown gateway error', response(503, 'service_unavailable'), 'retain'], ] it.each(table)('%s -> %s', (_label, outcome, expected) => { expect(generationKeyDisposition(outcome)).toBe(expected) }) }) describe('generationAttemptOutcome', () => { it('uses the server response carried by the service error', () => { const error = new TemplateServiceError('provider', 'provider_timeout', true, { status: 504, errorCode: 'provider_timeout', }) expect(generationAttemptOutcome(error)).toEqual(response(504, 'provider_timeout')) }) it('treats errors without a server response as no response', () => { expect(generationAttemptOutcome(new TemplateServiceError('network', 'Network request failed', true))) .toEqual({ kind: 'no-response' }) expect(generationAttemptOutcome(new TypeError('Network request failed'))) .toEqual({ kind: 'no-response' }) }) }) describe('generateMeetingDocumentOnce', () => { const fetchMock = jest.fn() const originalFetch = global.fetch function jsonResponse(status: number, body: unknown): Response { return { ok: status >= 200 && status < 300, status, json: async () => body, } as unknown as Response } function sentKey(callIndex: number): string { const init = fetchMock.mock.calls[callIndex]?.[1] as { body: string } return (JSON.parse(init.body) as { idempotencyKey: string }).idempotencyKey } const document = { id: DOCUMENT_ID, meeting_id: MEETING_ID, template_id: TEMPLATE_ID, } as unknown as MeetingDocument const options = { userId: USER_ID, accessToken: ACCESS_TOKEN, meetingId: MEETING_ID, templateId: TEMPLATE_ID, title: 'Weekly sync ยท Minutes', } beforeEach(async () => { await AsyncStorage.clear() fetchMock.mockReset() global.fetch = fetchMock as unknown as typeof fetch }) afterAll(() => { global.fetch = originalFetch }) it('starts a fresh operation after the server marks the request failed (provider timeout)', async () => { fetchMock .mockResolvedValueOnce(jsonResponse(504, { error: 'provider_timeout' })) .mockResolvedValueOnce(jsonResponse(200, { document, idempotent: false })) await expect(generateMeetingDocumentOnce(options)).rejects.toMatchObject({ code: 'provider' }) const generated = await generateMeetingDocumentOnce(options) expect(generated.document.id).toBe(DOCUMENT_ID) expect(sentKey(1)).not.toBe(sentKey(0)) }) it('starts a fresh operation after an idempotency hash conflict', async () => { fetchMock .mockResolvedValueOnce(jsonResponse(400, { error: 'invalid_request' })) .mockResolvedValueOnce(jsonResponse(200, { document, idempotent: false })) await expect(generateMeetingDocumentOnce(options)).rejects.toMatchObject({ code: 'validation' }) await generateMeetingDocumentOnce(options) expect(sentKey(1)).not.toBe(sentKey(0)) }) it('reports 409 generation_failed as a retryable server error and discards the key', async () => { fetchMock .mockResolvedValueOnce(jsonResponse(409, { error: 'generation_failed' })) .mockResolvedValueOnce(jsonResponse(200, { document, idempotent: false })) await expect(generateMeetingDocumentOnce(options)) .rejects.toMatchObject({ code: 'server', retryable: true }) await generateMeetingDocumentOnce(options) expect(sentKey(1)).not.toBe(sentKey(0)) }) it('reuses the key while the server is still processing it', async () => { fetchMock .mockResolvedValueOnce(jsonResponse(409, { error: 'generation_in_progress' })) .mockResolvedValueOnce(jsonResponse(200, { document, idempotent: true })) await expect(generateMeetingDocumentOnce(options)).rejects.toMatchObject({ code: 'in-progress' }) await generateMeetingDocumentOnce(options) expect(sentKey(1)).toBe(sentKey(0)) }) it('reuses the key when no response arrived (network loss or abort)', async () => { fetchMock .mockRejectedValueOnce(new TypeError('Network request failed')) .mockResolvedValueOnce(jsonResponse(200, { document, idempotent: true })) await expect(generateMeetingDocumentOnce(options)).rejects.toMatchObject({ code: 'network' }) await generateMeetingDocumentOnce(options) expect(sentKey(1)).toBe(sentKey(0)) }) it('clears the key after success so the next generation is a new operation', async () => { fetchMock.mockResolvedValue(jsonResponse(200, { document, idempotent: false })) await generateMeetingDocumentOnce(options) await generateMeetingDocumentOnce(options) expect(sentKey(1)).not.toBe(sentKey(0)) }) it('keeps the key when local validation fails before any request is sent', async () => { const pending = await getOrCreateGenerationIdempotencyKey(USER_ID, MEETING_ID, TEMPLATE_ID) await expect(generateMeetingDocumentOnce({ ...options, title: ' ' })) .rejects.toMatchObject({ code: 'validation' }) expect(fetchMock).not.toHaveBeenCalled() expect(await getOrCreateGenerationIdempotencyKey(USER_ID, MEETING_ID, TEMPLATE_ID)).toBe(pending) }) it('returns the result even when clearing the key fails', async () => { const deps: GenerateMeetingDocumentOnceDeps = { keyStore: { getOrCreate: jest.fn(async () => '66666666-6666-4666-8666-666666666666'), clear: jest.fn(async () => { throw new Error('storage unavailable') }), }, generate: jest.fn(async (request: GenerateMeetingDocumentOptions) => { expect(request.idempotencyKey).toBe('66666666-6666-4666-8666-666666666666') return { document, idempotent: false } }), } await expect(generateMeetingDocumentOnce(options, deps)).resolves.toMatchObject({ document }) expect(deps.keyStore.clear).toHaveBeenCalledWith(USER_ID, MEETING_ID, TEMPLATE_ID) }) it('maps key-store failures to a service error without calling the server', async () => { const deps: GenerateMeetingDocumentOnceDeps = { keyStore: { getOrCreate: jest.fn(async () => { throw new Error('invalid_generation_identity') }), clear: jest.fn(async () => undefined), }, generate: jest.fn(), } await expect(generateMeetingDocumentOnce(options, deps)).rejects.toBeInstanceOf(TemplateServiceError) expect(deps.generate).not.toHaveBeenCalled() expect(deps.keyStore.clear).not.toHaveBeenCalled() }) })