feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
366
apps/mobile-rn/__tests__/meetings.test.ts
Normal file
366
apps/mobile-rn/__tests__/meetings.test.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue