chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Knowledge Utils Unit Tests
|
||||
*
|
||||
* This file contains unit tests for the knowledge base utility functions,
|
||||
* including access checks, document processing, and embedding generation.
|
||||
*/
|
||||
import { createEnvMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: (...args: any[]) => args,
|
||||
eq: (...args: any[]) => args,
|
||||
isNull: () => true,
|
||||
sql: (strings: TemplateStringsArray, ...expr: any[]) => ({ strings, expr }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => createEnvMock({ OPENAI_API_KEY: 'test-key' }))
|
||||
|
||||
vi.mock('@/lib/knowledge/documents/utils', () => ({
|
||||
retryWithExponentialBackoff: (fn: any) => fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/utils', () => ({
|
||||
getWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('user1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/knowledge/documents/document-processor', () => ({
|
||||
processDocument: vi.fn().mockResolvedValue({
|
||||
chunks: [
|
||||
{
|
||||
text: 'alpha',
|
||||
tokenCount: 1,
|
||||
metadata: { startIndex: 0, endIndex: 4 },
|
||||
},
|
||||
{
|
||||
text: 'beta',
|
||||
tokenCount: 1,
|
||||
metadata: { startIndex: 5, endIndex: 8 },
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
filename: 'dummy',
|
||||
fileSize: 10,
|
||||
mimeType: 'text/plain',
|
||||
characterCount: 9,
|
||||
tokenCount: 3,
|
||||
chunkCount: 2,
|
||||
processingMethod: 'file-parser',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const dbOps: {
|
||||
order: string[]
|
||||
insertRecords: any[][]
|
||||
updatePayloads: any[]
|
||||
} = {
|
||||
order: [],
|
||||
insertRecords: [],
|
||||
updatePayloads: [],
|
||||
}
|
||||
|
||||
let kbRows: any[] = []
|
||||
let docRows: any[] = []
|
||||
let chunkRows: any[] = []
|
||||
|
||||
function resetDatasets() {
|
||||
kbRows = []
|
||||
docRows = []
|
||||
chunkRows = []
|
||||
}
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ embedding: [0.1, 0.2], index: 0 },
|
||||
{ embedding: [0.3, 0.4], index: 1 },
|
||||
],
|
||||
usage: { prompt_tokens: 2, total_tokens: 2 },
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@sim/db', async () => {
|
||||
const { schemaMock } = (await import('@sim/testing')) as typeof import('@sim/testing')
|
||||
const tableNameFor = (table: any) => {
|
||||
if (table === schemaMock.knowledgeBase) return 'knowledge_base'
|
||||
if (table === schemaMock.document) return 'document'
|
||||
if (table === schemaMock.embedding) return 'embedding'
|
||||
return ''
|
||||
}
|
||||
const selectBuilder = {
|
||||
from(table: any) {
|
||||
return {
|
||||
where() {
|
||||
return {
|
||||
limit(n: number) {
|
||||
const tableName = tableNameFor(table)
|
||||
|
||||
if (tableName === 'knowledge_base') {
|
||||
return Promise.resolve(kbRows.slice(0, n))
|
||||
}
|
||||
if (tableName === 'document') {
|
||||
return Promise.resolve(docRows.slice(0, n))
|
||||
}
|
||||
if (tableName === 'embedding') {
|
||||
return Promise.resolve(chunkRows.slice(0, n))
|
||||
}
|
||||
|
||||
return Promise.resolve([])
|
||||
},
|
||||
}
|
||||
},
|
||||
innerJoin() {
|
||||
// document × knowledge_base context JOIN — return the first kb and
|
||||
// doc row merged (covers processDocumentAsync's prefetch).
|
||||
return {
|
||||
leftJoin: () => ({
|
||||
where: () => ({
|
||||
limit: (n: number) =>
|
||||
Promise.resolve(
|
||||
kbRows.length > 0 && docRows.length > 0
|
||||
? [
|
||||
{ ...kbRows[0], ...docRows[0], billedAccountUserId: 'billing-user-1' },
|
||||
].slice(0, n)
|
||||
: []
|
||||
),
|
||||
}),
|
||||
}),
|
||||
where: () => ({
|
||||
limit: (n: number) =>
|
||||
Promise.resolve(
|
||||
kbRows.length > 0 && docRows.length > 0
|
||||
? [{ ...kbRows[0], ...docRows[0] }].slice(0, n)
|
||||
: []
|
||||
),
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(() => selectBuilder),
|
||||
update: (table: any) => ({
|
||||
set: (payload: any) => ({
|
||||
where: () => {
|
||||
const tableName = tableNameFor(table)
|
||||
if (tableName === 'knowledge_base') {
|
||||
dbOps.order.push('updateKb')
|
||||
dbOps.updatePayloads.push(payload)
|
||||
} else if (tableName === 'document') {
|
||||
if (payload.processingStatus !== 'processing') {
|
||||
dbOps.order.push('updateDoc')
|
||||
dbOps.updatePayloads.push(payload)
|
||||
}
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
}),
|
||||
delete: () => ({
|
||||
where: () => Promise.resolve(),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (records: any) => {
|
||||
dbOps.order.push('insert')
|
||||
dbOps.insertRecords.push(records)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
transaction: vi.fn(async (fn: any) => {
|
||||
await fn({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
innerJoin: () => ({
|
||||
where: () => ({
|
||||
limit: () => Promise.resolve([{ id: 'doc1' }]),
|
||||
}),
|
||||
}),
|
||||
where: () => ({
|
||||
limit: () => Promise.resolve([{}]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
delete: () => ({
|
||||
where: () => Promise.resolve(),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (records: any) => {
|
||||
dbOps.order.push('insert')
|
||||
dbOps.insertRecords.push(records)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (payload: any) => ({
|
||||
where: () => {
|
||||
dbOps.updatePayloads.push(payload)
|
||||
const label = payload.processingStatus !== undefined ? 'updateDoc' : 'updateKb'
|
||||
dbOps.order.push(label)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
}),
|
||||
},
|
||||
document: {},
|
||||
knowledgeBase: {},
|
||||
embedding: {},
|
||||
}
|
||||
})
|
||||
|
||||
import { processDocumentAsync } from '@/lib/knowledge/documents/service'
|
||||
import { generateEmbeddings } from '@/lib/knowledge/embeddings'
|
||||
import {
|
||||
checkChunkAccess,
|
||||
checkDocumentAccess,
|
||||
checkKnowledgeBaseAccess,
|
||||
} from '@/app/api/knowledge/utils'
|
||||
|
||||
describe('Knowledge Utils', () => {
|
||||
beforeEach(() => {
|
||||
dbOps.order.length = 0
|
||||
dbOps.insertRecords.length = 0
|
||||
dbOps.updatePayloads.length = 0
|
||||
resetDatasets()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('processDocumentAsync', () => {
|
||||
it.concurrent('should insert embeddings before updating document counters', async () => {
|
||||
kbRows.push({
|
||||
id: 'kb1',
|
||||
userId: 'user1',
|
||||
workspaceId: 'workspace1',
|
||||
embeddingModel: 'text-embedding-3-small',
|
||||
chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 200 },
|
||||
})
|
||||
docRows.push({ id: 'doc1', knowledgeBaseId: 'kb1' })
|
||||
|
||||
await processDocumentAsync(
|
||||
'kb1',
|
||||
'doc1',
|
||||
{
|
||||
filename: 'file.txt',
|
||||
fileUrl: 'https://example.com/file.txt',
|
||||
fileSize: 10,
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
// Embeddings are inserted first, then the document counter update. A
|
||||
// usage_log billing insert (recordUsage) may trail after updateDoc and is
|
||||
// irrelevant to this ordering invariant, so assert position rather than
|
||||
// exact array equality.
|
||||
expect(dbOps.order[0]).toBe('insert')
|
||||
expect(dbOps.order.indexOf('updateDoc')).toBeGreaterThan(0)
|
||||
|
||||
expect(dbOps.updatePayloads[0]).toMatchObject({
|
||||
processingStatus: 'completed',
|
||||
chunkCount: 2,
|
||||
})
|
||||
|
||||
expect(dbOps.insertRecords[0].length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkKnowledgeBaseAccess', () => {
|
||||
it.concurrent('should return success for owner', async () => {
|
||||
kbRows.push({ id: 'kb1', userId: 'user1' })
|
||||
const result = await checkKnowledgeBaseAccess('kb1', 'user1')
|
||||
|
||||
expect(result.hasAccess).toBe(true)
|
||||
})
|
||||
|
||||
it('should return notFound when knowledge base is missing', async () => {
|
||||
const result = await checkKnowledgeBaseAccess('missing', 'user1')
|
||||
|
||||
expect(result.hasAccess).toBe(false)
|
||||
expect('notFound' in result && result.notFound).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkDocumentAccess', () => {
|
||||
it.concurrent('should return unauthorized when user mismatch', async () => {
|
||||
kbRows.push({ id: 'kb1', userId: 'owner' })
|
||||
const result = await checkDocumentAccess('kb1', 'doc1', 'intruder')
|
||||
|
||||
expect(result.hasAccess).toBe(false)
|
||||
if ('reason' in result) {
|
||||
expect(result.reason).toBe('Unauthorized knowledge base access')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkChunkAccess', () => {
|
||||
it.concurrent('should fail when document is not completed', async () => {
|
||||
kbRows.push({ id: 'kb1', userId: 'user1' })
|
||||
docRows.push({ id: 'doc1', knowledgeBaseId: 'kb1', processingStatus: 'processing' })
|
||||
|
||||
const result = await checkChunkAccess('kb1', 'doc1', 'chunk1', 'user1')
|
||||
|
||||
expect(result.hasAccess).toBe(false)
|
||||
if ('reason' in result) {
|
||||
expect(result.reason).toContain('Document is not ready')
|
||||
}
|
||||
})
|
||||
|
||||
it('should return success for valid access', async () => {
|
||||
kbRows.push({ id: 'kb1', userId: 'user1' })
|
||||
docRows.push({ id: 'doc1', knowledgeBaseId: 'kb1', processingStatus: 'completed' })
|
||||
chunkRows.push({ id: 'chunk1', documentId: 'doc1' })
|
||||
|
||||
const result = await checkChunkAccess('kb1', 'doc1', 'chunk1', 'user1')
|
||||
|
||||
expect(result.hasAccess).toBe(true)
|
||||
if ('chunk' in result) {
|
||||
expect(result.chunk.id).toBe('chunk1')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateEmbeddings', () => {
|
||||
it.concurrent('should return same length as input', async () => {
|
||||
const result = await generateEmbeddings(['a', 'b'])
|
||||
|
||||
expect(result.embeddings.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should use Azure OpenAI when Azure config is provided', async () => {
|
||||
const { env } = await import('@/lib/core/config/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2], index: 0 }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateEmbeddings(['test text'])
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings?api-version=2024-12-01-preview',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'api-key': 'test-azure-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should fallback to OpenAI when no Azure config provided', async () => {
|
||||
const { env } = await import('@/lib/core/config/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2], index: 0 }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateEmbeddings(['test text'])
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://api.openai.com/v1/embeddings',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer test-openai-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should throw error when no API configuration provided', async () => {
|
||||
const { env } = await import('@/lib/core/config/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
|
||||
await expect(generateEmbeddings(['test text'])).rejects.toThrow(
|
||||
'OPENAI_API_KEY is not configured'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user