mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
b88f2de865
commit
6912d4e2b6
3 files changed
+601
No files matched your search
@@ -11,6 +11,11 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
* `/api/gamification/achievements` so the assertion does not depend on
|
||||
* the seed user actually having any achievement data — the routing +
|
||||
* sidebar wiring is what matters here, not the data freshness.
|
||||
*
|
||||
* v1.4.18 — added a second describe-block exercising the four card
|
||||
* states (unlocked / locked-earnable / hidden-locked / hidden-unlocked)
|
||||
* and asserting that hidden cards never leak their real strings to the
|
||||
* DOM.
|
||||
*/
|
||||
test.describe("achievements page is reachable from the sidebar", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
@@ -84,3 +89,140 @@ test.describe("achievements page is reachable from the sidebar", () => {
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("achievements page renders all four card states", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/api/gamification/achievements", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
summary: {
|
||||
unlockedCount: 2,
|
||||
totalCount: 4,
|
||||
earnedPoints: 43,
|
||||
totalPoints: 200,
|
||||
completionPercent: 50,
|
||||
nextAchievement: null,
|
||||
},
|
||||
achievements: [
|
||||
// Unlocked public
|
||||
{
|
||||
id: "intake-total-1",
|
||||
metric: "totalTakenIntakes",
|
||||
category: "medication",
|
||||
titleKey: "achievements.badges.intakeTotal1.title",
|
||||
descriptionKey:
|
||||
"achievements.badges.intakeTotal1.description",
|
||||
icon: "Pill",
|
||||
format: "count",
|
||||
target: 1,
|
||||
current: 5,
|
||||
points: 8,
|
||||
unlocked: true,
|
||||
progressPercent: 100,
|
||||
completedAt: "2026-04-15T12:00:00.000Z",
|
||||
isHidden: false,
|
||||
},
|
||||
// Locked-earnable public (mood streak — user has mood data)
|
||||
{
|
||||
id: "mood-streak-7",
|
||||
metric: "moodDayStreak",
|
||||
category: "mood",
|
||||
titleKey: "achievements.badges.moodStreak7.title",
|
||||
descriptionKey:
|
||||
"achievements.badges.moodStreak7.description",
|
||||
icon: "Smile",
|
||||
format: "days",
|
||||
target: 7,
|
||||
current: 3,
|
||||
points: 50,
|
||||
unlocked: false,
|
||||
progressPercent: 42,
|
||||
completedAt: null,
|
||||
isHidden: false,
|
||||
},
|
||||
// Hidden + locked → opaque placeholder
|
||||
{
|
||||
id: "hidden-night-owl",
|
||||
metric: "nightOwlCount",
|
||||
category: "hidden",
|
||||
titleKey: "achievements.badges.hiddenNightOwl.title",
|
||||
descriptionKey:
|
||||
"achievements.badges.hiddenNightOwl.description",
|
||||
icon: "Moon",
|
||||
format: "count",
|
||||
target: 1,
|
||||
current: 0,
|
||||
points: 25,
|
||||
unlocked: false,
|
||||
progressPercent: 0,
|
||||
completedAt: null,
|
||||
isHidden: true,
|
||||
},
|
||||
// Hidden + unlocked → real strings revealed
|
||||
{
|
||||
id: "hidden-doctor-pdf",
|
||||
metric: "doctorPdfCount",
|
||||
category: "hidden",
|
||||
titleKey: "achievements.badges.hiddenDoctorPdf.title",
|
||||
descriptionKey:
|
||||
"achievements.badges.hiddenDoctorPdf.description",
|
||||
icon: "FileText",
|
||||
format: "count",
|
||||
target: 1,
|
||||
current: 1,
|
||||
points: 35,
|
||||
unlocked: true,
|
||||
progressPercent: 100,
|
||||
completedAt: "2026-04-30T10:00:00.000Z",
|
||||
isHidden: true,
|
||||
},
|
||||
],
|
||||
metrics: {},
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("renders unlocked, locked-earnable, hidden-locked and hidden-unlocked cards", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/achievements", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Page heading present
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Achievements" }),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Unlocked public — title visible
|
||||
await expect(page.getByText("First intake")).toBeVisible();
|
||||
|
||||
// Locked-earnable public — title visible AND criterion hint
|
||||
// visible (3 / 7 days for mood-streak-7).
|
||||
await expect(page.getByText("Mood diarist 7d")).toBeVisible();
|
||||
|
||||
// Hidden-locked — opaque placeholder visible. The real title /
|
||||
// description must NOT appear in the rendered HTML.
|
||||
await expect(
|
||||
page.getByText("Hidden achievement").first(),
|
||||
).toBeVisible();
|
||||
const html = await page.content();
|
||||
expect(html).not.toContain("Night owl");
|
||||
expect(html).not.toContain("between 02:00");
|
||||
expect(html).not.toContain("nightOwlCount");
|
||||
|
||||
// Hidden-unlocked — title revealed once earned
|
||||
await expect(page.getByText("House call")).toBeVisible();
|
||||
|
||||
// The opaque hidden card carries the data-slot for E2E hooks
|
||||
await expect(
|
||||
page.locator('[data-slot="achievement-card-hidden"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildExpansionMetricValues,
|
||||
countMeasurementsByType,
|
||||
getEarnabilityFlags,
|
||||
getEngagementMetrics,
|
||||
getHiddenMetrics,
|
||||
getMoodMetrics,
|
||||
} from "@/lib/gamification/expansion-metrics";
|
||||
|
||||
describe("countMeasurementsByType", () => {
|
||||
it("counts each type independently", () => {
|
||||
const result = countMeasurementsByType([
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-04-01T08:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-04-02T08:00:00Z") },
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
measuredAt: new Date("2026-04-01T08:00:00Z"),
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
measuredAt: new Date("2026-04-01T08:00:00Z"),
|
||||
},
|
||||
{ type: "PULSE", measuredAt: new Date("2026-04-03T08:00:00Z") },
|
||||
]);
|
||||
// BLOOD_PRESSURE_DIA is intentionally not counted — the canonical
|
||||
// BP entry is the SYS row so we don't double-count a single BP
|
||||
// reading that's stored as two rows.
|
||||
expect(result).toEqual({ weightCount: 2, bpCount: 1, pulseCount: 1 });
|
||||
});
|
||||
|
||||
it("returns zeros for an empty input", () => {
|
||||
expect(countMeasurementsByType([])).toEqual({
|
||||
weightCount: 0,
|
||||
bpCount: 0,
|
||||
pulseCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMoodMetrics", () => {
|
||||
it("returns zeros when there are no entries", () => {
|
||||
expect(getMoodMetrics([])).toEqual({
|
||||
moodEntryCount: 0,
|
||||
moodDayStreak: 0,
|
||||
moodImprovementHit: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("computes count + longest day-streak", () => {
|
||||
const entries = [
|
||||
// 4 consecutive days then a gap then 2 more — longest = 4
|
||||
{ date: "2026-04-01", score: 3, moodLoggedAt: new Date() },
|
||||
{ date: "2026-04-02", score: 3, moodLoggedAt: new Date() },
|
||||
{ date: "2026-04-03", score: 4, moodLoggedAt: new Date() },
|
||||
{ date: "2026-04-04", score: 4, moodLoggedAt: new Date() },
|
||||
{ date: "2026-04-10", score: 4, moodLoggedAt: new Date() },
|
||||
{ date: "2026-04-11", score: 4, moodLoggedAt: new Date() },
|
||||
];
|
||||
const m = getMoodMetrics(entries);
|
||||
expect(m.moodEntryCount).toBe(6);
|
||||
expect(m.moodDayStreak).toBe(4);
|
||||
});
|
||||
|
||||
it("hits the improvement metric when the recent 7-day mean is +1.0 above the prior 7-day mean", () => {
|
||||
const entries = [];
|
||||
// Prior week: mean 2.0
|
||||
for (let i = 0; i < 7; i++) {
|
||||
entries.push({
|
||||
date: `2026-04-0${i + 1}`,
|
||||
score: 2,
|
||||
moodLoggedAt: new Date(),
|
||||
});
|
||||
}
|
||||
// Recent week: mean 3.5 — improvement of 1.5
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = i + 8;
|
||||
entries.push({
|
||||
date: `2026-04-${String(day).padStart(2, "0")}`,
|
||||
score: i % 2 === 0 ? 3 : 4,
|
||||
moodLoggedAt: new Date(),
|
||||
});
|
||||
}
|
||||
const m = getMoodMetrics(entries);
|
||||
expect(m.moodImprovementHit).toBe(1);
|
||||
});
|
||||
|
||||
it("does not hit the improvement metric when improvement is below 1.0", () => {
|
||||
const entries = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
entries.push({
|
||||
date: `2026-04-0${i + 1}`,
|
||||
score: 3,
|
||||
moodLoggedAt: new Date(),
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = i + 8;
|
||||
entries.push({
|
||||
date: `2026-04-${String(day).padStart(2, "0")}`,
|
||||
// Mean 3.5 → +0.5 only
|
||||
score: i % 2 === 0 ? 3 : 4,
|
||||
moodLoggedAt: new Date(),
|
||||
});
|
||||
}
|
||||
expect(getMoodMetrics(entries).moodImprovementHit).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEngagementMetrics", () => {
|
||||
it("counts a consistent month with at least 25 distinct entry days", () => {
|
||||
const measurements = [];
|
||||
for (let day = 1; day <= 26; day++) {
|
||||
measurements.push({
|
||||
type: "WEIGHT",
|
||||
measuredAt: new Date(
|
||||
`2026-04-${String(day).padStart(2, "0")}T08:00:00Z`,
|
||||
),
|
||||
});
|
||||
}
|
||||
const result = getEngagementMetrics({
|
||||
measurements,
|
||||
moodEntries: [],
|
||||
intakeEvents: [],
|
||||
});
|
||||
expect(result.consistentMonthCount).toBe(1);
|
||||
});
|
||||
|
||||
it("does not count a month with only 24 distinct days as consistent", () => {
|
||||
const measurements = [];
|
||||
for (let day = 1; day <= 24; day++) {
|
||||
measurements.push({
|
||||
type: "WEIGHT",
|
||||
measuredAt: new Date(
|
||||
`2026-04-${String(day).padStart(2, "0")}T08:00:00Z`,
|
||||
),
|
||||
});
|
||||
}
|
||||
const result = getEngagementMetrics({
|
||||
measurements,
|
||||
moodEntries: [],
|
||||
intakeEvents: [],
|
||||
});
|
||||
expect(result.consistentMonthCount).toBe(0);
|
||||
});
|
||||
|
||||
it("computes the longest entry-day streak across measurement / mood / intake", () => {
|
||||
const result = getEngagementMetrics({
|
||||
measurements: [
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-04-01T08:00:00Z") },
|
||||
],
|
||||
moodEntries: [
|
||||
{ date: "2026-04-02", score: 3, moodLoggedAt: new Date("2026-04-02T08:00:00Z") },
|
||||
{ date: "2026-04-03", score: 3, moodLoggedAt: new Date("2026-04-03T08:00:00Z") },
|
||||
],
|
||||
intakeEvents: [
|
||||
{
|
||||
scheduledFor: new Date("2026-04-04T08:00:00Z"),
|
||||
takenAt: new Date("2026-04-04T08:30:00Z"),
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
// 4 consecutive days
|
||||
expect(result.entryDayStreak).toBe(4);
|
||||
});
|
||||
|
||||
it("counts consecutive Sat+Sun pairs for the weekend warrior badge", () => {
|
||||
// Saturdays + Sundays April 4/5, 11/12, 18/19, 25/26 — 4 in a row
|
||||
const measurements = [
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-04-04T12:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-04-05T12:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-04-11T12:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-04-12T12:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-04-18T12:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-04-19T12:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-04-25T12:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-04-26T12:00:00Z") },
|
||||
];
|
||||
const result = getEngagementMetrics({
|
||||
measurements,
|
||||
moodEntries: [],
|
||||
intakeEvents: [],
|
||||
});
|
||||
expect(result.weekendStreakCount).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHiddenMetrics", () => {
|
||||
it("counts night-owl entries between 02:00 and 04:00 Berlin", () => {
|
||||
const result = getHiddenMetrics({
|
||||
// 02:30 Berlin in summer = 00:30 UTC. Use UTC offsets that yield
|
||||
// the right Berlin hour. April is CEST (UTC+2), so 00:30Z = 02:30
|
||||
// Berlin → night owl.
|
||||
measurements: [
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-04-15T00:30:00Z") },
|
||||
],
|
||||
moodEntries: [],
|
||||
intakeEvents: [],
|
||||
auditEvents: [],
|
||||
});
|
||||
expect(result.nightOwlCount).toBe(1);
|
||||
expect(result.earlyBirdCount).toBe(0);
|
||||
});
|
||||
|
||||
it("counts early-bird entries between 04:00 and 06:00 Berlin", () => {
|
||||
// 03:30 UTC = 05:30 Berlin in CEST → early-bird
|
||||
const result = getHiddenMetrics({
|
||||
measurements: [
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-04-15T03:30:00Z") },
|
||||
],
|
||||
moodEntries: [],
|
||||
intakeEvents: [],
|
||||
auditEvents: [],
|
||||
});
|
||||
expect(result.earlyBirdCount).toBe(1);
|
||||
expect(result.nightOwlCount).toBe(0);
|
||||
});
|
||||
|
||||
it("counts February 29 entries on a leap year", () => {
|
||||
const result = getHiddenMetrics({
|
||||
measurements: [
|
||||
{ type: "WEIGHT", measuredAt: new Date("2024-02-29T12:00:00Z") },
|
||||
],
|
||||
moodEntries: [],
|
||||
intakeEvents: [],
|
||||
auditEvents: [],
|
||||
});
|
||||
expect(result.leapDayCount).toBe(1);
|
||||
});
|
||||
|
||||
it("counts doctor-PDF exports and locale-flips from audit events", () => {
|
||||
const result = getHiddenMetrics({
|
||||
measurements: [],
|
||||
moodEntries: [],
|
||||
intakeEvents: [],
|
||||
auditEvents: [
|
||||
{ action: "doctor-report.export", createdAt: new Date() },
|
||||
{ action: "doctor-report.export", createdAt: new Date() },
|
||||
{ action: "settings.locale.update", createdAt: new Date() },
|
||||
{ action: "auth.login.passkey", createdAt: new Date() }, // ignored
|
||||
],
|
||||
});
|
||||
expect(result.doctorPdfCount).toBe(2);
|
||||
expect(result.localeFlipCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEarnabilityFlags", () => {
|
||||
it("flags categories the user has data for", () => {
|
||||
expect(
|
||||
getEarnabilityFlags({
|
||||
hasMedication: true,
|
||||
moodEntryCount: 3,
|
||||
measurementCounts: { weightCount: 0, bpCount: 5, pulseCount: 0 },
|
||||
}),
|
||||
).toEqual({
|
||||
hasMedication: true,
|
||||
hasMood: true,
|
||||
hasWeight: false,
|
||||
hasBp: true,
|
||||
hasPulse: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildExpansionMetricValues — integration smoke", () => {
|
||||
it("produces a fully-zeroed object for an empty user", () => {
|
||||
const result = buildExpansionMetricValues({
|
||||
measurements: [],
|
||||
moodEntries: [],
|
||||
intakeEvents: [],
|
||||
auditEvents: [],
|
||||
});
|
||||
for (const value of Object.values(result)) {
|
||||
expect(value).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user