603 lines
20 KiB
TypeScript
603 lines
20 KiB
TypeScript
/**
|
|
* Unit tests for RepositoryService.
|
|
*
|
|
* The service uses raw better-sqlite3 queries, so returned rows have
|
|
* snake_case column names matching the SQLite schema. Tests assert against
|
|
* those actual runtime keys.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { RepositoryService } from './repository.service';
|
|
import {
|
|
loadSqliteVec,
|
|
sqliteVecRowidTableName,
|
|
sqliteVecTableName
|
|
} from '$lib/server/db/sqlite-vec.js';
|
|
import { SqliteVecStore } from '$lib/server/search/sqlite-vec.store.js';
|
|
import {
|
|
AlreadyExistsError,
|
|
InvalidInputError,
|
|
InvalidUrlError,
|
|
NotFoundError
|
|
} from '$lib/server/utils/validation';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test DB factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createTestDb(): Database.Database {
|
|
const client = new Database(':memory:');
|
|
client.pragma('foreign_keys = ON');
|
|
loadSqliteVec(client);
|
|
|
|
const migrationsFolder = join(import.meta.dirname, '../db/migrations');
|
|
|
|
for (const migration of [
|
|
'0000_large_master_chief.sql',
|
|
'0001_quick_nighthawk.sql',
|
|
'0002_silky_stellaris.sql',
|
|
'0003_multiversion_config.sql',
|
|
'0004_complete_sentry.sql',
|
|
'0005_fix_stage_defaults.sql',
|
|
'0006_yielding_centennial.sql'
|
|
]) {
|
|
const statements = readFileSync(join(migrationsFolder, migration), 'utf-8')
|
|
.split('--> statement-breakpoint')
|
|
.map((statement) => statement.trim())
|
|
.filter(Boolean);
|
|
|
|
for (const statement of statements) {
|
|
client.exec(statement);
|
|
}
|
|
}
|
|
|
|
return client;
|
|
}
|
|
|
|
// Raw row shape returned by better-sqlite3 SELECT * FROM repositories.
|
|
interface RawRepo {
|
|
id: string;
|
|
title: string;
|
|
description: string | null;
|
|
source: string;
|
|
source_url: string;
|
|
branch: string | null;
|
|
state: string;
|
|
total_snippets: number;
|
|
total_tokens: number;
|
|
trust_score: number;
|
|
benchmark_score: number;
|
|
stars: number | null;
|
|
github_token: string | null;
|
|
last_indexed_at: number | null;
|
|
created_at: number;
|
|
updated_at: number;
|
|
}
|
|
|
|
function makeService(client: Database.Database): RepositoryService {
|
|
return new RepositoryService(client);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// list() and count()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('RepositoryService.list()', () => {
|
|
let service: RepositoryService;
|
|
|
|
beforeEach(() => {
|
|
service = makeService(createTestDb());
|
|
});
|
|
|
|
it('returns empty array when no repositories exist', () => {
|
|
const result = service.list();
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('returns all repositories after adding some', () => {
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/foo/a' });
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/foo/b' });
|
|
const result = service.list();
|
|
expect(result).toHaveLength(2);
|
|
});
|
|
|
|
it('filters by state', () => {
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/foo/a' });
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/foo/b' });
|
|
// All repos start as 'pending'.
|
|
const pending = service.list({ state: 'pending' });
|
|
expect(pending).toHaveLength(2);
|
|
const indexed = service.list({ state: 'indexed' });
|
|
expect(indexed).toHaveLength(0);
|
|
});
|
|
|
|
it('respects limit and offset', () => {
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/foo/a' });
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/foo/b' });
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/foo/c' });
|
|
const page1 = service.list({ limit: 2, offset: 0 });
|
|
const page2 = service.list({ limit: 2, offset: 2 });
|
|
expect(page1).toHaveLength(2);
|
|
expect(page2).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('RepositoryService.count()', () => {
|
|
let service: RepositoryService;
|
|
|
|
beforeEach(() => {
|
|
service = makeService(createTestDb());
|
|
});
|
|
|
|
it('returns 0 when empty', () => {
|
|
expect(service.count()).toBe(0);
|
|
});
|
|
|
|
it('returns correct count after adding repositories', () => {
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/foo/a' });
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/foo/b' });
|
|
expect(service.count()).toBe(2);
|
|
});
|
|
|
|
it('filters count by state', () => {
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/foo/a' });
|
|
expect(service.count('pending')).toBe(1);
|
|
expect(service.count('indexed')).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// get()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('RepositoryService.get()', () => {
|
|
let service: RepositoryService;
|
|
|
|
beforeEach(() => {
|
|
service = makeService(createTestDb());
|
|
});
|
|
|
|
it('returns null for a non-existent repository', () => {
|
|
expect(service.get('/not/found')).toBeNull();
|
|
});
|
|
|
|
it('returns the repository when it exists', () => {
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
|
|
const repo = service.get('/facebook/react') as unknown as RawRepo;
|
|
expect(repo).not.toBeNull();
|
|
expect(repo.id).toBe('/facebook/react');
|
|
expect(repo.source).toBe('github');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// add()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('RepositoryService.add()', () => {
|
|
let service: RepositoryService;
|
|
|
|
beforeEach(() => {
|
|
service = makeService(createTestDb());
|
|
});
|
|
|
|
it('adds a GitHub repository and derives the correct ID', () => {
|
|
const repo = service.add({
|
|
source: 'github',
|
|
sourceUrl: 'https://github.com/facebook/react'
|
|
}) as unknown as RawRepo;
|
|
expect(repo.id).toBe('/facebook/react');
|
|
expect(repo.source).toBe('github');
|
|
expect(repo.state).toBe('pending');
|
|
});
|
|
|
|
it('accepts github.com/owner/repo URL format', () => {
|
|
const repo = service.add({
|
|
source: 'github',
|
|
sourceUrl: 'github.com/facebook/react'
|
|
});
|
|
expect(repo.id).toBe('/facebook/react');
|
|
});
|
|
|
|
it('strips .git suffix from GitHub URLs', () => {
|
|
const repo = service.add({
|
|
source: 'github',
|
|
sourceUrl: 'https://github.com/facebook/react.git'
|
|
});
|
|
expect(repo.id).toBe('/facebook/react');
|
|
});
|
|
|
|
it('uses override title when provided', () => {
|
|
const repo = service.add({
|
|
source: 'github',
|
|
sourceUrl: 'https://github.com/facebook/react',
|
|
title: 'React Library'
|
|
});
|
|
expect(repo.title).toBe('React Library');
|
|
});
|
|
|
|
it('defaults title to repo name for GitHub repos', () => {
|
|
const repo = service.add({
|
|
source: 'github',
|
|
sourceUrl: 'https://github.com/facebook/react'
|
|
});
|
|
expect(repo.title).toBe('react');
|
|
});
|
|
|
|
it('adds a local repository and derives the /local/ ID', () => {
|
|
const repo = service.add({
|
|
source: 'local',
|
|
sourceUrl: '/home/user/projects/my-sdk'
|
|
}) as unknown as RawRepo;
|
|
expect(repo.id).toBe('/local/my-sdk');
|
|
expect(repo.source).toBe('local');
|
|
});
|
|
|
|
it('resolves ID collisions for local repositories with -2, -3 suffix', () => {
|
|
service.add({ source: 'local', sourceUrl: '/home/user/a/my-sdk' });
|
|
const repo2 = service.add({ source: 'local', sourceUrl: '/home/user/b/my-sdk' });
|
|
expect(repo2.id).toBe('/local/my-sdk-2');
|
|
const repo3 = service.add({ source: 'local', sourceUrl: '/home/user/c/my-sdk' });
|
|
expect(repo3.id).toBe('/local/my-sdk-3');
|
|
});
|
|
|
|
it('throws AlreadyExistsError when a GitHub repo already exists', () => {
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
|
|
expect(() =>
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' })
|
|
).toThrow(AlreadyExistsError);
|
|
});
|
|
|
|
it('throws InvalidUrlError for a bad GitHub URL', () => {
|
|
expect(() =>
|
|
service.add({ source: 'github', sourceUrl: 'https://gitlab.com/foo/bar' })
|
|
).toThrow(InvalidUrlError);
|
|
});
|
|
|
|
it('throws InvalidInputError when sourceUrl is empty', () => {
|
|
expect(() => service.add({ source: 'github', sourceUrl: '' })).toThrow(InvalidInputError);
|
|
});
|
|
|
|
it('stores description and branch when provided', () => {
|
|
const repo = service.add({
|
|
source: 'github',
|
|
sourceUrl: 'https://github.com/facebook/react',
|
|
description: 'A UI library',
|
|
branch: 'main'
|
|
}) as unknown as RawRepo;
|
|
expect(repo.description).toBe('A UI library');
|
|
expect(repo.branch).toBe('main');
|
|
});
|
|
|
|
it('initialises counters to zero', () => {
|
|
const repo = service.add({
|
|
source: 'github',
|
|
sourceUrl: 'https://github.com/facebook/react'
|
|
});
|
|
expect(repo.totalSnippets).toBe(0);
|
|
expect(repo.totalTokens).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// update()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('RepositoryService.update()', () => {
|
|
let service: RepositoryService;
|
|
|
|
beforeEach(() => {
|
|
service = makeService(createTestDb());
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
|
|
});
|
|
|
|
it('updates the title', () => {
|
|
const updated = service.update('/facebook/react', { title: 'React' });
|
|
expect(updated.title).toBe('React');
|
|
});
|
|
|
|
it('updates the description', () => {
|
|
const updated = service.update('/facebook/react', { description: 'UI library' });
|
|
expect(updated.description).toBe('UI library');
|
|
});
|
|
|
|
it('updates the branch', () => {
|
|
const updated = service.update('/facebook/react', { branch: 'canary' }) as unknown as RawRepo;
|
|
expect(updated.branch).toBe('canary');
|
|
});
|
|
|
|
it('returns unchanged repository when input has no fields', () => {
|
|
const before = service.get('/facebook/react')!;
|
|
const updated = service.update('/facebook/react', {});
|
|
expect(updated.title).toBe(before.title);
|
|
});
|
|
|
|
it('throws NotFoundError for a non-existent repository', () => {
|
|
expect(() => service.update('/not/found', { title: 'New Title' })).toThrow(NotFoundError);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// remove()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('RepositoryService.remove()', () => {
|
|
let service: RepositoryService;
|
|
|
|
beforeEach(() => {
|
|
service = makeService(createTestDb());
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
|
|
});
|
|
|
|
it('removes an existing repository', () => {
|
|
service.remove('/facebook/react');
|
|
expect(service.get('/facebook/react')).toBeNull();
|
|
});
|
|
|
|
it('throws NotFoundError when the repository does not exist', () => {
|
|
expect(() => service.remove('/not/found')).toThrow(NotFoundError);
|
|
});
|
|
|
|
it('removes derived vec rows before the repository cascade deletes snippets', () => {
|
|
const docId = crypto.randomUUID();
|
|
const snippetId = crypto.randomUUID();
|
|
const embedding = Float32Array.from([1, 0, 0]);
|
|
const vecStore = new SqliteVecStore((service as unknown as { db: Database.Database }).db);
|
|
const db = (service as unknown as { db: Database.Database }).db;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
db.prepare(
|
|
`INSERT INTO documents (id, repository_id, version_id, file_path, checksum, indexed_at)
|
|
VALUES (?, '/facebook/react', NULL, 'README.md', 'repo-doc', ?)`
|
|
).run(docId, now);
|
|
db.prepare(
|
|
`INSERT INTO snippets (id, document_id, repository_id, version_id, type, content, created_at)
|
|
VALUES (?, ?, '/facebook/react', NULL, 'info', 'repo snippet', ?)`
|
|
).run(snippetId, docId, now);
|
|
db.prepare(
|
|
`INSERT INTO snippet_embeddings (snippet_id, profile_id, model, dimensions, embedding, created_at)
|
|
VALUES (?, 'local-default', 'test-model', 3, ?, ?)`
|
|
).run(snippetId, Buffer.from(embedding.buffer), now);
|
|
vecStore.upsertEmbedding('local-default', snippetId, embedding);
|
|
|
|
service.remove('/facebook/react');
|
|
|
|
const vecTable = sqliteVecTableName('local-default');
|
|
const rowidTable = sqliteVecRowidTableName('local-default');
|
|
const vecCount = db.prepare(`SELECT COUNT(*) as n FROM "${vecTable}"`).get() as { n: number };
|
|
const rowidCount = db.prepare(`SELECT COUNT(*) as n FROM "${rowidTable}"`).get() as {
|
|
n: number;
|
|
};
|
|
|
|
expect(vecCount.n).toBe(0);
|
|
expect(rowidCount.n).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getStats()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('RepositoryService.getStats()', () => {
|
|
let client: Database.Database;
|
|
let service: RepositoryService;
|
|
|
|
beforeEach(() => {
|
|
client = createTestDb();
|
|
service = makeService(client);
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
|
|
});
|
|
|
|
it('returns zero stats for a freshly added repository', () => {
|
|
const stats = service.getStats('/facebook/react');
|
|
expect(stats.totalSnippets).toBe(0);
|
|
expect(stats.totalTokens).toBe(0);
|
|
expect(stats.totalDocuments).toBe(0);
|
|
// lastIndexedAt may be undefined or null depending on the raw row value.
|
|
expect(stats.lastIndexedAt == null).toBe(true);
|
|
});
|
|
|
|
it('throws NotFoundError for a non-existent repository', () => {
|
|
expect(() => service.getStats('/not/found')).toThrow(NotFoundError);
|
|
});
|
|
|
|
it('counts documents correctly', () => {
|
|
const docId = crypto.randomUUID();
|
|
const now = Math.floor(Date.now() / 1000);
|
|
client
|
|
.prepare(
|
|
`INSERT INTO documents (id, repository_id, file_path, checksum, indexed_at)
|
|
VALUES (?, '/facebook/react', 'README.md', 'abc', ?)`
|
|
)
|
|
.run(docId, now);
|
|
|
|
const stats = service.getStats('/facebook/react');
|
|
expect(stats.totalDocuments).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getVersions()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('RepositoryService.getVersions()', () => {
|
|
let client: Database.Database;
|
|
let service: RepositoryService;
|
|
|
|
beforeEach(() => {
|
|
client = createTestDb();
|
|
service = makeService(client);
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
|
|
});
|
|
|
|
it('returns an empty array when no versions exist', () => {
|
|
expect(service.getVersions('/facebook/react')).toEqual([]);
|
|
});
|
|
|
|
it('returns tags for all versions of a repository', () => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
client
|
|
.prepare(
|
|
`INSERT INTO repository_versions (id, repository_id, tag, created_at)
|
|
VALUES (?, '/facebook/react', ?, ?)`
|
|
)
|
|
.run('/facebook/react/v18.3.0', 'v18.3.0', now);
|
|
client
|
|
.prepare(
|
|
`INSERT INTO repository_versions (id, repository_id, tag, created_at)
|
|
VALUES (?, '/facebook/react', ?, ?)`
|
|
)
|
|
.run('/facebook/react/v17.0.2', 'v17.0.2', now - 1);
|
|
|
|
const versions = service.getVersions('/facebook/react');
|
|
expect(versions).toContain('v18.3.0');
|
|
expect(versions).toContain('v17.0.2');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getIndexSummary()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('RepositoryService.getIndexSummary()', () => {
|
|
let client: Database.Database;
|
|
let service: RepositoryService;
|
|
|
|
beforeEach(() => {
|
|
client = createTestDb();
|
|
service = makeService(client);
|
|
service.add({
|
|
source: 'github',
|
|
sourceUrl: 'https://github.com/facebook/react',
|
|
branch: 'main'
|
|
});
|
|
});
|
|
|
|
it('returns embedding counts and indexed version labels', () => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const docId = crypto.randomUUID();
|
|
const versionDocId = crypto.randomUUID();
|
|
const snippetId = crypto.randomUUID();
|
|
const versionSnippetId = crypto.randomUUID();
|
|
|
|
client
|
|
.prepare(
|
|
`INSERT INTO repository_versions (id, repository_id, tag, state, created_at)
|
|
VALUES (?, '/facebook/react', ?, 'indexed', ?)`
|
|
)
|
|
.run('/facebook/react/v18.3.0', 'v18.3.0', now);
|
|
|
|
client
|
|
.prepare(
|
|
`INSERT INTO documents (id, repository_id, version_id, file_path, checksum, indexed_at)
|
|
VALUES (?, '/facebook/react', NULL, 'README.md', 'base', ?)`
|
|
)
|
|
.run(docId, now);
|
|
|
|
client
|
|
.prepare(
|
|
`INSERT INTO documents (id, repository_id, version_id, file_path, checksum, indexed_at)
|
|
VALUES (?, '/facebook/react', ?, 'README.md', 'version', ?)`
|
|
)
|
|
.run(versionDocId, '/facebook/react/v18.3.0', now);
|
|
|
|
client
|
|
.prepare(
|
|
`INSERT INTO snippets (id, document_id, repository_id, version_id, type, content, created_at)
|
|
VALUES (?, ?, '/facebook/react', NULL, 'info', 'base snippet', ?)`
|
|
)
|
|
.run(snippetId, docId, now);
|
|
|
|
client
|
|
.prepare(
|
|
`INSERT INTO snippets (id, document_id, repository_id, version_id, type, content, created_at)
|
|
VALUES (?, ?, '/facebook/react', ?, 'info', 'version snippet', ?)`
|
|
)
|
|
.run(versionSnippetId, versionDocId, '/facebook/react/v18.3.0', now);
|
|
|
|
client
|
|
.prepare(
|
|
`INSERT INTO snippet_embeddings (snippet_id, profile_id, model, dimensions, embedding, created_at)
|
|
VALUES (?, 'local-default', 'Xenova/all-MiniLM-L6-v2', 2, ?, ?)`
|
|
)
|
|
.run(snippetId, Buffer.from(Float32Array.from([1, 0]).buffer), now);
|
|
|
|
client
|
|
.prepare(
|
|
`INSERT INTO snippet_embeddings (snippet_id, profile_id, model, dimensions, embedding, created_at)
|
|
VALUES (?, 'local-default', 'Xenova/all-MiniLM-L6-v2', 2, ?, ?)`
|
|
)
|
|
.run(versionSnippetId, Buffer.from(Float32Array.from([0, 1]).buffer), now);
|
|
|
|
expect(service.getIndexSummary('/facebook/react')).toEqual({
|
|
embeddingCount: 2,
|
|
indexedVersions: ['main', 'v18.3.0']
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// createIndexingJob()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('RepositoryService.createIndexingJob()', () => {
|
|
let service: RepositoryService;
|
|
|
|
beforeEach(() => {
|
|
service = makeService(createTestDb());
|
|
service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
|
|
});
|
|
|
|
it('creates a queued indexing job', () => {
|
|
const job = service.createIndexingJob('/facebook/react');
|
|
expect(job.id).toBeTruthy();
|
|
expect(job.repositoryId).toBe('/facebook/react');
|
|
expect(job.status).toBe('queued');
|
|
expect(job.progress).toBe(0);
|
|
});
|
|
|
|
it('returns the existing job when one is already queued', () => {
|
|
const job1 = service.createIndexingJob('/facebook/react');
|
|
const job2 = service.createIndexingJob('/facebook/react');
|
|
expect(job2.id).toBe(job1.id);
|
|
});
|
|
|
|
it('creates a new job once the previous job is done', () => {
|
|
const job1 = service.createIndexingJob('/facebook/react');
|
|
// Access the underlying db to simulate job completion.
|
|
const db = (service as unknown as { db: Database.Database }).db;
|
|
db.prepare(`UPDATE indexing_jobs SET status = 'done' WHERE id = ?`).run(job1.id);
|
|
|
|
const job2 = service.createIndexingJob('/facebook/react');
|
|
expect(job2.id).not.toBe(job1.id);
|
|
});
|
|
|
|
it('accepts an optional versionId', () => {
|
|
const job = service.createIndexingJob('/facebook/react', '/facebook/react/v18.3.0');
|
|
expect(job.versionId).toBe('/facebook/react/v18.3.0');
|
|
});
|
|
|
|
it('allows separate jobs for the same repo but different versions', () => {
|
|
const defaultJob = service.createIndexingJob('/facebook/react');
|
|
const versionJob = service.createIndexingJob('/facebook/react', '/facebook/react/v18.3.0');
|
|
expect(versionJob.id).not.toBe(defaultJob.id);
|
|
expect(defaultJob.versionId).toBeNull();
|
|
expect(versionJob.versionId).toBe('/facebook/react/v18.3.0');
|
|
});
|
|
|
|
it('returns the existing job when the same (repo, version) pair is already queued', () => {
|
|
const job1 = service.createIndexingJob('/facebook/react', '/facebook/react/v18.3.0');
|
|
const job2 = service.createIndexingJob('/facebook/react', '/facebook/react/v18.3.0');
|
|
expect(job2.id).toBe(job1.id);
|
|
});
|
|
|
|
it('returns the existing default-branch job when called again without a versionId', () => {
|
|
const job1 = service.createIndexingJob('/facebook/react');
|
|
const job2 = service.createIndexingJob('/facebook/react');
|
|
expect(job2.id).toBe(job1.id);
|
|
});
|
|
});
|