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);
});
});
+398
View File
@@ -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();
});
});