fix: add psql binary guard + delete expired quarantine tests (ratchet) (#2090)
## Summary Follow-up to PR #2086 addressing two Greptile review findings. ## P2 — Missing `psql` binary guard (Greptile P2) `hasPg` in `_helpers.ts` previously checked only TCP connectivity to PostgreSQL. But `adminExecAsync()` shells out to the `psql` CLI for DDL (`CREATE/DROP DATABASE`). On a runner where Postgres is reachable but `psql` isn't installed, tests would fail with `spawn psql ENOENT` instead of skipping cleanly. **Fix**: Added `hasPsql = spawnSync("psql", ["--version"]).status === 0` to the `hasPg` guard, so tests skip when either Postgres is unreachable OR `psql` is missing. ## P1 — Expired quarantine entries (Greptile P1) The 16 dashboard test files quarantined on 2026-06-25 were past the 14-day deletion ratchet (AGENTS.md: "DELETED after 14 days unless rescued"). Per the ratchet, the test files were deleted and all references removed: - **Deleted 16 test files** (CSS drift, mock drift, mobile-render regressions) - **Removed 16 entries** from `scripts/lib/test-quarantine.json` (only the CLI entry remains) - **Emptied `quarantinedDashboardTests` array** in `packages/dashboard/vitest.config.ts` ## Verification | Check | Result | |---|---| | Merge gate (`pnpm test:gate`) | ✅ 294 + 99 + 63 = 456 passed | | Dashboard curated-gate | ✅ passes (891 files, 892 executed, 1 skip-listed, 1 quarantined) | | Typecheck (engine) | ✅ clean | | Lint | ✅ exit 0 | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Removed multiple outdated dashboard UI, CSS/token, theme contrast, and API/route test suites. * Updated dashboard test configuration to stop excluding quarantined tests and to prune the quality shard to the current set. * Updated the Vitest split/config guard to match the new test fixture set. * Improved PostgreSQL test detection by requiring the `psql` CLI before running database checks. * Adjusted quarantine tracking by adding a new CLI extension distribution ledger entry and removing obsolete dashboard quarantine entries. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,172 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ResearchLifecycleError } from "@fusion/core";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { createServer } from "../../../src/server.js";
|
||||
import { request } from "../../../src/test-request.js";
|
||||
|
||||
const researchStore = {
|
||||
listRuns: vi.fn(),
|
||||
createRun: vi.fn(),
|
||||
getRun: vi.fn(),
|
||||
updateRun: vi.fn(),
|
||||
deleteRun: vi.fn(),
|
||||
appendEvent: vi.fn(),
|
||||
addSource: vi.fn(),
|
||||
updateSource: vi.fn(),
|
||||
setResults: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
requestCancellation: vi.fn(),
|
||||
createRetryRun: vi.fn(),
|
||||
createExport: vi.fn(),
|
||||
getExports: vi.fn(),
|
||||
getExport: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
searchRuns: vi.fn(),
|
||||
};
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
getRootDir() { return "/tmp/fn-2991"; }
|
||||
getFusionDir() { return "/tmp/fn-2991/.fusion"; }
|
||||
getDatabase() { return { exec: vi.fn(), prepare: vi.fn(() => ({ run: vi.fn().mockReturnValue({ changes: 0 }), all: vi.fn().mockReturnValue([]), get: vi.fn() })) }; }
|
||||
getResearchStore() { return researchStore; }
|
||||
}
|
||||
|
||||
describe("research routes", () => {
|
||||
const app = createServer(new MockStore() as any);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
researchStore.listRuns.mockReturnValue([]);
|
||||
researchStore.getRun.mockReturnValue(undefined);
|
||||
researchStore.createRun.mockReturnValue({ id: "RR-1", query: "q", status: "queued", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
|
||||
researchStore.updateRun.mockReturnValue({ id: "RR-1", query: "q", status: "running", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "y" });
|
||||
researchStore.requestCancellation.mockReturnValue({ id: "RR-1", query: "q", status: "cancelling", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "y" });
|
||||
researchStore.createRetryRun.mockReturnValue({ id: "RR-2", query: "q", status: "retry_waiting", sources: [], events: [], tags: [], lifecycle: { retryOfRunId: "RR-1", rootRunId: "RR-1" }, createdAt: "x", updatedAt: "y" });
|
||||
researchStore.deleteRun.mockReturnValue(true);
|
||||
researchStore.appendEvent.mockReturnValue({ id: "E1", timestamp: "x", type: "info", message: "ok" });
|
||||
researchStore.addSource.mockReturnValue({ id: "S1", type: "web", reference: "https://e.com", status: "pending" });
|
||||
researchStore.getExports.mockReturnValue([]);
|
||||
researchStore.getStats.mockReturnValue({ total: 0, byStatus: { pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 } });
|
||||
researchStore.searchRuns.mockReturnValue([]);
|
||||
});
|
||||
|
||||
it("supports run CRUD", async () => {
|
||||
const list = await request(app, "GET", "/api/research/runs");
|
||||
expect(list.status).toBe(200);
|
||||
|
||||
const created = await request(app, "POST", "/api/research/runs", JSON.stringify({ query: "topic" }), { "Content-Type": "application/json" });
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
researchStore.getRun.mockReturnValue(researchStore.createRun.mock.results[0]?.value ?? { id: "RR-1" });
|
||||
const get = await request(app, "GET", "/api/research/runs/RR-1");
|
||||
expect(get.status).toBe(200);
|
||||
|
||||
const patch = await request(app, "PATCH", "/api/research/runs/RR-1", JSON.stringify({ topic: "x" }), { "Content-Type": "application/json" });
|
||||
expect(patch.status).toBe(200);
|
||||
|
||||
const del = await request(app, "DELETE", "/api/research/runs/RR-1");
|
||||
expect(del.status).toBe(204);
|
||||
});
|
||||
|
||||
it("supports events, sources, results and exports", async () => {
|
||||
const evt = await request(app, "POST", "/api/research/runs/RR-1/events", JSON.stringify({ type: "info", message: "hello" }), { "Content-Type": "application/json" });
|
||||
expect(evt.status).toBe(201);
|
||||
|
||||
const src = await request(app, "POST", "/api/research/runs/RR-1/sources", JSON.stringify({ type: "web", reference: "https://x.com", status: "pending" }), { "Content-Type": "application/json" });
|
||||
expect(src.status).toBe(201);
|
||||
|
||||
const srcPatch = await request(app, "PATCH", "/api/research/runs/RR-1/sources/S1", JSON.stringify({ status: "completed" }), { "Content-Type": "application/json" });
|
||||
expect(srcPatch.status).toBe(204);
|
||||
|
||||
const results = await request(app, "PUT", "/api/research/runs/RR-1/results", JSON.stringify({ findings: [] }), { "Content-Type": "application/json" });
|
||||
expect(results.status).toBe(204);
|
||||
|
||||
researchStore.createExport.mockReturnValue({ id: "EX1", runId: "RR-1", format: "json", content: "{}", createdAt: "x" });
|
||||
const createEx = await request(app, "POST", "/api/research/runs/RR-1/exports", JSON.stringify({ format: "json", content: "{}" }), { "Content-Type": "application/json" });
|
||||
expect(createEx.status).toBe(201);
|
||||
|
||||
researchStore.getExports.mockReturnValue([{ id: "EX1", runId: "RR-1", format: "json", content: "{}", createdAt: "x" }]);
|
||||
const listEx = await request(app, "GET", "/api/research/runs/RR-1/exports");
|
||||
expect(listEx.status).toBe(200);
|
||||
expect((listEx.body as { exports: unknown[] }).exports).toHaveLength(1);
|
||||
|
||||
researchStore.getExport.mockReturnValue({ id: "EX1", runId: "RR-1", format: "json", content: "{}", createdAt: "x" });
|
||||
const getEx = await request(app, "GET", "/api/research/exports/EX1");
|
||||
expect(getEx.status).toBe(200);
|
||||
});
|
||||
|
||||
it("supports cancel and retry with structured success and 409 responses", async () => {
|
||||
researchStore.getRun.mockReturnValue({ id: "RR-1", query: "q", status: "running", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
|
||||
|
||||
const cancel = await request(app, "POST", "/api/research/runs/RR-1/cancel");
|
||||
expect(cancel.status).toBe(200);
|
||||
expect((cancel.body as any).run.status).toBe("cancelling");
|
||||
|
||||
const retry = await request(app, "POST", "/api/research/runs/RR-1/retry");
|
||||
expect(retry.status).toBe(200);
|
||||
expect((retry.body as any).run.status).toBe("retry_waiting");
|
||||
|
||||
researchStore.getRun.mockReturnValue({ id: "RR-1", query: "q", status: "completed", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
|
||||
const cancelConflict = await request(app, "POST", "/api/research/runs/RR-1/cancel");
|
||||
expect(cancelConflict.status).toBe(409);
|
||||
expect((cancelConflict.body as any).error).toContain("cannot be cancelled");
|
||||
expect((cancelConflict.body as any).code).toBe("INVALID_TRANSITION");
|
||||
|
||||
researchStore.getRun.mockReturnValue({ id: "RR-1", query: "q", status: "retry_exhausted", lifecycle: { errorCode: "RETRY_EXHAUSTED" }, sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
|
||||
researchStore.createRetryRun.mockImplementationOnce(() => {
|
||||
throw new ResearchLifecycleError("Run RR-1 exhausted retries", "not_retryable");
|
||||
});
|
||||
const retryConflict = await request(app, "POST", "/api/research/runs/RR-1/retry");
|
||||
expect(retryConflict.status).toBe(409);
|
||||
expect((retryConflict.body as any).error).toContain("exhausted");
|
||||
});
|
||||
|
||||
it("supports stats, search and validation errors", async () => {
|
||||
const stats = await request(app, "GET", "/api/research/stats");
|
||||
expect(stats.status).toBe(200);
|
||||
|
||||
const search = await request(app, "GET", "/api/research/search?q=test");
|
||||
expect(search.status).toBe(200);
|
||||
|
||||
const invalidStatus = await request(app, "PATCH", "/api/research/runs/RR-1/status", JSON.stringify({ status: "bogus" }), { "Content-Type": "application/json" });
|
||||
expect(invalidStatus.status).toBe(400);
|
||||
expect((invalidStatus.body as any).error).toContain("Invalid status");
|
||||
if ((invalidStatus.body as any).code !== undefined) {
|
||||
expect(typeof (invalidStatus.body as any).code).toBe("string");
|
||||
}
|
||||
|
||||
const invalidEvent = await request(app, "POST", "/api/research/runs/RR-1/events", JSON.stringify({ type: "bad", message: "x" }), { "Content-Type": "application/json" });
|
||||
expect(invalidEvent.status).toBe(400);
|
||||
|
||||
const invalidSourceType = await request(app, "POST", "/api/research/runs/RR-1/sources", JSON.stringify({ type: "bad", reference: "x", status: "pending" }), { "Content-Type": "application/json" });
|
||||
expect(invalidSourceType.status).toBe(400);
|
||||
|
||||
const invalidSourceStatus = await request(app, "POST", "/api/research/runs/RR-1/sources", JSON.stringify({ type: "web", reference: "x", status: "bad" }), { "Content-Type": "application/json" });
|
||||
expect(invalidSourceStatus.status).toBe(400);
|
||||
|
||||
researchStore.getExport.mockReturnValue(undefined);
|
||||
const missingExport = await request(app, "GET", "/api/research/exports/EX-404");
|
||||
expect(missingExport.status).toBe(404);
|
||||
expect((missingExport.body as any).error).toContain("Export not found");
|
||||
if ((missingExport.body as any).code !== undefined) {
|
||||
expect(typeof (missingExport.body as any).code).toBe("string");
|
||||
}
|
||||
|
||||
researchStore.getRun.mockReturnValue(undefined);
|
||||
const missing = await request(app, "GET", "/api/research/runs/RR-404");
|
||||
expect(missing.status).toBe(404);
|
||||
expect((missing.body as any).error).toContain("Run not found");
|
||||
if ((missing.body as any).code !== undefined) {
|
||||
expect(typeof (missing.body as any).code).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns export payload with json content type", async () => {
|
||||
researchStore.getExport.mockReturnValue({ id: "EX1", runId: "RR-1", format: "json", content: "{}", createdAt: "x" });
|
||||
|
||||
const response = await request(app, "GET", "/api/research/exports/EX1");
|
||||
expect(response.status).toBe(200);
|
||||
expect((response.headers["content-type"] as string)).toContain("application/json");
|
||||
expect(response.body).toMatchObject({ id: "EX1", runId: "RR-1", format: "json", content: "{}" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user