Files
HealthLog/tests/integration/achievements-expansion.test.ts
T
Marc-André BombeckandClaude Opus 4.7 75c74f1c49 feat(achievements): worker re-evaluates new + hidden achievements on data change
Two trigger fixes so the new achievements actually fire end-to-end.

The doctor-PDF hidden Easter-egg used to watch a non-existent
`doctor-report.export` audit action. The real route emits two:
`doctor-report.generate` (the JSON helper) and
`doctor-report.pdf.generate` (the PDF route). Either now counts.

The locale-flip hidden Easter-egg used to watch
`settings.locale.update`, which no endpoint emits — the locale
field shares the catch-all `profile.update` audit row with every
other profile field. Profile-update now emits a separate
`settings.locale.update` row when (and only when) the locale value
actually changes (compares the new value to the prior persisted
locale, suppresses the row when they match), with the from/to
values in the details payload for traceability.

Already-existing TanStack Query invalidation map covers the new
metrics: `measurementDependentKeys`, `moodDependentKeys`, and
`medicationDependentKeys` already include
`queryKeys.gamificationAchievements()`, so any measurement / mood /
intake mutation triggers a refetch and the route persists the new
unlock to `UserAchievement` (existing v1.4.15 mechanism).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:34:32 +02:00

181 lines
5.2 KiB
TypeScript

/**
* Integration test for the v1.4.18 achievement expansion.
*
* Drives a fresh Postgres user through the moments when expansion
* predicates fire, verifying:
* - mood entries persisted as `MoodEntry` rows are picked up by
* `getMoodMetrics` (the row shape uses YYYY-MM-DD strings, not
* Date objects, which is the most common drift surface)
* - measurement counts are split by `MeasurementType` correctly
* - earnability flags reflect what the user actually has
*
* We don't drive the full HTTP route here because the existing
* `medication_schedules.days_of_week` migration drift makes the route
* fail in the integration container; that's a pre-existing issue
* tracked separately. Instead we round-trip the prisma rows through
* `buildExpansionMetricValues` and `getEarnabilityFlags`, which is
* the same code-path the route exercises after the fetch.
*/
import { beforeEach, describe, expect, it } from "vitest";
import {
buildExpansionMetricValues,
getEarnabilityFlags,
} from "@/lib/gamification/expansion-metrics";
import { getPrismaClient, truncateAllTables } from "./setup";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
describe("achievements expansion — fresh user, real prisma rows", () => {
it("recognises a single mood entry as `hasMood: true`", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "mood-tracker",
email: "mood@example.test",
createdAt: new Date("2026-01-01T00:00:00Z"),
},
});
await prisma.moodEntry.create({
data: {
userId: user.id,
date: "2026-04-15",
mood: "GUT",
score: 4,
moodLoggedAt: new Date("2026-04-15T08:00:00Z"),
},
});
const moodEntries = await prisma.moodEntry.findMany({
where: { userId: user.id },
select: { date: true, score: true, moodLoggedAt: true },
});
const result = buildExpansionMetricValues({
measurements: [],
moodEntries,
intakeEvents: [],
auditEvents: [],
});
expect(result.moodEntryCount).toBe(1);
const flags = getEarnabilityFlags({
hasMedication: false,
moodEntryCount: result.moodEntryCount,
measurementCounts: {
weightCount: result.weightMeasurementCount,
bpCount: result.bpMeasurementCount,
pulseCount: result.pulseMeasurementCount,
},
});
expect(flags.hasMood).toBe(true);
expect(flags.hasWeight).toBe(false);
});
it("counts measurements by type from real DB rows", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "many-readings",
email: "readings@example.test",
createdAt: new Date("2026-01-01T00:00:00Z"),
},
});
await prisma.measurement.createMany({
data: [
{
userId: user.id,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date("2026-04-01T08:00:00Z"),
},
{
userId: user.id,
type: "WEIGHT",
value: 80.5,
unit: "kg",
measuredAt: new Date("2026-04-02T08:00:00Z"),
},
{
userId: user.id,
type: "BLOOD_PRESSURE_SYS",
value: 120,
unit: "mmHg",
measuredAt: new Date("2026-04-01T08:00:00Z"),
},
{
userId: user.id,
type: "BLOOD_PRESSURE_DIA",
value: 80,
unit: "mmHg",
measuredAt: new Date("2026-04-01T08:00:00Z"),
},
{
userId: user.id,
type: "PULSE",
value: 65,
unit: "bpm",
measuredAt: new Date("2026-04-03T08:00:00Z"),
},
],
});
const measurements = await prisma.measurement.findMany({
where: {
userId: user.id,
type: {
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
},
},
select: { type: true, measuredAt: true },
});
const result = buildExpansionMetricValues({
measurements,
moodEntries: [],
intakeEvents: [],
auditEvents: [],
});
expect(result.weightMeasurementCount).toBe(2);
expect(result.bpMeasurementCount).toBe(1); // SYS canonical, DIA not double-counted
expect(result.pulseMeasurementCount).toBe(1);
});
it("fires the doctor-PDF hidden trigger from a real audit-log row", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "exporter",
email: "exporter@example.test",
createdAt: new Date("2026-01-01T00:00:00Z"),
},
});
await prisma.auditLog.create({
data: {
userId: user.id,
action: "doctor-report.pdf.generate",
},
});
const auditEvents = await prisma.auditLog.findMany({
where: { userId: user.id, action: "doctor-report.pdf.generate" },
select: { action: true, createdAt: true },
});
const result = buildExpansionMetricValues({
measurements: [],
moodEntries: [],
intakeEvents: [],
auditEvents,
});
expect(result.doctorPdfCount).toBe(1);
});
});