d3ro-voice/apps/mobile-rn/__tests__/meeting-recording-lifecycle.test.ts
2026-08-29 18:33:45 +09:00

134 lines
4.5 KiB
TypeScript

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');
});
});