test(achievements): coverage for evaluators + UI states

Three new layers of coverage for the v1.4.18 expansion set:

- Unit (expansion-metrics.test.ts) — drives synthetic data through
  every new evaluator (count by type, mood mean improvement, weekend
  warrior, hidden Easter-egg triggers including the Berlin TZ
  classifier for night-owl / early-bird) and the earnability flag
  builder. 13 new assertions.

- Integration (achievements-expansion.test.ts) — round-trips real
  prisma rows (MoodEntry's YYYY-MM-DD `date` string, Measurement
  type discrimination, audit log row) through
  `buildExpansionMetricValues` so a schema drift in any of the row
  shapes surfaces here rather than at runtime. We don't drive the
  full HTTP route in this spec because of pre-existing
  medication_schedules.days_of_week migration drift.

- E2E (achievements.spec.ts) — the existing sidebar smoke gets a
  sibling describe-block exercising all four card states (unlocked /
  locked-earnable / hidden-locked / hidden-unlocked) and asserting
  hidden-locked cards never leak their real strings to the rendered
  HTML, which is the entire point of the "hidden" state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 10:34:32 +02:00
1 parent b88f2de865
commit 6912d4e2b6
3 files changed
+601

No files matched your search

@@ -0,0 +1,180 @@
/**
* 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.export",
},
});
const auditEvents = await prisma.auditLog.findMany({
where: { userId: user.id, action: "doctor-report.export" },
select: { action: true, createdAt: true },
});
const result = buildExpansionMetricValues({
measurements: [],
moodEntries: [],
intakeEvents: [],
auditEvents,
});
expect(result.doctorPdfCount).toBe(1);
});
});