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