fix(mobile-templates): discard the generation idempotency key once the server gives a final answer
This commit is contained in:
parent
65320288d5
commit
7a8e1e3e25
5 changed files with 393 additions and 19 deletions
226
apps/mobile-rn/__tests__/generation-key-lifecycle-r2-29.test.ts
Normal file
226
apps/mobile-rn/__tests__/generation-key-lifecycle-r2-29.test.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue