test(mood-rollups): writer, reader, and route parity coverage

Unit tests pin the per-write hook contract (DAY upsert + WEEK/MONTH/
YEAR enqueue + delete-on-empty + idempotency under re-run), the
warm-up helper (no-op on fresh / dedup on concurrent / annotate on
populator failure), and the boot-time discovery enqueue. Route
tests pin the parity contract: identical entries + summary shape
between the rollup fast path and the live-fallback walk, plus the
LRU warm-read invariant.
This commit is contained in:
Marc-André Bombeck committed 2026-05-18 12:28:58 +02:00
1 parent d15850d526
commit 87d682d40b
2 files changed
+734

No files matched your search

@@ -0,0 +1,336 @@
/**
* v1.4.39 W-MOOD — integration coverage for the rollup-tier read swap
* in `/api/mood/analytics`.
*
* Pins the parity contract the audit at
* `.planning/round-v1438-perf-analysis.md` §2.3 calls for: the
* response shape stays byte-identical when the rollup tier is warm,
* and falls back to the legacy live walk once when a legacy account
* has mood entries but no rollup rows yet.
*
* The fast path consumes `prisma.moodEntryRollup.findMany`; the
* coverage fallback consumes `prisma.moodEntry.findMany` (legacy
* shape). Both branches go through the `cached(caches.moodAnalytics)`
* LRU so the second read in a test must observe the same response.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
findMany: vi.fn(),
findFirst: vi.fn().mockResolvedValue(null),
},
moodEntryRollup: {
findMany: vi.fn(),
findFirst: vi.fn().mockResolvedValue(null),
},
$queryRawUnsafe: vi.fn().mockResolvedValue([]),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// Stub the warm-up helper so test runs don't fire the real
// `recomputeUserMoodRollups` aggregate. The warm-up is fire-and-
// forget on the route so its return value is irrelevant for parity.
vi.mock("@/lib/mood/rollups", async () => {
const actual = await vi.importActual<typeof import("@/lib/mood/rollups")>(
"@/lib/mood/rollups",
);
return {
...actual,
ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
};
});
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_USER = {
id: "user-mood-1",
username: "marc",
role: "USER" as const,
timezone: "Europe/Berlin",
heightCm: 180,
dateOfBirth: new Date("1980-01-01T00:00:00Z"),
sourcePriorityJson: null,
};
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: SESSION_USER as never,
};
const callGet = GET as unknown as (...args: never[]) => Promise<Response>;
interface MoodAnalyticsBody {
data: {
entries: Array<{ date: string; score: number; samples: number }>;
summary: {
count: number;
latest: number | null;
min: number | null;
max: number | null;
mean: number | null;
};
};
}
beforeEach(() => {
vi.resetAllMocks();
__resetAllCachesForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("GET /api/mood/analytics", () => {
it("returns an empty envelope for a brand-new user with zero rollups and zero raw entries", async () => {
// Both branches return an empty result: rollup tier empty, raw
// mood-entry table empty. The legacy `entries` array is also
// empty so the response is identical to the pre-swap shape.
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as MoodAnalyticsBody;
expect(body.data.entries).toEqual([]);
expect(body.data.summary.count).toBe(0);
expect(body.data.summary.latest).toBeNull();
expect(body.data.summary.min).toBeNull();
expect(body.data.summary.max).toBeNull();
expect(body.data.summary.mean).toBeNull();
});
it("consumes the rollup tier when DAY rows exist and skips the raw findMany", async () => {
// One DAY-rollup row per calendar day, three days running.
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2026-05-08T00:00:00.000Z"),
count: 1,
mean: 4,
minScore: 4,
maxScore: 4,
sd: null,
computedAt: new Date(),
},
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2026-05-09T00:00:00.000Z"),
count: 1,
mean: 5,
minScore: 5,
maxScore: 5,
sd: null,
computedAt: new Date(),
},
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 2,
mean: 4.5,
minScore: 4,
maxScore: 5,
sd: 0.5,
computedAt: new Date(),
},
] as never);
const res = await callGet();
expect(res.status).toBe(200);
// The raw `moodEntry.findMany` must not fire on the fast path.
expect(prisma.moodEntry.findMany).not.toHaveBeenCalled();
const body = (await res.json()) as MoodAnalyticsBody;
expect(body.data.entries).toEqual([
{ date: "2026-05-08", score: 4, samples: 1 },
{ date: "2026-05-09", score: 5, samples: 1 },
{ date: "2026-05-10", score: 4.5, samples: 2 },
]);
expect(body.data.summary.count).toBe(3);
expect(body.data.summary.latest).toBe(4.5);
expect(body.data.summary.min).toBe(4);
expect(body.data.summary.max).toBe(5);
});
it("falls back to the live findMany once when the rollup tier is empty but mood entries exist", async () => {
// Coverage-fallback: legacy account with raw entries but no
// rollup coverage yet. The route runs the legacy walk once;
// the warm-up helper is supposed to fire asynchronously
// (stubbed above).
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{
date: "2026-05-08",
score: 4,
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
},
{
date: "2026-05-09",
score: 5,
moodLoggedAt: new Date("2026-05-09T12:00:00.000Z"),
},
] as never);
const res = await callGet();
expect(res.status).toBe(200);
// Live fallback fired exactly once.
expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1);
const body = (await res.json()) as MoodAnalyticsBody;
expect(body.data.entries).toEqual([
{ date: "2026-05-08", score: 4, samples: 1 },
{ date: "2026-05-09", score: 5, samples: 1 },
]);
expect(body.data.summary.count).toBe(2);
expect(body.data.summary.latest).toBe(5);
});
it("serves the cached envelope on a warm read without touching the DB", async () => {
// First read populates the cache.
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 1,
mean: 4,
minScore: 4,
maxScore: 4,
sd: null,
computedAt: new Date(),
},
] as never);
const first = await callGet();
expect(first.status).toBe(200);
expect(prisma.moodEntryRollup.findMany).toHaveBeenCalledTimes(1);
// Second read hits the LRU and never touches Prisma again.
const second = await callGet();
expect(second.status).toBe(200);
expect(prisma.moodEntryRollup.findMany).toHaveBeenCalledTimes(1);
const firstBody = (await first.json()) as MoodAnalyticsBody;
const secondBody = (await second.json()) as MoodAnalyticsBody;
expect(secondBody).toEqual(firstBody);
});
it("emits byte-identical entries + summary shape between rollup tier and live fallback for the same canonical data", async () => {
// Same one-entry-per-day series, surfaced once via the rollup
// tier and once via the legacy live walk. Response shape is
// expected to be byte-compatible — the rollup carries the
// pre-aggregated daily mean which is precisely what
// aggregateDailyAverages would produce from a single entry
// per day.
const canonicalEntries = [
{
date: "2026-05-08",
score: 3,
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
},
{
date: "2026-05-09",
score: 4,
moodLoggedAt: new Date("2026-05-09T12:00:00.000Z"),
},
{
date: "2026-05-10",
score: 5,
moodLoggedAt: new Date("2026-05-10T12:00:00.000Z"),
},
];
// Live-fallback request first (rollup empty + raw populated).
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue(
canonicalEntries as never,
);
const liveRes = await callGet();
const liveBody = (await liveRes.json()) as MoodAnalyticsBody;
// Bust the cache before the rollup-tier read so the second call
// re-evaluates the read path with the rollup populated.
__resetAllCachesForTests();
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2026-05-08T00:00:00.000Z"),
count: 1,
mean: 3,
minScore: 3,
maxScore: 3,
sd: null,
computedAt: new Date(),
},
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2026-05-09T00:00:00.000Z"),
count: 1,
mean: 4,
minScore: 4,
maxScore: 4,
sd: null,
computedAt: new Date(),
},
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 1,
mean: 5,
minScore: 5,
maxScore: 5,
sd: null,
computedAt: new Date(),
},
] as never);
const rollupRes = await callGet();
const rollupBody = (await rollupRes.json()) as MoodAnalyticsBody;
// `entries` shape — date / score / samples — must be byte-identical.
expect(rollupBody.data.entries).toEqual(liveBody.data.entries);
// Summary core stats must match (count / min / max / mean /
// latest). Slope windows depend on Date.now() so we don't compare
// those — they are deterministic for the same input and the
// input is identical here.
expect(rollupBody.data.summary.count).toBe(liveBody.data.summary.count);
expect(rollupBody.data.summary.latest).toBe(liveBody.data.summary.latest);
expect(rollupBody.data.summary.min).toBe(liveBody.data.summary.min);
expect(rollupBody.data.summary.max).toBe(liveBody.data.summary.max);
expect(rollupBody.data.summary.mean).toBe(liveBody.data.summary.mean);
});
});