mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
fix(ingest): hold the stability floor for identities no request schema sees
The dedup key on the medication intake surfaces is not only `externalId`. `MedicationIntakeEvent.idempotency_key` is a `@unique` column persisted with the dose row forever, with no TTL and no sweep, matched by plain equality on the per-medication POST, the bulk drain and the external ingest bridge. That makes it an identity, not a replay token, and it carries the same failure mode: a key that rotates per client launch never matches its own earlier post, so every retry logs the dose again. The blank case is sharper still — the column is unique table-wide, so an empty key resolves to whichever unrelated row stored it first. Refuse both shapes at the schema on the single-entry paths and per entry on the bulk drain. The MCP write path hashes a host-supplied key into the `externalId` half of its dedup key, so the shape is laundered before anything downstream could see it and the check has to run on the input. That closes a live one: every tool handler falls back to `""` when the host omits the argument, and `""` digests to a constant, so each keyless write after the first reported `already_logged` and was silently dropped. The HTTP `Idempotency-Key` header stays out, and now says why in the guard: `IdempotencyKey.expiresAt` bounds it and the cleanup job sweeps it, so it is a windowed replay token and rotation is its design. Extend the structural guard with a provenance rule for identities that never pass a Zod request schema — a spreadsheet column, an MCP tool argument. Inside the import and MCP trees, a module handling an identity must apply the floor itself or import a module that does. That is a real chain rather than a list of known offenders: a new importer that mints an `externalId` from file input and imports nothing checked fails it, with no allowlist to escape into.
This commit is contained in:
1 parent
6e4bcffadf
commit
15b5f6f92f
19 files changed
+1574
-43
No files matched your search
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* The two single-entry cycle writes — `POST /api/cycle/day-logs` and
|
||||
* `POST /api/cycle/period` — against the external-id stability floor.
|
||||
*
|
||||
* Both upsert on `(userId, source, externalId)`, so an id that changes
|
||||
* between two launches of the same client never matches its own earlier
|
||||
* row: the mirror mints a fresh day log (or a fresh period marker) on
|
||||
* every sweep. Unlike their bulk twin these are single-entry, so the
|
||||
* refusal is the standard 422 multi-issue envelope and nothing is written.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { create: vi.fn() },
|
||||
cycleDayLog: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findFirstOrThrow: vi.fn(),
|
||||
},
|
||||
menstrualCycle: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/cycle/gate", () => ({
|
||||
requireCycleEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/cycle/day-log-write", () => ({
|
||||
upsertCycleDayLog: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/cycle/cycle-attribution", () => ({
|
||||
findOwningCycleId: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
vi.mock("@/lib/cycle/dto", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/cycle/dto")>()),
|
||||
toCycleDayLogDTO: vi.fn(() => ({ id: "day-1" })),
|
||||
toMenstrualCycleDTO: vi.fn(() => ({ id: "cycle-1" })),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST as postDayLog } from "../day-logs/route";
|
||||
import { POST as postPeriod } from "../period/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(path: string, body: unknown): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const POISONED = "<HKHealthConceptIdentifier: 0x12568db80>";
|
||||
/** What the HealthKit cycle mirror really writes (`import-accumulator.ts`). */
|
||||
const REAL_ID = "hkcycle:2026-01-01";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(requireCycleEnabled).mockResolvedValue({
|
||||
enabled: true,
|
||||
profile: { sensitiveCategoryEncryption: false },
|
||||
} as never);
|
||||
vi.mocked(upsertCycleDayLog).mockResolvedValue({
|
||||
id: "day-1",
|
||||
existed: false,
|
||||
changed: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.cycleDayLog.findUniqueOrThrow).mockResolvedValue({
|
||||
id: "day-1",
|
||||
symptoms: [],
|
||||
} as never);
|
||||
vi.mocked(prisma.cycleDayLog.findFirst).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.cycleDayLog.findFirstOrThrow).mockResolvedValue({
|
||||
id: "day-1",
|
||||
symptoms: [],
|
||||
} as never);
|
||||
vi.mocked(prisma.menstrualCycle.findUniqueOrThrow).mockResolvedValue({
|
||||
id: "cycle-1",
|
||||
startDate: "2026-01-01",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/cycle/day-logs — external-id stability floor", () => {
|
||||
it("422s on an object-description externalId and writes nothing", async () => {
|
||||
const res = await postDayLog(
|
||||
req("/api/cycle/day-logs", {
|
||||
date: "2026-01-01",
|
||||
loggedAt: "2026-01-01T08:00:00.000Z",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: POISONED,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; message: string }> };
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
const issue = body.details.issues.find((i) => i.path === "externalId");
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue!.message).toContain("stable across app restarts");
|
||||
expect(body.meta?.errorCode).toBe("cycle.day-log.invalid");
|
||||
expect(upsertCycleDayLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on a bare memory-address externalId", async () => {
|
||||
const res = await postDayLog(
|
||||
req("/api/cycle/day-logs", {
|
||||
date: "2026-01-01",
|
||||
loggedAt: "2026-01-01T08:00:00.000Z",
|
||||
externalId: "0x126b25160",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(upsertCycleDayLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts the HealthKit cycle-day id the mirror really sends", async () => {
|
||||
const res = await postDayLog(
|
||||
req("/api/cycle/day-logs", {
|
||||
date: "2026-01-01",
|
||||
loggedAt: "2026-01-01T08:00:00.000Z",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: REAL_ID,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(upsertCycleDayLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/cycle/period — external-id stability floor", () => {
|
||||
it("422s on an object-description externalId and opens no cycle", async () => {
|
||||
const res = await postPeriod(
|
||||
req("/api/cycle/period", {
|
||||
action: "start",
|
||||
date: "2026-01-01",
|
||||
loggedAt: "2026-01-01T08:00:00.000Z",
|
||||
externalId: POISONED,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; message: string }> };
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(
|
||||
body.details.issues.some(
|
||||
(i) =>
|
||||
i.path === "externalId" &&
|
||||
i.message.includes("stable across app restarts"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(body.meta?.errorCode).toBe("cycle.period.invalid");
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts the real HealthKit id and reaches the write transaction", async () => {
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue({
|
||||
cycleId: "cycle-1",
|
||||
} as never);
|
||||
const res = await postPeriod(
|
||||
req("/api/cycle/period", {
|
||||
action: "start",
|
||||
date: "2026-01-01",
|
||||
loggedAt: "2026-01-01T08:00:00.000Z",
|
||||
externalId: REAL_ID,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBeLessThan(400);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { NextRequest } from "next/server";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { create: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -48,6 +49,7 @@ vi.mock("next/headers", () => ({
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
@@ -72,7 +74,9 @@ beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(requireCycleEnabled).mockResolvedValue({
|
||||
enabled: true,
|
||||
profile: { sensitiveCategoryEncryption: false },
|
||||
} as never);
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("POST /api/cycle/day-logs/bulk — body cap", () => {
|
||||
@@ -94,3 +98,108 @@ describe("POST /api/cycle/day-logs/bulk — body cap", () => {
|
||||
expect(upsertCycleDayLog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/cycle/day-logs/bulk — external-id stability floor", () => {
|
||||
/**
|
||||
* The bulk drain upserts on `(userId, source, externalId)`. A per-launch
|
||||
* id never matches its own earlier row, so every re-post mints another
|
||||
* day log. Refused PER ENTRY — the client's other days must still land.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
let n = 0;
|
||||
vi.mocked(upsertCycleDayLog).mockImplementation(
|
||||
async () =>
|
||||
({ id: `day-${++n}`, existed: false, changed: true }) as never,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips the poisoned entry and still writes the two good ones", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
date: "2026-01-01",
|
||||
loggedAt: "2026-01-01T08:00:00.000Z",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "hkcycle:2026-01-01",
|
||||
},
|
||||
{
|
||||
date: "2026-01-02",
|
||||
loggedAt: "2026-01-02T08:00:00.000Z",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "<HKHealthConceptIdentifier: 0x12568db80>",
|
||||
},
|
||||
{
|
||||
date: "2026-01-03",
|
||||
loggedAt: "2026-01-03T08:00:00.000Z",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
processed: number;
|
||||
inserted: number;
|
||||
skipped: number;
|
||||
entries: Array<{
|
||||
index: number;
|
||||
status: string;
|
||||
reason?: string;
|
||||
externalId?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
expect(body.data.processed).toBe(3);
|
||||
expect(body.data.inserted).toBe(2);
|
||||
expect(body.data.skipped).toBe(1);
|
||||
expect(body.data.entries[0].status).toBe("inserted");
|
||||
expect(body.data.entries[1]).toMatchObject({
|
||||
index: 1,
|
||||
status: "skipped",
|
||||
reason: "unstable_external_id",
|
||||
externalId: "<HKHealthConceptIdentifier: 0x12568db80>",
|
||||
});
|
||||
expect(body.data.entries[2].status).toBe("inserted");
|
||||
// Only the two good entries reached the writer.
|
||||
expect(upsertCycleDayLog).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("never turns one poisoned entry into a whole-batch 422", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
date: "2026-01-01",
|
||||
loggedAt: "2026-01-01T08:00:00.000Z",
|
||||
externalId: "0xdeadbeef",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts the HealthKit cycle-day id shape the mirror really sends", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
date: "2026-01-01",
|
||||
loggedAt: "2026-01-01T08:00:00.000Z",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "hkcycle:2026-01-01",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { entries: Array<{ status: string }> };
|
||||
};
|
||||
expect(body.data.entries[0].status).toBe("inserted");
|
||||
expect(upsertCycleDayLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1163,3 +1163,123 @@ describe("POST /api/import — write failure classification", () => {
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/import — external-id stability floor", () => {
|
||||
/**
|
||||
* The importer upserts on `(userId, type, source=IMPORT, externalId)` so
|
||||
* a re-import of the same export converges instead of duplicating. An id
|
||||
* that cannot be stable between two exports defeats that. The importer
|
||||
* has no per-entry status envelope — its vocabulary is a skipped COUNT —
|
||||
* so the offending row lands there and every other row still imports.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: new Date(Date.now() + 1000),
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue([
|
||||
{ id: "m-1", type: "WEIGHT", measuredAt: new Date() },
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.create).mockResolvedValue({
|
||||
id: "m-2",
|
||||
type: "WEIGHT",
|
||||
measuredAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.moodEntry.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
function importReq(body: unknown) {
|
||||
return new NextRequest("http://localhost/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function measurement(externalId?: string) {
|
||||
return {
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-05-01T08:00:00.000Z",
|
||||
...(externalId ? { externalId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
it("skips the poisoned row, imports the two good ones, and never writes the bad id", async () => {
|
||||
const response = await POST(
|
||||
importReq({
|
||||
measurements: [
|
||||
measurement("scale-row-42"),
|
||||
measurement("<HKQuantitySample: 0x12568db80>"),
|
||||
measurement("scale-row-43"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBeLessThan(400);
|
||||
const body = (await response.json()) as {
|
||||
data: { measurements: number; skipped: number };
|
||||
};
|
||||
expect(body.data.measurements).toBe(2);
|
||||
expect(body.data.skipped).toBe(1);
|
||||
const written = vi
|
||||
.mocked(prisma.measurement.createManyAndReturn)
|
||||
.mock.calls.flatMap(
|
||||
(call) => (call[0] as { data: Array<{ externalId?: string }> }).data,
|
||||
);
|
||||
expect(written).toHaveLength(2);
|
||||
expect(written.map((r) => r.externalId)).toEqual([
|
||||
"scale-row-42",
|
||||
"scale-row-43",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips a poisoned mood row and still imports the good one", async () => {
|
||||
const response = await POST(
|
||||
importReq({
|
||||
moodEntries: [
|
||||
{
|
||||
date: "2026-05-01",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
loggedAt: "2026-05-01T08:00:00.000Z",
|
||||
externalId: "daylio-1041",
|
||||
},
|
||||
{
|
||||
date: "2026-05-02",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
loggedAt: "2026-05-02T08:00:00.000Z",
|
||||
externalId: "0xdeadbeef",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBeLessThan(400);
|
||||
const body = (await response.json()) as {
|
||||
data: { moodEntries: number; skipped: number };
|
||||
};
|
||||
expect(body.data.moodEntries).toBe(1);
|
||||
expect(body.data.skipped).toBe(1);
|
||||
expect(vi.mocked(prisma.moodEntry.upsert)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("accepts the exporter row ids a real re-import carries", async () => {
|
||||
const response = await POST(
|
||||
importReq({
|
||||
measurements: [
|
||||
measurement("scale-row-42"),
|
||||
measurement("8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBeLessThan(400);
|
||||
const body = (await response.json()) as {
|
||||
data: { measurements: number; skipped: number };
|
||||
};
|
||||
expect(body.data.measurements).toBe(2);
|
||||
expect(body.data.skipped).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -349,3 +349,87 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/ingest/medication — idempotency-key stability floor", () => {
|
||||
/**
|
||||
* `MedicationIntakeEvent.idempotency_key` is not a windowed replay
|
||||
* token: it is a `@unique` column persisted with the dose row forever
|
||||
* and matched by plain equality, which makes it an identity. A bridge
|
||||
* that mints a per-process key never matches its own earlier post, so
|
||||
* every retry logs the dose again.
|
||||
*
|
||||
* The blank case is the sharper one here — the column is unique
|
||||
* table-wide, so an empty key would resolve to whichever unrelated row
|
||||
* stored "" first rather than to this caller's own earlier post.
|
||||
*/
|
||||
it("422s on an object-description key and logs nothing", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
medicationName: "Metformin",
|
||||
idempotencyKey: "<NSObject: 0x600000c1a2b0>",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; message: string }> };
|
||||
};
|
||||
const issue = body.details.issues.find((i) => i.path === "idempotencyKey");
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue!.message).toContain("idempotency key");
|
||||
expect(issue!.message).toContain("stable across app restarts");
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on a whitespace-only key rather than matching a stranger's row", async () => {
|
||||
const res = await POST(
|
||||
postReq({ medicationName: "Metformin", idempotencyKey: " " }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; message: string }> };
|
||||
};
|
||||
expect(
|
||||
body.details.issues.some(
|
||||
(i) =>
|
||||
i.path === "idempotencyKey" &&
|
||||
i.message.includes("must not be empty"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts the timestamped key the documented bridge example sends", async () => {
|
||||
// Ad-hoc take: no schedules on the medication, so the write goes
|
||||
// straight to a create instead of converging onto a slot row.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue({
|
||||
id: "med-1",
|
||||
name: "Metformin",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
schedules: [],
|
||||
scheduleRevisions: [],
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({
|
||||
id: "evt-new",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
medicationName: "Metformin",
|
||||
takenAt: "2026-06-10T06:42:00.000Z",
|
||||
idempotencyKey: "intake-202602191230",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { externalIntakeSchema } from "@/lib/validations/medication";
|
||||
import {
|
||||
unstableExternalIdMeta,
|
||||
unstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import {
|
||||
@@ -108,9 +112,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// integration sees the full validation shape at once. Audit
|
||||
// breadcrumb keyed `ingest.medication.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
const shape = unstableExternalIdShape(body, "idempotencyKey");
|
||||
annotate({
|
||||
action: { name: "ingest.medication.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
meta: {
|
||||
issue_count: issues.length,
|
||||
...(shape ? unstableExternalIdMeta("medication.ingest", [shape]) : {}),
|
||||
},
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; external
|
||||
// ingest carries free-text `medicationName` + opaque
|
||||
|
||||
@@ -698,3 +698,95 @@ describe("POST /api/medications/[id]/intake — as-needed (schedule-less) medica
|
||||
expect(restoreForIntake).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/intake — idempotency-key stability floor", () => {
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/med-1/intake", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `MedicationIntakeEvent.idempotency_key` is a `@unique` column
|
||||
* persisted with the dose row forever and matched by plain equality —
|
||||
* an identity, not a windowed replay token. A caller minting a
|
||||
* per-process key never matches its own earlier post, so a retried
|
||||
* submission logs the dose a second time.
|
||||
*/
|
||||
it("422s on an object-description key and records no dose", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
takenAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
idempotencyKey: "<NSObject: 0x600000c1a2b0>",
|
||||
}),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; message: string }> };
|
||||
};
|
||||
const issue = body.details.issues.find((i) => i.path === "idempotencyKey");
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue!.message).toContain("idempotency key");
|
||||
expect(issue!.message).toContain("stable across app restarts");
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on a bare memory-address key", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
takenAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
idempotencyKey: "0x126b25160",
|
||||
}),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a stable caller-issued key and records the dose", async () => {
|
||||
const takenAt = new Date(Date.now() - 60 * 60 * 1000);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([
|
||||
{
|
||||
id: "evt-idem-1",
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: takenAt,
|
||||
takenAt,
|
||||
skipped: false,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
schedules: [],
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
oneShot: false,
|
||||
active: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
takenAt: takenAt.toISOString(),
|
||||
idempotencyKey: "ios-outbox-8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30",
|
||||
}),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
intakeSchema,
|
||||
listIntakeEventsSchema,
|
||||
} from "@/lib/validations/medication";
|
||||
import {
|
||||
unstableExternalIdMeta,
|
||||
unstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
@@ -72,9 +76,16 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
// v1.4.43 W6 — per-med intake POST hot path; multi-issue 422 +
|
||||
// audit breadcrumb keyed `medications.intake.create.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
const shape = unstableExternalIdShape(body, "idempotencyKey");
|
||||
annotate({
|
||||
action: { name: "medications.intake.create.validation-failed" },
|
||||
meta: { issue_count: issues.length, medication_id: id },
|
||||
meta: {
|
||||
issue_count: issues.length,
|
||||
medication_id: id,
|
||||
...(shape
|
||||
? unstableExternalIdMeta("medication.intake.create", [shape])
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; the
|
||||
// intake payload carries `idempotencyKey` (opaque caller string).
|
||||
|
||||
@@ -1358,3 +1358,131 @@ describe("POST /api/medications/intake/bulk — v1.28 Apple Health dose events",
|
||||
expect("externalId" in createArgs.data).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/intake/bulk — identity stability floor", () => {
|
||||
/**
|
||||
* Two dedup keys ride this route and BOTH are identities matched by
|
||||
* equality forever: `(userId, externalId)` for the Apple dose event, and
|
||||
* the `@unique` `idempotency_key` column for the replay probe. A value
|
||||
* that rotates per client launch matches neither, so every re-sync logs
|
||||
* the dose again. Refused PER ENTRY — a client draining an offline queue
|
||||
* must not lose its good doses to one bad row.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{ id: "med-1", externalSource: "APPLE_HEALTH" },
|
||||
] 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.findUnique).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({
|
||||
id: "evt-new",
|
||||
} as never);
|
||||
});
|
||||
|
||||
function entry(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
medicationId: "med-1",
|
||||
takenAt: "2026-06-15T05:01:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("skips the poisoned dose event and still logs the two good ones", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
entry({
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30",
|
||||
}),
|
||||
entry({
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "<HKHealthConceptIdentifier: 0x12568db80>",
|
||||
}),
|
||||
entry({
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "3f0c4e4d-9c1e-4b7e-2a1d-6f30ad2a9cb1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
processed: number;
|
||||
skipped: Array<{ index: number; reason: string }>;
|
||||
entries: Array<{ index: number; status: string; reason?: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.processed).toBe(3);
|
||||
expect(body.data.entries[0].status).not.toBe("skipped");
|
||||
expect(body.data.entries[1]).toMatchObject({
|
||||
index: 1,
|
||||
status: "skipped",
|
||||
reason: "unstable_external_id",
|
||||
});
|
||||
expect(body.data.entries[2].status).not.toBe("skipped");
|
||||
expect(body.data.skipped).toEqual([
|
||||
{ index: 1, reason: "unstable_external_id" },
|
||||
]);
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips a rotating idempotencyKey — the column is a permanent identity, not a windowed token", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
entry({ idempotencyKey: "intake-2026-06-15T05:01:00Z-med-1" }),
|
||||
entry({ idempotencyKey: "<NSObject: 0x600000c1a2b0>" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
entries: Array<{ index: number; status: string; reason?: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.entries[0].status).not.toBe("skipped");
|
||||
expect(body.data.entries[1]).toMatchObject({
|
||||
index: 1,
|
||||
status: "skipped",
|
||||
reason: "unstable_external_id",
|
||||
});
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never turns one poisoned entry into a whole-batch 422", async () => {
|
||||
const res = await POST(
|
||||
postReq({ entries: [entry({ idempotencyKey: "0xdeadbeef" })] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts the HealthKit dose-event UUID the mirror really sends", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
entry({
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { entries: Array<{ status: string }> };
|
||||
};
|
||||
expect(body.data.entries[0].status).not.toBe("skipped");
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,11 @@ const bulkEntrySchema = z
|
||||
// 5-year floor), matching the single-intake create + edit paths.
|
||||
takenAt: boundedTakenAtSchema.optional(),
|
||||
skipped: z.boolean().optional().default(false),
|
||||
// Persisted to the `@unique` `idempotency_key` column and matched by
|
||||
// equality forever, so it is an identity and carries the same
|
||||
// stability floor. Checked per entry for the same reason as
|
||||
// `externalId` above.
|
||||
// @external-id-checked-per-entry: src/app/api/medications/intake/bulk/route.ts
|
||||
idempotencyKey: z.string().min(1).max(128).optional(),
|
||||
// v1.8.5 — optional per-entry injection site. Validated against the
|
||||
// medication's effective allowed set; a disallowed site marks the
|
||||
@@ -356,16 +361,20 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The `(userId, externalId)` dose-event dedup below is only
|
||||
// idempotent while the id is STABLE across client launches. An id
|
||||
// that rotates per process (an object description carrying a memory
|
||||
// address) never matches its own earlier row, so every re-sync mints
|
||||
// another intake event. Refused per entry — one bad row must not
|
||||
// stop the rest of the batch landing.
|
||||
// Both dedup keys on this route are only idempotent while the value
|
||||
// is STABLE across client launches: `(userId, externalId)` for the
|
||||
// Apple dose event, and the `@unique` `idempotency_key` column for
|
||||
// the replay probe below. A value that rotates per process (an
|
||||
// object description carrying a memory address) never matches its
|
||||
// own earlier row, so every re-sync logs the dose again. Refused per
|
||||
// entry — one bad row must not stop the rest of the batch landing.
|
||||
const unstable =
|
||||
entry.externalId === undefined
|
||||
(entry.externalId === undefined
|
||||
? null
|
||||
: classifyExternalId(entry.externalId);
|
||||
: classifyExternalId(entry.externalId)) ??
|
||||
(entry.idempotencyKey === undefined
|
||||
? null
|
||||
: classifyExternalId(entry.idempotencyKey));
|
||||
if (unstable) {
|
||||
unstableShapes.push(unstable);
|
||||
skipped.push({ index: i, reason: UNSTABLE_EXTERNAL_ID_REASON });
|
||||
|
||||
@@ -397,3 +397,54 @@ describe("POST /api/mental-health/assessments", () => {
|
||||
expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mental-health/assessments — external-id stability floor", () => {
|
||||
/**
|
||||
* `externalId` is the durable replay anchor for the native client's
|
||||
* outbox — the only thing standing between a retried submission and a
|
||||
* second recorded administration of the same screener. It only holds
|
||||
* while the value survives an app restart.
|
||||
*/
|
||||
it("422s on an object-description externalId and records nothing", async () => {
|
||||
const res = await callPost(
|
||||
makeReq({
|
||||
instrument: "PHQ9",
|
||||
items: [1, 1, 1, 1, 1, 1, 1, 1, 0],
|
||||
externalId: "<HKHealthConceptIdentifier: 0x12568db80>",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; message: string }> };
|
||||
};
|
||||
const issue = body.details.issues.find((i) => i.path === "externalId");
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue!.message).toContain("stable across app restarts");
|
||||
expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts the outbox UUID the native client actually sends", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({
|
||||
id: "mha_2",
|
||||
instrument: "PHQ9",
|
||||
locale: "en",
|
||||
version: "standard",
|
||||
totalScore: 8,
|
||||
severityBand: "mild",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: new Date("2026-06-28T00:00:00.000Z"),
|
||||
createdAt: new Date("2026-06-28T00:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
const res = await callPost(
|
||||
makeReq({
|
||||
instrument: "PHQ9",
|
||||
items: [1, 1, 1, 1, 1, 1, 1, 1, 0],
|
||||
externalId: "8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.mentalHealthAssessment.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -563,3 +563,71 @@ describe("POST /api/mood-entries — externalId idempotent re-import (v1.12.1)",
|
||||
expect(txClient.moodEntry.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries — external-id stability floor", () => {
|
||||
const BASE = {
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-06-01T08:00:00.000Z",
|
||||
};
|
||||
|
||||
/**
|
||||
* The create upserts on `(userId, source, externalId)` so a re-post with
|
||||
* the same id updates in place. A per-launch id defeats that and mints a
|
||||
* new entry every sync.
|
||||
*/
|
||||
it("422s on an object-description externalId and writes nothing", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
...BASE,
|
||||
externalId: "<HKHealthConceptIdentifier: 0x12568db80>",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; message: string }> };
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
const issue = body.details.issues.find((i) => i.path === "externalId");
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue!.message).toContain("stable across app restarts");
|
||||
expect(issue!.message).toContain("object description");
|
||||
expect(body.meta?.errorCode).toBe("mood.create.invalid");
|
||||
expect(txClient.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
expect(txClient.moodEntry.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on a bare memory-address externalId", async () => {
|
||||
const res = await POST(postReq({ ...BASE, externalId: "0x126b25160" }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; message: string }> };
|
||||
};
|
||||
expect(
|
||||
body.details.issues.some(
|
||||
(i) => i.path === "externalId" && i.message.includes("memory address"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(txClient.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts the SwiftData row UUID the iOS client actually sends", async () => {
|
||||
txClient.moodEntry.upsert.mockResolvedValue({
|
||||
id: "mood-ext-1",
|
||||
tags: null,
|
||||
moodLoggedAt: new Date(BASE.moodLoggedAt),
|
||||
mood: "GUT",
|
||||
note: null,
|
||||
source: "MANUAL",
|
||||
date: "2026-06-01",
|
||||
});
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
...BASE,
|
||||
externalId: "8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(txClient.moodEntry.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* `POST /api/workouts/batch` — external-id stability floor.
|
||||
*
|
||||
* `(userId, source, externalId)` is idempotent only while the id survives
|
||||
* an app restart. A per-process value — an object description carrying a
|
||||
* memory address — never matches its own earlier row, so every re-sync
|
||||
* mints another workout for the same session.
|
||||
*
|
||||
* The refusal is PER ENTRY. A cold-start HealthKit backfill is "every
|
||||
* workout I have ever recorded"; losing the whole batch to one bad row
|
||||
* would be worse than the duplicate it prevents.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
workout: { findMany: vi.fn(), createManyAndReturn: vi.fn() },
|
||||
$transaction: vi.fn(async (fn: unknown) => {
|
||||
if (typeof fn === "function") {
|
||||
return (fn as (tx: unknown) => unknown)(txClient);
|
||||
}
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const txClient = {
|
||||
workout: { createManyAndReturn: vi.fn() },
|
||||
workoutRoute: { createMany: vi.fn() },
|
||||
workoutSamples: { createMany: vi.fn() },
|
||||
};
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/pr-detection", () => ({
|
||||
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/arrivals/emit-shared", () => ({
|
||||
emitDataArrival: 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/logging/context", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, annotate: 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";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/workouts/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** A minimal well-formed run; only the id varies per case. */
|
||||
function workout(externalId: string, offsetMin = 0) {
|
||||
const start = new Date(Date.UTC(2026, 5, 15, 6, offsetMin, 0));
|
||||
const end = new Date(start.getTime() + 30 * 60 * 1000);
|
||||
return {
|
||||
sportType: "running",
|
||||
startedAt: start.toISOString(),
|
||||
endedAt: end.toISOString(),
|
||||
source: "APPLE_HEALTH",
|
||||
externalId,
|
||||
};
|
||||
}
|
||||
|
||||
async function entriesFor(workouts: unknown[]) {
|
||||
const res = await POST(postReq({ workouts }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
entries: Array<{ index: number; status: string; reason?: string }>;
|
||||
skipped: Array<{ index: number; reason: string }>;
|
||||
};
|
||||
};
|
||||
return body.data;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
|
||||
// Every row handed to the writer comes back inserted, keyed by its id.
|
||||
txClient.workout.createManyAndReturn.mockImplementation(
|
||||
async (args: {
|
||||
data: Array<Record<string, unknown>> | Record<string, unknown>;
|
||||
}) => {
|
||||
const rows = Array.isArray(args.data) ? args.data : [args.data];
|
||||
return rows.map((row, i) => ({
|
||||
id: `w-${i}-${String(row.externalId ?? "null")}`,
|
||||
source: row.source,
|
||||
externalId: row.externalId ?? null,
|
||||
}));
|
||||
},
|
||||
);
|
||||
txClient.workoutRoute.createMany.mockResolvedValue({ count: 0 });
|
||||
txClient.workoutSamples.createMany.mockResolvedValue({ count: 0 });
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — unstable external ids", () => {
|
||||
it("skips the poisoned workout and still writes the two good ones", async () => {
|
||||
const data = await entriesFor([
|
||||
workout("8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30", 0),
|
||||
workout("<HKWorkout: 0x12568db80>", 40),
|
||||
workout("3f0c4e4d-9c1e-4b7e-2a1d-6f30ad2a9cb1", 80),
|
||||
]);
|
||||
expect(data.entries[0].status).toBe("inserted");
|
||||
expect(data.entries[1]).toMatchObject({
|
||||
index: 1,
|
||||
status: "skipped",
|
||||
reason: "unstable_external_id",
|
||||
});
|
||||
expect(data.entries[2].status).toBe("inserted");
|
||||
expect(data.skipped).toEqual([
|
||||
{ index: 1, reason: "unstable_external_id" },
|
||||
]);
|
||||
// The writer only ever saw the two good rows.
|
||||
const written = txClient.workout.createManyAndReturn.mock.calls.flatMap(
|
||||
(call) => {
|
||||
const arg = call[0] as { data: unknown };
|
||||
return Array.isArray(arg.data) ? arg.data : [arg.data];
|
||||
},
|
||||
) as Array<{ externalId: string | null }>;
|
||||
expect(written).toHaveLength(2);
|
||||
expect(written.map((r) => r.externalId)).not.toContain(
|
||||
"<HKWorkout: 0x12568db80>",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a bare memory-address id", async () => {
|
||||
const data = await entriesFor([workout("0x126b25160")]);
|
||||
expect(data.entries[0]).toMatchObject({
|
||||
status: "skipped",
|
||||
reason: "unstable_external_id",
|
||||
});
|
||||
expect(txClient.workout.createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never turns one poisoned entry into a whole-batch refusal", async () => {
|
||||
const res = await POST(
|
||||
postReq({ workouts: [workout("0xdeadbeef"), workout("hk-uuid-41", 40)] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts the HealthKit workout UUID the iOS sync really sends", async () => {
|
||||
const data = await entriesFor([
|
||||
workout("8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30"),
|
||||
]);
|
||||
expect(data.entries[0].status).toBe("inserted");
|
||||
expect(txClient.workout.createManyAndReturn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a workout with no externalId alone — NULL is legitimately distinct", async () => {
|
||||
const noId: Record<string, unknown> = { ...workout("unused") };
|
||||
delete noId.externalId;
|
||||
const data = await entriesFor([noId]);
|
||||
expect(data.entries[0].status).toBe("inserted");
|
||||
});
|
||||
|
||||
it("annotates the refusal with the shape, never the id", async () => {
|
||||
await entriesFor([
|
||||
workout("<HKWorkout: 0x12568db80>", 0),
|
||||
workout("0xdeadbeef", 40),
|
||||
]);
|
||||
const calls = vi.mocked(annotate).mock.calls.map(([c]) => c);
|
||||
const rejection = calls.find(
|
||||
(c) => c?.meta && "external_id_rejected" in c.meta,
|
||||
);
|
||||
expect(rejection?.meta).toMatchObject({
|
||||
external_id_rejected: 2,
|
||||
external_id_shapes: "object_description,pointer_address",
|
||||
external_id_surface: "workout.batch",
|
||||
});
|
||||
expect(JSON.stringify(rejection)).not.toContain("0x12568db80");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user