const mockRpc = jest.fn() const mockFrom = jest.fn() 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: { rpc: (...args: unknown[]) => mockRpc(...args), from: (...args: unknown[]) => mockFrom(...args), channel: jest.fn(() => mockRealtimeChannel), removeChannel: jest.fn(async () => 'ok'), }, })) import type { HistoryEntry, UserTemplate } from '@d3ro/api-client' import { createTemplate, deleteTemplate, normalizeTemplateDraft, renderDictationTemplate, selectTemplate, TemplateServiceError, updateTemplate, } from '../src/features/templates' import { addMemoTag, buildMemoShareText, MemoServiceError, normalizeMemoTag, renameMemoTag, searchMemos, } from '../src/features/memos' const USER_A = '11111111-1111-4111-8111-111111111111' const USER_B = '22222222-2222-4222-8222-222222222222' const TEMPLATE_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' const HISTORY_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' function template(overrides: Partial = {}): UserTemplate { return { id: TEMPLATE_ID, user_id: USER_A, template_kind: 'dictation', builtin_key: null, name: 'Email', description: null, fields: [{ id: 'recipient', name: 'recipient', label: 'Recipient', promptText: 'Who is this for?', required: true, maxDurationSec: 15, }, { id: 'body', name: 'body', label: 'Body', promptText: 'Dictate the body', required: false, maxDurationSec: 120, }], output_format: 'To: {{recipient}}\n\n{{body}}', template_type: null, system_prompt: null, is_builtin: false, revision: 3, created_at: '2026-08-21T00:00:00.000Z', updated_at: '2026-08-21T00:00:00.000Z', ...overrides, } } function history(overrides: Partial = {}): HistoryEntry { return { id: HISTORY_ID, user_id: USER_A, title: 'Launch note', original_text: 'Raw launch detail', polished_text: 'Polished launch detail', focused_app: null, focused_app_name: null, focused_app_window_title: null, mode: 'dictation', status: 'completed', error_code: null, audio_storage_key: null, duration: 1, detected_language: 'en', mic_device: null, word_count: 3, stt_model: null, llm_model: null, stt_latency_ms: null, llm_latency_ms: null, app_version: '1.0.0', summary_text: null, is_favorite: false, revision: 1, created_at: '2026-08-21T00:00:00.000Z', updated_at: '2026-08-21T00:00:00.000Z', ...overrides, } } beforeEach(() => { mockRpc.mockReset() mockFrom.mockReset() mockRealtimeOn.mockClear() }) describe('template service contracts', () => { it('normalizes complete dictation fields and rejects case-insensitive duplicate keys', () => { expect(normalizeTemplateDraft('dictation', { name: ' Customer email ', fields: template().fields, outputFormat: ' {{recipient}} ', })).toMatchObject({ name: 'Customer email', outputFormat: '{{recipient}}' }) expect(() => normalizeTemplateDraft('dictation', { name: 'Duplicate', fields: [ template().fields[0], { ...template().fields[0], id: 'RECIPIENT', name: 'recipient_2' }, ], outputFormat: '{{recipient}}', })).toThrow(TemplateServiceError) }) it('renders the desktop-compatible mustache output and enforces required fields', () => { expect(renderDictationTemplate(template(), { recipient: 'Yun', body: 'Hello' })) .toBe('To: Yun\n\nHello') expect(() => renderDictationTemplate(template(), { body: 'Hello' })) .toThrow('Required field') }) it('uses revision CAS for custom update, delete, and selection', async () => { const current = template() const updated = template({ name: 'Updated', revision: 4 }) mockRpc .mockResolvedValueOnce({ data: updated, error: null }) .mockResolvedValueOnce({ data: true, error: null }) .mockResolvedValueOnce({ data: { user_id: USER_A, template_kind: 'dictation', template_id: TEMPLATE_ID, revision: 8, updated_at: '2026-08-21T01:00:00.000Z', }, error: null, }) await expect(updateTemplate(USER_A, current, { name: 'Updated', fields: current.fields, outputFormat: current.output_format, })).resolves.toEqual(updated) await expect(deleteTemplate(USER_A, current)).resolves.toBeUndefined() await expect(selectTemplate(USER_A, current, { user_id: USER_A, template_kind: 'dictation', template_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', revision: 7, updated_at: '2026-08-21T00:00:00.000Z', })).resolves.toMatchObject({ revision: 8 }) expect(mockRpc.mock.calls[0]).toEqual(['update_user_template_v1', expect.objectContaining({ p_template_id: TEMPLATE_ID, p_expected_revision: 3, })]) expect(mockRpc.mock.calls[1]).toEqual(['delete_user_template_v1', { p_template_id: TEMPLATE_ID, p_expected_revision: 3, }]) expect(mockRpc.mock.calls[2]).toEqual(['select_user_template_v1', { p_template_id: TEMPLATE_ID, p_expected_revision: 7, }]) }) it('fails closed before RPC for builtin or cross-owner mutations', async () => { await expect(deleteTemplate(USER_A, template({ is_builtin: true, builtin_key: 'builtin-email' }))) .rejects.toMatchObject({ code: 'builtin' }) await expect(updateTemplate(USER_A, template({ user_id: USER_B }), { name: 'Changed', fields: template().fields, outputFormat: '{{recipient}}', })).rejects.toMatchObject({ code: 'auth' }) expect(mockRpc).not.toHaveBeenCalled() }) it('creates custom meeting templates only with bounded generation instructions', async () => { const created = template({ template_kind: 'meeting_document', fields: [], output_format: null, template_type: 'custom', system_prompt: 'Use facts only', }) mockRpc.mockResolvedValueOnce({ data: created, error: null }) await expect(createTemplate(USER_A, 'meeting_document', { name: 'Facts', systemPrompt: 'Use facts only', })).resolves.toEqual(created) expect(mockRpc).toHaveBeenCalledWith('create_user_template_v1', expect.objectContaining({ p_template_kind: 'meeting_document', p_system_prompt: 'Use facts only', p_fields: [], })) }) }) describe('memo service contracts', () => { it('normalizes case-insensitive tag identity and preserves a readable label', () => { expect(normalizeMemoTag(' Launch Plan ')).toEqual({ tag: 'Launch Plan', normalizedTag: 'launch plan', }) expect(() => normalizeMemoTag(' ')).toThrow(MemoServiceError) }) it('rejects a server search row owned by another account', async () => { mockRpc .mockResolvedValueOnce({ data: [{ history_row: history({ user_id: USER_B }), tags: ['private'] }], error: null, }) .mockResolvedValueOnce({ data: [], error: null }) await expect(searchMemos(USER_A)).rejects.toMatchObject({ code: 'server' }) }) it('adds and renames tags only through owner-scoped RPC contracts', async () => { mockRpc .mockResolvedValueOnce({ data: { id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', user_id: USER_A, history_id: HISTORY_ID, tag: 'Launch', normalized_tag: 'launch', created_at: '2026-08-21T00:00:00.000Z', }, error: null, }) .mockResolvedValueOnce({ data: 2, error: null }) await expect(addMemoTag(USER_A, HISTORY_ID, ' launch ')) .resolves.toMatchObject({ normalized_tag: 'launch' }) await expect(renameMemoTag(USER_A, 'Launch', 'Release')).resolves.toBe(2) expect(mockRpc.mock.calls[0]).toEqual(['mobile_add_memo_tag_v1', { p_history_id: HISTORY_ID, p_tag: 'launch', }]) expect(mockRpc.mock.calls[1]).toEqual(['mobile_rename_memo_tag_v1', { p_old_tag: 'Launch', p_new_tag: 'Release', }]) }) it('builds a share payload without audio or cloud identifiers', () => { const text = buildMemoShareText({ history: history(), tags: ['Launch', 'Team'] }) expect(text).toBe('Launch note\n\nPolished launch detail\n\n#Launch #Team') expect(text).not.toContain(HISTORY_ID) expect(text).not.toContain('audio') }) })