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

366 lines
12 KiB
TypeScript

const mockMaybeSingle = jest.fn()
const mockQuery: Record<string, jest.Mock> = {
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<string, unknown> = {
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> = {}): 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)
})
})