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
269
apps/mobile-rn/__tests__/templates-memos-service.test.ts
Normal file
269
apps/mobile-rn/__tests__/templates-memos-service.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue