test(medications,measurements): cover transport-derived intake source and the diagnostic syncTrigger

Route-level tests that run the real apiHandler + requireAuth so the
cookie-vs-Bearer derivation is exercised end to end, not papered over by a
unit mock:

- a cookie POST records WEB, a Bearer POST records API, and a body that
  forges `source` is ignored;
- the dose-history ledger selects and returns `source` per row, null on a
  legacy row;
- a batch carrying `syncTrigger:"background"` rides the ingest annotation,
  a batch without it annotates null, an invalid value is 422, and the field
  leaves the per-row insert/update outcomes byte-identical.
This commit is contained in:
Marc-André Bombeck committed 2026-07-23 21:21:21 +02:00
1 parent 27b548ab13
commit 61a1592665
3 files changed
+558

No files matched your search

@@ -0,0 +1,305 @@
/**
* v1.32.8 (iOS #66) — the optional per-request `syncTrigger` on the HealthKit
* batch ingest.
*
* It is DIAGNOSTIC-ONLY: recorded on the `measurement.batch.ingest` wide event
* so an operator can see, per batch, what woke the client (foreground app open,
* background refresh, or a push), and NOTHING else. These pin that contract:
*
* - a batch carrying `syncTrigger:"background"` is accepted and the value
* rides the ingest annotation;
* - a batch WITHOUT it is accepted and the annotation carries `null`;
* - an invalid value is a 422 (closed enum);
* - the field never changes which rows are inserted / updated — the per-row
* outcomes are byte-identical with and without it, so it cannot leak into
* dedup, attribution, or storage.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({ annotate: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
measurement: {
findMany: vi.fn(),
createManyAndReturn: vi.fn(),
updateMany: vi.fn(),
},
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (fn as any)(prisma as unknown as { measurement: unknown });
}
}),
},
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: mocks.annotate };
});
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: async (
tx: {
measurement: {
findMany: (args: unknown) => Promise<
Array<{
id?: string;
type?: string;
source?: string;
externalId?: string | null;
}>
>;
createManyAndReturn: (args: {
data: Record<string, unknown>;
}) => Promise<Array<Record<string, unknown>>>;
updateMany: (args: {
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => Promise<unknown>;
};
},
input: Record<string, unknown> & {
userId: string;
type: string;
source: string;
externalId: string;
},
options: { exactExternalMatch?: "update" | "duplicate" } = {},
) => {
const existing = await tx.measurement.findMany({});
const exact = existing.find(
(row: { type?: string; source?: string; externalId?: string | null }) =>
row.type === input.type &&
row.source === input.source &&
row.externalId === input.externalId,
);
if (exact && options.exactExternalMatch !== "update") {
return { status: "duplicate", row: exact };
}
if (exact) {
await tx.measurement.updateMany({
where: { id: exact.id },
data: { ...input, deletedAt: null },
});
return { status: "updated", row: { ...exact, ...input } };
}
const [inserted] = await tx.measurement.createManyAndReturn({
data: input,
});
if (inserted) return { status: "inserted", row: inserted };
return { status: "duplicate" };
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
collapseToTypeDayKeys: vi.fn(() => []),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const SAMPLE_ID = "C0FFEE00-0000-4000-8000-000000000000";
const STATS_ID = "stats:HKQuantityTypeIdentifierStepCount:2026-06-21";
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function sampleEntry(externalId: string, value: number) {
return {
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value,
unit: "count/min",
startDate: "2026-06-21T14:00:00.000Z",
endDate: "2026-06-21T14:00:01.000Z",
externalId,
};
}
function stepsEntry(externalId: string, value: number) {
return {
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
value,
unit: "count",
startDate: "2026-06-21T00:00:00.000Z",
endDate: "2026-06-21T23:59:59.000Z",
externalId,
};
}
function ingestMeta(): Record<string, unknown> {
const call = mocks.annotate.mock.calls.find(
([arg]) =>
(arg as { action?: { name?: string } })?.action?.name ===
"measurement.batch.ingest",
);
return (call?.[0] as { meta: Record<string, unknown> }).meta;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.createManyAndReturn).mockImplementation((async (
args: unknown,
) => {
const { data } = args as {
data:
| { type: unknown; source: string; externalId: string | null }
| Array<{ type: unknown; source: string; externalId: string | null }>;
};
const rows = Array.isArray(data) ? data : [data];
return rows.map(({ type, source, externalId }) => ({
type,
source,
externalId,
}));
}) as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 });
});
describe("POST /api/measurements/batch — syncTrigger diagnostic (iOS #66)", () => {
it("records `syncTrigger` on the ingest annotation when the client sends it", async () => {
const res = await POST(
makeRequest({
entries: [sampleEntry(SAMPLE_ID, 64)],
syncTrigger: "background",
}),
);
expect(res.status).toBe(200);
expect(ingestMeta().syncTrigger).toBe("background");
});
it("accepts a batch WITHOUT `syncTrigger` and annotates null", async () => {
const res = await POST(
makeRequest({ entries: [sampleEntry(SAMPLE_ID, 64)] }),
);
expect(res.status).toBe(200);
expect(ingestMeta().syncTrigger).toBe(null);
});
it("rejects an invalid `syncTrigger` value with 422 (closed enum)", async () => {
const res = await POST(
makeRequest({
entries: [sampleEntry(SAMPLE_ID, 64)],
syncTrigger: "wifi",
}),
);
expect(res.status).toBe(422);
});
it("does not change which rows insert / update with vs without the field", async () => {
// A pre-existing per-day steps row makes the re-post an overwrite; a fresh
// sample row inserts. The per-row outcome must be identical regardless of
// whether `syncTrigger` rides the request.
const seedExisting = () =>
vi
.mocked(prisma.measurement.findMany)
.mockResolvedValue([
{
id: "m1",
type: "ACTIVITY_STEPS",
source: "APPLE_HEALTH",
externalId: STATS_ID,
},
] as never);
const entries = [sampleEntry(SAMPLE_ID, 64), stepsEntry(STATS_ID, 5000)];
seedExisting();
const withoutRes = await POST(makeRequest({ entries }));
expect(withoutRes.status).toBe(200);
const withoutBody = (await withoutRes.json()).data;
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.createManyAndReturn).mockImplementation(
(async (args: unknown) => {
const { data } = args as { data: unknown };
const rows = Array.isArray(data) ? data : [data];
return rows.map((r) => {
const row = r as {
type: unknown;
source: string;
externalId: string | null;
};
return {
type: row.type,
source: row.source,
externalId: row.externalId,
};
});
}) as never,
);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 });
seedExisting();
const withRes = await POST(makeRequest({ entries, syncTrigger: "push" }));
expect(withRes.status).toBe(200);
const withBody = (await withRes.json()).data;
// The row-level outcome (what inserted, what updated, per-entry statuses)
// is byte-identical; only the diagnostic annotation differs.
expect(withBody.inserted).toBe(withoutBody.inserted);
expect(withBody.updated).toBe(withoutBody.updated);
expect(withBody.duplicates).toBe(withoutBody.duplicates);
expect(withBody.entries).toEqual(withoutBody.entries);
expect(withoutBody.updated).toBe(1);
expect(withoutBody.inserted).toBe(1);
});
});
@@ -201,6 +201,56 @@ describe("GET /api/medications/[id]/dose-history", () => {
expect(adHoc?.intake?.id).toBe("evt-adhoc");
});
it("returns each intake's write provenance in `source` (iOS #64)", async () => {
const from = at(DAY, 0, 0).toISOString();
const to = at(DAY, 23, 59).toISOString();
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
// a native-app take carries API provenance
{
id: "evt-morning",
scheduledFor: at(DAY, 7, 0),
takenAt: at(DAY, 7, 5),
skipped: false,
autoMissed: false,
source: "API",
},
// a legacy row written before the column carried a value → null
{
id: "evt-adhoc",
scheduledFor: at(DAY, 11, 29),
takenAt: at(DAY, 11, 29),
skipped: false,
autoMissed: false,
source: null,
},
] as never);
// The route must SELECT `source` from Prisma, or it could never surface it.
const res = await GET(
getReq(`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`),
ROUTE_PARAMS,
);
expect(res.status).toBe(200);
const select = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
.calls[0][0]?.select as Record<string, unknown> | undefined;
expect(select?.source).toBe(true);
const json = await res.json();
const rows = json.data.rows as Array<{
kind: string;
timeOfDay: string | null;
intake: { id: string | null; source: string | null } | null;
}>;
const morning = rows.find((r) => r.timeOfDay === "07:00");
expect(morning?.intake?.id).toBe("evt-morning");
expect(morning?.intake?.source).toBe("API");
const adHoc = rows.find((r) => r.kind === "ad_hoc");
expect(adHoc?.intake?.id).toBe("evt-adhoc");
expect(adHoc?.intake?.source).toBe(null);
});
it("422 on a reversed from/to window", async () => {
const from = at(DAY, 12, 0).toISOString();
const to = at(DAY, 6, 0).toISOString();
@@ -0,0 +1,203 @@
/**
* v1.32.8 (iOS #64) — write-provenance of a medication intake is derived from
* the TRANSPORT, never from the request body.
*
* - a cookie (browser) POST records `source: "WEB"`;
* - a Bearer (native app) POST records `source: "API"`;
* - a body that tries to set `source` is ignored (no mass assignment);
* - the producer-owned values (`REMINDER` / `IMPORT` / `APPLE_HEALTH`) are
* minted by OTHER routes and are unreachable here, so this route can only
* ever stamp WEB / API — the cookie-vs-Bearer split is the whole surface.
*
* These run the REAL `apiHandler` + `requireAuth` (only `getSession`,
* `next/headers`, and the Bearer resolver are mocked) so the auth-method
* derivation that a unit mock would paper over is exercised end to end. The
* medication has no schedules, so a taken write lands on the standalone
* (ad-hoc) create path where `source` is stamped directly.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
medicationIntakeEvent: {
findMany: vi.fn(),
findFirst: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
auditLog: { create: vi.fn() },
$transaction: vi.fn(),
},
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
// The medication carries no schedules, so a taken write resolves to no
// canonical slot and lands on the standalone create path. Stub the resolvers
// so the route deterministically takes that branch regardless of the fixtures.
vi.mock("@/lib/medications/scheduling/slot-upsert", () => ({
resolveSlotForWriteByBand: vi.fn(async () => ({ slotInstant: null })),
resolveSlotInstantForWrite: vi.fn(async () => null),
resolveForcedSlotForWrite: vi.fn(async () => null),
findPinConflict: vi.fn(async () => false),
mayConvergeOntoSuppliedSlot: vi.fn(() => true),
applyCanonicalSlotWrite: vi.fn(),
}));
vi.mock("@/lib/medications/inventory/consumption", () => ({
consumeForIntake: vi.fn().mockResolvedValue(undefined),
restoreForIntake: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/medications/lifecycle", () => ({
reconcileOneShotState: vi.fn().mockResolvedValue("noop"),
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
queueMedicationIntakeSync: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/bearer", () => ({ resolveBearerToken: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// Default to no Authorization header; the Bearer tests override this.
const headerStore = { authorization: null as string | null };
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({
get: (name: string) =>
name.toLowerCase() === "authorization" ? headerStore.authorization : null,
})),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { resolveBearerToken } from "@/lib/auth/bearer";
const USER = {
id: "user-1",
username: "testuser",
role: "USER" as const,
timezone: "Europe/Berlin",
};
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: USER,
};
const MED_OK = { id: "med-1", userId: "user-1" };
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
function postReq(body: Record<string, unknown>): NextRequest {
return new NextRequest("http://localhost/api/medications/med-1/intake", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
/** The `source` value stamped on the freshly-created standalone intake row. */
function createdSource(): unknown {
const call = vi.mocked(prisma.medicationIntakeEvent.create).mock.calls[0];
return (call?.[0] as { data?: { source?: unknown } } | undefined)?.data
?.source;
}
beforeEach(() => {
vi.resetAllMocks();
headerStore.authorization = null;
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// The standalone branch wraps the create in a `$transaction([...])`. The
// create mock records the `data` it was called with; the transaction
// resolves to the created row so the rest of the handler proceeds.
vi.mocked(prisma.medicationIntakeEvent.create).mockReturnValue({
id: "evt-1",
takenAt: new Date(),
scheduledFor: new Date(),
} as never);
vi.mocked(prisma.$transaction).mockResolvedValue([
{ id: "evt-1", takenAt: new Date(), scheduledFor: new Date() },
] as never);
});
describe("POST intake — transport-derived source (iOS #64)", () => {
it("stamps WEB for a cookie (browser) session", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(postReq({}), ROUTE_PARAMS);
expect(res.status).toBe(201);
expect(createdSource()).toBe("WEB");
});
it("stamps API for a Bearer (native app) token", async () => {
vi.mocked(getSession).mockResolvedValue(null as never);
headerStore.authorization = "Bearer hlk_test-token";
vi.mocked(resolveBearerToken).mockResolvedValue({
user: USER as never,
tokenId: "tok-1",
permissions: ["*"],
expiresAt: new Date(Date.now() + 3_600_000),
});
const res = await POST(postReq({}), ROUTE_PARAMS);
expect(res.status).toBe(201);
expect(createdSource()).toBe("API");
});
it("ignores a client-supplied `source` in the body (no mass assignment)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// A cookie caller forging `source: "APPLE_HEALTH"` must still record WEB —
// the value is derived from the transport, never read from the body.
const res = await POST(postReq({ source: "APPLE_HEALTH" }), ROUTE_PARAMS);
expect(res.status).toBe(201);
expect(createdSource()).toBe("WEB");
});
});