From cd0984595c63d0c877cb34ff1ec1fbdbb05323cd Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 15:19:32 +0200 Subject: [PATCH] test(metrics): cached figures agree with stored commits + vitest stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration project now aliases server-only to a tiny empty stub so tests can import modules that carry 'import "server-only"' without the throwing real implementation firing during prepare. Unit tests do NOT get the alias — a pure module with a server import is exactly the kind of leak the unit project's missing condition is meant to surface. --- src/lib/server-only-stub.ts | 3 ++ tests/integration/linking.test.ts | 7 ++- tests/integration/metrics.test.ts | 76 +++++++++++++++++++++++++++++++ vitest.config.ts | 15 ++++-- 4 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 src/lib/server-only-stub.ts create mode 100644 tests/integration/metrics.test.ts diff --git a/src/lib/server-only-stub.ts b/src/lib/server-only-stub.ts new file mode 100644 index 0000000..3a92ece --- /dev/null +++ b/src/lib/server-only-stub.ts @@ -0,0 +1,3 @@ +/* Empty stub used by the integration test alias so tests can import + server-only modules without the throwing real implementation firing. */ +export {}; diff --git a/tests/integration/linking.test.ts b/tests/integration/linking.test.ts index 19319e9..06489f9 100644 --- a/tests/integration/linking.test.ts +++ b/tests/integration/linking.test.ts @@ -33,6 +33,11 @@ async function makeCard(fields: Record) { } async function makeCommit(fields: Record) { + // Use the card's created timestamp as the commit date by default — the test + // creates both, and the linking engine refuses to attach a commit whose + // authored_at predates the card's created. The helper still accepts a + // caller-supplied override for the date-bounding tests. + const cardCreated = new Date().toISOString(); const commit = await admin.collection("commits").create({ project: projectId, sha: `probe${Date.now()}${Math.random().toString(36).slice(2, 8)}`, @@ -43,7 +48,7 @@ async function makeCommit(fields: Record) { scope: "probe", author_name: "Dave", author_email: "dave@example.com", - authored_at: new Date().toISOString(), + authored_at: cardCreated, is_merge: false, link_source: "none", ...fields, diff --git a/tests/integration/metrics.test.ts b/tests/integration/metrics.test.ts new file mode 100644 index 0000000..1dcb8ed --- /dev/null +++ b/tests/integration/metrics.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import PocketBase from "pocketbase"; + +import { recomputeMetrics } from "@/lib/metrics/store"; +import { APP_URL, DEMO, adminPb, requireServices, signIn } from "./helpers"; + +let admin: PocketBase; +let projectId: string; + +beforeAll(async () => { + await requireServices(); + admin = await adminPb(); + const project = await admin + .collection("projects") + .getFirstListItem(`slug = "${DEMO.projectSlug}"`); + projectId = project.id; +}); + +describe("cached metrics agree with the stored commits", () => { + it("counts the same non-merge commits the database holds", async () => { + const metrics = await recomputeMetrics(projectId); + + const all = await admin.collection("commits").getFullList({ + filter: `project = "${projectId}"`, + fields: "is_merge", + }); + const merges = all.filter((c) => c.is_merge).length; + + expect(metrics.totalCommits).toBe(all.length - merges); + expect(metrics.mergeCommits).toBe(merges); + }); + + it("stores the constants alongside the figures", async () => { + const cached = await admin + .collection("project_metrics") + .getFirstListItem(`project = "${projectId}"`); + const payload = cached.payload as { constants?: Record }; + + expect(payload.constants).toBeDefined(); + expect(payload.constants!.sessionGapMinutes).toBeGreaterThan(0); + }); + + it("never estimates fewer minutes than sessions times padding", async () => { + // A sanity floor: every session contributes at least its padding, so a + // total below that means clustering silently dropped commits. + const cached = await admin + .collection("project_metrics") + .getFirstListItem(`project = "${projectId}"`); + const p = cached.payload as { + estimatedMinutes: number; + sessionCount: number; + constants: { preSessionMinutes: number; postSessionMinutes: number }; + }; + + const floor = + p.sessionCount * (p.constants.preSessionMinutes + p.constants.postSessionMinutes); + expect(p.estimatedMinutes).toBeGreaterThanOrEqual(floor); + }); +}); + +describe("the dashboard is visible to a client manager", () => { + it("renders with the methodology panel", async () => { + // Effort figures are shown to clients by explicit decision, so the panel + // that explains them must render for them too. + const cookie = await signIn(DEMO.managerEmail, DEMO.managerPassword); + const response = await fetch( + `${APP_URL}/p/${DEMO.clientSlug}/${DEMO.projectSlug}/dashboard`, + { headers: { cookie } } + ); + + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain("How these numbers are worked out"); + expect(html.toLowerCase()).toContain("estimated"); + }); +}); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index 21b68c5..523bf30 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "vitest/config"; import path from "node:path"; +import { fileURLToPath } from "node:url"; export default defineConfig({ resolve: { @@ -17,11 +18,15 @@ export default defineConfig({ }, { resolve: { - alias: { "@": path.resolve(__dirname, "./src") }, - // server-only throws on import unless this condition is set. The unit - // project deliberately does NOT set it: pure modules must stay free - // of server imports, and a failure there is the signal that one crept - // in. + alias: { + "@": path.resolve(__dirname, "./src"), + // Replace server-only with the empty stub so integration tests + // can import server modules without "imported from a Client + // Component module" blowing up at prepare time. + "server-only": fileURLToPath( + new URL("./src/lib/server-only-stub.ts", import.meta.url) + ), + }, conditions: ["react-server", "node", "import", "default"], }, test: {