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
230
apps/mobile-rn/__tests__/dictionary-service.test.ts
Normal file
230
apps/mobile-rn/__tests__/dictionary-service.test.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
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}` }),
|
||||
])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue