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,269 @@
const mockRpc = jest.fn()
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: {
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> = {}): 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> = {}): 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')
})
})