mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the login, register, token and session paths, mirroring columns every migration under prisma/migrations already creates. The entrypoint applies those migrations before the server listens and stops the boot when one fails, so the mirror never had work to do, and DDL on a request path needs the table's owner: a connection that may only read and write rows fails every login on it. Drop the module, its call sites and the mocks that stubbed it out.
603 lines
20 KiB
TypeScript
603 lines
20 KiB
TypeScript
/**
|
|
* Integration suite for `POST /api/workouts/batch` — the v1.5 iOS
|
|
* HealthKit ingest endpoint and the deferred Withings activity sync
|
|
* target. Asserts the contract the iOS client relies on against a real
|
|
* Postgres in a testcontainer:
|
|
*
|
|
* - Mixed batches with nested routes succeed end-to-end
|
|
* - Re-posting the same batch yields per-entry duplicates
|
|
* - Over-cap batches return 400 with the documented error code
|
|
* - Over-cap routes return 400 with the documented error code
|
|
* - Idempotency-Key replay returns the cached envelope
|
|
* - Rate-limit kicks in at the documented ceiling
|
|
* - Narrow-scope Bearer tokens are refused; the wildcard token iOS
|
|
* actually holds is admitted
|
|
*
|
|
* A separate file at this directory carries the concurrent-write race
|
|
* test so the contention assertion stays focused on the invariant that
|
|
* was the original target of the W10 fix-C reconciliation.
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
// The HMAC key must be present before any module that touches
|
|
// `@/lib/auth/hmac` is imported — `hashToken()` reads it lazily but
|
|
// only the first call's process state is exercised here.
|
|
process.env.API_TOKEN_HMAC_KEY ??=
|
|
"test-hmac-key-workout-batch-integration-32-bytes-min-1234567890";
|
|
process.env.ENCRYPTION_KEY =
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
const { hashToken } = await import("@/lib/auth/hmac");
|
|
|
|
const TEST_USER_ID = "user-workout-batch-test";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await getPrismaClient().user.create({
|
|
data: {
|
|
id: TEST_USER_ID,
|
|
username: "workout-batch",
|
|
email: "workout-batch@example.test",
|
|
},
|
|
});
|
|
const session = await getPrismaClient().session.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
});
|
|
|
|
interface WorkoutFixture {
|
|
sportType?: string;
|
|
startedAt?: string;
|
|
endedAt?: string;
|
|
totalEnergyKcal?: number;
|
|
totalDistanceM?: number;
|
|
avgHeartRate?: number;
|
|
maxHeartRate?: number;
|
|
minHeartRate?: number;
|
|
stepCount?: number;
|
|
elevationM?: number;
|
|
pauseDurationSec?: number;
|
|
source?: string;
|
|
externalId?: string;
|
|
externalSourceVersion?: string;
|
|
metadata?: Record<string, unknown>;
|
|
route?: {
|
|
geometry: { type: "LineString"; coordinates: [number, number][] };
|
|
sampleTimestamps?: Array<{ t: string; speedMs?: number; hr?: number }>;
|
|
};
|
|
samples?: Array<{
|
|
t: string;
|
|
hr?: number;
|
|
speedMs?: number;
|
|
power?: number;
|
|
cadence?: number;
|
|
}>;
|
|
}
|
|
|
|
function makeRequest(
|
|
body: { workouts: WorkoutFixture[] },
|
|
opts: {
|
|
idempotencyKey?: string;
|
|
bearer?: string;
|
|
contentLength?: number | null;
|
|
} = {},
|
|
): NextRequest {
|
|
const headers: Record<string, string> = {
|
|
"content-type": "application/json",
|
|
};
|
|
if (opts.idempotencyKey) {
|
|
headers["idempotency-key"] = opts.idempotencyKey;
|
|
}
|
|
if (opts.bearer) {
|
|
headers["authorization"] = `Bearer ${opts.bearer}`;
|
|
}
|
|
if (opts.contentLength !== null && opts.contentLength !== undefined) {
|
|
headers["content-length"] = String(opts.contentLength);
|
|
}
|
|
const serialized = JSON.stringify(body);
|
|
return new NextRequest("http://localhost/api/workouts/batch", {
|
|
method: "POST",
|
|
headers,
|
|
body: serialized,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* GPS tracks are kept only after the account opts in (v1.44,
|
|
* `storeWorkoutRoutes`, off by default). The route-storing cases turn it on.
|
|
*/
|
|
async function keepWorkoutRoutes(): Promise<void> {
|
|
await getPrismaClient().user.update({
|
|
where: { id: TEST_USER_ID },
|
|
data: { storeWorkoutRoutes: true },
|
|
});
|
|
}
|
|
|
|
function baseWorkout(
|
|
externalId: string,
|
|
overrides: WorkoutFixture = {},
|
|
): WorkoutFixture {
|
|
return {
|
|
sportType: "running",
|
|
startedAt: "2026-05-14T06:30:00.000Z",
|
|
endedAt: "2026-05-14T07:15:00.000Z",
|
|
source: "APPLE_HEALTH",
|
|
externalId,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("POST /api/workouts/batch (real Postgres)", () => {
|
|
it("inserts a single workout with a nested route", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
await keepWorkoutRoutes();
|
|
|
|
const body = {
|
|
workouts: [
|
|
baseWorkout("hk-uuid-route-001", {
|
|
totalEnergyKcal: 412,
|
|
totalDistanceM: 7800,
|
|
avgHeartRate: 154,
|
|
route: {
|
|
geometry: {
|
|
type: "LineString",
|
|
coordinates: [
|
|
[11.077, 49.452],
|
|
[11.078, 49.453],
|
|
[11.079, 49.454],
|
|
],
|
|
},
|
|
sampleTimestamps: [
|
|
{ t: "2026-05-14T06:30:00.000Z", speedMs: 3.2, hr: 142 },
|
|
{ t: "2026-05-14T06:30:05.000Z", speedMs: 3.3, hr: 144 },
|
|
{ t: "2026-05-14T06:30:10.000Z", speedMs: 3.1, hr: 146 },
|
|
],
|
|
},
|
|
}),
|
|
],
|
|
};
|
|
|
|
const res = await POST(makeRequest(body));
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as {
|
|
data: { processed: number; inserted: number; duplicates: number };
|
|
};
|
|
expect(json.data.processed).toBe(1);
|
|
expect(json.data.inserted).toBe(1);
|
|
|
|
const workouts = await getPrismaClient().workout.findMany({
|
|
where: { userId: TEST_USER_ID },
|
|
include: { route: true },
|
|
});
|
|
expect(workouts).toHaveLength(1);
|
|
expect(workouts[0]?.sportType).toBe("running");
|
|
expect(workouts[0]?.totalDistanceM).toBe(7800);
|
|
expect(workouts[0]?.route).not.toBeNull();
|
|
expect(workouts[0]?.durationSec).toBe(45 * 60);
|
|
|
|
// v1.39.4 — the track is on disk sealed only. The readable column is SQL
|
|
// NULL, the ciphertext does not contain a coordinate, and it opens to the
|
|
// exact geometry the client sent.
|
|
const onDisk = await getPrismaClient().$queryRaw<
|
|
{ readable_is_null: boolean; leaks: boolean }[]
|
|
>`
|
|
SELECT geometry IS NULL AS readable_is_null,
|
|
position(convert_to('49.452', 'UTF8') IN geometry_encrypted) > 0
|
|
AS leaks
|
|
FROM workout_routes WHERE workout_id = ${workouts[0]!.id}`;
|
|
expect(onDisk).toEqual([{ readable_is_null: true, leaks: false }]);
|
|
const { decryptRouteGeometry } =
|
|
await import("@/lib/workouts/route-geometry-cipher");
|
|
expect(
|
|
decryptRouteGeometry(workouts[0]!.route!.geometryEncrypted!),
|
|
).toEqual(body.workouts[0]!.route!.geometry);
|
|
});
|
|
|
|
it("keeps the workout and drops its track for an account that has not opted in", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
const before = await getPrismaClient().user.findUniqueOrThrow({
|
|
where: { id: TEST_USER_ID },
|
|
select: { storeWorkoutRoutes: true },
|
|
});
|
|
expect(before.storeWorkoutRoutes).toBe(false);
|
|
|
|
const body = {
|
|
workouts: [
|
|
baseWorkout("hk-uuid-no-optin-001", {
|
|
totalDistanceM: 5200,
|
|
route: {
|
|
geometry: {
|
|
type: "LineString",
|
|
coordinates: [
|
|
[11.077, 49.452],
|
|
[11.078, 49.453],
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
};
|
|
|
|
const res = await POST(makeRequest(body));
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as {
|
|
data: {
|
|
inserted: number;
|
|
entries: Array<{ index: number; status: string }>;
|
|
};
|
|
};
|
|
// The entry status speaks for the workout, which was stored; the track
|
|
// follows the account's own setting, which the client reads separately.
|
|
expect(json.data.inserted).toBe(1);
|
|
expect(json.data.entries).toEqual([{ index: 0, status: "inserted" }]);
|
|
|
|
const workouts = await getPrismaClient().workout.findMany({
|
|
where: { userId: TEST_USER_ID },
|
|
include: { route: true },
|
|
});
|
|
expect(workouts).toHaveLength(1);
|
|
expect(workouts[0]?.totalDistanceM).toBe(5200);
|
|
expect(workouts[0]?.route).toBeNull();
|
|
expect(
|
|
await getPrismaClient().workoutRoute.count({
|
|
where: { workout: { userId: TEST_USER_ID } },
|
|
}),
|
|
).toBe(0);
|
|
});
|
|
|
|
it("persists a route-independent HR series for an indoor workout (v1.10.0)", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
const body = {
|
|
workouts: [
|
|
baseWorkout("hk-uuid-indoor-001", {
|
|
sportType: "strength",
|
|
// No route — an indoor session has no GPS geometry.
|
|
samples: [
|
|
{ t: "2026-05-14T06:30:00.000Z", hr: 110 },
|
|
{ t: "2026-05-14T06:30:05.000Z", hr: 118, power: 0, cadence: 0 },
|
|
{ t: "2026-05-14T06:30:10.000Z", hr: 124 },
|
|
],
|
|
}),
|
|
],
|
|
};
|
|
|
|
const res = await POST(makeRequest(body));
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as {
|
|
data: { inserted: number };
|
|
};
|
|
expect(json.data.inserted).toBe(1);
|
|
|
|
const workouts = await getPrismaClient().workout.findMany({
|
|
where: { userId: TEST_USER_ID, externalId: "hk-uuid-indoor-001" },
|
|
include: { route: true, samples: true },
|
|
});
|
|
expect(workouts).toHaveLength(1);
|
|
// No route row — the series lives in the dedicated child table.
|
|
expect(workouts[0]?.route).toBeNull();
|
|
expect(workouts[0]?.samples).not.toBeNull();
|
|
expect(workouts[0]?.samples?.sampleCount).toBe(3);
|
|
expect((workouts[0]?.samples?.samples as unknown[]).length).toBe(3);
|
|
});
|
|
|
|
it("inserts a 100-workout batch with a mix of routed and routeless entries", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
await keepWorkoutRoutes();
|
|
|
|
const workouts: WorkoutFixture[] = Array.from({ length: 100 }, (_, i) => {
|
|
const offsetMin = i * 60;
|
|
const start = new Date(2026, 4, 1, 6, offsetMin).toISOString();
|
|
const end = new Date(2026, 4, 1, 6, offsetMin + 30).toISOString();
|
|
const hasRoute = i % 5 === 0;
|
|
return baseWorkout(`hk-uuid-batch-${i}`, {
|
|
startedAt: start,
|
|
endedAt: end,
|
|
...(hasRoute
|
|
? {
|
|
route: {
|
|
geometry: {
|
|
type: "LineString",
|
|
coordinates: [
|
|
[11.077 + i * 1e-4, 49.452],
|
|
[11.078 + i * 1e-4, 49.453],
|
|
],
|
|
},
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
});
|
|
|
|
const res = await POST(makeRequest({ workouts }));
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as {
|
|
data: { processed: number; inserted: number };
|
|
};
|
|
expect(json.data.processed).toBe(100);
|
|
expect(json.data.inserted).toBe(100);
|
|
|
|
const storedWorkouts = await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(storedWorkouts).toBe(100);
|
|
const storedRoutes = await getPrismaClient().workoutRoute.count({
|
|
where: { workout: { userId: TEST_USER_ID } },
|
|
});
|
|
expect(storedRoutes).toBe(20);
|
|
});
|
|
|
|
it("returns 400 with workout.batch.too_large when workouts exceed the cap", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
const workouts = Array.from({ length: 101 }, (_, i) =>
|
|
baseWorkout(`hk-uuid-cap-${i}`),
|
|
);
|
|
|
|
const res = await POST(makeRequest({ workouts }));
|
|
expect(res.status).toBe(400);
|
|
const body = (await res.json()) as {
|
|
error: string;
|
|
meta?: { errorCode?: string };
|
|
};
|
|
expect(body.meta?.errorCode).toBe("workout.batch.too_large");
|
|
|
|
const stored = await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(stored).toBe(0);
|
|
});
|
|
|
|
it("returns 400 when a single route exceeds the 20 000-point cap", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
const coordinates: [number, number][] = [];
|
|
for (let i = 0; i < 20_001; i++) {
|
|
coordinates.push([11 + i * 1e-7, 49 + i * 1e-7]);
|
|
}
|
|
|
|
const res = await POST(
|
|
makeRequest(
|
|
{
|
|
workouts: [
|
|
baseWorkout("hk-uuid-route-cap", {
|
|
route: { geometry: { type: "LineString", coordinates } },
|
|
}),
|
|
],
|
|
},
|
|
// Skip the Content-Length pre-flight — the test wants to
|
|
// exercise the schema cap, not the byte cap.
|
|
{ contentLength: null },
|
|
),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = (await res.json()) as {
|
|
error: string;
|
|
meta?: { errorCode?: string };
|
|
};
|
|
expect(body.meta?.errorCode).toBe("workout.batch.invalid");
|
|
|
|
const stored = await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(stored).toBe(0);
|
|
});
|
|
|
|
it("preserves same-source workouts and surfaces exact re-posts as duplicates", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
// Two sessions of one source in the same five-minute slot: different
|
|
// start and length, so two workouts.
|
|
const body = {
|
|
workouts: [
|
|
baseWorkout("hk-uuid-dup-1"),
|
|
baseWorkout("hk-uuid-dup-2", {
|
|
startedAt: "2026-05-14T06:32:00.000Z",
|
|
endedAt: "2026-05-14T06:50:00.000Z",
|
|
}),
|
|
],
|
|
};
|
|
|
|
const first = await POST(makeRequest(body));
|
|
expect(first.status).toBe(200);
|
|
const firstJson = (await first.json()) as {
|
|
data: {
|
|
inserted: number;
|
|
duplicates: number;
|
|
entries: Array<{ status: string }>;
|
|
};
|
|
};
|
|
expect(firstJson.data.inserted).toBe(2);
|
|
expect(firstJson.data.duplicates).toBe(0);
|
|
|
|
const second = await POST(makeRequest(body));
|
|
expect(second.status).toBe(200);
|
|
const secondJson = (await second.json()) as {
|
|
data: {
|
|
inserted: number;
|
|
duplicates: number;
|
|
entries: Array<{ status: string }>;
|
|
};
|
|
};
|
|
expect(secondJson.data.inserted).toBe(0);
|
|
expect(secondJson.data.duplicates).toBe(2);
|
|
expect(secondJson.data.entries.every((e) => e.status === "duplicate")).toBe(
|
|
true,
|
|
);
|
|
|
|
const stored = await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(stored).toBe(2);
|
|
});
|
|
|
|
it("stores a session re-sent under a new id once, the way the list shows it", async () => {
|
|
// HealthKit can hand the same session over twice with two external ids.
|
|
// The list collapses it to one workout; the write keeps one row and
|
|
// reports the other as a duplicate instead of storing what no read shows.
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
const res = await POST(
|
|
makeRequest({
|
|
workouts: [
|
|
baseWorkout("hk-uuid-resend-1"),
|
|
baseWorkout("hk-uuid-resend-2"),
|
|
],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as {
|
|
data: { inserted: number; duplicates: number };
|
|
};
|
|
expect(json.data.inserted).toBe(1);
|
|
expect(json.data.duplicates).toBe(1);
|
|
const stored = await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(stored).toBe(1);
|
|
});
|
|
|
|
it("replays a cached response when the same Idempotency-Key is reused", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
const body = {
|
|
workouts: [baseWorkout("hk-uuid-idem-1")],
|
|
};
|
|
|
|
const key = "ios-workout-batch-12345678";
|
|
const first = await POST(makeRequest(body, { idempotencyKey: key }));
|
|
expect(first.status).toBe(200);
|
|
const firstJson = (await first.json()) as { data: { inserted: number } };
|
|
expect(firstJson.data.inserted).toBe(1);
|
|
|
|
const second = await POST(makeRequest(body, { idempotencyKey: key }));
|
|
expect(second.status).toBe(200);
|
|
expect(second.headers.get("X-Idempotent-Replay")).toBe("true");
|
|
const secondJson = (await second.json()) as { data: { inserted: number } };
|
|
// Same envelope as the first call — even though it would otherwise
|
|
// have surfaced as a duplicate.
|
|
expect(secondJson.data.inserted).toBe(1);
|
|
|
|
const stored = await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID, externalId: "hk-uuid-idem-1" },
|
|
});
|
|
expect(stored).toBe(1);
|
|
});
|
|
|
|
it("rate-limits a user at 60 batches per minute", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
const cap = 60;
|
|
const resetAt = new Date(Date.now() + 60 * 1000);
|
|
await getPrismaClient().$executeRaw`
|
|
INSERT INTO rate_limits (key, count, reset_at)
|
|
VALUES (${`workouts:batch:${TEST_USER_ID}`}, ${cap}, ${resetAt})
|
|
`;
|
|
|
|
const res = await POST(
|
|
makeRequest({ workouts: [baseWorkout("hk-uuid-rate-limit-1")] }),
|
|
);
|
|
expect(res.status).toBe(429);
|
|
const json = (await res.json()) as { error: string };
|
|
expect(json.error).toMatch(/too many/i);
|
|
|
|
const stored = await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(stored).toBe(0);
|
|
});
|
|
|
|
it("refuses a narrow-scope Bearer token, admits the wildcard one", async () => {
|
|
// Supersedes the W10 fix-C case, whose comment claimed "the iOS
|
|
// narrow-scope sync depends on" a narrow token being admitted here. It
|
|
// does not: `workouts:ingest` has no mint site anywhere in the tree, and
|
|
// iOS holds a `["*"]` token from login / passkey / refresh rotation. What
|
|
// that case actually pinned was the fail-open default — a token scoped to
|
|
// anything at all reaching a route that named no scope.
|
|
//
|
|
// Both halves are asserted together so the real iOS contract is the thing
|
|
// under test, not an inference from the deny case.
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
// Drop the cookie session so the Bearer path is the ONLY auth in play.
|
|
cookieJar.delete("healthlog_session");
|
|
|
|
const narrowToken =
|
|
"hlk_narrow_scope_workout_ingest_token_value_a1b2c3d4e5";
|
|
await getPrismaClient().apiToken.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
name: "narrow-scope-workouts",
|
|
tokenHash: hashToken(narrowToken),
|
|
permissions: ["workouts:ingest"], // NO wildcard
|
|
},
|
|
});
|
|
headerJar.set("authorization", `Bearer ${narrowToken}`);
|
|
|
|
const denied = await POST(
|
|
makeRequest(
|
|
{ workouts: [baseWorkout("hk-uuid-narrow-1")] },
|
|
{ bearer: narrowToken },
|
|
),
|
|
);
|
|
expect(denied.status).toBe(403);
|
|
expect(
|
|
await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
}),
|
|
).toBe(0);
|
|
|
|
// The credential iOS actually holds.
|
|
const wildcardToken = "hlk_wildcard_workout_ingest_token_value_f6g7h8i9j0";
|
|
await getPrismaClient().apiToken.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
name: "wildcard-workouts",
|
|
tokenHash: hashToken(wildcardToken),
|
|
permissions: ["*"],
|
|
},
|
|
});
|
|
headerJar.set("authorization", `Bearer ${wildcardToken}`);
|
|
|
|
const admitted = await POST(
|
|
makeRequest(
|
|
{ workouts: [baseWorkout("hk-uuid-wildcard-1")] },
|
|
{ bearer: wildcardToken },
|
|
),
|
|
);
|
|
expect(admitted.status).toBe(200);
|
|
const json = (await admitted.json()) as { data: { inserted: number } };
|
|
expect(json.data.inserted).toBe(1);
|
|
});
|
|
});
|