Files
HealthLog/tests/integration/mood-insights-crosstab.test.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

176 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.12.0 — tag × health-metric crosstab over the real DB read path.
*
* Seeds a user with structured-tagged mood entries and ACTIVE_ENERGY_BURNED
* measurements, then asserts `fetchMoodAggregates` (the production read +
* orchestration used by `GET /api/mood/insights`) returns a populated
* `tagMetricCrosstab` row with the expected delta + display unit + lag mode.
* Exercises the widened measurement `type: { in: [...] }` filter and the
* structured-tag join end-to-end against Postgres.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { fetchMoodAggregates } from "@/lib/insights/mood-aggregates";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const TEST_USER_ID = "user-crosstab";
const dayMs = 24 * 60 * 60 * 1000;
const NOW = new Date("2026-06-01T12:00:00.000Z");
function dayKey(offset: number): string {
return new Date(NOW.getTime() - offset * dayMs).toISOString().slice(0, 10);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
const prisma = getPrismaClient();
await prisma.user.create({
data: {
id: TEST_USER_ID,
username: "crosstab",
email: "crosstab@example.test",
timezone: "Europe/Berlin",
},
});
});
describe("mood-insights tag × metric crosstab (DB read path)", () => {
it("surfaces a structured tag × ACTIVE_ENERGY_BURNED same-day association", async () => {
const prisma = getPrismaClient();
// The structured-tag catalog is seeded by migrations (0118/0119) and is
// NOT truncated between tests — use an existing catalog tag rather than
// minting one (its `key` is globally unique).
const tag = await prisma.moodTag.findFirst({
include: { category: true },
orderBy: { key: "asc" },
});
expect(tag).toBeTruthy();
// 12 tag-present days with high active energy, 12 tag-absent with low.
for (let i = 0; i < 24; i++) {
const present = i < 12;
const ts = new Date(NOW.getTime() - i * dayMs);
const moodEntry = await prisma.moodEntry.create({
data: {
userId: TEST_USER_ID,
date: dayKey(i),
mood: "GUT",
score: present ? 4 : 3,
source: "WEB",
moodLoggedAt: ts,
...(present
? { tagLinks: { create: [{ moodTagId: tag!.id }] } }
: {}),
},
});
expect(moodEntry.id).toBeTruthy();
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "ACTIVE_ENERGY_BURNED",
value: present
? 600 + (i % 2 === 0 ? 20 : -20)
: 350 + (i % 2 === 0 ? 20 : -20),
unit: "kcal",
source: "APPLE_HEALTH",
measuredAt: ts,
externalId: `stats:HKQuantityTypeIdentifierActiveEnergyBurned:${dayKey(i)}`,
},
});
}
const aggregates = await fetchMoodAggregates(TEST_USER_ID, NOW);
const row = aggregates.tagMetricCrosstab.find(
(r) => r.tag === tag!.key && r.metricKey === "activeEnergy",
);
expect(row).toBeDefined();
expect(row!.display).toBe("kcal");
expect(row!.mode).toBe("sameDay");
expect(row!.labelKey).toBe(tag!.labelKey);
expect(row!.categoryKey).toBe(tag!.category.key);
expect(row!.withDays).toBe(12);
expect(row!.withoutDays).toBe(12);
expect(row!.withAvg).toBeCloseTo(600, 0);
expect(row!.withoutAvg).toBeCloseTo(350, 0);
expect(row!.delta).toBeGreaterThan(0);
expect(row!.confidence).toBe("high");
expect(row!.qValue).toBeLessThanOrEqual(0.1);
});
// v1.12.1 — cross-source double-count guard over the real read path. Two
// sources (Apple + Fitbit) report the same active-energy total each present
// day; the read's canonical-source pick must keep one (Apple, per the
// activeEnergy ladder) so the per-day sum is ~600, not ~1200.
it("counts active energy once when Apple + Fitbit report the same day", async () => {
const prisma = getPrismaClient();
const tag = await prisma.moodTag.findFirst({
include: { category: true },
orderBy: { key: "asc" },
});
expect(tag).toBeTruthy();
for (let i = 0; i < 24; i++) {
const present = i < 12;
const ts = new Date(NOW.getTime() - i * dayMs);
await prisma.moodEntry.create({
data: {
userId: TEST_USER_ID,
date: dayKey(i),
mood: "GUT",
score: present ? 4 : 3,
source: "WEB",
moodLoggedAt: ts,
...(present
? { tagLinks: { create: [{ moodTagId: tag!.id }] } }
: {}),
},
});
const value = present
? 600 + (i % 2 === 0 ? 20 : -20)
: 350 + (i % 2 === 0 ? 20 : -20);
// Apple stream every day.
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "ACTIVE_ENERGY_BURNED",
value,
unit: "kcal",
source: "APPLE_HEALTH",
measuredAt: ts,
externalId: `apple:${dayKey(i)}`,
},
});
// Fitbit twin on the present days — must NOT add on top of Apple's total.
if (present) {
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "ACTIVE_ENERGY_BURNED",
value,
unit: "kcal",
source: "FITBIT",
measuredAt: ts,
externalId: `fitbit:${dayKey(i)}`,
},
});
}
}
const aggregates = await fetchMoodAggregates(TEST_USER_ID, NOW);
const row = aggregates.tagMetricCrosstab.find(
(r) => r.tag === tag!.key && r.metricKey === "activeEnergy",
);
expect(row).toBeDefined();
// ~600 (Apple wins), NOT ~1200 (Apple + Fitbit double-counted).
expect(row!.withAvg).toBeCloseTo(600, 0);
expect(row!.withoutAvg).toBeCloseTo(350, 0);
expect(row!.delta).toBeCloseTo(250, 0);
});
});