mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user