feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
243
apps/mobile-rn/__tests__/knowledge-service.test.ts
Normal file
243
apps/mobile-rn/__tests__/knowledge-service.test.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
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'])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue