226 lines
8.9 KiB
TypeScript
226 lines
8.9 KiB
TypeScript
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()
|
|
})
|
|
})
|