test: add document repo testing

This commit is contained in:
Morten Olsen
2025-09-09 12:56:46 +02:00
parent 7eb5266fb6
commit ba7aa90434
2 changed files with 608 additions and 20 deletions

View File

@@ -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 { DocumentsService } from './documents.ts';
import type { DocumentUpsert, DocumentSearchOptions, MetaCondition } from './documents.schemas.ts';
import { Services } from '#root/utils/services.ts'; import { Services } from '#root/utils/services.ts';
describe('DocumentsService', () => { describe('DocumentsService', () => {
let services: Services; let services: Services;
let documentsService: DocumentsService; let documentsService: DocumentsService;
let databaseService: DatabaseService;
beforeEach(() => { beforeEach(() => {
services = new Services(); services = new Services();
documentsService = services.get(DocumentsService); documentsService = services.get(DocumentsService);
databaseService = services.get(DatabaseService);
}); });
it('should upsert a document', async () => { afterEach(async () => {
const document = { const db = await databaseService.getDb();
uri: 'test', await db(tableNames.documents).del();
type: 'test', });
metadata: {
test: 'test', describe('upsert', () => {
}, it('should insert a new document', async () => {
data: { const document: DocumentUpsert = {
test: 'test', uri: 'test-doc-1',
}, type: 'article',
}; metadata: {
await documentsService.upsert(document); title: 'Test Article',
priority: 5,
published: true,
},
data: {
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);
});
}); });
}); });

View File

@@ -20,7 +20,7 @@ class DocumentsService {
updatedAt: new Date(), updatedAt: new Date(),
deletedAt: null, deletedAt: null,
}; };
await db('documents') await db(tableNames.documents)
.insert({ ...baseItem, createdAt: new Date() }) .insert({ ...baseItem, createdAt: new Date() })
.onConflict(['uri', 'type']) .onConflict(['uri', 'type'])
.merge({ .merge({
@@ -101,9 +101,15 @@ class DocumentsService {
if (nill !== undefined) { if (nill !== undefined) {
if (nill) { 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 { } 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; return;
} }
@@ -131,9 +137,15 @@ class DocumentsService {
if (nill !== undefined) { if (nill !== undefined) {
if (nill) { 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 { } 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; return;
} }
@@ -155,9 +167,15 @@ class DocumentsService {
if (nill !== undefined) { if (nill !== undefined) {
if (nill) { 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 { } 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; return;
} }