366 lines
12 KiB
TypeScript
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)
|
|
})
|
|
})
|