test(metrics): cached figures agree with stored commits + vitest stubs

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.
This commit is contained in:
Dave 2026-09-16 15:19:32 +02:00
parent 53979f389d
commit cd0984595c
4 changed files with 95 additions and 6 deletions

View file

@ -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 {};

View file

@ -33,6 +33,11 @@ async function makeCard(fields: Record<string, unknown>) {
}
async function makeCommit(fields: Record<string, unknown>) {
// 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<string, unknown>) {
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,

View file

@ -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<string, number> };
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");
});
});

View file

@ -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: {