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: { from: (...args: unknown[]) => mockFrom(...args), channel: jest.fn(() => mockRealtimeChannel), removeChannel: jest.fn(async () => 'ok'), }, })) import type { DictionaryEntry } from '@d3ro/api-client' import { createDictionaryEntry, deleteDictionaryEntry, DictionaryServiceError, getDictionaryPromptHints, listDictionaryPage, mergeDictionaryEntries, normalizeDictionaryDraft, sanitizeDictionarySearch, subscribeToDictionary, updateDictionaryEntry, } from '../src/features/dictionary/dictionary-service' const USER_A = '11111111-1111-4111-8111-111111111111' const USER_B = '22222222-2222-4222-8222-222222222222' function entry(overrides: Partial = {}): DictionaryEntry { return { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', user_id: USER_A, word: 'D3RO', pronunciation: '디쓰리오', category: 'technical', usage_count: 3, last_used_at: null, created_at: '2026-08-21T00:00:00.000Z', updated_at: '2026-08-21T00:00:00.000Z', ...overrides, } } function queryBuilder(result: Record): Record { const builder: Record = {} for (const method of [ 'select', 'eq', 'neq', 'ilike', 'limit', 'insert', 'update', 'delete', 'order', 'or', ]) { builder[method] = jest.fn(() => builder) } builder.maybeSingle = jest.fn(async () => result) builder.single = jest.fn(async () => result) builder.then = jest.fn((resolve, reject) => Promise.resolve(result).then(resolve, reject)) return builder } beforeEach(() => { mockFrom.mockReset() mockRealtimeOn.mockClear() }) describe('dictionary service', () => { it('normalizes fields and rejects invalid lengths and categories', () => { expect(normalizeDictionaryDraft({ word: ' D3RO Voice ', pronunciation: ' 디쓰리오 보이스 ', category: 'technical', })).toEqual({ word: 'D3RO Voice', pronunciation: '디쓰리오 보이스', category: 'technical', }) expect(() => normalizeDictionaryDraft({ word: ' ' })) .toThrow(DictionaryServiceError) expect(() => normalizeDictionaryDraft({ word: 'word', category: 'other' as 'user', })).toThrow(DictionaryServiceError) }) it('removes PostgREST control characters from search input', () => { expect(sanitizeDictionarySearch(" hello),user_id.eq.other,%_'world ")) .toBe('hello user id eq other world') }) it('merges by id without allowing a stale realtime row to overwrite a new revision', () => { const old = entry({ word: 'old' }) const current = entry({ word: 'current', updated_at: '2026-08-22T00:00:00.000Z', }) const newer = entry({ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', word: 'newer', updated_at: '2026-08-23T00:00:00.000Z', }) expect(mergeDictionaryEntries([current], [old, newer])) .toEqual([newer, current]) }) it('creates only under the authenticated owner after duplicate preflight', async () => { const duplicateQuery = queryBuilder({ data: null, error: null }) const created = entry() const insertQuery = queryBuilder({ data: created, error: null }) mockFrom .mockReturnValueOnce(duplicateQuery) .mockReturnValueOnce(insertQuery) await expect(createDictionaryEntry(USER_A, { word: ' D3RO ', pronunciation: '디쓰리오', category: 'technical', })).resolves.toEqual(created) expect(duplicateQuery.eq).toHaveBeenCalledWith('user_id', USER_A) expect(insertQuery.insert).toHaveBeenCalledWith(expect.objectContaining({ user_id: USER_A, word: 'D3RO', category: 'technical', })) }) it('lists only owner rows and applies sanitized server search', async () => { const owned = entry() const listQuery = queryBuilder({ data: [owned], error: null, count: 1 }) mockFrom.mockReturnValueOnce(listQuery) await expect(listDictionaryPage({ userId: USER_A, search: "D3RO),user_id.eq.other", category: 'technical', })).resolves.toEqual({ entries: [owned], nextCursor: null, total: 1, }) expect(listQuery.eq).toHaveBeenCalledWith('user_id', USER_A) expect(listQuery.eq).toHaveBeenCalledWith('category', 'technical') expect(listQuery.or).toHaveBeenCalledWith( 'word.ilike.%D3RO user id eq other%,pronunciation.ilike.%D3RO user id eq other%', ) }) it('uses owner, id, and previous updated_at as the optimistic revision token', async () => { const duplicateQuery = queryBuilder({ data: null, error: null }) const current = entry() const updated = entry({ word: 'D3RO Voice', updated_at: '2026-08-22T00:00:00.000Z', }) const updateQuery = queryBuilder({ data: updated, error: null }) mockFrom .mockReturnValueOnce(duplicateQuery) .mockReturnValueOnce(updateQuery) await expect(updateDictionaryEntry(USER_A, current, { word: 'D3RO Voice', pronunciation: null, category: 'technical', })).resolves.toEqual(updated) expect(updateQuery.eq).toHaveBeenCalledWith('user_id', USER_A) expect(updateQuery.eq).toHaveBeenCalledWith('id', current.id) expect(updateQuery.eq).toHaveBeenCalledWith('updated_at', current.updated_at) }) it('deletes only when owner and optimistic revision both match', async () => { const current = entry() const deleteQuery = queryBuilder({ data: { id: current.id }, error: null }) mockFrom.mockReturnValueOnce(deleteQuery) await expect(deleteDictionaryEntry(USER_A, current)).resolves.toBeUndefined() expect(deleteQuery.delete).toHaveBeenCalledTimes(1) expect(deleteQuery.eq).toHaveBeenCalledWith('user_id', USER_A) expect(deleteQuery.eq).toHaveBeenCalledWith('id', current.id) expect(deleteQuery.eq).toHaveBeenCalledWith('updated_at', current.updated_at) }) it('builds bounded STT prompt hints from owner-filtered high-usage words', async () => { const hintsQuery = queryBuilder({ data: [{ word: 'D3RO' }, { word: ' Supabase ' }], error: null, }) mockFrom.mockReturnValueOnce(hintsQuery) await expect(getDictionaryPromptHints(USER_A, 10)) .resolves.toBe('D3RO, Supabase') expect(hintsQuery.eq).toHaveBeenCalledWith('user_id', USER_A) expect(hintsQuery.order).toHaveBeenCalledWith('usage_count', { ascending: false }) expect(hintsQuery.limit).toHaveBeenCalledWith(10) }) it('fails closed before network I/O for cross-owner mutations', async () => { await expect(updateDictionaryEntry(USER_A, entry({ user_id: USER_B }), { word: 'changed', })).rejects.toMatchObject({ code: 'auth' }) expect(mockFrom).not.toHaveBeenCalled() }) it('subscribes only to owner-filtered insert and update events', () => { subscribeToDictionary(USER_A, jest.fn(), jest.fn()) const filters = mockRealtimeOn.mock.calls.map((call) => call[1] as { event: string filter: string }) expect(filters).toEqual([ expect.objectContaining({ event: 'INSERT', filter: `user_id=eq.${USER_A}` }), expect.objectContaining({ event: 'UPDATE', filter: `user_id=eq.${USER_A}` }), ]) }) })