const mockFrom = jest.fn() const mockRealtimeOn = jest.fn() const mockRealtimeSubscribe = jest.fn() const mockRealtimeChannel: Record = { 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 { 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): Record { const builder: Record = {} 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']) }) })