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
215 lines
7.6 KiB
TypeScript
215 lines
7.6 KiB
TypeScript
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)
|
|
})
|
|
})
|