feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View 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}` }),
])
})
})