230 lines
7.5 KiB
TypeScript
230 lines
7.5 KiB
TypeScript
const mockFrom = jest.fn()
|
|
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: (...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> = {}): 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<string, unknown>): Record<string, jest.Mock> {
|
|
const builder: Record<string, jest.Mock> = {}
|
|
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}` }),
|
|
])
|
|
})
|
|
})
|