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
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:
parent
f505a03d34
commit
5ca3709b32
98 changed files with 5168 additions and 2093 deletions
215
apps/mobile-rn/__tests__/unified-search-service.test.ts
Normal file
215
apps/mobile-rn/__tests__/unified-search-service.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue