test: add document repo testing
This commit is contained in:
@@ -1,28 +1,598 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { DatabaseService, tableNames } from '../database/database.ts';
|
||||
|
||||
import { DocumentsService } from './documents.ts';
|
||||
import type { DocumentUpsert, DocumentSearchOptions, MetaCondition } from './documents.schemas.ts';
|
||||
|
||||
import { Services } from '#root/utils/services.ts';
|
||||
|
||||
describe('DocumentsService', () => {
|
||||
let services: Services;
|
||||
let documentsService: DocumentsService;
|
||||
let databaseService: DatabaseService;
|
||||
|
||||
beforeEach(() => {
|
||||
services = new Services();
|
||||
documentsService = services.get(DocumentsService);
|
||||
databaseService = services.get(DatabaseService);
|
||||
});
|
||||
|
||||
it('should upsert a document', async () => {
|
||||
const document = {
|
||||
uri: 'test',
|
||||
type: 'test',
|
||||
afterEach(async () => {
|
||||
const db = await databaseService.getDb();
|
||||
await db(tableNames.documents).del();
|
||||
});
|
||||
|
||||
describe('upsert', () => {
|
||||
it('should insert a new document', async () => {
|
||||
const document: DocumentUpsert = {
|
||||
uri: 'test-doc-1',
|
||||
type: 'article',
|
||||
metadata: {
|
||||
test: 'test',
|
||||
title: 'Test Article',
|
||||
priority: 5,
|
||||
published: true,
|
||||
},
|
||||
data: {
|
||||
test: 'test',
|
||||
content: 'This is test content',
|
||||
},
|
||||
};
|
||||
|
||||
await documentsService.upsert(document);
|
||||
|
||||
const db = await databaseService.getDb();
|
||||
const result = await db(tableNames.documents).where({ uri: 'test-doc-1', type: 'article' }).first();
|
||||
|
||||
// Handle PGLite returning raw result objects instead of just the row
|
||||
const actualResult = result?.rows?.[0] || result;
|
||||
|
||||
expect(actualResult).toBeDefined();
|
||||
expect(actualResult?.uri).toBe('test-doc-1');
|
||||
expect(actualResult?.type).toBe('article');
|
||||
expect(actualResult?.metadata).toEqual(document.metadata);
|
||||
expect(actualResult?.data).toEqual(document.data);
|
||||
expect(actualResult?.createdAt).toBeDefined();
|
||||
expect(actualResult?.updatedAt).toBeDefined();
|
||||
expect(actualResult?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should update an existing document', async () => {
|
||||
const document: DocumentUpsert = {
|
||||
uri: 'test-doc-1',
|
||||
type: 'article',
|
||||
metadata: {
|
||||
title: 'Original Title',
|
||||
priority: 3,
|
||||
},
|
||||
data: {
|
||||
content: 'Original content',
|
||||
},
|
||||
};
|
||||
|
||||
await documentsService.upsert(document);
|
||||
|
||||
const updatedDocument: DocumentUpsert = {
|
||||
uri: 'test-doc-1',
|
||||
type: 'article',
|
||||
metadata: {
|
||||
title: 'Updated Title',
|
||||
priority: 8,
|
||||
published: true,
|
||||
},
|
||||
data: {
|
||||
content: 'Updated content',
|
||||
tags: ['test', 'updated'],
|
||||
},
|
||||
};
|
||||
|
||||
await documentsService.upsert(updatedDocument);
|
||||
|
||||
const db = await databaseService.getDb();
|
||||
const result = await db(tableNames.documents).where({ uri: 'test-doc-1', type: 'article' }).first();
|
||||
const actualResult = result?.rows?.[0] || result;
|
||||
|
||||
expect(actualResult?.metadata).toEqual(updatedDocument.metadata);
|
||||
expect(actualResult?.data).toEqual(updatedDocument.data);
|
||||
expect(actualResult?.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow same URI with different types', async () => {
|
||||
const doc1: DocumentUpsert = {
|
||||
uri: 'shared-uri',
|
||||
type: 'article',
|
||||
metadata: { category: 'news' },
|
||||
data: { content: 'Article content' },
|
||||
};
|
||||
|
||||
const doc2: DocumentUpsert = {
|
||||
uri: 'shared-uri',
|
||||
type: 'video',
|
||||
metadata: { duration: 300 },
|
||||
data: { url: 'video-url' },
|
||||
};
|
||||
|
||||
await documentsService.upsert(doc1);
|
||||
await documentsService.upsert(doc2);
|
||||
|
||||
const db = await databaseService.getDb();
|
||||
const results = await db(tableNames.documents).where({ uri: 'shared-uri' });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.some((r) => r.type === 'article')).toBe(true);
|
||||
expect(results.some((r) => r.type === 'video')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
beforeEach(async () => {
|
||||
// Setup test data
|
||||
const testDocuments: DocumentUpsert[] = [
|
||||
{
|
||||
uri: 'doc-1',
|
||||
type: 'article',
|
||||
metadata: {
|
||||
title: 'First Article',
|
||||
priority: 5,
|
||||
published: true,
|
||||
category: 'tech',
|
||||
},
|
||||
data: { content: 'First article content' },
|
||||
},
|
||||
{
|
||||
uri: 'doc-2',
|
||||
type: 'article',
|
||||
metadata: {
|
||||
title: 'Second Article',
|
||||
priority: 3,
|
||||
published: false,
|
||||
category: 'science',
|
||||
},
|
||||
data: { content: 'Second article content' },
|
||||
},
|
||||
{
|
||||
uri: 'doc-3',
|
||||
type: 'video',
|
||||
metadata: {
|
||||
title: 'Test Video',
|
||||
duration: 120,
|
||||
published: true,
|
||||
quality: 'HD',
|
||||
},
|
||||
data: { url: 'video-url' },
|
||||
},
|
||||
{
|
||||
uri: 'doc-4',
|
||||
type: 'article',
|
||||
metadata: {
|
||||
title: 'Third Article',
|
||||
priority: 8,
|
||||
published: true,
|
||||
category: 'tech',
|
||||
},
|
||||
data: { content: 'Third article content' },
|
||||
},
|
||||
];
|
||||
|
||||
for (const doc of testDocuments) {
|
||||
await documentsService.upsert(doc);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return all documents when no filters applied', async () => {
|
||||
const options: DocumentSearchOptions = {};
|
||||
const results = await documentsService.search(options);
|
||||
|
||||
expect(results).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should filter by uris', async () => {
|
||||
const options: DocumentSearchOptions = {
|
||||
uris: ['doc-1', 'doc-3'],
|
||||
};
|
||||
const results = await documentsService.search(options);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['doc-1', 'doc-3']);
|
||||
});
|
||||
|
||||
it('should filter by types', async () => {
|
||||
const options: DocumentSearchOptions = {
|
||||
types: ['article'],
|
||||
};
|
||||
const results = await documentsService.search(options);
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results.every((r) => r.type === 'article')).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply limit', async () => {
|
||||
const options: DocumentSearchOptions = {
|
||||
limit: 2,
|
||||
};
|
||||
const results = await documentsService.search(options);
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should apply offset', async () => {
|
||||
const options: DocumentSearchOptions = {
|
||||
limit: 2,
|
||||
offset: 1,
|
||||
};
|
||||
const results = await documentsService.search(options);
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should combine multiple filters', async () => {
|
||||
const options: DocumentSearchOptions = {
|
||||
types: ['article'],
|
||||
uris: ['doc-1', 'doc-2', 'doc-4'],
|
||||
limit: 2,
|
||||
};
|
||||
const results = await documentsService.search(options);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.every((r) => r.type === 'article')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('meta search', () => {
|
||||
beforeEach(async () => {
|
||||
// Setup test data with various metadata types
|
||||
const testDocuments: DocumentUpsert[] = [
|
||||
{
|
||||
uri: 'meta-1',
|
||||
type: 'article',
|
||||
metadata: {
|
||||
title: 'High Priority',
|
||||
priority: 8,
|
||||
published: true,
|
||||
category: 'technology',
|
||||
score: 95.5,
|
||||
},
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
uri: 'meta-2',
|
||||
type: 'article',
|
||||
metadata: {
|
||||
title: 'Medium Priority',
|
||||
priority: 5,
|
||||
published: false,
|
||||
category: 'science',
|
||||
score: 78.2,
|
||||
},
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
uri: 'meta-3',
|
||||
type: 'video',
|
||||
metadata: {
|
||||
title: 'Tutorial Video',
|
||||
duration: 300,
|
||||
published: true,
|
||||
category: 'education',
|
||||
hd: true,
|
||||
},
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
uri: 'meta-4',
|
||||
type: 'article',
|
||||
metadata: {
|
||||
title: 'Draft Article',
|
||||
priority: 2,
|
||||
published: false,
|
||||
category: 'technology',
|
||||
score: 45.8,
|
||||
},
|
||||
data: {},
|
||||
},
|
||||
];
|
||||
|
||||
for (const doc of testDocuments) {
|
||||
await documentsService.upsert(doc);
|
||||
}
|
||||
});
|
||||
|
||||
describe('number filters', () => {
|
||||
it('should filter by exact number equality', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { eq: 5 },
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].uri).toBe('meta-2');
|
||||
});
|
||||
|
||||
it('should filter by number range', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { gte: 5, lt: 8 },
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].uri).toBe('meta-2');
|
||||
});
|
||||
|
||||
it('should filter by greater than', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { gt: 5 },
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].uri).toBe('meta-1');
|
||||
});
|
||||
|
||||
it('should filter by not equal', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { neq: 5 },
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-1', 'meta-4']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('text filters', () => {
|
||||
it('should filter by exact text equality', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'text',
|
||||
field: 'category',
|
||||
filter: { eq: 'technology' },
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-1', 'meta-4']);
|
||||
});
|
||||
|
||||
it('should filter by text like pattern', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'text',
|
||||
field: 'title',
|
||||
filter: { like: '%Priority%' },
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-1', 'meta-2']);
|
||||
});
|
||||
|
||||
it('should filter by text not like pattern', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'text',
|
||||
field: 'title',
|
||||
filter: { nlike: '%Priority%' },
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-3', 'meta-4']);
|
||||
});
|
||||
|
||||
it('should filter by text not equal', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'text',
|
||||
field: 'category',
|
||||
filter: { neq: 'technology' },
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-2', 'meta-3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean filters', () => {
|
||||
it('should filter by boolean true', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'bool',
|
||||
field: 'published',
|
||||
filter: { eq: true },
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-1', 'meta-3']);
|
||||
});
|
||||
|
||||
it('should filter by boolean false', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'bool',
|
||||
field: 'published',
|
||||
filter: { eq: false },
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-2', 'meta-4']);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('null filters', () => {
|
||||
it('should filter by null values for numbers', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'number',
|
||||
field: 'nonexistent',
|
||||
filter: { nill: true },
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(4); // All documents should match as none have this field
|
||||
});
|
||||
|
||||
it('should filter by non-null values', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { nill: false },
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(3); // All articles have priority, video doesn't
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex conditions', () => {
|
||||
it('should handle AND conditions', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'bool',
|
||||
field: 'published',
|
||||
filter: { eq: true },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
field: 'category',
|
||||
filter: { eq: 'technology' },
|
||||
},
|
||||
],
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].uri).toBe('meta-1');
|
||||
});
|
||||
|
||||
it('should handle OR conditions', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { gt: 7 },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
field: 'category',
|
||||
filter: { eq: 'education' },
|
||||
},
|
||||
],
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-1', 'meta-3']);
|
||||
});
|
||||
|
||||
it('should handle nested AND/OR conditions', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'text',
|
||||
field: 'category',
|
||||
filter: { eq: 'technology' },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
field: 'category',
|
||||
filter: { eq: 'science' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'bool',
|
||||
field: 'published',
|
||||
filter: { eq: false },
|
||||
},
|
||||
],
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-2', 'meta-4']);
|
||||
});
|
||||
|
||||
it('should handle deeply nested conditions', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { gte: 8 },
|
||||
},
|
||||
{
|
||||
type: 'bool',
|
||||
field: 'published',
|
||||
filter: { eq: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'text',
|
||||
field: 'category',
|
||||
filter: { eq: 'education' },
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
field: 'duration',
|
||||
filter: { gt: 100 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-1', 'meta-3']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty search options', async () => {
|
||||
const results = await documentsService.search({});
|
||||
expect(results).toBeDefined();
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle search with no results', async () => {
|
||||
const options: DocumentSearchOptions = {
|
||||
uris: ['non-existent-uri'],
|
||||
};
|
||||
const results = await documentsService.search(options);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle meta search with no matching conditions', async () => {
|
||||
await documentsService.upsert({
|
||||
uri: 'test-doc',
|
||||
type: 'article',
|
||||
metadata: { priority: 5 },
|
||||
data: {},
|
||||
});
|
||||
|
||||
const meta: MetaCondition = {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { eq: 999 },
|
||||
};
|
||||
const results = await documentsService.search({ meta });
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ class DocumentsService {
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
};
|
||||
await db('documents')
|
||||
await db(tableNames.documents)
|
||||
.insert({ ...baseItem, createdAt: new Date() })
|
||||
.onConflict(['uri', 'type'])
|
||||
.merge({
|
||||
@@ -101,9 +101,15 @@ class DocumentsService {
|
||||
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
builder.whereNull(`metadata->'${filter.field}'`);
|
||||
// Field doesn't exist or is null
|
||||
builder.where((subBuilder) => {
|
||||
subBuilder
|
||||
.whereRaw(`NOT (metadata ? '${filter.field}')`)
|
||||
.orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
|
||||
});
|
||||
} else {
|
||||
builder.whereNotNull(`metadata->'${filter.field}'`);
|
||||
// Field exists and is not null
|
||||
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -131,9 +137,15 @@ class DocumentsService {
|
||||
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
builder.whereNull(`metadata->'${filter.field}'`);
|
||||
// Field doesn't exist or is null
|
||||
builder.where((subBuilder) => {
|
||||
subBuilder
|
||||
.whereRaw(`NOT (metadata ? '${filter.field}')`)
|
||||
.orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
|
||||
});
|
||||
} else {
|
||||
builder.whereNotNull(`metadata->'${filter.field}'`);
|
||||
// Field exists and is not null
|
||||
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -155,9 +167,15 @@ class DocumentsService {
|
||||
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
builder.whereNull(`metadata->'${filter.field}'`);
|
||||
// Field doesn't exist or is null
|
||||
builder.where((subBuilder) => {
|
||||
subBuilder
|
||||
.whereRaw(`NOT (metadata ? '${filter.field}')`)
|
||||
.orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
|
||||
});
|
||||
} else {
|
||||
builder.whereNotNull(`metadata->'${filter.field}'`);
|
||||
// Field exists and is not null
|
||||
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user