jest.mock('../src/lib/supabase', () => ({ supabase: { rpc: jest.fn(), }, })); import type { Meeting, ProcessingJob } from '@d3ro/api-client'; import { supabase } from '../src/lib/supabase'; import { beginMeetingProcessing, beginMeetingRecording, completeMeetingProcessing, markMeetingProcessingFailure, queueMeetingRecording, } from '../src/features/meetings/meetings-service'; const USER_ID = '11111111-1111-4111-8111-111111111111'; const MEETING_ID = '22222222-2222-4222-8222-222222222222'; const AUDIO_ID = '33333333-3333-4333-8333-333333333333'; const JOB_ID = '44444444-4444-4444-8444-444444444444'; const IDEMPOTENCY = `mobile-meeting:${MEETING_ID}:${'a'.repeat(64)}`; const mockRpc = supabase.rpc as jest.Mock; function meeting(status: Meeting['status']): Meeting { return { id: MEETING_ID, user_id: USER_ID, team_id: null, title: 'Mobile meeting', status, started_at: '2026-08-21T00:00:00.000Z', ended_at: status === 'recording' ? null : '2026-08-21T00:01:00.000Z', duration_ms: status === 'recording' ? null : 60_000, raw_transcript: status === 'completed' ? 'verified' : null, edited_transcript: null, minutes_markdown: null, minutes_json: null, stt_model: status === 'completed' ? 'whisper' : null, llm_model: null, stt_latency_ms: status === 'completed' ? 100 : null, llm_latency_ms: null, error_message: null, audio_storage_key: status === 'completed' ? `${USER_ID}/imports/audio.wav` : null, created_at: '2026-08-21T00:00:00.000Z', updated_at: '2026-08-21T00:01:00.000Z', }; } function job(status: ProcessingJob['status']): ProcessingJob { return { id: JOB_ID, user_id: USER_ID, audio_file_id: AUDIO_ID, history_id: null, meeting_id: MEETING_ID, kind: 'transcription', status, progress: status === 'succeeded' ? 100 : 70, attempt_count: 1, idempotency_key: IDEMPOTENCY, error_code: null, error_message: null, result: null, started_at: '2026-08-21T00:00:00.000Z', completed_at: status === 'succeeded' ? '2026-08-21T00:01:00.000Z' : null, created_at: '2026-08-21T00:00:00.000Z', updated_at: '2026-08-21T00:00:00.000Z', }; } describe('meeting recording RPC lifecycle', () => { beforeEach(() => mockRpc.mockReset()); test('moves recording to queued processing with server-confirmed ownership', async () => { mockRpc .mockResolvedValueOnce({ data: meeting('recording'), error: null }) .mockResolvedValueOnce({ data: meeting('processing'), error: null }) .mockResolvedValueOnce({ data: job('running'), error: null }); await expect(beginMeetingRecording(USER_ID, MEETING_ID)).resolves.toMatchObject({ status: 'recording' }); await expect(queueMeetingRecording(USER_ID, MEETING_ID, 60_000)).resolves.toMatchObject({ status: 'processing' }); await expect(beginMeetingProcessing(USER_ID, MEETING_ID, AUDIO_ID, IDEMPOTENCY)) .resolves.toMatchObject({ id: JOB_ID, status: 'running' }); expect(mockRpc.mock.calls.map(call => call[0])).toEqual([ 'mobile_begin_meeting_recording', 'mobile_queue_meeting_recording', 'mobile_begin_meeting_processing', ]); }); test('completes transcript/audio/job atomically and maps transient failure to queued', async () => { mockRpc .mockResolvedValueOnce({ data: meeting('completed'), error: null }) .mockResolvedValueOnce({ data: job('queued'), error: null }); await expect(completeMeetingProcessing( USER_ID, MEETING_ID, AUDIO_ID, IDEMPOTENCY, { transcript: 'verified', language: 'en', provider: 'whisper', durationMs: 60_000, sttLatencyMs: 100, }, )).resolves.toMatchObject({ status: 'completed', raw_transcript: 'verified' }); expect(mockRpc.mock.calls[0][1]).toMatchObject({ p_audio_file_id: AUDIO_ID, p_transcript: 'verified', p_duration_ms: 60_000, }); await expect(markMeetingProcessingFailure( USER_ID, MEETING_ID, IDEMPOTENCY, 'upload', 'offline', false, )).resolves.toMatchObject({ status: 'queued' }); expect(mockRpc.mock.calls[1][1]).toMatchObject({ p_terminal: false }); }); test('fails closed when the server returns a job linked to another audio file', async () => { mockRpc.mockResolvedValueOnce({ data: { ...job('running'), audio_file_id: '55555555-5555-4555-8555-555555555555' }, error: null, }); await expect(beginMeetingProcessing(USER_ID, MEETING_ID, AUDIO_ID, IDEMPOTENCY)) .rejects.toThrow('linkage is invalid'); }); });