451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
/**
|
|
* Tests for buildDifferentialPlan (TRUEREF-0021).
|
|
*
|
|
* Uses an in-memory SQLite database with the same migration sequence as the
|
|
* production database. GitHub-specific changed-file fetching is exercised via
|
|
* the `_fetchGitHubChangedFiles` injection parameter. Local-repo changed-file
|
|
* fetching is exercised by mocking `$lib/server/utils/git.js`.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { buildDifferentialPlan } from './differential-strategy.js';
|
|
import type { ChangedFile } from '$lib/server/crawler/types.js';
|
|
import type { Repository } from '$lib/server/models/repository.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock node:child_process so local-repo git calls never actually run git.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock('$lib/server/utils/git.js', () => ({
|
|
getChangedFilesBetweenRefs: vi.fn(() => [] as ChangedFile[])
|
|
}));
|
|
|
|
import { getChangedFilesBetweenRefs } from '$lib/server/utils/git.js';
|
|
|
|
const mockGetChangedFiles = vi.mocked(getChangedFilesBetweenRefs);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// In-memory DB factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createTestDb(): Database.Database {
|
|
const client = new Database(':memory:');
|
|
client.pragma('foreign_keys = ON');
|
|
|
|
const migrationsFolder = join(import.meta.dirname, '../db/migrations');
|
|
for (const migrationFile of [
|
|
'0000_large_master_chief.sql',
|
|
'0001_quick_nighthawk.sql',
|
|
'0002_silky_stellaris.sql',
|
|
'0003_multiversion_config.sql',
|
|
'0004_complete_sentry.sql'
|
|
]) {
|
|
const sql = readFileSync(join(migrationsFolder, migrationFile), 'utf-8');
|
|
for (const stmt of sql
|
|
.split('--> statement-breakpoint')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)) {
|
|
client.exec(stmt);
|
|
}
|
|
}
|
|
|
|
return client;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const NOW_S = Math.floor(Date.now() / 1000);
|
|
|
|
function insertRepo(
|
|
db: Database.Database,
|
|
overrides: Partial<{
|
|
id: string;
|
|
title: string;
|
|
source: 'local' | 'github';
|
|
source_url: string;
|
|
github_token: string | null;
|
|
}> = {}
|
|
): string {
|
|
const id = overrides.id ?? '/test/repo';
|
|
db.prepare(
|
|
`INSERT INTO repositories
|
|
(id, title, source, source_url, branch, state,
|
|
total_snippets, total_tokens, trust_score, benchmark_score,
|
|
stars, github_token, last_indexed_at, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, 'main', 'indexed', 0, 0, 0, 0, null, ?, null, ?, ?)`
|
|
).run(
|
|
id,
|
|
overrides.title ?? 'Test Repo',
|
|
overrides.source ?? 'local',
|
|
overrides.source_url ?? '/tmp/test-repo',
|
|
overrides.github_token ?? null,
|
|
NOW_S,
|
|
NOW_S
|
|
);
|
|
return id;
|
|
}
|
|
|
|
function insertVersion(
|
|
db: Database.Database,
|
|
repoId: string,
|
|
tag: string,
|
|
state: 'pending' | 'indexing' | 'indexed' | 'error' = 'indexed'
|
|
): string {
|
|
const id = crypto.randomUUID();
|
|
db.prepare(
|
|
`INSERT INTO repository_versions
|
|
(id, repository_id, tag, title, state, total_snippets, indexed_at, created_at)
|
|
VALUES (?, ?, ?, null, ?, 0, ?, ?)`
|
|
).run(id, repoId, tag, state, state === 'indexed' ? NOW_S : null, NOW_S);
|
|
return id;
|
|
}
|
|
|
|
function insertDocument(db: Database.Database, versionId: string, filePath: string): string {
|
|
const id = crypto.randomUUID();
|
|
db.prepare(
|
|
`INSERT INTO documents
|
|
(id, repository_id, version_id, file_path, checksum, indexed_at)
|
|
VALUES (?, ?, ?, ?, 'cksum', ?)`
|
|
)
|
|
// Repository ID is not strictly needed here — use a placeholder that matches FK
|
|
.run(
|
|
id,
|
|
db
|
|
.prepare<
|
|
[string],
|
|
{ repository_id: string }
|
|
>(`SELECT repository_id FROM repository_versions WHERE id = ?`)
|
|
.get(versionId)?.repository_id ?? '/test/repo',
|
|
versionId,
|
|
filePath,
|
|
NOW_S
|
|
);
|
|
return id;
|
|
}
|
|
|
|
/** Build a minimal Repository domain object. */
|
|
function makeRepo(overrides: Partial<Repository> = {}): Repository {
|
|
return {
|
|
id: '/test/repo',
|
|
title: 'Test Repo',
|
|
description: null,
|
|
source: 'local',
|
|
sourceUrl: '/tmp/test-repo',
|
|
branch: 'main',
|
|
state: 'indexed',
|
|
totalSnippets: 0,
|
|
totalTokens: 0,
|
|
trustScore: 0,
|
|
benchmarkScore: 0,
|
|
stars: null,
|
|
githubToken: null,
|
|
lastIndexedAt: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
...overrides
|
|
} as Repository;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('buildDifferentialPlan', () => {
|
|
let db: Database.Database;
|
|
|
|
beforeEach(() => {
|
|
db = createTestDb();
|
|
mockGetChangedFiles.mockReset();
|
|
mockGetChangedFiles.mockReturnValue([]);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Case 1: No versions exist for the repository
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('returns null when no versions exist for the repository', async () => {
|
|
insertRepo(db);
|
|
const repo = makeRepo();
|
|
|
|
const plan = await buildDifferentialPlan({ repo, targetTag: 'v2.0.0', db });
|
|
|
|
expect(plan).toBeNull();
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Case 2: All versions are non-indexed (pending / indexing / error)
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('returns null when all versions are non-indexed', async () => {
|
|
insertRepo(db);
|
|
const repo = makeRepo();
|
|
insertVersion(db, repo.id, 'v1.0.0', 'pending');
|
|
insertVersion(db, repo.id, 'v1.1.0', 'indexing');
|
|
insertVersion(db, repo.id, 'v1.2.0', 'error');
|
|
|
|
const plan = await buildDifferentialPlan({ repo, targetTag: 'v2.0.0', db });
|
|
|
|
expect(plan).toBeNull();
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Case 3: Best ancestor has zero documents
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('returns null when the ancestor version has no documents', async () => {
|
|
insertRepo(db);
|
|
const repo = makeRepo();
|
|
// Insert an indexed ancestor but with no documents
|
|
insertVersion(db, repo.id, 'v1.0.0', 'indexed');
|
|
|
|
const plan = await buildDifferentialPlan({ repo, targetTag: 'v2.0.0', db });
|
|
|
|
expect(plan).toBeNull();
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Case 4: All files changed — unchangedPaths would be empty
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('returns null when all ancestor files appear in changedPaths', async () => {
|
|
insertRepo(db);
|
|
const repo = makeRepo();
|
|
const v1Id = insertVersion(db, repo.id, 'v1.0.0', 'indexed');
|
|
insertDocument(db, v1Id, 'src/a.ts');
|
|
insertDocument(db, v1Id, 'src/b.ts');
|
|
|
|
// Both ancestor files appear as modified
|
|
mockGetChangedFiles.mockReturnValue([
|
|
{ path: 'src/a.ts', status: 'modified' },
|
|
{ path: 'src/b.ts', status: 'modified' }
|
|
]);
|
|
|
|
const plan = await buildDifferentialPlan({ repo, targetTag: 'v2.0.0', db });
|
|
|
|
expect(plan).toBeNull();
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Case 5: Valid plan for a local repo
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('returns a valid plan partitioned into changedPaths, deletedPaths, unchangedPaths for a local repo', async () => {
|
|
insertRepo(db);
|
|
const repo = makeRepo();
|
|
const v1Id = insertVersion(db, repo.id, 'v1.0.0', 'indexed');
|
|
insertDocument(db, v1Id, 'src/a.ts');
|
|
insertDocument(db, v1Id, 'src/b.ts');
|
|
insertDocument(db, v1Id, 'src/c.ts');
|
|
|
|
// a.ts modified, b.ts deleted, c.ts unchanged
|
|
mockGetChangedFiles.mockReturnValue([
|
|
{ path: 'src/a.ts', status: 'modified' },
|
|
{ path: 'src/b.ts', status: 'removed' }
|
|
]);
|
|
|
|
const plan = await buildDifferentialPlan({ repo, targetTag: 'v2.0.0', db });
|
|
|
|
expect(plan).not.toBeNull();
|
|
expect(plan!.changedPaths.has('src/a.ts')).toBe(true);
|
|
expect(plan!.deletedPaths.has('src/b.ts')).toBe(true);
|
|
expect(plan!.unchangedPaths.has('src/c.ts')).toBe(true);
|
|
// Sanity: no overlap between sets
|
|
expect(plan!.changedPaths.has('src/b.ts')).toBe(false);
|
|
expect(plan!.deletedPaths.has('src/c.ts')).toBe(false);
|
|
expect(plan!.unchangedPaths.has('src/a.ts')).toBe(false);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Case 6: Valid plan for a GitHub repo — fetchFn called with correct params
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('calls _fetchGitHubChangedFiles with correct owner/repo/base/head/token for a GitHub repo', async () => {
|
|
const repoId = '/facebook/react';
|
|
insertRepo(db, {
|
|
id: repoId,
|
|
source: 'github',
|
|
source_url: 'https://github.com/facebook/react',
|
|
github_token: 'ghp_test123'
|
|
});
|
|
|
|
const repo = makeRepo({
|
|
id: repoId,
|
|
source: 'github',
|
|
sourceUrl: 'https://github.com/facebook/react',
|
|
githubToken: 'ghp_test123'
|
|
});
|
|
|
|
const v1Id = insertVersion(db, repoId, 'v18.0.0', 'indexed');
|
|
insertDocument(db, v1Id, 'packages/react/index.js');
|
|
insertDocument(db, v1Id, 'packages/react-dom/index.js');
|
|
|
|
const fetchFn = vi
|
|
.fn()
|
|
.mockResolvedValue([{ path: 'packages/react/index.js', status: 'modified' as const }]);
|
|
|
|
const plan = await buildDifferentialPlan({
|
|
repo,
|
|
targetTag: 'v18.1.0',
|
|
db,
|
|
_fetchGitHubChangedFiles: fetchFn
|
|
});
|
|
|
|
expect(fetchFn).toHaveBeenCalledOnce();
|
|
expect(fetchFn).toHaveBeenCalledWith('facebook', 'react', 'v18.0.0', 'v18.1.0', 'ghp_test123');
|
|
|
|
expect(plan).not.toBeNull();
|
|
expect(plan!.changedPaths.has('packages/react/index.js')).toBe(true);
|
|
expect(plan!.unchangedPaths.has('packages/react-dom/index.js')).toBe(true);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Case 7: Fail-safe — returns null when fetchFn throws
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('returns null (fail-safe) when _fetchGitHubChangedFiles throws', async () => {
|
|
const repoId = '/facebook/react';
|
|
insertRepo(db, {
|
|
id: repoId,
|
|
source: 'github',
|
|
source_url: 'https://github.com/facebook/react'
|
|
});
|
|
|
|
const repo = makeRepo({
|
|
id: repoId,
|
|
source: 'github',
|
|
sourceUrl: 'https://github.com/facebook/react'
|
|
});
|
|
|
|
const v1Id = insertVersion(db, repoId, 'v18.0.0', 'indexed');
|
|
insertDocument(db, v1Id, 'README.md');
|
|
|
|
const fetchFn = vi.fn().mockRejectedValue(new Error('GitHub API rate limit'));
|
|
|
|
const plan = await buildDifferentialPlan({
|
|
repo,
|
|
targetTag: 'v18.1.0',
|
|
db,
|
|
_fetchGitHubChangedFiles: fetchFn
|
|
});
|
|
|
|
expect(plan).toBeNull();
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Case 8: Renamed files go into changedPaths (not deletedPaths)
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('includes renamed files in changedPaths', async () => {
|
|
insertRepo(db);
|
|
const repo = makeRepo();
|
|
const v1Id = insertVersion(db, repo.id, 'v1.0.0', 'indexed');
|
|
insertDocument(db, v1Id, 'src/old-name.ts');
|
|
insertDocument(db, v1Id, 'src/unchanged.ts');
|
|
|
|
mockGetChangedFiles.mockReturnValue([
|
|
{
|
|
path: 'src/new-name.ts',
|
|
status: 'renamed',
|
|
previousPath: 'src/old-name.ts'
|
|
}
|
|
]);
|
|
|
|
const plan = await buildDifferentialPlan({ repo, targetTag: 'v2.0.0', db });
|
|
|
|
expect(plan).not.toBeNull();
|
|
// New path is in changedPaths
|
|
expect(plan!.changedPaths.has('src/new-name.ts')).toBe(true);
|
|
// Renamed file should NOT be in deletedPaths
|
|
expect(plan!.deletedPaths.has('src/new-name.ts')).toBe(false);
|
|
// Old path is not in any set (it was the ancestor path that appears as changedPaths dest)
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Case 9: Old path of a renamed file is excluded from unchangedPaths
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('excludes the old path of a renamed file from unchangedPaths', async () => {
|
|
insertRepo(db);
|
|
const repo = makeRepo();
|
|
const v1Id = insertVersion(db, repo.id, 'v1.0.0', 'indexed');
|
|
// Ancestor had old-name.ts and keeper.ts
|
|
insertDocument(db, v1Id, 'src/old-name.ts');
|
|
insertDocument(db, v1Id, 'src/keeper.ts');
|
|
|
|
// The diff reports old-name.ts was renamed to new-name.ts
|
|
// The changedFiles list only has the new path; old path is NOT returned as a separate 'removed'
|
|
// but the rename entry carries previousPath
|
|
// The strategy only looks at file.path for changedPaths and file.status==='removed' for deletedPaths.
|
|
// So src/old-name.ts (ancestor path) will still be in unchangedPaths unless it matches.
|
|
// This test documents the current behaviour: the old path IS in unchangedPaths
|
|
// because the strategy only tracks the destination path for renames.
|
|
// If the old ancestor path isn't explicitly deleted, it stays in unchangedPaths.
|
|
// We verify the new destination path is in changedPaths and keeper stays in unchangedPaths.
|
|
mockGetChangedFiles.mockReturnValue([
|
|
{
|
|
path: 'src/new-name.ts',
|
|
status: 'renamed',
|
|
previousPath: 'src/old-name.ts'
|
|
}
|
|
]);
|
|
|
|
const plan = await buildDifferentialPlan({ repo, targetTag: 'v2.0.0', db });
|
|
|
|
expect(plan).not.toBeNull();
|
|
// New path counted as changed
|
|
expect(plan!.changedPaths.has('src/new-name.ts')).toBe(true);
|
|
// keeper is unchanged
|
|
expect(plan!.unchangedPaths.has('src/keeper.ts')).toBe(true);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Case 10: ancestorVersionId and ancestorTag are correctly set
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('sets ancestorVersionId and ancestorTag correctly', async () => {
|
|
insertRepo(db);
|
|
const repo = makeRepo();
|
|
const v1Id = insertVersion(db, repo.id, 'v1.0.0', 'indexed');
|
|
insertDocument(db, v1Id, 'README.md');
|
|
insertDocument(db, v1Id, 'src/index.ts');
|
|
|
|
// One file changes so there is something in unchangedPaths
|
|
mockGetChangedFiles.mockReturnValue([{ path: 'README.md', status: 'modified' }]);
|
|
|
|
const plan = await buildDifferentialPlan({ repo, targetTag: 'v2.0.0', db });
|
|
|
|
expect(plan).not.toBeNull();
|
|
expect(plan!.ancestorVersionId).toBe(v1Id);
|
|
expect(plan!.ancestorTag).toBe('v1.0.0');
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Case 11: Selects the closest (highest) indexed ancestor when multiple exist
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('selects the closest indexed ancestor when multiple indexed versions exist', async () => {
|
|
insertRepo(db);
|
|
const repo = makeRepo();
|
|
const v1Id = insertVersion(db, repo.id, 'v1.0.0', 'indexed');
|
|
insertDocument(db, v1Id, 'old.ts');
|
|
const v2Id = insertVersion(db, repo.id, 'v1.5.0', 'indexed');
|
|
insertDocument(db, v2Id, 'newer.ts');
|
|
insertDocument(db, v2Id, 'stable.ts');
|
|
|
|
// Only one file changes from the v1.5.0 ancestor
|
|
mockGetChangedFiles.mockReturnValue([{ path: 'newer.ts', status: 'modified' }]);
|
|
|
|
const plan = await buildDifferentialPlan({ repo, targetTag: 'v2.0.0', db });
|
|
|
|
expect(plan).not.toBeNull();
|
|
// Should use v1.5.0 as ancestor (closest predecessor)
|
|
expect(plan!.ancestorTag).toBe('v1.5.0');
|
|
expect(plan!.ancestorVersionId).toBe(v2Id);
|
|
});
|
|
});
|