mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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.
176 lines
5.9 KiB
TypeScript
176 lines
5.9 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|