mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
505fbd722b
When SOCRATICODE_PROJECT_ID is set to share a Qdrant collection across git worktrees, the indexer still used absolute paths as keys in the file hash map, chunk IDs, and for deleting file chunks. This caused every worktree to see all files as "new" and trigger a full re-index, defeating the purpose of the shared project ID feature. Switch all internal keying from absolute paths to relative paths: - chunkId() now hashes on relativePath for stable IDs across worktrees - File hash map (change detection) keyed by relativePath - deleteFileChunks() filters on relativePath Qdrant field - Deleted file detection uses relative path sets Absolute paths are now only used for actual file I/O (stat, readFile).
266 lines
8.6 KiB
TypeScript
266 lines
8.6 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { ensureQdrantReady } from "../../src/services/docker.js";
|
|
import { ensureOllamaReady } from "../../src/services/ollama.js";
|
|
import {
|
|
deleteCollection,
|
|
deleteFileChunks,
|
|
deleteProjectMetadata,
|
|
ensureCollection,
|
|
getCollectionInfo,
|
|
getProjectMetadata,
|
|
listCodebaseCollections,
|
|
loadProjectHashes,
|
|
saveProjectMetadata,
|
|
searchChunks,
|
|
upsertChunks,
|
|
} from "../../src/services/qdrant.js";
|
|
import type { FileChunk } from "../../src/types.js";
|
|
import { isDockerAvailable } from "../helpers/fixtures.js";
|
|
import { deleteTestCollection, waitForOllama, waitForQdrant } from "../helpers/setup.js";
|
|
|
|
const dockerAvailable = isDockerAvailable();
|
|
const TEST_COLLECTION = "codebase_test_qdrant_integration";
|
|
|
|
describe.skipIf(!dockerAvailable)("qdrant service", () => {
|
|
beforeAll(async () => {
|
|
await ensureQdrantReady();
|
|
await ensureOllamaReady();
|
|
await waitForQdrant();
|
|
await waitForOllama();
|
|
|
|
// Clean up from any previous test run
|
|
await deleteTestCollection(TEST_COLLECTION);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await deleteTestCollection(TEST_COLLECTION);
|
|
});
|
|
|
|
describe("collection management", () => {
|
|
it("creates a collection with correct dimensions", async () => {
|
|
await ensureCollection(TEST_COLLECTION);
|
|
const info = await getCollectionInfo(TEST_COLLECTION);
|
|
|
|
expect(info).toBeDefined();
|
|
expect(info?.status).toBe("green");
|
|
});
|
|
|
|
it("is idempotent — creating an existing collection does not error", async () => {
|
|
await expect(ensureCollection(TEST_COLLECTION)).resolves.not.toThrow();
|
|
});
|
|
|
|
it("lists collections including the test collection", async () => {
|
|
const collections = await listCodebaseCollections();
|
|
expect(collections).toContain(TEST_COLLECTION);
|
|
});
|
|
|
|
it("returns collection info", async () => {
|
|
const info = await getCollectionInfo(TEST_COLLECTION);
|
|
expect(info).toBeDefined();
|
|
expect(info?.pointsCount).toBe(0); // no data yet
|
|
});
|
|
|
|
it("returns null info for non-existent collection", async () => {
|
|
const info = await getCollectionInfo("nonexistent_collection_xyz");
|
|
expect(info).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("chunk upsert and search with real embeddings", () => {
|
|
const chunks: FileChunk[] = [
|
|
{
|
|
id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
filePath: "/project/src/auth.ts",
|
|
relativePath: "src/auth.ts",
|
|
content:
|
|
"export function authenticateUser(token: string): boolean {\n // Validate JWT token and check permissions\n const parts = token.split('.');\n return parts.length === 3;\n}",
|
|
startLine: 1,
|
|
endLine: 5,
|
|
language: "typescript",
|
|
type: "code",
|
|
},
|
|
{
|
|
id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
|
filePath: "/project/src/math.ts",
|
|
relativePath: "src/math.ts",
|
|
content:
|
|
"export function fibonacci(n: number): number {\n if (n <= 0) return 0;\n if (n === 1) return 1;\n let prev = 0, curr = 1;\n for (let i = 2; i <= n; i++) {\n [prev, curr] = [curr, prev + curr];\n }\n return curr;\n}",
|
|
startLine: 1,
|
|
endLine: 9,
|
|
language: "typescript",
|
|
type: "code",
|
|
},
|
|
{
|
|
id: "cccccccc-cccc-cccc-cccc-cccccccccccc",
|
|
filePath: "/project/lib/data.py",
|
|
relativePath: "lib/data.py",
|
|
content:
|
|
'def load_json_file(filepath: str) -> dict:\n """Load and parse a JSON file from disk."""\n with open(filepath, "r") as f:\n return json.load(f)',
|
|
startLine: 1,
|
|
endLine: 4,
|
|
language: "python",
|
|
type: "code",
|
|
},
|
|
];
|
|
|
|
const _embeddings: number[][] = [];
|
|
|
|
it("upserts chunks with real embeddings (generated internally)", async () => {
|
|
await upsertChunks(TEST_COLLECTION, chunks, "test-content-hash");
|
|
|
|
// Verify points were created
|
|
const info = await getCollectionInfo(TEST_COLLECTION);
|
|
expect(info).toBeDefined();
|
|
expect(info?.pointsCount).toBe(3);
|
|
});
|
|
|
|
it("searches for authentication-related code", async () => {
|
|
const results = await searchChunks(
|
|
TEST_COLLECTION,
|
|
"user authentication with JWT tokens",
|
|
5,
|
|
);
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
// The auth.ts chunk should rank highest for auth-related queries
|
|
expect(results[0].relativePath).toBe("src/auth.ts");
|
|
expect(results[0].score).toBeGreaterThan(0);
|
|
expect(results[0].content).toContain("authenticateUser");
|
|
});
|
|
|
|
it("searches for mathematical algorithms", async () => {
|
|
const results = await searchChunks(
|
|
TEST_COLLECTION,
|
|
"fibonacci sequence calculation algorithm",
|
|
5,
|
|
);
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
// The math.ts chunk should rank highest for math queries
|
|
expect(results[0].relativePath).toBe("src/math.ts");
|
|
expect(results[0].content).toContain("fibonacci");
|
|
});
|
|
|
|
it("searches for data loading utilities", async () => {
|
|
const results = await searchChunks(
|
|
TEST_COLLECTION,
|
|
"load and parse JSON data from file",
|
|
5,
|
|
);
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
// The data.py chunk should rank highest
|
|
expect(results[0].relativePath).toBe("lib/data.py");
|
|
});
|
|
|
|
it("supports limit parameter", async () => {
|
|
const results = await searchChunks(TEST_COLLECTION, "code", 2);
|
|
expect(results.length).toBeLessThanOrEqual(2);
|
|
});
|
|
|
|
it("supports file filter", async () => {
|
|
const results = await searchChunks(
|
|
TEST_COLLECTION,
|
|
"function",
|
|
10,
|
|
"auth",
|
|
);
|
|
|
|
// Only auth.ts should match the file filter
|
|
for (const r of results) {
|
|
expect(r.relativePath).toContain("auth");
|
|
}
|
|
});
|
|
|
|
it("supports language filter", async () => {
|
|
const results = await searchChunks(
|
|
TEST_COLLECTION,
|
|
"function",
|
|
10,
|
|
undefined,
|
|
"python",
|
|
);
|
|
|
|
for (const r of results) {
|
|
expect(r.language).toBe("python");
|
|
}
|
|
});
|
|
|
|
it("returns empty results for unrelated queries in filtered search", async () => {
|
|
const results = await searchChunks(
|
|
TEST_COLLECTION,
|
|
"authentication",
|
|
10,
|
|
undefined,
|
|
"python",
|
|
);
|
|
|
|
// Should return python results (only data.py), even though
|
|
// the query is more related to auth.ts
|
|
for (const r of results) {
|
|
expect(r.language).toBe("python");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("delete file chunks", () => {
|
|
it("deletes chunks for a specific file", async () => {
|
|
await deleteFileChunks(TEST_COLLECTION, "lib/data.py");
|
|
|
|
const info = await getCollectionInfo(TEST_COLLECTION);
|
|
expect(info?.pointsCount).toBe(2); // 2 remaining
|
|
});
|
|
});
|
|
|
|
describe("metadata collection", () => {
|
|
const metadataCollection = TEST_COLLECTION; // reuse the test collection for metadata
|
|
const projectPath = "/test/project/path";
|
|
|
|
it("saves project metadata", async () => {
|
|
const fileHashes = new Map<string, string>([
|
|
["src/auth.ts", "hash-auth"],
|
|
["src/math.ts", "hash-math"],
|
|
]);
|
|
|
|
await saveProjectMetadata(metadataCollection, projectPath, 42, 2, fileHashes, "completed");
|
|
|
|
const metadata = await getProjectMetadata(metadataCollection);
|
|
expect(metadata).toBeDefined();
|
|
expect(metadata?.projectPath).toBe(projectPath);
|
|
expect(metadata?.filesTotal).toBe(42);
|
|
expect(metadata?.filesIndexed).toBe(2);
|
|
});
|
|
|
|
it("loads project hashes", async () => {
|
|
const hashes = await loadProjectHashes(metadataCollection);
|
|
// Initially might be empty or contain entries based on implementation
|
|
expect(hashes).toBeDefined();
|
|
expect(typeof hashes).toBe("object");
|
|
});
|
|
|
|
it("can delete project metadata", async () => {
|
|
await deleteProjectMetadata(metadataCollection);
|
|
// After deletion, metadata should be gone
|
|
const metadata = await getProjectMetadata(metadataCollection);
|
|
expect(metadata).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("collection deletion", () => {
|
|
it("deletes the test collection", async () => {
|
|
await deleteCollection(TEST_COLLECTION);
|
|
|
|
const info = await getCollectionInfo(TEST_COLLECTION);
|
|
expect(info).toBeNull();
|
|
});
|
|
|
|
it("does not error when deleting non-existent collection", async () => {
|
|
await expect(
|
|
deleteCollection("nonexistent_collection_xyz_test"),
|
|
).resolves.not.toThrow();
|
|
});
|
|
});
|
|
});
|