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>) {
|
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({
|
const commit = await admin.collection("commits").create({
|
||||||
project: projectId,
|
project: projectId,
|
||||||
sha: `probe${Date.now()}${Math.random().toString(36).slice(2, 8)}`,
|
sha: `probe${Date.now()}${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
|
@ -43,7 +48,7 @@ async function makeCommit(fields: Record<string, unknown>) {
|
||||||
scope: "probe",
|
scope: "probe",
|
||||||
author_name: "Dave",
|
author_name: "Dave",
|
||||||
author_email: "dave@example.com",
|
author_email: "dave@example.com",
|
||||||
authored_at: new Date().toISOString(),
|
authored_at: cardCreated,
|
||||||
is_merge: false,
|
is_merge: false,
|
||||||
link_source: "none",
|
link_source: "none",
|
||||||
...fields,
|
...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 { defineConfig } from "vitest/config";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|
@ -17,11 +18,15 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: { "@": path.resolve(__dirname, "./src") },
|
alias: {
|
||||||
// server-only throws on import unless this condition is set. The unit
|
"@": path.resolve(__dirname, "./src"),
|
||||||
// project deliberately does NOT set it: pure modules must stay free
|
// Replace server-only with the empty stub so integration tests
|
||||||
// of server imports, and a failure there is the signal that one crept
|
// can import server modules without "imported from a Client
|
||||||
// in.
|
// 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"],
|
conditions: ["react-server", "node", "import", "default"],
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue