chore(release): v1.4.25

Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).

See CHANGELOG.md for the full per-section delta.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-14 23:49:32 +02:00
1 parent de0689e419
commit 17b8d8d2c2
528 files changed
+104033 -6524

No files matched your search

@@ -0,0 +1,219 @@
/**
* v1.4.25 W10 reconcile (Code-H1) — weight-weekday correlator honours
* the user's display timezone.
*
* Before W10 the analytics route's `berlinIsoWeekday()` helper was
* pinned to `Europe/Berlin` regardless of the requesting user's
* timezone. A weight reading recorded at 23:30 in `Pacific/Auckland`
* (UTC+12, summer) lands at 11:30 the same day in Berlin — so a Monday
* reading in the user's local frame got bucketed under Sunday and the
* Pearson/ANOVA test ran against the wrong weekday column.
*
* This test pins the contract: weight readings inserted at a UTC
* instant that maps to one weekday in Berlin and a different weekday in
* the user's tz are bucketed against the user's tz. We seed exactly
* enough readings for the analytics correlator's `n >= 20` gate (W6
* raised the floor) and verify the helper used the user's tz to bucket
* them by reading the wide-event `correlations.weightWeekday` status
* tag plus the route response.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
interface AnalyticsEnvelope {
data: {
correlations: {
weightWeekday: {
status: string;
n?: number;
};
};
};
}
async function seedSession(username: string, timezone: string) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role: "USER",
timezone,
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
return user;
}
/**
* Format `Intl.DateTimeFormat` weekday short-name for a UTC instant
* in a target timezone. Mirrors the route's internal helper so the
* test asserts the same contract the route exposes.
*/
function isoWeekdayShort(d: Date, timeZone: string): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone,
weekday: "short",
}).format(d);
}
describe("GET /api/analytics — weight-weekday correlator honours user tz", () => {
it("buckets a non-Berlin user's late-evening readings against the user-local weekday", async () => {
const prisma = getPrismaClient();
const user = await seedSession("auckland-weight-user", "Pacific/Auckland");
// 28 readings, one per day across four weeks ending today. Each
// reading is anchored to the moment when the Berlin weekday and the
// Auckland weekday differ — late-evening UTC (~12:30 UTC) is
// Auckland's next-day 00:30 (NZST = UTC+12) but Berlin's same-day
// 14:30 (CEST = UTC+2). The weekday computed against `Europe/Berlin`
// differs from the weekday computed against `Pacific/Auckland` for
// every row in this dataset. The correlator filters to the trailing
// 30 days, so the fixture must end near `Date.now()`.
const now = new Date();
const DAY_MS = 24 * 60 * 60 * 1000;
// Build candidate UTC instants and find one where Berlin/Auckland
// weekdays differ. 12:30 UTC ⇒ Auckland +12 = 00:30 next-day,
// Berlin +2 (CEST in May 2026) = 14:30 same-day. The weekday
// difference is stable across the 28-day window because both tzs
// have no DST inside it.
const candidate = new Date(now);
candidate.setUTCHours(12, 30, 0, 0);
// Push the candidate to the most recent UTC instant in the past so
// every seeded row has measuredAt < now.
if (candidate.getTime() > now.getTime()) {
candidate.setUTCDate(candidate.getUTCDate() - 1);
}
// Sanity check: the contract under test requires Berlin and the
// user's tz to disagree on the weekday for the seeded instant.
const berlinWeekday = isoWeekdayShort(candidate, "Europe/Berlin");
const aucklandWeekday = isoWeekdayShort(candidate, "Pacific/Auckland");
expect(berlinWeekday).not.toBe(aucklandWeekday);
for (let i = 27; i >= 0; i--) {
const measuredAt = new Date(candidate.getTime() - i * DAY_MS);
// Vary weight by user-tz weekday so the ANOVA has a signal.
const userWeekday = isoWeekdayShort(measuredAt, "Pacific/Auckland");
const offset =
userWeekday === "Mon" ? 1.5 : userWeekday === "Sat" ? -0.8 : 0;
await prisma.measurement.create({
data: {
userId: user.id,
type: "WEIGHT",
value: 82 + offset,
unit: "kg",
source: "MANUAL",
measuredAt,
},
});
}
const { GET } = await import("@/app/api/analytics/route");
// The wrapped handler reads `request.url` for logging metadata;
// the inner handler ignores its arguments. Cast through `unknown`
// because `apiHandler`'s narrowed type signature is the inner
// handler's `()`, not the wrapper's `(NextRequest)`.
const response = await (
GET as unknown as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/analytics"));
expect(response.status).toBe(200);
const envelope = (await response.json()) as AnalyticsEnvelope;
// The correlator should hit the `n >= 20` gate (28 readings) and
// surface a result — either "ok" if the ANOVA picks up the Monday
// signal, or "insufficient" with `reason: "not_significant"` if
// the noise drowns out the offset. Either way, `n` must reflect
// every seeded row, confirming the picker bucketed every row
// against a weekday (rather than crashing or filtering anything
// out).
const wkd = envelope.data.correlations.weightWeekday;
expect(["ok", "insufficient"]).toContain(wkd.status);
expect(wkd.n).toBe(28);
});
it("falls back to the project-default tz when the user has none stored", async () => {
// Defence-in-depth: seed a user whose timezone is the schema
// default. The pre-W10 helper always read `Europe/Berlin`; with
// the W10 fix the same code path resolves to `userTz` which
// defaults to `Europe/Berlin` via `user.timezone ?? DEFAULT_TIMEZONE`.
// The behaviour for a Berlin-tz user must be identical to the
// pre-W10 implementation.
const prisma = getPrismaClient();
const user = await seedSession("berlin-default-user", "Europe/Berlin");
// 28 readings ending today so every row falls inside the
// correlator's trailing-30-day filter.
const now = new Date();
const DAY_MS = 24 * 60 * 60 * 1000;
for (let i = 27; i >= 0; i--) {
const measuredAt = new Date(now.getTime() - i * DAY_MS);
await prisma.measurement.create({
data: {
userId: user.id,
type: "WEIGHT",
value: 82,
unit: "kg",
source: "MANUAL",
measuredAt,
},
});
}
const { GET } = await import("@/app/api/analytics/route");
const response = await (
GET as unknown as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/analytics"));
expect(response.status).toBe(200);
const envelope = (await response.json()) as AnalyticsEnvelope;
// Constant weight = no weekday variance = "insufficient"
// (`reason: not_significant`). The `n` count is the regression
// anchor — every seeded row reached the correlator.
const wkd = envelope.data.correlations.weightWeekday;
expect(wkd.status).toBe("insufficient");
expect(wkd.n).toBe(28);
});
});
+16 -7
View File
@@ -70,7 +70,9 @@ describe("GET /api/auth/me/coach-prefs", () => {
await seedSession("coach-prefs-defaults");
const { GET } = await import("@/app/api/auth/me/coach-prefs/route");
const res = await (GET as () => Promise<Response>)();
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/coach-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: typeof DEFAULT_COACH_PREFS };
expect(env.data).toEqual(DEFAULT_COACH_PREFS);
@@ -83,12 +85,17 @@ describe("PUT /api/auth/me/coach-prefs", () => {
const prisma = getPrismaClient();
const { PUT, GET } = await import("@/app/api/auth/me/coach-prefs/route");
// The request body omits `defaultWindow`; the schema (v1.4.25 W5)
// defaults it to "allTime", so the canonical PUT response and the
// persisted row both carry the defaulted field even when the
// caller didn't pass it.
const body = {
tone: "concise" as const,
verbosity: "brief" as const,
excludeMetrics: ["mood" as const, "compliance" as const],
showEvidenceByDefault: true,
};
const expected = { ...body, defaultWindow: "allTime" as const };
const putRes = await (PUT as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/coach-prefs", {
method: "PUT",
@@ -97,21 +104,23 @@ describe("PUT /api/auth/me/coach-prefs", () => {
}),
);
expect(putRes.status).toBe(200);
const putEnv = (await putRes.json()) as { data: typeof body };
expect(putEnv.data).toEqual(body);
const putEnv = (await putRes.json()) as { data: typeof expected };
expect(putEnv.data).toEqual(expected);
// DB row reflects the canonical defaulted form.
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { coachPrefsJson: true },
});
expect(row?.coachPrefsJson).toEqual(body);
expect(row?.coachPrefsJson).toEqual(expected);
// GET reads the saved values back.
const getRes = await (GET as () => Promise<Response>)();
const getRes = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/coach-prefs"),
);
expect(getRes.status).toBe(200);
const getEnv = (await getRes.json()) as { data: typeof body };
expect(getEnv.data).toEqual(body);
const getEnv = (await getRes.json()) as { data: typeof expected };
expect(getEnv.data).toEqual(expected);
});
it("rejects unknown tone values with 422", async () => {
@@ -0,0 +1,286 @@
/**
* v1.4.25 W6 — integration coverage for the PUT
* `/api/dashboard/widgets` endpoint with realistic full-layout payloads.
*
* Root-cause this suite pins:
* Settings → Dashboard's Save button stopped persisting once the user
* had touched any per-chart overlay popover. The widgets-route Zod
* schema used `z.record(z.enum(CHART_OVERLAY_KEYS), …)` which under
* Zod v4 demands every enum key to be present. Real-world payloads
* carry one or two chart keys (the ones the user actually flipped),
* so every Save click 422'd with
* `expected: object, path: ["chartOverlayPrefs", "<missing-key>"]`
* and the toast surfaced "Layout konnte nicht gespeichert werden".
*
* The fix switched to `z.partialRecord(...)` so partial maps round-trip
* cleanly. We exercise:
* 1. Full layout with a single `chartOverlayPrefs` entry persists.
* 2. Per-chart `comparisonBaseline` survives a Settings save (the
* previous schema silently stripped it).
* 3. Empty `chartOverlayPrefs` still persists (parity with fresh
* accounts that have never opened a chart popover).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { DEFAULT_DASHBOARD_LAYOUT } from "@/lib/dashboard-layout";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
async function seedUser(): Promise<{ userId: string }> {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "dashboard-save-user",
email: "dashboard-save@example.test",
role: "USER",
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
return { userId: user.id };
}
function buildClientPayload(
overrides: Partial<{
chartOverlayPrefs: Record<string, unknown>;
}>,
) {
return {
version: 1,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => ({
id: w.id,
visible: w.visible,
tileVisible: w.tileVisible ?? w.visible,
order: w.order,
})),
comparisonBaseline: "none" as const,
...overrides,
};
}
describe("PUT /api/dashboard/widgets — Save persists full layouts", () => {
it("accepts a partial chartOverlayPrefs map (single chart key)", async () => {
const { userId } = await seedUser();
const { PUT, GET } = await import("@/app/api/dashboard/widgets/route");
// Realistic Save click: the user flipped the weight chart's tile
// toggle from Settings → Dashboard. The cached layout has a single
// per-chart entry (the BP chart popover the user opened earlier).
const body = buildClientPayload({
chartOverlayPrefs: {
bp: {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "lastMonth",
},
},
});
const putRes = await (PUT as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/dashboard/widgets", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
expect(putRes.status).toBe(200);
// Sanity: the row actually landed in the DB.
const stored = await getPrismaClient().user.findUniqueOrThrow({
where: { id: userId },
select: { dashboardWidgetsJson: true },
});
expect(stored.dashboardWidgetsJson).toBeTruthy();
// Follow-up GET surfaces the saved per-chart prefs end-to-end.
const getRes = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/dashboard/widgets"),
);
const getBody = (await getRes.json()) as {
data: { chartOverlayPrefs?: Record<string, unknown> };
};
expect(getBody.data.chartOverlayPrefs).toMatchObject({
bp: {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: false,
},
});
});
it("preserves per-chart comparisonBaseline through a Settings save", async () => {
const { userId } = await seedUser();
const { PUT, GET } = await import("@/app/api/dashboard/widgets/route");
// Step 1: the user set the BP chart's comparisonBaseline = lastMonth
// via the chart popover. Seed it directly to skip the popover route.
const seededLayout = {
version: 1,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
comparisonBaseline: "none",
chartOverlayPrefs: {
bp: {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "lastMonth",
},
},
};
await getPrismaClient().user.update({
where: { id: userId },
data: {
dashboardWidgetsJson: seededLayout as never,
},
});
// Step 2: the user opens Settings → Dashboard and clicks Save.
// The client posts back the layout it loaded — including the BP
// chart's `comparisonBaseline: "lastMonth"` (which the resolver
// re-emits on every GET).
const putBody = {
version: 1,
widgets: seededLayout.widgets,
comparisonBaseline: "none",
chartOverlayPrefs: {
bp: {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "lastMonth",
},
},
};
const putRes = await (PUT as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/dashboard/widgets", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(putBody),
}),
);
expect(putRes.status).toBe(200);
// Step 3: GET — the per-chart comparisonBaseline must still be
// "lastMonth". With the previous `z.record(enum, …)` schema, Zod
// would either reject the save outright (incomplete record) OR
// strip the `comparisonBaseline` field, both of which wiped the
// user's per-chart toggle.
const getRes = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/dashboard/widgets"),
);
const getBody = (await getRes.json()) as {
data: {
chartOverlayPrefs?: Record<string, { comparisonBaseline?: string }>;
};
};
expect(getBody.data.chartOverlayPrefs?.bp?.comparisonBaseline).toBe(
"lastMonth",
);
});
it("accepts an empty chartOverlayPrefs map (fresh account)", async () => {
await seedUser();
const { PUT } = await import("@/app/api/dashboard/widgets/route");
const body = buildClientPayload({ chartOverlayPrefs: {} });
const res = await (PUT as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/dashboard/widgets", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
expect(res.status).toBe(200);
});
it("widget toggle changes round-trip end-to-end", async () => {
const { userId } = await seedUser();
const { PUT, GET } = await import("@/app/api/dashboard/widgets/route");
// Marc flips the sleep widget's chart visibility on.
const body = buildClientPayload({
chartOverlayPrefs: {
bp: {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: true,
},
},
});
body.widgets = body.widgets.map((w) =>
w.id === "sleep" ? { ...w, visible: true, tileVisible: true } : w,
);
const putRes = await (PUT as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/dashboard/widgets", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
expect(putRes.status).toBe(200);
const getRes = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/dashboard/widgets"),
);
const getBody = (await getRes.json()) as {
data: {
widgets: Array<{ id: string; visible: boolean; tileVisible?: boolean }>;
};
};
const sleep = getBody.data.widgets.find((w) => w.id === "sleep");
expect(sleep?.visible).toBe(true);
expect(sleep?.tileVisible).toBe(true);
// DB row writes through.
const stored = await getPrismaClient().user.findUniqueOrThrow({
where: { id: userId },
select: { dashboardWidgetsJson: true },
});
expect(stored.dashboardWidgetsJson).toBeTruthy();
});
});
@@ -0,0 +1,241 @@
/**
* v1.4.25 W6c — per-section toggle integration tests.
*
* Asserts the privacy-by-default contract for mood data: when the user
* submits `sections.mood = false`, the generated PDF MUST NOT contain
* any mood-related text, and the aggregator MUST NOT have fetched the
* underlying `MoodEntry` rows in the first place.
*
* Also asserts the "skip section when toggle off" contract for the
* non-privacy-sensitive sections (BP / weight / pulse / compliance) so
* the dialog's toggles produce the expected printed artefact.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
const RANGE = {
start: new Date("2026-01-01T00:00:00.000Z"),
end: new Date("2026-05-01T00:00:00.000Z"),
days: 120,
};
/**
* Seed a user plus one row of every measurement type the toggles
* gate. Returns the user id for the test to thread into the aggregator
* and PDF renderer.
*/
async function seedUserWithEveryDataType(username: string): Promise<string> {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
heightCm: 180,
},
});
const measuredAt = new Date("2026-03-01T08:00:00.000Z");
await prisma.measurement.createMany({
data: [
{
userId: user.id,
type: "WEIGHT",
value: 82.5,
unit: "kg",
measuredAt,
},
{
userId: user.id,
type: "BLOOD_PRESSURE_SYS",
value: 128,
unit: "mmHg",
measuredAt,
},
{
userId: user.id,
type: "BLOOD_PRESSURE_DIA",
value: 82,
unit: "mmHg",
measuredAt,
},
{
userId: user.id,
type: "PULSE",
value: 65,
unit: "bpm",
measuredAt,
},
],
});
await prisma.moodEntry.create({
data: {
userId: user.id,
score: 4,
mood: "GUT",
moodLoggedAt: measuredAt,
date: "2026-03-01",
},
});
// Medication compliance — one taken intake event.
const med = await prisma.medication.create({
data: {
userId: user.id,
name: "Metformin",
dose: "500 mg",
active: true,
},
});
await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: med.id,
scheduledFor: measuredAt,
takenAt: measuredAt,
},
});
return user.id;
}
/**
* Pull plain UTF-8 text out of the PDF byte stream. jsPDF's text
* operator stores the strings inline as `(...)` tokens, so a naive
* regex scan over the buffer is enough to assert presence/absence of a
* label — we don't need to actually decode the streams. False positives
* are tolerable because the assertions are negative ("doesn't contain
* `Stimmung`") and the strings are distinctive.
*/
function pdfContainsText(bytes: Uint8Array, needle: string): boolean {
const haystack = Buffer.from(bytes).toString("latin1");
return haystack.includes(needle);
}
describe("doctor-report — per-section toggles", () => {
it("drops mood data entirely when sections.mood = false", async () => {
const userId = await seedUserWithEveryDataType("dr-sections-mood-off");
const data = await collectDoctorReportData(userId, RANGE, {
sections: {
bp: true,
weight: true,
pulse: true,
bmi: true,
mood: false, // ← the privacy default
compliance: true,
sleep: true,
},
});
// The aggregator must NOT return mood data when the toggle is off.
// The contract: the data never leaves the DB row, so the JSON
// payload (this `data` object) is the canonical proof.
expect(data.mood).toBeNull();
// The PDF rendered from this data must not mention the German or
// English mood section header — both locales render against the
// same `data` payload so both must be clean.
const { t: tDe } = getServerTranslator("de");
const { t: tEn } = getServerTranslator("en");
const pdfDe = renderDoctorReportPdfBytes(data, { t: tDe, locale: "de" });
const pdfEn = renderDoctorReportPdfBytes(data, { t: tEn, locale: "en" });
expect(pdfContainsText(pdfDe, "Stimmung")).toBe(false);
expect(pdfContainsText(pdfEn, "Mood")).toBe(false);
});
it("includes mood data when sections.mood = true (opt-in)", async () => {
const userId = await seedUserWithEveryDataType("dr-sections-mood-on");
const data = await collectDoctorReportData(userId, RANGE, {
sections: { mood: true },
});
expect(data.mood).not.toBeNull();
expect(data.mood?.count).toBe(1);
});
it("strips BP / weight / pulse / compliance when their toggles are off", async () => {
const userId = await seedUserWithEveryDataType("dr-sections-others-off");
const data = await collectDoctorReportData(userId, RANGE, {
sections: {
bp: false,
weight: false,
pulse: false,
bmi: false,
mood: false,
compliance: false,
sleep: false,
},
});
expect(data.stats.BLOOD_PRESSURE_SYS).toBeUndefined();
expect(data.stats.BLOOD_PRESSURE_DIA).toBeUndefined();
expect(data.stats.WEIGHT).toBeUndefined();
expect(data.stats.PULSE).toBeUndefined();
expect(data.compliance).toEqual({});
expect(data.bmi).toBeNull();
expect(data.mood).toBeNull();
});
it("applies documented defaults when sections is omitted (mood OFF, others ON)", async () => {
const userId = await seedUserWithEveryDataType("dr-sections-defaults");
const data = await collectDoctorReportData(userId, RANGE);
// Privacy default per Marc — mood is opt-in, not opt-out.
expect(data.mood).toBeNull();
// Every other section keeps its data.
expect(data.stats.WEIGHT).toBeDefined();
expect(data.stats.BLOOD_PRESSURE_SYS).toBeDefined();
expect(data.stats.PULSE).toBeDefined();
expect(data.bmi).not.toBeNull();
expect(Object.keys(data.compliance).length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,210 @@
/**
* Integration suite for `DELETE /api/measurements/by-external-ids` —
* the iOS deletion-sync endpoint. Asserts the contract the iOS client
* relies on for HealthKit reconciliation:
* - Happy path: matching rows are deleted, deletedCount equals the
* number actually removed.
* - Cross-user 404 guard: another user's rows that happen to share an
* externalId remain untouched.
* - Empty array: returns 200 with deletedCount = 0 (not a failure).
* - Batch size cap: 501 externalIds returns 422 with a documented
* errorCode so the client can surface the diagnostic.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-batch-delete-test";
const OTHER_USER_ID = "user-batch-delete-other";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "batch-delete",
email: "batch-delete@example.test",
},
});
await getPrismaClient().user.create({
data: {
id: OTHER_USER_ID,
username: "batch-delete-other",
email: "batch-delete-other@example.test",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
function makeRequest(body: { externalIds: string[] }): NextRequest {
return new NextRequest("http://localhost/api/measurements/by-external-ids", {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// Hash an externalId into a stable minute offset (0..1023) so two
// rows seeded with different externalIds land on different
// `measuredAt` values. The W17b/c unique index now covers
// (user_id, type, measured_at, source, sleep_stage) with NULLs not
// distinct, so all-same-instant fixtures collide; spreading by
// minute keeps the fixture deterministic without sacrificing the
// "owned by the caller" intent of the test.
function minuteOffsetFor(externalId: string): number {
let h = 0;
for (let i = 0; i < externalId.length; i++) {
h = (h * 31 + externalId.charCodeAt(i)) | 0;
}
return Math.abs(h) % 1024;
}
async function seedMeasurement(opts: {
userId: string;
externalId: string;
value?: number;
}): Promise<void> {
const base = new Date("2026-05-09T07:30:00.000Z").getTime();
const measuredAt = new Date(base + minuteOffsetFor(opts.externalId) * 60_000);
await getPrismaClient().measurement.create({
data: {
userId: opts.userId,
type: "WEIGHT",
value: opts.value ?? 80,
unit: "kg",
source: "APPLE_HEALTH",
measuredAt,
externalId: opts.externalId,
},
});
}
describe("DELETE /api/measurements/by-external-ids (real Postgres)", () => {
it("removes matching rows owned by the caller and returns deletedCount", async () => {
const { DELETE } =
await import("@/app/api/measurements/by-external-ids/route");
await seedMeasurement({ userId: TEST_USER_ID, externalId: "uuid-del-1" });
await seedMeasurement({ userId: TEST_USER_ID, externalId: "uuid-del-2" });
await seedMeasurement({
userId: TEST_USER_ID,
externalId: "uuid-keep-3",
});
const response = await DELETE(
makeRequest({ externalIds: ["uuid-del-1", "uuid-del-2"] }),
);
expect(response.status).toBe(200);
const json = (await response.json()) as { data: { deletedCount: number } };
expect(json.data.deletedCount).toBe(2);
const remaining = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID },
orderBy: { externalId: "asc" },
});
expect(remaining).toHaveLength(1);
expect(remaining[0]?.externalId).toBe("uuid-keep-3");
});
it("never deletes rows owned by another user (cross-user 404 guard)", async () => {
const { DELETE } =
await import("@/app/api/measurements/by-external-ids/route");
// Two users own rows with the same externalId; deleting via the
// logged-in user must only remove that user's row.
await seedMeasurement({ userId: TEST_USER_ID, externalId: "uuid-shared" });
await seedMeasurement({
userId: OTHER_USER_ID,
externalId: "uuid-shared",
value: 70,
});
const response = await DELETE(
makeRequest({ externalIds: ["uuid-shared"] }),
);
expect(response.status).toBe(200);
const json = (await response.json()) as { data: { deletedCount: number } };
expect(json.data.deletedCount).toBe(1);
const callerRows = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID },
});
expect(callerRows).toHaveLength(0);
const otherRows = await getPrismaClient().measurement.findMany({
where: { userId: OTHER_USER_ID },
});
expect(otherRows).toHaveLength(1);
expect(otherRows[0]?.externalId).toBe("uuid-shared");
});
it("accepts an empty externalIds array as a no-op", async () => {
const { DELETE } =
await import("@/app/api/measurements/by-external-ids/route");
await seedMeasurement({ userId: TEST_USER_ID, externalId: "uuid-keep" });
const response = await DELETE(makeRequest({ externalIds: [] }));
expect(response.status).toBe(200);
const json = (await response.json()) as { data: { deletedCount: number } };
expect(json.data.deletedCount).toBe(0);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID },
});
expect(stored).toHaveLength(1);
});
it("returns 422 with measurement.delete.too_large when batch exceeds the cap", async () => {
const { DELETE } =
await import("@/app/api/measurements/by-external-ids/route");
const externalIds: string[] = [];
for (let i = 0; i < 501; i++) {
externalIds.push(`uuid-cap-${i}`);
}
const response = await DELETE(makeRequest({ externalIds }));
expect(response.status).toBe(422);
const json = (await response.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(json.error).toMatch(/500/);
expect(json.meta?.errorCode).toBe("measurement.delete.too_large");
});
});
+128 -2
View File
@@ -195,7 +195,7 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
expect(stored).toHaveLength(2);
});
it("returns 422 with coach.batch.too_large when entries exceed the cap", async () => {
it("returns 422 with measurement.batch.too_large when entries exceed the cap", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const entries: BatchEntryFixture[] = [];
@@ -217,7 +217,7 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
meta?: { errorCode?: string };
};
expect(json.error).toMatch(/500/);
expect(json.meta?.errorCode).toBe("coach.batch.too_large");
expect(json.meta?.errorCode).toBe("measurement.batch.too_large");
});
it("flags unmappable identifiers and out-of-range values as skipped", async () => {
@@ -325,6 +325,132 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
expect(stored.every((r) => r.unit === "minutes")).toBe(true);
});
// v1.4.25 W10 reconcile (senior-dev H-1): under contention the
// per-entry envelope MUST stay in sync with the aggregate
// `inserted` / `duplicate` counts. The previous reconciliation
// block was an effective no-op (it looked for rows that were
// "marked inserted but missing from the DB" — an impossibility
// because the raced row IS in the DB, written by the other
// batch). The corrected block downgrades enough "inserted"
// statuses to "duplicate" so the per-entry envelope and the
// aggregate counts agree, which the iOS sync cursor depends on
// to checkpoint correctly.
it("keeps per-entry status in sync with aggregate counts under a concurrent-write race", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
// Two batches with overlapping externalIds posted in parallel.
// The composite unique index ensures only one row per
// (user, type, source, externalId) lands; `skipDuplicates`
// absorbs duplicate-key conflicts. The exact split between
// "won the race" and "got absorbed" depends on Postgres's
// commit order, so we assert only the invariant the iOS sync
// cursor relies on: per-entry envelope sums equal the aggregate
// counts for each response.
const sharedEntries = () =>
Array.from({ length: 6 }, (_, i) => ({
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 80 + i * 0.1,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: `uuid-race-${i}`,
}));
const [first, second] = await Promise.all([
POST(makeRequest({ entries: sharedEntries() })),
POST(makeRequest({ entries: sharedEntries() })),
]);
expect(first.status).toBe(200);
expect(second.status).toBe(200);
const firstJson = (await first.json()) as {
data: {
processed: number;
inserted: number;
duplicates: number;
entries: Array<{ status: string }>;
};
};
const secondJson = (await second.json()) as typeof firstJson;
// Invariant 1 — per-entry statuses sum to the aggregate counts.
// Before the fix this failed under contention because the
// no-op reconciliation left the envelope out of sync.
for (const json of [firstJson, secondJson]) {
const insertedEntries = json.data.entries.filter(
(e) => e.status === "inserted",
).length;
const duplicateEntries = json.data.entries.filter(
(e) => e.status === "duplicate",
).length;
const skippedEntries = json.data.entries.filter(
(e) => e.status === "skipped",
).length;
expect(insertedEntries).toBe(json.data.inserted);
expect(duplicateEntries).toBe(json.data.duplicates);
expect(
insertedEntries + duplicateEntries + skippedEntries,
).toBe(json.data.processed);
}
// Invariant 2 — aggregate counts are non-negative. The previous
// logic could not produce a negative `inserted` count but a
// naive "downgrade and also decrement" implementation can.
expect(firstJson.data.inserted).toBeGreaterThanOrEqual(0);
expect(secondJson.data.inserted).toBeGreaterThanOrEqual(0);
// Invariant 3 — the DB ends up with at most 6 rows because the
// composite unique index enforces single-copy, and the
// `inserted` counts across both requests cannot exceed the
// number of rows actually present.
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID },
});
expect(stored.length).toBeLessThanOrEqual(6);
expect(firstJson.data.inserted + secondJson.data.inserted).toBe(
stored.length,
);
});
it("rate-limits a user at 60 batches per minute (security H-2)", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
// Pre-seed the rate-limit counter to the cap so the next call
// exercises the over-limit path without 60 round-trips.
const cap = 60;
const resetAt = new Date(Date.now() + 60 * 1000);
await getPrismaClient().$executeRaw`
INSERT INTO rate_limits (key, count, reset_at)
VALUES (${`measurements:batch:${TEST_USER_ID}`}, ${cap}, ${resetAt})
`;
const body = {
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 81.4,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: "uuid-rate-limit-001",
},
],
};
const response = await POST(makeRequest(body));
expect(response.status).toBe(429);
const json = (await response.json()) as { error: string };
expect(json.error).toMatch(/too many/i);
// The over-limit response must not have written anything to the
// measurement table — the iOS client should retry, not assume the
// row landed.
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID },
});
expect(stored).toHaveLength(0);
});
it("replays a cached response when the same Idempotency-Key is reused", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
@@ -0,0 +1,188 @@
/**
* v1.4.25 W16c — PR detection end-to-end.
*
* The handler runs against a real Postgres testcontainer + real
* Measurement / Workout rows. We assert two flows:
*
* 1. After enough measurements land, the detector writes a
* PersonalRecord row at the all-time best and tags the
* `sourceMeasurementId` to the winning row.
* 2. The detector is idempotent — re-running the same handler over
* the same dataset produces no duplicate rows because the
* `(userId, metricType, metricSlot, achievedAt)` unique index
* absorbs the second insert.
*
* The HTTP-layer enqueue is covered by the per-route unit suites
* (`src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts`
* and the workouts equivalent). This file walks the handler instead
* of going through pg-boss so the integration suite stays free of
* out-of-process job-queue plumbing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const TEST_USER_ID = "user-pr-detection-e2e";
const { detectPersonalRecordsForUser, PR_DETECTION_WARMUP_THRESHOLD } =
await import("@/lib/personal-records/pr-detection-worker");
const { isPRTrackable } = await import("@/lib/personal-records/pr-direction");
import type { MeasurementType } from "@/generated/prisma/client";
const { measurementTypeEnum } = await import("@/lib/validations/measurement");
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "pr-detection",
email: "pr-detection@example.test",
},
});
});
describe("PR detection — measurement-driven (real DB)", () => {
it("writes one PersonalRecord row for the all-time best after the warm-up gate", async () => {
const prisma = getPrismaClient();
// Seed enough samples to clear the warm-up gate plus one obvious
// all-time best. Days are stepped backwards from today so the
// best row's `measuredAt` lands at a deterministic, unique
// timestamp.
const baseDate = new Date("2026-05-14T00:00:00Z");
const samples = [];
for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD; i++) {
samples.push({
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS" as const,
value: 8000 + i * 100,
unit: "count",
source: "APPLE_HEALTH" as const,
measuredAt: new Date(baseDate.getTime() - (i + 1) * 86_400_000),
externalId: `seed-${i}`,
});
}
samples.push({
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS" as const,
value: 18234,
unit: "count",
source: "APPLE_HEALTH" as const,
measuredAt: baseDate,
externalId: "all-time-best",
});
await prisma.measurement.createMany({ data: samples });
const result = await detectPersonalRecordsForUser(TEST_USER_ID);
expect(result.inserted).toBeGreaterThanOrEqual(1);
const stepsPR = await prisma.personalRecord.findFirst({
where: {
userId: TEST_USER_ID,
metricType: "ACTIVITY_STEPS",
metricSlot: null,
},
});
expect(stepsPR).not.toBeNull();
expect(stepsPR?.value).toBe(18234);
expect(stepsPR?.direction).toBe("MAX");
expect(stepsPR?.sourceMeasurementId).toBeDefined();
});
it("is idempotent — re-running the handler does not duplicate the row", async () => {
const prisma = getPrismaClient();
const baseDate = new Date("2026-05-14T00:00:00Z");
const samples = [];
for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD; i++) {
samples.push({
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE" as const,
value: 65 + i,
unit: "bpm",
source: "APPLE_HEALTH" as const,
measuredAt: new Date(baseDate.getTime() - (i + 1) * 86_400_000),
externalId: `rhr-seed-${i}`,
});
}
samples.push({
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE" as const,
value: 47,
unit: "bpm",
source: "APPLE_HEALTH" as const,
measuredAt: baseDate,
externalId: "rhr-best",
});
await prisma.measurement.createMany({ data: samples });
await detectPersonalRecordsForUser(TEST_USER_ID);
const after1 = await prisma.personalRecord.count({
where: { userId: TEST_USER_ID, metricType: "RESTING_HEART_RATE" },
});
await detectPersonalRecordsForUser(TEST_USER_ID);
const after2 = await prisma.personalRecord.count({
where: { userId: TEST_USER_ID, metricType: "RESTING_HEART_RATE" },
});
expect(after1).toBe(1);
expect(after2).toBe(1);
});
it("propagates the silent flag without affecting the row written", async () => {
const prisma = getPrismaClient();
const baseDate = new Date("2026-05-14T00:00:00Z");
const samples = [];
for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD; i++) {
samples.push({
userId: TEST_USER_ID,
type: "VO2_MAX" as const,
value: 38 + i * 0.5,
unit: "ml/kg/min",
source: "APPLE_HEALTH" as const,
measuredAt: new Date(baseDate.getTime() - (i + 1) * 86_400_000),
externalId: `vo2-${i}`,
});
}
samples.push({
userId: TEST_USER_ID,
type: "VO2_MAX" as const,
value: 52.5,
unit: "ml/kg/min",
source: "APPLE_HEALTH" as const,
measuredAt: baseDate,
externalId: "vo2-best",
});
await prisma.measurement.createMany({ data: samples });
const result = await detectPersonalRecordsForUser(TEST_USER_ID, {
silent: true,
});
expect(result.silent).toBe(true);
const pr = await prisma.personalRecord.findFirst({
where: { userId: TEST_USER_ID, metricType: "VO2_MAX" },
});
expect(pr).not.toBeNull();
expect(pr?.value).toBeCloseTo(52.5);
});
});
describe("PR detection — drift guard (real DB)", () => {
it("scans every PR-trackable metric type when no row exists", async () => {
// Drift assertion — `scanned` should match the count of PR-trackable
// metrics (and at least every direction enum case must appear in
// either the worker or the suppress branch via `getPRDirection`).
const result = await detectPersonalRecordsForUser(TEST_USER_ID);
const trackable = measurementTypeEnum.options.filter((t) =>
isPRTrackable(t as MeasurementType),
);
expect(result.scanned).toBeGreaterThanOrEqual(trackable.length);
});
});
+3
View File
@@ -57,6 +57,7 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
"notification_channels",
"notification_preferences",
"passkeys",
"personal_records",
"push_subscriptions",
"rate_limits",
"refresh_tokens",
@@ -67,6 +68,8 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
"user_achievements",
"users",
"withings_connections",
"workout_routes",
"workouts",
];
const quoted = tables.map((t) => `"${t}"`).join(", ");
@@ -0,0 +1,263 @@
/**
* v1.4.25 W8c — two-axis source-priority resolution, end-to-end.
*
* Wave 8c lays down a per-user, per-metric, per-device-type canonical
* picker. The picker is unit-tested at the helper level; this file
* locks the integration path: a user with both axes set in
* `User.sourcePriorityJson`, multiple Measurement rows for the same
* day across sources + device-types, and the analytics route's
* SLEEP_DURATION aggregator picking exactly the canonical row.
*
* Three fixtures, all on the same Berlin day:
*
* 1. Plain default user — only Withings rows. The picker is a
* pass-through; the route returns the raw total. Locks the
* v1.4.25-today behaviour against regressions.
*
* 2. Per-metric override — Withings + Apple Health both contributed
* sleep for the same night. The user's per-metric ladder pins
* Withings first (default for sleep is Apple Health > Withings),
* so the route should report Withings' total only.
*
* 3. Per-device override — Apple Watch + iPhone both wrote sleep
* rows under APPLE_HEALTH. The default device-type ladder ranks
* watch above phone, so the route should pick the watch row and
* drop the iPhone row.
*
* Sleep is the right metric to verify end-to-end because the
* analytics route's sleep aggregation is the only call site of
* `pickCanonicalSourceRows()` today; this test guards the wire-up
* without needing a new aggregator.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
interface AnalyticsEnvelope {
data: {
summaries: Record<
string,
{ count: number; latest: number | null; mean: number | null }
>;
};
}
async function seedUserWithSession(
username: string,
sourcePriorityJson?: unknown,
) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role: "USER",
...(sourcePriorityJson !== undefined
? {
sourcePriorityJson:
sourcePriorityJson as import("@/generated/prisma/client").Prisma.InputJsonValue,
}
: {}),
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
return user;
}
async function callAnalytics(): Promise<AnalyticsEnvelope> {
const { GET } = await import("@/app/api/analytics/route");
const response = await (
GET as unknown as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/analytics"));
expect(response.status).toBe(200);
return (await response.json()) as AnalyticsEnvelope;
}
describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () => {
it("plain default user: single-source rows pass through unchanged", async () => {
// Locks the v1.4.25-today behaviour: a user with no override and
// only one source per metric should see the raw daily total.
const prisma = getPrismaClient();
const user = await seedUserWithSession("plain-default");
// 420 minutes of sleep, one Withings row, no override.
await prisma.measurement.create({
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 420,
unit: "minutes",
source: "WITHINGS",
measuredAt: new Date("2026-05-12T07:00:00.000Z"),
},
});
const envelope = await callAnalytics();
const summary = envelope.data.summaries.SLEEP_DURATION;
expect(summary.count).toBe(1);
expect(summary.latest).toBe(420);
});
it("per-metric override pins Withings first when Apple Health also reported sleep", async () => {
// Same night, both sources contributed sleep duration. Without an
// override the default ladder (APPLE_HEALTH > WITHINGS) would win;
// with the override the picker returns the Withings row instead.
const prisma = getPrismaClient();
const user = await seedUserWithSession("per-metric-override", {
metricPriority: {
sleep: ["WITHINGS", "APPLE_HEALTH"],
},
});
// Apple Health stage row — 480 minutes total.
await prisma.measurement.create({
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 480,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-12T06:00:00.000Z"),
externalId: "ah-sleep-1",
},
});
// Withings nightly summary — 410 minutes.
await prisma.measurement.create({
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 410,
unit: "minutes",
source: "WITHINGS",
measuredAt: new Date("2026-05-12T07:00:00.000Z"),
},
});
const envelope = await callAnalytics();
const summary = envelope.data.summaries.SLEEP_DURATION;
// One canonical night (the Withings row); the Apple Health row is
// dropped from the aggregation but still lives in the DB.
expect(summary.count).toBe(1);
expect(summary.latest).toBe(410);
});
it("per-device override drops iPhone rows in favour of Apple Watch rows", async () => {
// Same source, same night — but Apple Watch and iPhone both wrote
// sleep samples. The default device-type ladder ranks watch above
// phone, so the watch row should survive.
const prisma = getPrismaClient();
const user = await seedUserWithSession("per-device-override");
await prisma.measurement.create({
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 480,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-12T06:00:00.000Z"),
externalId: "ah-sleep-watch",
deviceType: "watch",
},
});
await prisma.measurement.create({
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 380,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-12T06:30:00.000Z"),
externalId: "ah-sleep-phone",
deviceType: "phone",
},
});
const envelope = await callAnalytics();
const summary = envelope.data.summaries.SLEEP_DURATION;
// Watch wins; the phone row drops out of the aggregate.
expect(summary.count).toBe(1);
expect(summary.latest).toBe(480);
});
it("user override flips device-type ladder so phone wins", async () => {
// Same fixture as the previous test, but the user pinned phone
// first via the deviceTypePriority.default ladder.
const prisma = getPrismaClient();
const user = await seedUserWithSession("phone-first", {
deviceTypePriority: { default: ["phone", "watch"] },
});
await prisma.measurement.create({
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 480,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-12T06:00:00.000Z"),
externalId: "ah-sleep-watch",
deviceType: "watch",
},
});
await prisma.measurement.create({
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 380,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-12T06:30:00.000Z"),
externalId: "ah-sleep-phone",
deviceType: "phone",
},
});
const envelope = await callAnalytics();
const summary = envelope.data.summaries.SLEEP_DURATION;
expect(summary.count).toBe(1);
expect(summary.latest).toBe(380);
});
});
+443
View File
@@ -0,0 +1,443 @@
/**
* v1.4.25 W7 — per-user-timezone end-to-end guard.
*
* Creates a Pacific/Auckland user (UTC+12 in May, UTC+13 with DST
* later in the year), inserts a measurement at a UTC instant that
* maps to "today" in Auckland but "yesterday" in Berlin, then
* verifies:
*
* 1. Withing the Auckland user's session, the CSV export emits
* `+12:00` (or `+13:00` if DST is active) on the timestamp
* column — NOT the trailing `Z`. This is the issue #167 fix
* from the user's perspective.
*
* 2. The `PUT /api/auth/me/timezone` route correctly persists a
* new zone and the next read picks it up via the resolver
* cache (write-time invalidation).
*
* 3. The signup payload's browser-detected timezone is captured
* onto `User.timezone`, and an invalid value falls back to
* the server default.
*
* The Coach snapshot itself buckets by UTC and is not yet
* user-tz-aware (proposal §3 symptom 9 — deferred to a separate
* v1.5 wave). That assertion is intentionally NOT in this file.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
async function seedAucklandUser() {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "akl-user",
email: "akl@example.test",
role: "USER",
timezone: "Pacific/Auckland",
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
return user;
}
describe("per-user timezone — Pacific/Auckland end-to-end", () => {
it("CSV export emits the user's offset (not Z) on measuredAt", async () => {
const prisma = getPrismaClient();
const me = await seedAucklandUser();
// 12:00 UTC on May 15 → 00:00 Auckland on May 16 (UTC+12 in
// May, NZST). Auckland switches to NZDT (UTC+13) only in
// late September → early April; pick a May instant to keep
// the offset deterministic for the test snapshot.
await prisma.measurement.create({
data: {
userId: me.id,
type: "WEIGHT",
value: 75.5,
unit: "kg",
measuredAt: new Date("2026-05-15T12:00:00.000Z"),
source: "MANUAL",
},
});
const { GET } = await import("@/app/api/export/measurements/route");
const res = await GET(
new Request("http://localhost/api/export/measurements", {
method: "GET",
}) as Parameters<typeof GET>[0],
);
expect(res.status).toBe(200);
const body = await res.text();
// Header row + one data row.
expect(body).toContain(
"type,value,unit,measuredAt,source,notes,glucoseContext",
);
// The Auckland offset in May = +12:00 (NZST). The CSV row
// should carry it verbatim, never the bare Z.
expect(body).toContain("2026-05-16T00:00:00+12:00");
expect(body).not.toContain("2026-05-15T12:00:00.000Z");
});
it("PUT /api/auth/me/timezone writes the new zone and the resolver picks it up immediately", async () => {
const prisma = getPrismaClient();
const me = await seedAucklandUser();
const { PUT } = await import("@/app/api/auth/me/timezone/route");
const req = new Request("http://localhost/api/auth/me/timezone", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ timezone: "Asia/Tokyo" }),
});
const res = await PUT(req as Parameters<typeof PUT>[0]);
expect(res.status).toBe(200);
const fresh = await prisma.user.findUnique({
where: { id: me.id },
select: { timezone: true },
});
expect(fresh?.timezone).toBe("Asia/Tokyo");
// Resolver returns the new value without waiting for the 60s
// TTL — the route's invalidateUserTimezone() call evicts the
// cache on write.
const { resolveUserTimezone } = await import("@/lib/tz/resolver");
expect(await resolveUserTimezone(me.id)).toBe("Asia/Tokyo");
});
it("PUT /api/auth/me/timezone rejects an invalid IANA zone with 422", async () => {
await seedAucklandUser();
const { PUT } = await import("@/app/api/auth/me/timezone/route");
const req = new Request("http://localhost/api/auth/me/timezone", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ timezone: "Mars/Olympus_Mons" }),
});
const res = await PUT(req as Parameters<typeof PUT>[0]);
expect(res.status).toBe(422);
});
it("CSV export for an Asia/Tokyo user shows +09:00 (no DST)", async () => {
const prisma = getPrismaClient();
const me = await prisma.user.create({
data: {
username: "tokyo-user",
email: "tokyo@example.test",
role: "USER",
timezone: "Asia/Tokyo",
},
});
const session = await prisma.session.create({
data: { userId: me.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
await prisma.measurement.create({
data: {
userId: me.id,
type: "WEIGHT",
value: 70,
unit: "kg",
measuredAt: new Date("2026-05-15T03:00:00.000Z"), // 12:00 Tokyo
source: "MANUAL",
},
});
const { GET } = await import("@/app/api/export/measurements/route");
const res = await GET(
new Request("http://localhost/api/export/measurements", {
method: "GET",
}) as Parameters<typeof GET>[0],
);
const body = await res.text();
expect(body).toContain("2026-05-15T12:00:00+09:00");
});
it("CSV export with no userTz (legacy callers) emits the Z suffix", async () => {
// This protects the canonical-backup-on-disk contract — the
// export library has a backward-compatible no-userTz path.
const { formatMeasurementsForExport, toCSV } = await import("@/lib/export");
const csv = toCSV(
formatMeasurementsForExport([
{
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date("2026-05-15T12:00:00.000Z"),
source: "MANUAL",
notes: null,
},
]),
);
expect(csv).toContain("2026-05-15T12:00:00.000Z");
});
it("registration captures the browser-detected timezone", async () => {
const prisma = getPrismaClient();
const { POST } = await import("@/app/api/auth/register/route");
const req = new Request("http://localhost/api/auth/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: "fresh-akl@example.test",
username: "fresh-akl",
password: "S3cure-Passw0rd-12345",
timezone: "Pacific/Auckland",
}),
});
const res = await POST(req as Parameters<typeof POST>[0]);
// The first ever signup becomes admin → 201; subsequent signups
// succeed too. Either way it's a 2xx.
expect(res.status).toBeGreaterThanOrEqual(200);
expect(res.status).toBeLessThan(300);
const stored = await prisma.user.findFirst({
where: { username: "fresh-akl" },
select: { timezone: true },
});
expect(stored?.timezone).toBe("Pacific/Auckland");
});
it("registration with an invalid timezone falls back to Europe/Berlin", async () => {
// No admin server default is set in this test, so the resolver
// chain bottoms out at the hard-coded "Europe/Berlin". This
// covers the worst-case fallback path. Setting an admin default
// and observing the new value would require the testcontainer
// migrations to carry every AppSettings column the running
// schema requires; the integration suite is intentionally
// tolerant of unmigrated app_settings columns, so we exercise
// the unset-default path here.
const prisma = getPrismaClient();
// Make sure no leftover row pins a default.
const { invalidateServerDefaultTimezone } =
await import("@/lib/tz/resolver");
invalidateServerDefaultTimezone();
const { POST } = await import("@/app/api/auth/register/route");
const req = new Request("http://localhost/api/auth/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: "bogus@example.test",
username: "bogus-tz",
password: "S3cure-Passw0rd-12345",
timezone: "Mars/Olympus_Mons",
}),
});
const res = await POST(req as Parameters<typeof POST>[0]);
expect(res.status).toBeGreaterThanOrEqual(200);
expect(res.status).toBeLessThan(300);
const stored = await prisma.user.findFirst({
where: { username: "bogus-tz" },
select: { timezone: true },
});
expect(stored?.timezone).toBe("Europe/Berlin");
});
it("dashboard streak counts the Auckland-day for a 23:50 NZST reading", async () => {
// v1.4.25 W7b — surface 1. A Pacific/Auckland user logs a
// reading at 11:50 UTC, which is 23:50 NZST (May, UTC+12). In
// Berlin that's 13:50, well clear of midnight either way — but
// the test pins the principle: streak day-keys honour the user's
// tz. Without the fix, the legacy Berlin bucketing would pin
// this reading to the same Berlin day for every user regardless
// of where their personal "today" actually is.
const prisma = getPrismaClient();
const me = await seedAucklandUser();
// 11:50 UTC on May 14 → 23:50 NZST May 14 (UTC+12 in May).
await prisma.measurement.create({
data: {
userId: me.id,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date("2026-05-14T11:50:00.000Z"),
source: "MANUAL",
},
});
const { userDayKey } = await import("@/lib/tz/resolver");
// The Auckland day-key for this reading is "2026-05-14"; the
// Berlin day-key would be "2026-05-14" too in May because Berlin
// is UTC+2, but Auckland users at the day boundary depend on
// their own tz. Pin the principle directly: the key the
// dashboard would store for this reading IS the user's tz day.
expect(
userDayKey(new Date("2026-05-14T11:50:00.000Z"), "Pacific/Auckland"),
).toBe("2026-05-14");
expect(
userDayKey(new Date("2026-05-14T11:50:00.000Z"), "Europe/Berlin"),
).toBe("2026-05-14");
// The actual boundary case: 13:00 UTC on May 14 → 01:00 NZST
// May 15 in Auckland, but still 15:00 on May 14 in Berlin. The
// dashboard's streak for an Auckland user should pin this to
// May 15 (their tomorrow); a Berlin user would pin it to May 14.
expect(
userDayKey(new Date("2026-05-14T13:00:00.000Z"), "Pacific/Auckland"),
).toBe("2026-05-15");
expect(
userDayKey(new Date("2026-05-14T13:00:00.000Z"), "Europe/Berlin"),
).toBe("2026-05-14");
});
it("dashboard summary aggregates streaks in the user's tz", async () => {
// Hit the actual /api/dashboard/summary route as the Auckland
// user and confirm the streak counts days using Auckland's
// calendar, not Berlin's. We seed three consecutive Auckland
// days of activity; each measurement is timestamped late-evening
// UTC so a Berlin-bucketed pipeline would group two of them
// into one day and report a shorter streak.
const prisma = getPrismaClient();
const me = await seedAucklandUser();
// Three consecutive Auckland days, each at 23:30 NZST (11:30 UTC).
// For Auckland (UTC+12 in May): 2026-05-12, 2026-05-13, 2026-05-14.
// For Berlin (UTC+2 in May): 13:30 on the same UTC day; the
// legacy bucketing would still see three days, so this test
// doesn't trigger a tz divergence on its own. The point is to
// confirm the route returns a non-zero streak and goes through
// the user-tz path without crashing.
for (let i = 0; i < 3; i++) {
const dt = new Date(`2026-05-${12 + i}T11:30:00.000Z`);
await prisma.measurement.create({
data: {
userId: me.id,
type: "WEIGHT",
value: 80 + i,
unit: "kg",
measuredAt: dt,
source: "MANUAL",
},
});
}
const { GET } = await import("@/app/api/dashboard/summary/route");
// The handler is typed `() => Promise<Response>`, but the
// `apiHandler` wrapper unconditionally reads `args[0]` as the
// Next.js Request — cast through unknown to push the test fixture
// through without weakening the runtime type.
const handler = GET as unknown as (req: Request) => Promise<Response>;
const res = await handler(
new Request("http://localhost/api/dashboard/summary", {
method: "GET",
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { streak: { currentDays: number; longest: number } };
};
// The longest streak across the 3 seeded days is 3.
expect(body.data.streak.longest).toBeGreaterThanOrEqual(3);
});
it("coach snapshot anchors timeline.recent to the user's tz", async () => {
// v1.4.25 W7b — surface 3. A reading at HH:13:00 UTC sits at
// 01:00 NZST on the *next* Auckland day (NZST = UTC+12 in May).
// The snapshot must pin the reading to the Auckland day, not the
// (incidentally-equal) UTC day a Berlin user would see.
//
// The instant must stay inside the snapshot's 14-day `recent`
// window across every CI run, so we anchor it to "today − 2d at
// 13:00 UTC" rather than a hard-coded May-2026 calendar date.
// The − 2d cushion keeps the reading clear of `Date.now()` clock
// jitter and the late-evening DST cutover in late September
// (Auckland flips NZST → NZDT, which shifts the day-key for
// 13:00 UTC by an extra hour but never crosses the date line).
const prisma = getPrismaClient();
const me = await seedAucklandUser();
const anchor = new Date();
anchor.setUTCDate(anchor.getUTCDate() - 2);
anchor.setUTCHours(13, 0, 0, 0);
const dayKey = (tz: string) =>
new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(anchor);
const aucklandDay = dayKey("Pacific/Auckland");
const berlinDay = dayKey("Europe/Berlin");
// Sanity check: 13:00 UTC means Auckland is on a different
// calendar day than Berlin. Without this, the test couldn't
// assert the tz divergence we care about.
expect(aucklandDay).not.toBe(berlinDay);
await prisma.measurement.create({
data: {
userId: me.id,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: anchor,
source: "MANUAL",
},
});
const { buildCoachSnapshot } = await import("@/lib/ai/coach/snapshot");
const out = await buildCoachSnapshot(me.id, {
window: "last30days",
sources: ["weight"],
});
const parsed = JSON.parse(out.snapshotJson) as {
weight?: {
timeline?: {
recent?: Array<{ date: string; weekday: string; value: number }>;
};
};
};
const recent = parsed.weight?.timeline?.recent ?? [];
expect(recent.length).toBeGreaterThanOrEqual(1);
// Bucket lands on the Auckland day, not the Berlin day.
expect(recent.some((r) => r.date === aucklandDay)).toBe(true);
expect(recent.some((r) => r.date === berlinDay)).toBe(false);
});
});
@@ -0,0 +1,115 @@
/**
* v1.4.25 W17b — Withings activity-sync end-to-end.
*
* Drives `syncUserActivity` against a mocked Withings response and the
* real Postgres testcontainer. Asserts:
* - Per-day activity entries map to one row per (date, metric)
* - The composite-unique with NULLS NOT DISTINCT serializes a
* second sync of the same window into UPDATEs (no duplicates)
* - Activity rows leave sleep_stage NULL so they do not collide
* with the W17c sleep rows
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { encrypt } from "@/lib/crypto";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-withings-activity-sync";
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: TEST_USER_ID,
username: "withings-activity-sync",
email: "withings-activity-sync@example.test",
},
});
await prisma.withingsConnection.create({
data: {
userId: TEST_USER_ID,
withingsUserId: "wu-1",
accessToken: encrypt("access-token"),
refreshToken: encrypt("refresh-token"),
// Token valid for an hour — keeps `getValidToken` off the
// refresh path.
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
scope: "user.metrics,user.activity",
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function stubWithingsActivity(entries: unknown[]) {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: 200,
json: async () => ({
status: 0,
body: { activities: entries, more: false, offset: 0 },
}),
})),
);
}
describe("syncUserActivity — integration", () => {
it("writes one Measurement row per (date, metric) on first sync", async () => {
stubWithingsActivity([
{ date: "2026-05-10", steps: 8420, distance: 6720, calories: 412 },
{ date: "2026-05-11", steps: 5012, distance: 3950, calories: 245 },
]);
const { syncUserActivity } = await import("@/lib/withings/sync-activity");
const imported = await syncUserActivity(TEST_USER_ID);
expect(imported).toBe(6); // 2 days × 3 fields
const prisma = getPrismaClient();
const rows = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, source: "WITHINGS" },
orderBy: [{ measuredAt: "asc" }, { type: "asc" }],
});
expect(rows).toHaveLength(6);
// Activity rows never carry a sleepStage — confirms the new
// composite leaves the column NULL for non-sleep ingest.
expect(rows.every((r) => r.sleepStage === null)).toBe(true);
const types = new Set(rows.map((r) => r.type));
expect(types.has("ACTIVITY_STEPS")).toBe(true);
expect(types.has("WALKING_RUNNING_DISTANCE")).toBe(true);
expect(types.has("ACTIVE_ENERGY_BURNED")).toBe(true);
});
it("re-running the same sync updates rather than duplicating", async () => {
const entries = [
{ date: "2026-05-10", steps: 8420, distance: 6720, calories: 412 },
];
stubWithingsActivity(entries);
const { syncUserActivity } = await import("@/lib/withings/sync-activity");
await syncUserActivity(TEST_USER_ID);
// Withings re-aggregates a day when late samples arrive; second
// pull returns updated values.
stubWithingsActivity([
{ date: "2026-05-10", steps: 9001, distance: 7200, calories: 450 },
]);
await syncUserActivity(TEST_USER_ID);
const prisma = getPrismaClient();
const rows = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, source: "WITHINGS" },
orderBy: { type: "asc" },
});
expect(rows).toHaveLength(3);
const steps = rows.find((r) => r.type === "ACTIVITY_STEPS");
expect(steps?.value).toBe(9001);
const distance = rows.find((r) => r.type === "WALKING_RUNNING_DISTANCE");
expect(distance?.value).toBe(7200);
});
});
@@ -0,0 +1,173 @@
/**
* v1.4.25 W17b/c — Migration 0055 composite-uniqueness contract.
*
* The Withings Sleep v2 sync writes one Measurement row per stage
* segment (AWAKE | CORE | DEEP | REM) for the same night, sharing
* (user_id, type=SLEEP_DURATION, measured_at, source=WITHINGS). The
* legacy four-column composite collapsed them onto a single row;
* Migration 0055 added `sleep_stage` as the fifth axis and recreated
* the index with `NULLS NOT DISTINCT` so:
*
* - per-stage rows for the same night now co-exist (one per stage)
* - non-sleep rows (sleep_stage IS NULL) still dedup on the first
* four columns alone — no behavioural change for every meastype
* other than SLEEP_DURATION
* - re-syncing the same night is idempotent (the stage label still
* anchors the upsert key)
*/
import { Prisma } from "@/generated/prisma/client";
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-sleep-stage-composite";
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: TEST_USER_ID,
username: "sleep-stage-composite",
email: "sleep-stage-composite@example.test",
},
});
});
describe("Migration 0055 — measurement composite with sleep_stage", () => {
it("allows multiple sleep-stage rows for the same (user, type, measuredAt, source)", async () => {
const prisma = getPrismaClient();
const measuredAt = new Date("2026-05-12T22:00:00.000Z");
// Four stage rows that share every legacy-composite column — only
// sleep_stage differentiates them. All four must persist.
const stages: Array<"AWAKE" | "CORE" | "DEEP" | "REM"> = [
"AWAKE",
"CORE",
"DEEP",
"REM",
];
for (const stage of stages) {
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "SLEEP_DURATION",
value: 60,
unit: "minutes",
source: "WITHINGS",
measuredAt,
sleepStage: stage,
},
});
}
const rows = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "SLEEP_DURATION" },
orderBy: { sleepStage: "asc" },
});
expect(rows).toHaveLength(4);
expect(rows.map((r) => r.sleepStage).sort()).toEqual([
"AWAKE",
"CORE",
"DEEP",
"REM",
]);
});
it("still rejects duplicates within the same stage (idempotent re-sync)", async () => {
const prisma = getPrismaClient();
const measuredAt = new Date("2026-05-12T22:00:00.000Z");
const row = {
userId: TEST_USER_ID,
type: "SLEEP_DURATION" as const,
value: 60,
unit: "minutes",
source: "WITHINGS" as const,
measuredAt,
sleepStage: "DEEP" as const,
};
await prisma.measurement.create({ data: row });
// Same five-tuple → must surface as Prisma P2002.
await expect(prisma.measurement.create({ data: row })).rejects.toThrow(
Prisma.PrismaClientKnownRequestError,
);
const rows = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID },
});
expect(rows).toHaveLength(1);
});
it("preserves NULL-vs-NULL dedup for non-sleep rows (NULLS NOT DISTINCT)", async () => {
const prisma = getPrismaClient();
const measuredAt = new Date("2026-05-12T08:30:00.000Z");
const row = {
userId: TEST_USER_ID,
type: "WEIGHT" as const,
value: 82.3,
unit: "kg",
source: "WITHINGS" as const,
measuredAt,
// sleepStage omitted → NULL
};
await prisma.measurement.create({ data: row });
// Migration 0055 created the unique index with NULLS NOT DISTINCT,
// so the second insert MUST collide on the four legacy columns
// even though sleep_stage is NULL on both sides.
await expect(prisma.measurement.create({ data: row })).rejects.toThrow(
Prisma.PrismaClientKnownRequestError,
);
const rows = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "WEIGHT" },
});
expect(rows).toHaveLength(1);
});
it("supports findFirst + create/update idempotency for non-sleep measurements", async () => {
const prisma = getPrismaClient();
const measuredAt = new Date("2026-05-12T08:30:00.000Z");
// Prisma's typed compound input requires a non-null `sleepStage`,
// so the Withings sync writes via findFirst + create/update. This
// mirrors that path; the second pass must update in place rather
// than insert a duplicate.
for (const value of [82.4, 82.1]) {
const existing = await prisma.measurement.findFirst({
where: {
userId: TEST_USER_ID,
type: "WEIGHT",
measuredAt,
source: "WITHINGS",
sleepStage: null,
},
select: { id: true },
});
if (existing) {
await prisma.measurement.update({
where: { id: existing.id },
data: { value },
});
} else {
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "WEIGHT",
value,
unit: "kg",
measuredAt,
source: "WITHINGS",
},
});
}
}
const rows = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "WEIGHT" },
});
expect(rows).toHaveLength(1);
expect(rows[0].value).toBe(82.1);
});
});
@@ -0,0 +1,123 @@
/**
* v1.4.25 W17c — Withings Sleep v2 end-to-end integration.
*
* Drives `syncUserSleep` against a mocked Withings response and the
* real Postgres testcontainer. Asserts the contract Migration 0055
* was written for: per-stage rows for the SAME night must all persist
* (one per stage), and a re-sync must update in place rather than
* duplicate them.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { encrypt } from "@/lib/crypto";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-withings-sleep-sync";
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: TEST_USER_ID,
username: "withings-sleep-sync",
email: "withings-sleep-sync@example.test",
},
});
await prisma.withingsConnection.create({
data: {
userId: TEST_USER_ID,
withingsUserId: "wu-1",
accessToken: encrypt("access-token"),
refreshToken: encrypt("refresh-token"),
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
scope: "user.metrics,user.activity",
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function stubWithingsSleep(series: unknown[]) {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: 200,
json: async () => ({
status: 0,
body: { series },
}),
})),
);
}
describe("syncUserSleep — integration", () => {
it("persists one Measurement row per stage segment for a single night", async () => {
// Sleep session #99: 4 segments — CORE 60m, DEEP 30m, REM 30m, AWAKE 10m.
// All segments share the same parent night but each carries a
// distinct sleepStage, so the W17b/c composite (Migration 0055)
// keeps every row.
const base = 1715000000; // 2024-05-06 13:33:20 UTC (arbitrary)
stubWithingsSleep([
{ startdate: base, enddate: base + 3600, state: 1, id: 99 },
{ startdate: base + 3600, enddate: base + 5400, state: 2, id: 99 },
{ startdate: base + 5400, enddate: base + 7200, state: 3, id: 99 },
{ startdate: base + 7200, enddate: base + 7800, state: 0, id: 99 },
]);
const { syncUserSleep } = await import("@/lib/withings/sync-sleep");
const imported = await syncUserSleep(TEST_USER_ID);
expect(imported).toBe(4);
const prisma = getPrismaClient();
const rows = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "SLEEP_DURATION" },
orderBy: { measuredAt: "asc" },
});
expect(rows).toHaveLength(4);
// Every stage represented exactly once.
const stages = rows.map((r) => r.sleepStage).sort();
expect(stages).toEqual(["AWAKE", "CORE", "DEEP", "REM"]);
// Stage minutes — sanity check that seconds→minutes conversion is correct.
const byStage = new Map(rows.map((r) => [r.sleepStage, r.value]));
expect(byStage.get("CORE")).toBe(60);
expect(byStage.get("DEEP")).toBe(30);
expect(byStage.get("REM")).toBe(30);
expect(byStage.get("AWAKE")).toBe(10);
// Every row tagged as WITHINGS source — picker will treat them as
// a single source for the picker pipeline.
expect(rows.every((r) => r.source === "WITHINGS")).toBe(true);
});
it("re-syncing the same night updates rows in place (no duplicates)", async () => {
const base = 1715000000;
// First sync: DEEP 30 minutes.
stubWithingsSleep([
{ startdate: base, enddate: base + 1800, state: 2, id: 100 },
]);
const { syncUserSleep } = await import("@/lib/withings/sync-sleep");
await syncUserSleep(TEST_USER_ID);
// Withings re-aggregates the night and the DEEP segment grew to
// 45 minutes — second sync must UPDATE rather than INSERT.
stubWithingsSleep([
{ startdate: base, enddate: base + 2700, state: 2, id: 100 },
]);
await syncUserSleep(TEST_USER_ID);
const prisma = getPrismaClient();
const rows = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "SLEEP_DURATION" },
});
expect(rows).toHaveLength(1);
expect(rows[0].value).toBe(45);
expect(rows[0].sleepStage).toBe("DEEP");
});
});
@@ -0,0 +1,409 @@
/**
* Integration suite for `POST /api/workouts/batch` — the v1.5 iOS
* HealthKit ingest endpoint and the deferred Withings activity sync
* target. Asserts the contract the iOS client relies on against a real
* Postgres in a testcontainer:
*
* - Mixed batches with nested routes succeed end-to-end
* - Re-posting the same batch yields per-entry duplicates
* - Over-cap batches return 400 with the documented error code
* - Over-cap routes return 400 with the documented error code
* - Idempotency-Key replay returns the cached envelope
* - Rate-limit kicks in at the documented ceiling
* - Narrow-scope Bearer tokens are admitted (W10 fix-C parity)
*
* A separate file at this directory carries the concurrent-write race
* test so the contention assertion stays focused on the invariant that
* was the original target of the W10 fix-C reconciliation.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
// The HMAC key must be present before any module that touches
// `@/lib/auth/hmac` is imported — `hashToken()` reads it lazily but
// only the first call's process state is exercised here.
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-workout-batch-integration-32-bytes-min-1234567890";
const { hashToken } = await import("@/lib/auth/hmac");
const TEST_USER_ID = "user-workout-batch-test";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "workout-batch",
email: "workout-batch@example.test",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
interface WorkoutFixture {
sportType?: string;
startedAt?: string;
endedAt?: string;
totalEnergyKcal?: number;
totalDistanceM?: number;
avgHeartRate?: number;
maxHeartRate?: number;
minHeartRate?: number;
stepCount?: number;
elevationM?: number;
pauseDurationSec?: number;
source?: string;
externalId?: string;
externalSourceVersion?: string;
metadata?: Record<string, unknown>;
route?: {
geometry: { type: "LineString"; coordinates: [number, number][] };
sampleTimestamps?: Array<{ t: string; speedMs?: number; hr?: number }>;
};
}
function makeRequest(
body: { workouts: WorkoutFixture[] },
opts: {
idempotencyKey?: string;
bearer?: string;
contentLength?: number | null;
} = {},
): NextRequest {
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (opts.idempotencyKey) {
headers["idempotency-key"] = opts.idempotencyKey;
}
if (opts.bearer) {
headers["authorization"] = `Bearer ${opts.bearer}`;
}
if (opts.contentLength !== null && opts.contentLength !== undefined) {
headers["content-length"] = String(opts.contentLength);
}
const serialized = JSON.stringify(body);
return new NextRequest("http://localhost/api/workouts/batch", {
method: "POST",
headers,
body: serialized,
});
}
function baseWorkout(
externalId: string,
overrides: WorkoutFixture = {},
): WorkoutFixture {
return {
sportType: "running",
startedAt: "2026-05-14T06:30:00.000Z",
endedAt: "2026-05-14T07:15:00.000Z",
source: "APPLE_HEALTH",
externalId,
...overrides,
};
}
describe("POST /api/workouts/batch (real Postgres)", () => {
it("inserts a single workout with a nested route", async () => {
const { POST } = await import("@/app/api/workouts/batch/route");
const body = {
workouts: [
baseWorkout("hk-uuid-route-001", {
totalEnergyKcal: 412,
totalDistanceM: 7800,
avgHeartRate: 154,
route: {
geometry: {
type: "LineString",
coordinates: [
[11.077, 49.452],
[11.078, 49.453],
[11.079, 49.454],
],
},
sampleTimestamps: [
{ t: "2026-05-14T06:30:00.000Z", speedMs: 3.2, hr: 142 },
{ t: "2026-05-14T06:30:05.000Z", speedMs: 3.3, hr: 144 },
{ t: "2026-05-14T06:30:10.000Z", speedMs: 3.1, hr: 146 },
],
},
}),
],
};
const res = await POST(makeRequest(body));
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { processed: number; inserted: number; duplicates: number };
};
expect(json.data.processed).toBe(1);
expect(json.data.inserted).toBe(1);
const workouts = await getPrismaClient().workout.findMany({
where: { userId: TEST_USER_ID },
include: { route: true },
});
expect(workouts).toHaveLength(1);
expect(workouts[0]?.sportType).toBe("running");
expect(workouts[0]?.totalDistanceM).toBe(7800);
expect(workouts[0]?.route).not.toBeNull();
expect(workouts[0]?.durationSec).toBe(45 * 60);
});
it("inserts a 100-workout batch with a mix of routed and routeless entries", async () => {
const { POST } = await import("@/app/api/workouts/batch/route");
const workouts: WorkoutFixture[] = Array.from({ length: 100 }, (_, i) => {
const offsetMin = i * 60;
const start = new Date(2026, 4, 1, 6, offsetMin).toISOString();
const end = new Date(2026, 4, 1, 6, offsetMin + 30).toISOString();
const hasRoute = i % 5 === 0;
return baseWorkout(`hk-uuid-batch-${i}`, {
startedAt: start,
endedAt: end,
...(hasRoute
? {
route: {
geometry: {
type: "LineString",
coordinates: [
[11.077 + i * 1e-4, 49.452],
[11.078 + i * 1e-4, 49.453],
],
},
},
}
: {}),
});
});
const res = await POST(makeRequest({ workouts }));
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { processed: number; inserted: number };
};
expect(json.data.processed).toBe(100);
expect(json.data.inserted).toBe(100);
const storedWorkouts = await getPrismaClient().workout.count({
where: { userId: TEST_USER_ID },
});
expect(storedWorkouts).toBe(100);
const storedRoutes = await getPrismaClient().workoutRoute.count({
where: { workout: { userId: TEST_USER_ID } },
});
expect(storedRoutes).toBe(20);
});
it("returns 400 with workout.batch.too_large when workouts exceed the cap", async () => {
const { POST } = await import("@/app/api/workouts/batch/route");
const workouts = Array.from({ length: 101 }, (_, i) =>
baseWorkout(`hk-uuid-cap-${i}`),
);
const res = await POST(makeRequest({ workouts }));
expect(res.status).toBe(400);
const body = (await res.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("workout.batch.too_large");
const stored = await getPrismaClient().workout.count({
where: { userId: TEST_USER_ID },
});
expect(stored).toBe(0);
});
it("returns 400 when a single route exceeds the 20 000-point cap", async () => {
const { POST } = await import("@/app/api/workouts/batch/route");
const coordinates: [number, number][] = [];
for (let i = 0; i < 20_001; i++) {
coordinates.push([11 + i * 1e-7, 49 + i * 1e-7]);
}
const res = await POST(
makeRequest(
{
workouts: [
baseWorkout("hk-uuid-route-cap", {
route: { geometry: { type: "LineString", coordinates } },
}),
],
},
// Skip the Content-Length pre-flight — the test wants to
// exercise the schema cap, not the byte cap.
{ contentLength: null },
),
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("workout.batch.invalid");
const stored = await getPrismaClient().workout.count({
where: { userId: TEST_USER_ID },
});
expect(stored).toBe(0);
});
it("dedupes a re-posted batch into duplicate status without inserting twice", async () => {
const { POST } = await import("@/app/api/workouts/batch/route");
const body = {
workouts: [
baseWorkout("hk-uuid-dup-1"),
baseWorkout("hk-uuid-dup-2"),
],
};
const first = await POST(makeRequest(body));
expect(first.status).toBe(200);
const firstJson = (await first.json()) as { data: { inserted: number } };
expect(firstJson.data.inserted).toBe(2);
const second = await POST(makeRequest(body));
expect(second.status).toBe(200);
const secondJson = (await second.json()) as {
data: {
inserted: number;
duplicates: number;
entries: Array<{ status: string }>;
};
};
expect(secondJson.data.inserted).toBe(0);
expect(secondJson.data.duplicates).toBe(2);
expect(secondJson.data.entries.every((e) => e.status === "duplicate")).toBe(
true,
);
const stored = await getPrismaClient().workout.count({
where: { userId: TEST_USER_ID },
});
expect(stored).toBe(2);
});
it("replays a cached response when the same Idempotency-Key is reused", async () => {
const { POST } = await import("@/app/api/workouts/batch/route");
const body = {
workouts: [baseWorkout("hk-uuid-idem-1")],
};
const key = "ios-workout-batch-12345678";
const first = await POST(makeRequest(body, { idempotencyKey: key }));
expect(first.status).toBe(200);
const firstJson = (await first.json()) as { data: { inserted: number } };
expect(firstJson.data.inserted).toBe(1);
const second = await POST(makeRequest(body, { idempotencyKey: key }));
expect(second.status).toBe(200);
expect(second.headers.get("X-Idempotent-Replay")).toBe("true");
const secondJson = (await second.json()) as { data: { inserted: number } };
// Same envelope as the first call — even though it would otherwise
// have surfaced as a duplicate.
expect(secondJson.data.inserted).toBe(1);
const stored = await getPrismaClient().workout.count({
where: { userId: TEST_USER_ID, externalId: "hk-uuid-idem-1" },
});
expect(stored).toBe(1);
});
it("rate-limits a user at 60 batches per minute", async () => {
const { POST } = await import("@/app/api/workouts/batch/route");
const cap = 60;
const resetAt = new Date(Date.now() + 60 * 1000);
await getPrismaClient().$executeRaw`
INSERT INTO rate_limits (key, count, reset_at)
VALUES (${`workouts:batch:${TEST_USER_ID}`}, ${cap}, ${resetAt})
`;
const res = await POST(
makeRequest({ workouts: [baseWorkout("hk-uuid-rate-limit-1")] }),
);
expect(res.status).toBe(429);
const json = (await res.json()) as { error: string };
expect(json.error).toMatch(/too many/i);
const stored = await getPrismaClient().workout.count({
where: { userId: TEST_USER_ID },
});
expect(stored).toBe(0);
});
it("admits a narrow-scope Bearer token (W10 fix-C parity)", async () => {
const { POST } = await import("@/app/api/workouts/batch/route");
// Drop the cookie session so the Bearer path is the ONLY auth in
// play. A narrow-scope token (no wildcard) MUST still admit on a
// route that declares no required permission — that's the W10
// fix-C correctness fix the iOS narrow-scope sync depends on.
cookieJar.delete("healthlog_session");
const rawToken = "hlk_narrow_scope_workout_ingest_token_value_a1b2c3d4e5";
await getPrismaClient().apiToken.create({
data: {
userId: TEST_USER_ID,
name: "narrow-scope-workouts",
tokenHash: hashToken(rawToken),
permissions: ["workouts:ingest"], // NO wildcard
},
});
headerJar.set("authorization", `Bearer ${rawToken}`);
const res = await POST(
makeRequest(
{ workouts: [baseWorkout("hk-uuid-narrow-1")] },
{ bearer: rawToken },
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { inserted: number } };
expect(json.data.inserted).toBe(1);
});
});
@@ -0,0 +1,220 @@
/**
* Concurrent-write race coverage for `POST /api/workouts/batch`.
*
* Mirrors the v1.4.25 W10 fix-C reconciliation pattern that landed for
* the measurements batch endpoint
* (`tests/integration/measurements-batch.test.ts` — the
* `keeps per-entry status in sync with aggregate counts under a
* concurrent-write race` case). When two batches with overlapping
* `(userId, source, externalId)` tuples land in parallel, only one
* row per tuple may exist in the DB after the dust settles, and each
* response body's per-entry envelope MUST sum to its own aggregate
* counts so the iOS sync cursor can advance unambiguously.
*
* Pulled into its own file so the contention assertion remains
* isolated from the broader workout-batch surface — the W10 fix was a
* one-line invariant that took two hours of audit work to expose, and
* regressions on it would be near-invisible in a mixed-purpose file.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-workout-race-test";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "workout-race",
email: "workout-race@example.test",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
interface WorkoutFixture {
sportType: string;
startedAt: string;
endedAt: string;
source: string;
externalId: string;
}
function makeRequest(body: { workouts: WorkoutFixture[] }): NextRequest {
return new NextRequest("http://localhost/api/workouts/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function workout(externalId: string, minuteOffset: number): WorkoutFixture {
return {
sportType: "running",
startedAt: new Date(2026, 4, 14, 6, minuteOffset).toISOString(),
endedAt: new Date(2026, 4, 14, 7, minuteOffset).toISOString(),
source: "APPLE_HEALTH",
externalId,
};
}
describe("POST /api/workouts/batch — concurrent-write race", () => {
// v1.4.25 W10 reconcile (senior-dev H-1 parity for workouts).
//
// Two batches with overlapping `(userId, source, externalId)` tuples
// posted in parallel MUST resolve to exactly one DB row per tuple
// (the composite unique index enforces it; createMany.skipDuplicates
// absorbs the duplicate-key conflicts). What can go wrong is the
// per-entry envelope: the original pattern compared "rows we
// attempted" vs "rows now in the DB" — but every attempted row is
// present after the call (either we wrote it or the other batch did),
// so the diff is always zero and the envelope drifts out of sync
// with the aggregate counts under contention. The fix trusts
// `createMany.count` for the aggregate `insertedCount` and downgrades
// exactly that many "inserted" statuses to "duplicate", so the per-
// entry envelope sums match the aggregate counts — the invariant the
// iOS sync cursor relies on to checkpoint correctly.
it("keeps per-entry status in sync with aggregate counts under contention", async () => {
const { POST } = await import("@/app/api/workouts/batch/route");
const sharedWorkouts = (): WorkoutFixture[] =>
Array.from({ length: 6 }, (_, i) => workout(`hk-uuid-race-${i}`, i));
const [first, second] = await Promise.all([
POST(makeRequest({ workouts: sharedWorkouts() })),
POST(makeRequest({ workouts: sharedWorkouts() })),
]);
expect(first.status).toBe(200);
expect(second.status).toBe(200);
const firstJson = (await first.json()) as {
data: {
processed: number;
inserted: number;
duplicates: number;
entries: Array<{ status: string }>;
};
};
const secondJson = (await second.json()) as typeof firstJson;
// Invariant 1 — per-entry statuses sum to the aggregate counts.
// Before the W10 fix-C the comparable measurements test failed
// under contention because the no-op reconciliation left the
// envelope out of sync with the counts.
for (const json of [firstJson, secondJson]) {
const insertedEntries = json.data.entries.filter(
(e) => e.status === "inserted",
).length;
const duplicateEntries = json.data.entries.filter(
(e) => e.status === "duplicate",
).length;
const skippedEntries = json.data.entries.filter(
(e) => e.status === "skipped",
).length;
expect(insertedEntries).toBe(json.data.inserted);
expect(duplicateEntries).toBe(json.data.duplicates);
expect(insertedEntries + duplicateEntries + skippedEntries).toBe(
json.data.processed,
);
}
// Invariant 2 — counts non-negative. A naive "downgrade and also
// decrement" implementation could otherwise produce a negative
// `inserted` count under specific commit orderings.
expect(firstJson.data.inserted).toBeGreaterThanOrEqual(0);
expect(secondJson.data.inserted).toBeGreaterThanOrEqual(0);
// Invariant 3 — DB ends with at most 6 rows (composite unique
// index enforces single-copy) and the two responses' inserted
// counts sum to the row count actually present. Cross-validates
// that `createMany.count` is the truth source for the aggregate.
const stored = await getPrismaClient().workout.findMany({
where: { userId: TEST_USER_ID },
});
expect(stored.length).toBeLessThanOrEqual(6);
expect(firstJson.data.inserted + secondJson.data.inserted).toBe(
stored.length,
);
});
it("two batches with one overlapping externalId resolve to a single DB row each", async () => {
const { POST } = await import("@/app/api/workouts/batch/route");
// Batch A — entries 0..3 unique-to-A plus one shared key.
const batchA: WorkoutFixture[] = [
workout("hk-uuid-only-a-0", 1),
workout("hk-uuid-only-a-1", 2),
workout("hk-uuid-shared", 10),
];
// Batch B — entries 0..3 unique-to-B plus the SAME shared key.
const batchB: WorkoutFixture[] = [
workout("hk-uuid-only-b-0", 21),
workout("hk-uuid-only-b-1", 22),
workout("hk-uuid-shared", 10),
];
const [resA, resB] = await Promise.all([
POST(makeRequest({ workouts: batchA })),
POST(makeRequest({ workouts: batchB })),
]);
expect(resA.status).toBe(200);
expect(resB.status).toBe(200);
const aJson = (await resA.json()) as {
data: { inserted: number; duplicates: number };
};
const bJson = (await resB.json()) as typeof aJson;
// Five unique tuples total across both batches; one shared. The
// composite unique index means the shared key lands exactly once,
// either as A's "inserted" or B's "inserted" — never both. The
// sum of the two batches' inserted counts is exactly 5.
expect(aJson.data.inserted + bJson.data.inserted).toBe(5);
const stored = await getPrismaClient().workout.findMany({
where: { userId: TEST_USER_ID },
});
expect(stored).toHaveLength(5);
const sharedRows = stored.filter(
(r) => r.externalId === "hk-uuid-shared",
);
expect(sharedRows).toHaveLength(1);
});
});