Files
socraticode/tests/integration/qdrant.test.ts
T
Csaba Tuncsik 505fbd722b fix: use relative paths for index keys to support shared worktree indexes
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).
2026-03-16 08:39:22 +01:00

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();
});
});
});