d3ro-voice/apps/mobile-rn/__tests__/knowledge-service.test.ts
2026-08-29 18:33:45 +09:00

243 lines
9 KiB
TypeScript

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: {
from: (...args: unknown[]) => mockFrom(...args),
channel: jest.fn(() => mockRealtimeChannel),
removeChannel: jest.fn(async () => 'ok'),
},
}))
import type { KnowledgeDocument } from '@d3ro/api-client'
import {
chunkKnowledgeText,
createKnowledgeDocument,
deleteKnowledgeDocument,
indexKnowledgeDocument,
KnowledgeServiceError,
listKnowledgeDocuments,
mergeKnowledgeDocuments,
sanitizeKnowledgeSearch,
searchKnowledge,
subscribeToKnowledgeDocuments,
} from '../src/features/knowledge/knowledge-service'
import {
classifyKnowledgeFile,
validatePickedKnowledgeContent,
} from '../src/features/knowledge/knowledge-file-picker'
const USER_A = '11111111-1111-4111-8111-111111111111'
const USER_B = '22222222-2222-4222-8222-222222222222'
function document(overrides: Partial<KnowledgeDocument> = {}): KnowledgeDocument {
return {
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
user_id: USER_A,
team_id: null,
title: 'Architecture',
file_name: 'architecture.md',
file_type: 'md',
source_url: null,
storage_key: null,
chunk_count: 1,
indexed: false,
indexed_at: null,
created_at: '2026-08-21T00:00:00.000Z',
updated_at: '2026-08-21T00:00:00.000Z',
...overrides,
}
}
function queryBuilder(result: Record<string, unknown>): Record<string, jest.Mock> {
const builder: Record<string, jest.Mock> = {}
for (const method of [
'select',
'eq',
'ilike',
'range',
'order',
'insert',
'update',
'delete',
'not',
'is',
]) {
builder[method] = jest.fn(() => builder)
}
builder.single = jest.fn(async () => result)
builder.maybeSingle = jest.fn(async () => result)
builder.then = jest.fn((resolve, reject) => Promise.resolve(result).then(resolve, reject))
return builder
}
beforeEach(() => {
mockFrom.mockReset()
mockRealtimeOn.mockClear()
mockRealtimeSubscribe.mockClear()
mockRealtimeOn.mockReturnValue(mockRealtimeChannel)
mockRealtimeSubscribe.mockReturnValue(mockRealtimeChannel)
jest.restoreAllMocks()
})
describe('knowledge input and file validation', () => {
it('chunks at bounded newline boundaries without a source-text fallback', () => {
const first = 'a'.repeat(650)
const second = 'b'.repeat(500)
const chunks = chunkKnowledgeText(`${first}\n${second}`)
expect(chunks).toEqual([first, second])
expect(chunks.every((chunk) => chunk.length <= 800)).toBe(true)
expect(() => chunkKnowledgeText('binary\u0000payload')).toThrow(KnowledgeServiceError)
})
it('sanitizes PostgREST controls and only accepts UTF-8 TXT or Markdown metadata', () => {
expect(sanitizeKnowledgeSearch("roadmap),user_id.eq.other,%_'2026"))
.toBe("roadmap user id.eq.other '2026")
expect(classifyKnowledgeFile('Roadmap.MD', 'text/markdown')).toEqual({
fileName: 'Roadmap.MD',
fileType: 'md',
title: 'Roadmap',
})
expect(classifyKnowledgeFile('notes.txt', null).fileType).toBe('txt')
expect(() => classifyKnowledgeFile('report.pdf', 'application/pdf'))
.toThrow(KnowledgeServiceError)
expect(validatePickedKnowledgeContent('\ufeff valid text ')).toBe('valid text')
expect(() => validatePickedKnowledgeContent('\ufffd'.repeat(20)))
.toThrow(KnowledgeServiceError)
})
})
describe('knowledge document persistence', () => {
it('lists RLS-visible personal and team documents with sanitized title search', async () => {
const owned = document()
const shared = document({
id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
user_id: USER_B,
team_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
title: 'Shared',
})
const listQuery = queryBuilder({ data: [owned, shared], error: null, count: 2 })
mockFrom.mockReturnValueOnce(listQuery)
await expect(listKnowledgeDocuments({
userId: USER_A,
search: 'roadmap),user_id.eq.other',
})).resolves.toEqual(expect.objectContaining({
documents: [owned, shared],
total: 2,
hasMore: false,
}))
expect(listQuery.ilike).toHaveBeenCalledWith('title', '%roadmap user id.eq.other%')
expect(listQuery.eq).not.toHaveBeenCalledWith('user_id', USER_A)
})
it('removes an incomplete document when any chunk insert is not confirmed', async () => {
const created = document({ chunk_count: 2 })
const documentQuery = queryBuilder({ data: created, error: null })
const chunkQuery = queryBuilder({ data: [{ id: 'one' }], error: null })
const cleanupQuery = queryBuilder({ data: null, error: null })
mockFrom
.mockReturnValueOnce(documentQuery)
.mockReturnValueOnce(chunkQuery)
.mockReturnValueOnce(cleanupQuery)
await expect(createKnowledgeDocument({
userId: USER_A,
title: 'Architecture',
fileName: 'architecture.md',
fileType: 'md',
content: `${'a'.repeat(800)}\n${'b'.repeat(100)}`,
})).rejects.toMatchObject({ code: 'invalid-response' })
expect(cleanupQuery.delete).toHaveBeenCalledTimes(1)
expect(cleanupQuery.eq).toHaveBeenCalledWith('id', created.id)
expect(cleanupQuery.eq).toHaveBeenCalledWith('user_id', USER_A)
})
it('fails closed and resets pending state when indexed metadata lacks all embeddings', async () => {
const pending = document()
const ready = document({ indexed: true, indexed_at: '2026-08-21T00:01:00.000Z' })
const documentQuery = queryBuilder({ data: ready, error: null })
const countQuery = queryBuilder({ data: null, error: null, count: 0 })
const resetQuery = queryBuilder({ data: null, error: null })
mockFrom
.mockReturnValueOnce(documentQuery)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(resetQuery)
jest.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify({
embedded: 1,
total: 1,
indexed: true,
}), { status: 200 }))
await expect(indexKnowledgeDocument({
userId: USER_A,
accessToken: 'access-token',
document: pending,
})).rejects.toMatchObject({ code: 'invalid-response' })
expect(resetQuery.update).toHaveBeenCalledWith({ indexed: false, indexed_at: null })
})
it('deletes only an owner row guarded by its revision token', async () => {
const current = document()
const deleteQuery = queryBuilder({ data: { id: current.id }, error: null })
mockFrom.mockReturnValueOnce(deleteQuery)
await expect(deleteKnowledgeDocument(USER_A, current)).resolves.toBeUndefined()
expect(deleteQuery.eq).toHaveBeenCalledWith('id', current.id)
expect(deleteQuery.eq).toHaveBeenCalledWith('user_id', USER_A)
expect(deleteQuery.eq).toHaveBeenCalledWith('updated_at', current.updated_at)
await expect(deleteKnowledgeDocument(USER_A, document({ user_id: USER_B })))
.rejects.toMatchObject({ code: 'forbidden' })
})
it('keeps the newest document revision when realtime and pagination overlap', () => {
const newest = document({ updated_at: '2026-08-22T00:00:00.000Z', indexed: true })
const stale = document({ updated_at: '2026-08-20T00:00:00.000Z', indexed: false })
expect(mergeKnowledgeDocuments([newest], [stale])).toEqual([newest])
})
})
describe('knowledge edge contracts', () => {
it('maps an unconfigured embedding provider without fabricating search results', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify({
error: 'embedding_provider_unavailable',
}), { status: 503 }))
await expect(searchKnowledge({
accessToken: 'access-token',
query: 'architecture',
})).rejects.toMatchObject({ code: 'index-unavailable', status: 503 })
})
it('maps an upstream embedding failure separately from missing configuration', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify({
error: 'embedding_upstream_failed',
}), { status: 502 }))
await expect(searchKnowledge({
accessToken: 'access-token',
query: 'architecture',
})).rejects.toMatchObject({ code: 'index-failed', status: 502 })
})
it('rejects malformed semantic results instead of returning raw text', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify({
results: [{ id: 'not-a-uuid', content: 'raw fallback', similarity: 0.9 }],
}), { status: 200 }))
await expect(searchKnowledge({
accessToken: 'access-token',
query: 'architecture',
})).rejects.toMatchObject({ code: 'invalid-response' })
})
it('subscribes to document inserts, updates, and deletes through RLS realtime', () => {
subscribeToKnowledgeDocuments(jest.fn(), jest.fn())
const events = mockRealtimeOn.mock.calls.map((call) => (call[1] as { event: string }).event)
expect(events).toEqual(['INSERT', 'UPDATE', 'DELETE'])
})
})