diff --git a/src/app/api/mood/analytics/__tests__/route.test.ts b/src/app/api/mood/analytics/__tests__/route.test.ts new file mode 100644 index 000000000..7f80be156 --- /dev/null +++ b/src/app/api/mood/analytics/__tests__/route.test.ts @@ -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( + "@/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; + +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); + }); +}); diff --git a/src/lib/mood/__tests__/rollups.test.ts b/src/lib/mood/__tests__/rollups.test.ts new file mode 100644 index 000000000..8239eb9a2 --- /dev/null +++ b/src/lib/mood/__tests__/rollups.test.ts @@ -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(); + }); +});