fix(mobile-templates): discard the generation idempotency key once the server gives a final answer

This commit is contained in:
Yun Chan 2026-09-28 02:16:24 +09:00
parent 65320288d5
commit 7a8e1e3e25
5 changed files with 393 additions and 19 deletions

View 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()
})
})