feat: add mobile shell and unified search
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 50s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 20s
ci / .NET API 서버 테스트 (push) Successful in 12s
deploy-site / deploy (push) Successful in 39s
ci / 모바일 린트·타입·Jest (push) Failing after 3h10m31s

This commit is contained in:
Yun Chan 2026-09-28 23:23:36 +09:00
parent f505a03d34
commit 5ca3709b32
98 changed files with 5168 additions and 2093 deletions

View file

@ -0,0 +1,215 @@
jest.mock('../src/lib/supabase', () => ({ supabase: {} }))
import type { D3roSupabaseClient, HistoryEntry, Meeting } from '@d3ro/api-client'
import { normalizeUnifiedSearchInitialQuery } from '../src/features/search/unified-search-route'
import {
createMobileSearchExcerpt,
createPostgrestLikePattern,
createSupabaseUnifiedSearchSource,
sanitizeUnifiedSearchQuery,
searchUnifiedContent,
type MobileUnifiedSearchOptions,
type UnifiedSearchSourcePort,
} from '../src/features/search/unified-search-service'
const USER_ID = '11111111-1111-4111-8111-111111111111'
const record: HistoryEntry = {
id: '22222222-2222-4222-8222-222222222222',
user_id: USER_ID,
title: '메모',
original_text: '초기 로드맵을 기록했습니다.',
polished_text: '다음 분기 로드맵을 기록했습니다.',
focused_app: null,
focused_app_name: null,
focused_app_window_title: null,
mode: 'dictation',
status: 'completed',
error_code: null,
audio_storage_key: null,
duration: 12,
detected_language: 'ko',
mic_device: null,
word_count: 5,
stt_model: null,
llm_model: null,
stt_latency_ms: null,
llm_latency_ms: null,
app_version: '1.9.0',
summary_text: null,
is_favorite: false,
revision: 1,
created_at: '2026-09-27T00:00:00.000Z',
updated_at: '2026-09-27T00:00:00.000Z',
}
const meeting: Meeting = {
id: '33333333-3333-4333-8333-333333333333',
user_id: USER_ID,
team_id: null,
title: '주간 회의',
status: 'completed',
started_at: '2026-09-28T00:00:00.000Z',
ended_at: '2026-09-28T00:30:00.000Z',
duration_ms: 1_800_000,
raw_transcript: '로드맵을 다음 주에 검토합니다.',
edited_transcript: null,
minutes_markdown: null,
minutes_json: null,
stt_model: null,
llm_model: null,
stt_latency_ms: null,
llm_latency_ms: null,
error_message: null,
audio_storage_key: null,
created_at: '2026-09-28T00:00:00.000Z',
updated_at: '2026-09-28T00:30:00.000Z',
}
describe('mobile unified search service', () => {
test('sanitizes PostgREST control characters and bounds excerpts', () => {
expect(sanitizeUnifiedSearchQuery(' Q3.1, foo_bar 50% ("초안") O\'Reilly ')).toBe(
"Q3.1 foo_bar 50% 초안 O'Reilly",
)
expect(createPostgrestLikePattern('Q3.1')).toBe('*Q3.1*')
expect(createPostgrestLikePattern('foo_bar')).toBe('*foo\\_bar*')
expect(createPostgrestLikePattern('50%')).toBe('*50\\%*')
expect(createPostgrestLikePattern('literal*star')).toBe('*literal\\*star*')
const excerpt = createMobileSearchExcerpt(
`앞 ${'가'.repeat(80)} 로드맵 ${'나'.repeat(80)}`,
'로드맵',
60,
)
expect(excerpt).toContain('로드맵')
expect(excerpt.startsWith('…')).toBe(true)
expect(excerpt.endsWith('…')).toBe(true)
})
test('searches both sources, reports full totals, and opens newest source first', async () => {
const source: UnifiedSearchSourcePort = {
searchRecords: jest.fn(async () => ({ rows: [record], total: 4 })),
searchMeetings: jest.fn(async () => ({ rows: [meeting], total: 3 })),
}
const response = await searchUnifiedContent({
userId: USER_ID,
query: ' 로드맵 ',
limit: 10,
}, source)
expect(source.searchRecords).toHaveBeenCalledWith(USER_ID, '로드맵', 10)
expect(source.searchMeetings).toHaveBeenCalledWith('로드맵', 10)
expect(response.total).toBe(7)
expect(response.results.map(({ source: kind, id }) => ({ kind, id }))).toEqual([
{ kind: 'meeting', id: meeting.id },
{ kind: 'record', id: record.id },
])
expect(response.results[0]).toMatchObject({ matchedField: 'transcript' })
expect(response.results[1]).toMatchObject({ matchedField: 'content' })
})
test('does not query either table for blank input', async () => {
const source: UnifiedSearchSourcePort = {
searchRecords: jest.fn(),
searchMeetings: jest.fn(),
}
await expect(searchUnifiedContent({ userId: USER_ID, query: ' ' }, source)).resolves.toEqual({
query: '',
results: [],
total: 0,
})
expect(source.searchRecords).not.toHaveBeenCalled()
expect(source.searchMeetings).not.toHaveBeenCalled()
})
test('rejects a missing authentication boundary before querying', async () => {
const source: UnifiedSearchSourcePort = {
searchRecords: jest.fn(),
searchMeetings: jest.fn(),
}
const invalidUserIds = ['not-a-user', 42, null]
for (const userId of invalidUserIds) {
const options = { userId, query: '로드맵' } as unknown as MobileUnifiedSearchOptions
await expect(searchUnifiedContent(options, source)).rejects.toMatchObject({
code: 'auth',
})
}
expect(source.searchRecords).not.toHaveBeenCalled()
expect(source.searchMeetings).not.toHaveBeenCalled()
})
test('rejects malformed options, queries, and non-finite limits before querying', async () => {
const source: UnifiedSearchSourcePort = {
searchRecords: jest.fn(),
searchMeetings: jest.fn(),
}
const malformedOptions: MobileUnifiedSearchOptions[] = [
null as unknown as MobileUnifiedSearchOptions,
{ userId: USER_ID, query: 42 } as unknown as MobileUnifiedSearchOptions,
{ userId: USER_ID, query: '로드맵', limit: Number.NaN },
{ userId: USER_ID, query: '로드맵', limit: Number.POSITIVE_INFINITY },
]
for (const options of malformedOptions) {
await expect(searchUnifiedContent(options, source)).rejects.toMatchObject({ code: 'invalid' })
}
expect(source.searchRecords).not.toHaveBeenCalled()
expect(source.searchMeetings).not.toHaveBeenCalled()
})
test('escapes a raw PostgREST wildcard instead of creating a broad match', () => {
expect(createPostgrestLikePattern('*')).toBe('*\\**')
expect(createPostgrestLikePattern('*')).not.toBe('***')
})
test('uses minimal Supabase projections and leaves team meeting visibility to RLS', async () => {
const recordBuilder = {
select: jest.fn(),
eq: jest.fn(),
or: jest.fn(),
order: jest.fn(),
limit: jest.fn(),
}
const meetingBuilder = {
select: jest.fn(),
eq: jest.fn(),
or: jest.fn(),
order: jest.fn(),
limit: jest.fn(),
}
for (const builder of [recordBuilder, meetingBuilder]) {
builder.select.mockReturnValue(builder)
builder.eq.mockReturnValue(builder)
builder.or.mockReturnValue(builder)
builder.order.mockReturnValue(builder)
builder.limit.mockResolvedValue({ data: [], error: null, count: 0 })
}
const from = jest.fn((table: string) => (
table === 'history' ? recordBuilder : meetingBuilder
))
const source = createSupabaseUnifiedSearchSource({ from } as unknown as D3roSupabaseClient)
await source.searchRecords(USER_ID, '로드맵', 10)
await source.searchMeetings('*', 10)
expect(recordBuilder.select).toHaveBeenCalledWith(
'id,title,original_text,polished_text,summary_text,created_at',
{ count: 'exact' },
)
expect(recordBuilder.eq).toHaveBeenCalledWith('user_id', USER_ID)
expect(meetingBuilder.select).toHaveBeenCalledWith(
'id,title,raw_transcript,edited_transcript,started_at',
{ count: 'exact' },
)
expect(meetingBuilder.eq).not.toHaveBeenCalled()
expect(meetingBuilder.or).toHaveBeenCalledWith(
'title.ilike.*\\**,raw_transcript.ilike.*\\**,edited_transcript.ilike.*\\**',
)
})
test('guards restored navigation initialQuery values at runtime', () => {
expect(normalizeUnifiedSearchInitialQuery({ unexpected: true })).toBe('')
expect(normalizeUnifiedSearchInitialQuery(123)).toBe('')
expect(normalizeUnifiedSearchInitialQuery('가'.repeat(120))).toHaveLength(100)
})
})