jest.mock('../src/lib/supabase', () => ({ supabase: { rpc: jest.fn(), }, })); import type { Meeting, MeetingStatus } from '@d3ro/api-client'; import { supabase } from '../src/lib/supabase'; import { beginMeetingRecording, cancelMeetingRecording, completeMeetingProcessing, failMeetingRecording, queueMeetingRecording, } from '../src/features/meetings/meetings-service'; import { confirmsMeetingTransition, expectedMeetingStates, } from '../src/features/meetings/meeting-recording-state-policy'; const USER_ID = '11111111-1111-4111-8111-111111111111'; const OTHER_USER_ID = '55555555-5555-4555-8555-555555555555'; const MEETING_ID = '22222222-2222-4222-8222-222222222222'; const AUDIO_ID = '33333333-3333-4333-8333-333333333333'; const IDEMPOTENCY = `mobile-meeting:${MEETING_ID}:${'a'.repeat(64)}`; const mockRpc = supabase.rpc as jest.Mock; function meeting(status: MeetingStatus, userId = USER_ID): Meeting { return { id: MEETING_ID, user_id: userId, team_id: null, title: 'Re-recorded meeting', status, started_at: '2026-08-21T00:00:00.000Z', ended_at: '2026-08-21T00:01:00.000Z', duration_ms: 60_000, raw_transcript: 'earlier transcript', edited_transcript: null, minutes_markdown: 'earlier minutes', minutes_json: null, stt_model: 'whisper', llm_model: null, stt_latency_ms: 100, llm_latency_ms: null, error_message: null, audio_storage_key: `${USER_ID}/imports/earlier.wav`, created_at: '2026-08-21T00:00:00.000Z', updated_at: '2026-08-21T00:01:00.000Z', }; } describe('re-record failure paths (server restores a meeting with content to completed)', () => { beforeEach(() => mockRpc.mockReset()); test('cancelMeetingRecording accepts the restored completed meeting', async () => { mockRpc.mockResolvedValueOnce({ data: meeting('completed'), error: null }); await expect(cancelMeetingRecording(USER_ID, MEETING_ID)) .resolves.toMatchObject({ status: 'completed', raw_transcript: 'earlier transcript' }); expect(mockRpc).toHaveBeenCalledWith('mobile_cancel_meeting_recording', { p_meeting_id: MEETING_ID }); }); test('failMeetingRecording accepts the restored completed meeting', async () => { mockRpc.mockResolvedValueOnce({ data: meeting('completed'), error: null }); await expect(failMeetingRecording(USER_ID, MEETING_ID, 'Queued audio processing was cancelled')) .resolves.toMatchObject({ status: 'completed' }); expect(mockRpc).toHaveBeenCalledWith('mobile_fail_meeting_recording', { p_meeting_id: MEETING_ID, p_error_message: 'Queued audio processing was cancelled', }); }); test('a first recording still confirms the error outcome', async () => { mockRpc .mockResolvedValueOnce({ data: meeting('error'), error: null }) .mockResolvedValueOnce({ data: meeting('error'), error: null }); await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).resolves.toMatchObject({ status: 'error' }); await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).resolves.toMatchObject({ status: 'error' }); }); test.each(['recording', 'processing'])( 'cancel/fail still reject a meeting left in %s', async (status) => { mockRpc .mockResolvedValueOnce({ data: meeting(status), error: null }) .mockResolvedValueOnce({ data: meeting(status), error: null }); await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({ code: 'server', message: 'Meeting cancellation was not confirmed', }); await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).rejects.toMatchObject({ code: 'server', message: 'Meeting error state was not confirmed', }); }, ); test('cancel/fail still reject a row owned by another user', async () => { mockRpc .mockResolvedValueOnce({ data: meeting('completed', OTHER_USER_ID), error: null }) .mockResolvedValueOnce({ data: meeting('error', OTHER_USER_ID), error: null }); await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({ code: 'server' }); await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).rejects.toMatchObject({ code: 'server' }); }); test('the other transitions keep their single confirmed state', async () => { mockRpc .mockResolvedValueOnce({ data: meeting('completed'), error: null }) .mockResolvedValueOnce({ data: meeting('completed'), error: null }) .mockResolvedValueOnce({ data: meeting('error'), error: null }); await expect(beginMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({ message: 'Meeting recording state was not confirmed', }); await expect(queueMeetingRecording(USER_ID, MEETING_ID, 1_000)).rejects.toMatchObject({ message: 'Queued meeting state was not confirmed', }); await expect(completeMeetingProcessing(USER_ID, MEETING_ID, AUDIO_ID, IDEMPOTENCY, { transcript: 't', language: 'ko', provider: 'whisper', durationMs: 1_000, sttLatencyMs: 10, })).rejects.toMatchObject({ message: 'Meeting completion was not confirmed' }); }); }); describe('meeting recording state policy', () => { test('lists the states each transition may produce', () => { expect(expectedMeetingStates('recording-started')).toEqual(['recording']); expect(expectedMeetingStates('recording-queued')).toEqual(['processing']); expect(expectedMeetingStates('processing-completed')).toEqual(['completed']); expect(expectedMeetingStates('capture-abandoned')).toEqual(['error', 'completed']); }); test('confirms only the caller-owned meeting in an expected state', () => { expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'completed' }, USER_ID, 'capture-abandoned')).toBe(true); expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'error' }, USER_ID, 'capture-abandoned')).toBe(true); expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'processing' }, USER_ID, 'capture-abandoned')).toBe(false); expect(confirmsMeetingTransition({ user_id: OTHER_USER_ID, status: 'error' }, USER_ID, 'capture-abandoned')).toBe(false); expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'error' }, USER_ID, 'processing-completed')).toBe(false); }); });