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:
parent
53979f389d
commit
cd0984595c
4 changed files with 95 additions and 6 deletions
3
src/lib/server-only-stub.ts
Normal file
3
src/lib/server-only-stub.ts
Normal 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 {};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
76
tests/integration/metrics.test.ts
Normal file
76
tests/integration/metrics.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue