const mockMaybeSingle = jest.fn() const mockQuery: Record = { update: jest.fn(), delete: jest.fn(), eq: jest.fn(), select: jest.fn(), maybeSingle: mockMaybeSingle, } mockQuery.update.mockReturnValue(mockQuery) mockQuery.delete.mockReturnValue(mockQuery) mockQuery.eq.mockReturnValue(mockQuery) mockQuery.select.mockReturnValue(mockQuery) const mockRealtimeOn = jest.fn() const mockRealtimeSubscribe = jest.fn() const mockRealtimeChannel: Record = { on: mockRealtimeOn, subscribe: mockRealtimeSubscribe, } mockRealtimeOn.mockReturnValue(mockRealtimeChannel) mockRealtimeSubscribe.mockReturnValue(mockRealtimeChannel) jest.mock('../src/lib/supabase', () => ({ supabase: { from: jest.fn(() => mockQuery), rpc: jest.fn(), channel: jest.fn(() => mockRealtimeChannel), removeChannel: jest.fn(async () => 'ok'), }, })) const mockedSupabase = jest.requireMock('../src/lib/supabase').supabase as { rpc: jest.Mock } const mockRpc = mockedSupabase.rpc import type { Meeting, MeetingMemo, Transcript } from '@d3ro/api-client' import { buildMeetingTranscriptView, computeMemoTimestamp, createMeetingCreationIdempotencyKey, createMeetingWorkspaceWithMetadata, getMeetingRecordingLanguage, MeetingServiceError, normalizeDocumentDraft, normalizeMeetingAttendees, normalizeMeetingCreationDraft, normalizeMeetingLanguage, normalizeMeetingTitle, normalizeMemoContent, sanitizeMeetingSearch, subscribeToMeetingDetail, updateMeetingMemoConflictSafe, updateMeetingTitleRevisionSafe, } from '../src/features/meetings/meetings-service' const USER_ID = '11111111-1111-4111-8111-111111111111' const MEETING_ID = '22222222-2222-4222-8222-222222222222' function meeting(overrides: Partial = {}): Meeting { return { id: MEETING_ID, user_id: USER_ID, team_id: null, title: 'Planning', status: 'completed', started_at: '2026-08-21T00:00:00.000Z', ended_at: '2026-08-21T00:30:00.000Z', duration_ms: 1_800_000, raw_transcript: null, edited_transcript: null, minutes_markdown: null, minutes_json: null, stt_model: 'whisper', llm_model: null, stt_latency_ms: null, llm_latency_ms: null, error_message: null, audio_storage_key: null, created_at: '2026-08-21T00:00:00.000Z', updated_at: '2026-08-21T00:30:00.000Z', ...overrides, } } function transcript( id: string, segmentIndex: number, text: string, speaker: string | null = null, ): Transcript { return { id, meeting_id: MEETING_ID, segment_index: segmentIndex, timestamp_ms: segmentIndex * 1_000, duration_ms: 1_000, text, speaker, edited: false, created_at: '2026-08-21T00:00:00.000Z', updated_at: '2026-08-21T00:00:00.000Z', } } describe('meeting domain validation', () => { test('normalizes titles and rejects empty or oversized values', () => { expect(normalizeMeetingTitle(' 주간 계획 ')).toBe('주간 계획') expect(() => normalizeMeetingTitle(' ')).toThrow(MeetingServiceError) expect(() => normalizeMeetingTitle('a'.repeat(161))).toThrow(MeetingServiceError) }) test('keeps memo line breaks while enforcing content bounds', () => { expect(normalizeMemoContent(' first\nsecond ')).toBe('first\nsecond') expect(() => normalizeMemoContent('')).toThrow(MeetingServiceError) expect(() => normalizeMemoContent('a'.repeat(4_001))).toThrow(MeetingServiceError) }) test('sanitizes wildcard search and clamps memo timestamps before start', () => { expect(sanitizeMeetingSearch(" 100%_plan\\draft ")).toBe('100 plan draft') expect(computeMemoTimestamp('2026-08-21T00:00:10.000Z', Date.parse('2026-08-21T00:00:00.000Z'))) .toBe(0) expect(computeMemoTimestamp('2026-08-21T00:00:00.000Z', Date.parse('2026-08-21T00:00:12.345Z'))) .toBe(12_345) }) test('validates editable document title and content limits', () => { expect(normalizeDocumentDraft(' Report ', ' body\n')).toEqual({ title: 'Report', content: 'body', }) expect(() => normalizeDocumentDraft('Report', 'a'.repeat(100_001))) .toThrow(MeetingServiceError) }) test('normalizes explicit meeting creation metadata and rejects ambiguous attendees', () => { expect(normalizeMeetingCreationDraft({ title: ' Weekly launch ', attendees: [' Yun Chan ', 'Product Team'], language: 'ZH-CN', templateId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', idempotencyKey: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', })).toEqual({ title: 'Weekly launch', attendees: ['Yun Chan', 'Product Team'], language: 'zh-cn', templateId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', idempotencyKey: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', }) expect(normalizeMeetingCreationDraft({ title: 'Adhoc sync', attendees: [], language: 'ko', templateId: null, idempotencyKey: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', })).toEqual({ title: 'Adhoc sync', attendees: [], language: 'ko', templateId: null, idempotencyKey: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', }) expect(normalizeMeetingLanguage(' EN ')).toBe('en') expect(() => normalizeMeetingAttendees(['Yun Chan', 'yun chan'])) .toThrow(MeetingServiceError) expect(() => normalizeMeetingAttendees(['has\ncontrol'])) .toThrow(MeetingServiceError) expect(createMeetingCreationIdempotencyKey()).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, ) }) }) describe('meeting creation contract', () => { const templateId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' const idempotencyKey = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' const draft = { title: ' Weekly launch ', attendees: [' Yun Chan ', 'Product Team'], language: 'EN', templateId, idempotencyKey, } beforeEach(() => { mockRpc.mockReset() }) test('creates through the atomic RPC and accepts only exact server metadata readback', async () => { const serverRow = meeting({ status: 'recording', title: 'Weekly launch', language: 'en', attendees: ['Yun Chan', 'Product Team'], template_id: templateId, creation_idempotency_key: idempotencyKey, }) mockRpc.mockResolvedValue({ data: serverRow, error: null }) await expect(createMeetingWorkspaceWithMetadata(USER_ID, draft)).resolves.toBe(serverRow) expect(mockRpc).toHaveBeenCalledWith('mobile_create_meeting_workspace_v2', { p_title: 'Weekly launch', p_attendees: ['Yun Chan', 'Product Team'], p_language: 'en', p_template_id: templateId, p_idempotency_key: idempotencyKey, }) }) test('creates without a template and confirms the null template readback', async () => { const serverRow = meeting({ status: 'recording', title: 'Adhoc sync', language: 'ko', attendees: [], template_id: null, creation_idempotency_key: idempotencyKey, }) mockRpc.mockResolvedValue({ data: serverRow, error: null }) await expect(createMeetingWorkspaceWithMetadata(USER_ID, { ...draft, title: 'Adhoc sync', attendees: [], language: 'ko', templateId: null, })).resolves.toBe(serverRow) expect(mockRpc).toHaveBeenCalledWith('mobile_create_meeting_workspace_v2', { p_title: 'Adhoc sync', p_attendees: [], p_language: 'ko', p_template_id: null, p_idempotency_key: idempotencyKey, }) }) test('fails closed when the RPC returns stale or mismatched metadata', async () => { mockRpc.mockResolvedValue({ data: meeting({ status: 'recording', title: 'Weekly launch', language: 'ko', attendees: ['Yun Chan', 'Product Team'], template_id: templateId, creation_idempotency_key: idempotencyKey, }), error: null, }) await expect(createMeetingWorkspaceWithMetadata(USER_ID, draft)) .rejects.toMatchObject({ code: 'server' }) }) test('maps non-retryable idempotency conflicts to the mobile conflict state', async () => { mockRpc.mockResolvedValue({ data: null, error: { code: 'PT409', message: 'meeting_creation_idempotency_conflict' }, }) await expect(createMeetingWorkspaceWithMetadata(USER_ID, draft)) .rejects.toMatchObject({ code: 'conflict' }) }) }) describe('meeting recording language readback', () => { beforeEach(() => { mockMaybeSingle.mockReset() for (const method of ['eq', 'select'] as const) { mockQuery[method].mockClear() mockQuery[method].mockReturnValue(mockQuery) } }) test('restores the authoritative meeting language for a recovered recording', async () => { mockMaybeSingle.mockResolvedValue({ data: meeting({ language: 'ja' }), error: null }) await expect(getMeetingRecordingLanguage(USER_ID, MEETING_ID)).resolves.toBe('ja') expect(mockQuery.eq).toHaveBeenCalledWith('id', MEETING_ID) }) test('keeps legacy meetings unknown instead of inventing a language', async () => { mockMaybeSingle.mockResolvedValue({ data: meeting({ language: null }), error: null }) await expect(getMeetingRecordingLanguage(USER_ID, MEETING_ID)).resolves.toBeNull() }) }) describe('meeting transcript selection', () => { test('sorts server segments and includes speaker labels', () => { const view = buildMeetingTranscriptView(meeting(), [ transcript('b', 2, 'Second'), transcript('a', 1, 'First', 'Speaker 1'), ]) expect(view).toEqual({ source: 'segments', text: 'Speaker 1: First\nSecond', }) }) test('prefers edited fallback, then raw, and never invents text', () => { expect(buildMeetingTranscriptView(meeting({ edited_transcript: 'edited', raw_transcript: 'raw', }), [])).toEqual({ source: 'edited', text: 'edited' }) expect(buildMeetingTranscriptView(meeting({ raw_transcript: 'raw' }), [])) .toEqual({ source: 'raw', text: 'raw' }) expect(buildMeetingTranscriptView(meeting(), [])) .toEqual({ source: 'empty', text: '' }) }) }) describe('meeting mutation safety', () => { beforeEach(() => { mockMaybeSingle.mockReset() for (const method of ['update', 'delete', 'eq', 'select'] as const) { mockQuery[method].mockClear() mockQuery[method].mockReturnValue(mockQuery) } }) test('meeting edits compare the last server updated_at and report zero-row conflicts', async () => { mockMaybeSingle.mockResolvedValue({ data: null, error: null }) const current = meeting() await expect(updateMeetingTitleRevisionSafe(current, 'Changed')) .rejects.toMatchObject({ code: 'conflict' }) expect(mockQuery.eq).toHaveBeenCalledWith('id', current.id) expect(mockQuery.eq).toHaveBeenCalledWith('updated_at', current.updated_at) }) test('memo edits use all stable row fields as an atomic compare-and-swap', async () => { mockMaybeSingle.mockResolvedValue({ data: null, error: null }) const current: MeetingMemo = { id: '33333333-3333-4333-8333-333333333333', meeting_id: MEETING_ID, user_id: USER_ID, content: 'before', timestamp_ms: 12_345, created_at: '2026-08-21T00:00:12.345Z', } await expect(updateMeetingMemoConflictSafe(USER_ID, current, 'after')) .rejects.toMatchObject({ code: 'conflict' }) expect(mockQuery.eq).toHaveBeenCalledWith('content', current.content) expect(mockQuery.eq).toHaveBeenCalledWith('timestamp_ms', current.timestamp_ms) expect(mockQuery.eq).toHaveBeenCalledWith('created_at', current.created_at) }) }) describe('meeting realtime subscription', () => { test('subscribes only to filtered insert and update events', () => { mockRealtimeOn.mockClear() subscribeToMeetingDetail(MEETING_ID, jest.fn(), jest.fn()) const filters = mockRealtimeOn.mock.calls.map((call) => call[1] as { event: string table: string filter: string }) expect(filters).toHaveLength(7) expect(filters.every((filter) => filter.event === 'INSERT' || filter.event === 'UPDATE')) .toBe(true) expect(filters.every((filter) => filter.filter === `id=eq.${MEETING_ID}` || filter.filter === `meeting_id=eq.${MEETING_ID}`)).toBe(true) }) })