diff --git a/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts new file mode 100644 index 000000000..900b55c73 --- /dev/null +++ b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts @@ -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(); + 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; + }) => Promise>>; + updateMany: (args: { + where: Record; + data: Record; + }) => Promise; + }; + }, + input: Record & { + 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 { + const call = mocks.annotate.mock.calls.find( + ([arg]) => + (arg as { action?: { name?: string } })?.action?.name === + "measurement.batch.ingest", + ); + return (call?.[0] as { meta: Record }).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); + }); +}); diff --git a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts index 20640c271..ccc4f707d 100644 --- a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts @@ -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 | 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(); diff --git a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts new file mode 100644 index 000000000..3b4ebe141 --- /dev/null +++ b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts @@ -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: + (fn: (...args: Args) => Promise) => + (...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): 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"); + }); +});