mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* v1.4.39 W-MOOD — unit tests for the persistent mood-rollup
|
||||
* populator.
|
||||
*
|
||||
* `prisma` is mocked at the module level so we can pin:
|
||||
* - the DAY-bucket sync recompute writes through the upsert path,
|
||||
* - `recomputeMoodBucketsForEntry` is idempotent under re-run,
|
||||
* - `enqueueMoodRollupRecompute` calls `boss.send` with the
|
||||
* documented queue + singleton-key shape, and is a silent no-op
|
||||
* when no boss is attached,
|
||||
* - `ensureUserMoodRollupsFresh` short-circuits on fresh data and
|
||||
* dedups concurrent callers onto one in-flight promise,
|
||||
* - the boot-time backfill discovers users with mood entries but
|
||||
* no rollup coverage and enqueues one job per uncovered user.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// vi.mock factories run BEFORE module-level statements due to hoisting,
|
||||
// so the mock fns must live inside the factory closure. We re-export
|
||||
// them through `vi.hoisted` so the test body can reach them after.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
queryRaw: vi.fn(),
|
||||
queryRawUnsafe: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirstMoodEntry: vi.fn(),
|
||||
bossSend: vi.fn(),
|
||||
getGlobalBossMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: mocks.queryRaw,
|
||||
$queryRawUnsafe: mocks.queryRawUnsafe,
|
||||
moodEntryRollup: {
|
||||
upsert: mocks.upsert,
|
||||
deleteMany: mocks.deleteMany,
|
||||
findFirst: mocks.findFirst,
|
||||
findMany: mocks.findMany,
|
||||
},
|
||||
moodEntry: {
|
||||
findFirst: mocks.findFirstMoodEntry,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: () => mocks.getGlobalBossMock(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const {
|
||||
queryRaw,
|
||||
queryRawUnsafe,
|
||||
upsert,
|
||||
deleteMany,
|
||||
findFirst,
|
||||
findFirstMoodEntry,
|
||||
bossSend,
|
||||
getGlobalBossMock,
|
||||
} = mocks;
|
||||
// `findMany` is wired in so indirect lookups don't throw; the
|
||||
// populator tests below exercise the `$queryRawUnsafe` + upsert
|
||||
// paths so the reference stays unused inside this file.
|
||||
void mocks.findMany;
|
||||
void queryRaw;
|
||||
|
||||
import {
|
||||
MOOD_ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
MOOD_ROLLUP_RECOMPUTE_QUEUE,
|
||||
_resetEnsureUserMoodRollupsFreshInFlightForTests,
|
||||
enqueueBootTimeMoodRollupBackfill,
|
||||
enqueueMoodRollupRecompute,
|
||||
ensureUserMoodRollupsFresh,
|
||||
recomputeMoodBucketsForEntry,
|
||||
} from "../rollups";
|
||||
|
||||
beforeEach(() => {
|
||||
queryRaw.mockReset();
|
||||
queryRawUnsafe.mockReset();
|
||||
upsert.mockReset();
|
||||
deleteMany.mockReset();
|
||||
findFirst.mockReset();
|
||||
mocks.findMany.mockReset();
|
||||
findFirstMoodEntry.mockReset();
|
||||
bossSend.mockReset();
|
||||
getGlobalBossMock.mockReset();
|
||||
// v1.4.39 — clear the per-userId in-flight map so a previous
|
||||
// test's resolved promise does not short-circuit the next test.
|
||||
_resetEnsureUserMoodRollupsFreshInFlightForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("recomputeMoodBucketsForEntry", () => {
|
||||
it("upserts the DAY rollup synchronously and enqueues WEEK/MONTH/YEAR", async () => {
|
||||
queryRawUnsafe.mockResolvedValueOnce([
|
||||
{
|
||||
bucket_start: new Date("2026-05-10T00:00:00.000Z"),
|
||||
count: BigInt(3),
|
||||
mean: 4.0,
|
||||
min_score: 3,
|
||||
max_score: 5,
|
||||
sd: 0.82,
|
||||
},
|
||||
]);
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
bossSend.mockResolvedValue("job-id");
|
||||
|
||||
await recomputeMoodBucketsForEntry(
|
||||
"user-1",
|
||||
new Date("2026-05-10T14:30:00.000Z"),
|
||||
);
|
||||
|
||||
// DAY pass — single upsert against the rollup table.
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
const upsertArg = upsert.mock.calls[0][0];
|
||||
expect(upsertArg.where.userId_granularity_bucketStart.userId).toBe(
|
||||
"user-1",
|
||||
);
|
||||
expect(upsertArg.where.userId_granularity_bucketStart.granularity).toBe(
|
||||
"DAY",
|
||||
);
|
||||
expect(upsertArg.create.count).toBe(3);
|
||||
expect(upsertArg.create.mean).toBe(4);
|
||||
expect(upsertArg.create.minScore).toBe(3);
|
||||
expect(upsertArg.create.maxScore).toBe(5);
|
||||
expect(upsertArg.create.sd).toBeCloseTo(0.82);
|
||||
|
||||
// WEEK / MONTH / YEAR — three enqueues against the worker queue.
|
||||
expect(bossSend).toHaveBeenCalledTimes(3);
|
||||
for (const call of bossSend.mock.calls) {
|
||||
expect(call[0]).toBe(MOOD_ROLLUP_RECOMPUTE_QUEUE);
|
||||
expect(call[1].userId).toBe("user-1");
|
||||
expect(["WEEK", "MONTH", "YEAR"]).toContain(call[1].granularity);
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes the DAY row when the post-mutation aggregate is empty", async () => {
|
||||
// Post-delete recompute: the day now has zero mood rows.
|
||||
queryRawUnsafe.mockResolvedValueOnce([]);
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
|
||||
await recomputeMoodBucketsForEntry(
|
||||
"user-1",
|
||||
new Date("2026-05-10T14:30:00.000Z"),
|
||||
);
|
||||
|
||||
expect(deleteMany).toHaveBeenCalledTimes(1);
|
||||
expect(deleteMany.mock.calls[0][0].where.userId).toBe("user-1");
|
||||
expect(deleteMany.mock.calls[0][0].where.granularity).toBe("DAY");
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is idempotent under re-run for the same (user, day)", async () => {
|
||||
// Two consecutive calls with the same aggregate output should
|
||||
// result in two upserts that both carry the same payload — the
|
||||
// composite PK absorbs the second write into a no-op refresh on
|
||||
// the database side, but the populator must keep firing the
|
||||
// upsert each time (no in-memory dedup) so a real Postgres-side
|
||||
// change is picked up on the next call.
|
||||
const aggregate = [
|
||||
{
|
||||
bucket_start: new Date("2026-05-10T00:00:00.000Z"),
|
||||
count: BigInt(2),
|
||||
mean: 4.5,
|
||||
min_score: 4,
|
||||
max_score: 5,
|
||||
sd: 0.5,
|
||||
},
|
||||
];
|
||||
queryRawUnsafe.mockResolvedValue(aggregate);
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
|
||||
await recomputeMoodBucketsForEntry(
|
||||
"user-1",
|
||||
new Date("2026-05-10T14:30:00.000Z"),
|
||||
);
|
||||
await recomputeMoodBucketsForEntry(
|
||||
"user-1",
|
||||
new Date("2026-05-10T14:30:00.000Z"),
|
||||
);
|
||||
|
||||
expect(upsert).toHaveBeenCalledTimes(2);
|
||||
const firstPayload = upsert.mock.calls[0][0].create;
|
||||
const secondPayload = upsert.mock.calls[1][0].create;
|
||||
expect(firstPayload.count).toBe(secondPayload.count);
|
||||
expect(firstPayload.mean).toBe(secondPayload.mean);
|
||||
expect(firstPayload.minScore).toBe(secondPayload.minScore);
|
||||
expect(firstPayload.maxScore).toBe(secondPayload.maxScore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enqueueMoodRollupRecompute", () => {
|
||||
it("is a silent no-op when no boss is attached", async () => {
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
await enqueueMoodRollupRecompute({
|
||||
userId: "user-1",
|
||||
granularity: "WEEK",
|
||||
from: new Date("2026-05-04T00:00:00.000Z"),
|
||||
to: new Date("2026-05-11T00:00:00.000Z"),
|
||||
});
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends with the documented queue + singleton-key shape", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
bossSend.mockResolvedValue("job-1");
|
||||
const from = new Date("2026-05-04T00:00:00.000Z");
|
||||
await enqueueMoodRollupRecompute({
|
||||
userId: "user-9",
|
||||
granularity: "WEEK",
|
||||
from,
|
||||
to: new Date("2026-05-11T00:00:00.000Z"),
|
||||
});
|
||||
expect(bossSend).toHaveBeenCalledTimes(1);
|
||||
const [queue, payload, opts] = bossSend.mock.calls[0];
|
||||
expect(queue).toBe(MOOD_ROLLUP_RECOMPUTE_QUEUE);
|
||||
expect(payload.userId).toBe("user-9");
|
||||
expect(payload.granularity).toBe("WEEK");
|
||||
expect(payload.from).toBe(from.toISOString());
|
||||
expect(opts.singletonKey).toBe(`user-9|WEEK|${from.toISOString()}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureUserMoodRollupsFresh", () => {
|
||||
it("is a no-op when the user has no mood entries", async () => {
|
||||
findFirst.mockResolvedValueOnce(null);
|
||||
findFirstMoodEntry.mockResolvedValueOnce(null);
|
||||
const result = await ensureUserMoodRollupsFresh("user-1");
|
||||
expect(result.recomputed).toBe(false);
|
||||
expect(queryRawUnsafe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op when the rollup is already ahead of the newest mood entry", async () => {
|
||||
const rollupAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
const entryAt = new Date("2026-05-10T11:00:00.000Z");
|
||||
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
|
||||
findFirstMoodEntry.mockResolvedValueOnce({
|
||||
updatedAt: entryAt,
|
||||
moodLoggedAt: entryAt,
|
||||
});
|
||||
const result = await ensureUserMoodRollupsFresh("user-1");
|
||||
expect(result.recomputed).toBe(false);
|
||||
expect(queryRawUnsafe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers a DAY-window recompute when stale", async () => {
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const entryAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
|
||||
findFirstMoodEntry.mockResolvedValueOnce({
|
||||
updatedAt: entryAt,
|
||||
moodLoggedAt: entryAt,
|
||||
});
|
||||
queryRawUnsafe.mockResolvedValueOnce([]);
|
||||
const result = await ensureUserMoodRollupsFresh("user-1");
|
||||
expect(result.recomputed).toBe(true);
|
||||
expect(queryRawUnsafe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("swallows populator errors so the read path never fails", async () => {
|
||||
findFirst.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
findFirstMoodEntry.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const result = await ensureUserMoodRollupsFresh("user-1");
|
||||
expect(result.recomputed).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("dedups concurrent callers for the same userId onto one in-flight promise", async () => {
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const entryAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
findFirst.mockResolvedValue({ computedAt: rollupAt });
|
||||
findFirstMoodEntry.mockResolvedValue({
|
||||
updatedAt: entryAt,
|
||||
moodLoggedAt: entryAt,
|
||||
});
|
||||
queryRawUnsafe.mockResolvedValue([]);
|
||||
|
||||
const [a, b, c] = await Promise.all([
|
||||
ensureUserMoodRollupsFresh("user-1"),
|
||||
ensureUserMoodRollupsFresh("user-1"),
|
||||
ensureUserMoodRollupsFresh("user-1"),
|
||||
]);
|
||||
expect(a.recomputed).toBe(true);
|
||||
expect(b.recomputed).toBe(true);
|
||||
expect(c.recomputed).toBe(true);
|
||||
expect(queryRawUnsafe).toHaveBeenCalledTimes(1);
|
||||
expect(findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(findFirstMoodEntry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("annotates the failure when the inner recompute throws", async () => {
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const entryAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
|
||||
findFirstMoodEntry.mockResolvedValueOnce({
|
||||
updatedAt: entryAt,
|
||||
moodLoggedAt: entryAt,
|
||||
});
|
||||
queryRawUnsafe.mockRejectedValueOnce(new Error("deadlock detected"));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const result = await ensureUserMoodRollupsFresh("user-1");
|
||||
|
||||
expect(result.recomputed).toBe(false);
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
meta: {
|
||||
mood_rollup_refresh_failed: true,
|
||||
mood_rollup_refresh_error: "deadlock detected",
|
||||
},
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("enqueueBootTimeMoodRollupBackfill", () => {
|
||||
it("is a silent no-op when no boss is attached", async () => {
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
const result = await enqueueBootTimeMoodRollupBackfill();
|
||||
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
|
||||
expect(queryRaw).not.toHaveBeenCalled();
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enqueues one full-fold job per user with mood entries but no rollups", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValueOnce([
|
||||
{ id: "user-a" },
|
||||
{ id: "user-b" },
|
||||
{ id: "user-c" },
|
||||
]);
|
||||
bossSend
|
||||
.mockResolvedValueOnce("job-a")
|
||||
.mockResolvedValueOnce("job-b")
|
||||
.mockResolvedValueOnce("job-c");
|
||||
|
||||
const result = await enqueueBootTimeMoodRollupBackfill();
|
||||
|
||||
expect(result).toEqual({ enqueued: 3, skipped: 0, error: null });
|
||||
expect(bossSend).toHaveBeenCalledTimes(3);
|
||||
for (const call of bossSend.mock.calls) {
|
||||
expect(call[0]).toBe(MOOD_ROLLUP_FULL_BACKFILL_QUEUE);
|
||||
}
|
||||
expect(bossSend.mock.calls[0][2].singletonKey).toBe(
|
||||
"mood-boot-backfill|user-a",
|
||||
);
|
||||
expect(bossSend.mock.calls[1][2].singletonKey).toBe(
|
||||
"mood-boot-backfill|user-b",
|
||||
);
|
||||
expect(bossSend.mock.calls[2][2].singletonKey).toBe(
|
||||
"mood-boot-backfill|user-c",
|
||||
);
|
||||
});
|
||||
|
||||
it("counts a `boss.send` returning null as 'skipped' (singleton coalesce)", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValueOnce([{ id: "user-a" }, { id: "user-b" }]);
|
||||
bossSend.mockResolvedValueOnce(null).mockResolvedValueOnce("job-b");
|
||||
|
||||
const result = await enqueueBootTimeMoodRollupBackfill();
|
||||
|
||||
expect(result).toEqual({ enqueued: 1, skipped: 1, error: null });
|
||||
});
|
||||
|
||||
it("returns the error message when the discovery query throws", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
|
||||
const result = await enqueueBootTimeMoodRollupBackfill();
|
||||
|
||||
expect(result.enqueued).toBe(0);
|
||||
expect(result.error).toBe("pool exhausted");
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns { enqueued: 0 } when no users need backfill", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await enqueueBootTimeMoodRollupBackfill();
|
||||
|
||||
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user