Files
socraticode/tests/unit/qdrant-error-wrapping.test.ts
IritBrenerShalem f22b4d128f fix(qdrant): wrap propagated client errors with operation context (#55)
The deliberate catch-and-rethrow sites in `loadProjectHashes` and
`getCollectionInfo` were re-throwing the raw Qdrant client error. With
no wrapping, the original message ("Internal Server Error") landed
verbatim in the MCP response and `codebase_status` `lastCompleted.error`
field, leaving consumers no way to distinguish which operation failed,
which collection it targeted, or whether the underlying error carried
an HTTP status code.

Add a `wrapQdrantError(operation, context, err)` helper and apply it at
both rethrow sites. The new error message looks like:

    loadProjectHashes(collName=codebase_xxx) failed [status 500]: Internal Server Error

The original error is preserved via `cause`, so any consumer that walks
the cause chain still has full access. Also pick up `statusCode` as a
fallback for clients that use that field name.

Behaviour preserved:
- 404 / not-found still returns null from `getCollectionInfo` (no change).
- Re-throw is still the path for transient/unknown errors (no behaviour
  change for the deliberate hardening that protects against destructive
  clean-start cascades).

Adds `tests/unit/qdrant-error-wrapping.test.ts` covering the 404 pass-
through, wrapped-message format, status-code inclusion, missing-status
case, and `cause` preservation.

Closes #55.
2026-05-07 23:38:11 +03:00

134 lines
5.0 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
//
// Tests for issue #55: re-thrown Qdrant errors must surface with operation
// context (function name, parameters, status code, original message) instead
// of the bare "Internal Server Error" the Qdrant REST client produces. Without
// this wrapping the only signal a tool consumer gets via MCP is the literal
// string "Internal Server Error" with no clue about which operation failed.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/services/logger.js", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
const mockGetCollection = vi.fn();
const mockGetCollections = vi.fn();
const mockRetrieve = vi.fn();
const mockCreateCollection = vi.fn();
const mockCreatePayloadIndex = vi.fn();
vi.mock("@qdrant/js-client-rest", () => ({
QdrantClient: class {
getCollection = mockGetCollection;
getCollections = mockGetCollections;
retrieve = mockRetrieve;
createCollection = mockCreateCollection;
createPayloadIndex = mockCreatePayloadIndex;
},
}));
describe("qdrant error wrapping (issue #55)", () => {
beforeEach(() => {
vi.resetModules();
mockGetCollection.mockReset();
mockGetCollections.mockReset();
mockRetrieve.mockReset();
mockCreateCollection.mockReset();
mockCreatePayloadIndex.mockReset();
// Default: metadata collection already exists, so ensureMetadataCollection
// returns without trying to create one. Tests that need a fresh state
// override this in their own setup.
mockGetCollections.mockResolvedValue({
collections: [{ name: "socraticode_metadata" }],
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe("getCollectionInfo", () => {
it("still returns null on 404 (collection-not-found is not an error)", async () => {
const notFound: Error & { status?: number } = new Error("Not found");
notFound.status = 404;
mockGetCollection.mockRejectedValueOnce(notFound);
const { getCollectionInfo } = await import("../../src/services/qdrant.js");
const result = await getCollectionInfo("missing_collection");
expect(result).toBeNull();
});
it("wraps non-404 errors with operation name, collection, and status", async () => {
const serverErr: Error & { status?: number } = new Error("Internal Server Error");
serverErr.status = 500;
mockGetCollection.mockRejectedValueOnce(serverErr);
const { getCollectionInfo } = await import("../../src/services/qdrant.js");
await expect(getCollectionInfo("some_collection")).rejects.toThrow(
/getCollectionInfo\(collection=some_collection\) failed \[status 500\]: Internal Server Error/,
);
});
it("preserves the original error via `cause`", async () => {
const originalErr: Error & { status?: number } = new Error("boom");
originalErr.status = 503;
mockGetCollection.mockRejectedValueOnce(originalErr);
const { getCollectionInfo } = await import("../../src/services/qdrant.js");
try {
await getCollectionInfo("c1");
expect.fail("expected throw");
} catch (err) {
expect((err as Error & { cause?: unknown }).cause).toBe(originalErr);
}
});
});
describe("loadProjectHashes", () => {
it("wraps Qdrant errors with collName context", async () => {
const serverErr: Error & { status?: number } = new Error("Internal Server Error");
serverErr.status = 500;
mockRetrieve.mockRejectedValueOnce(serverErr);
const { loadProjectHashes } = await import("../../src/services/qdrant.js");
await expect(loadProjectHashes("codebase_xyz")).rejects.toThrow(
/loadProjectHashes\(collName=codebase_xyz\) failed \[status 500\]: Internal Server Error/,
);
});
it("includes original error as `cause`", async () => {
const original: Error & { statusCode?: number } = new Error("connection refused");
original.statusCode = 502;
mockRetrieve.mockRejectedValueOnce(original);
const { loadProjectHashes } = await import("../../src/services/qdrant.js");
try {
await loadProjectHashes("codebase_abc");
expect.fail("expected throw");
} catch (err) {
expect((err as Error).message).toContain("loadProjectHashes(collName=codebase_abc)");
expect((err as Error).message).toContain("[status 502]");
expect((err as Error).message).toContain("connection refused");
expect((err as Error & { cause?: unknown }).cause).toBe(original);
}
});
it("works without a status code on the underlying error", async () => {
const noStatus: Error = new Error("opaque failure");
mockRetrieve.mockRejectedValueOnce(noStatus);
const { loadProjectHashes } = await import("../../src/services/qdrant.js");
await expect(loadProjectHashes("codebase_no_status")).rejects.toThrow(
/loadProjectHashes\(collName=codebase_no_status\) failed: opaque failure/,
);
});
});
});