134 lines
4.5 KiB
TypeScript
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');
|
|
});
|
|
});
|