243 lines
9 KiB
TypeScript
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'])
|
|
})
|
|
})
|