diff --git a/src/app/api/workouts/__tests__/batch-create.test.ts b/src/app/api/workouts/__tests__/batch-create.test.ts new file mode 100644 index 000000000..1446a1f82 --- /dev/null +++ b/src/app/api/workouts/__tests__/batch-create.test.ts @@ -0,0 +1,351 @@ +/** + * Unit suite for `POST /api/workouts/batch`. + * + * Database-free coverage of the surface that doesn't require a real + * Postgres — the per-entry status envelope, the rate-limit gate, the + * payload-size ceiling, the Zod-parse short-circuits, and the + * race-reconciliation downgrade pass. Full request-lifecycle assertions + * (idempotency replay, real-DB dedup, concurrent-write race) live in + * the matching `tests/integration/workout-batch-create.test.ts`. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + workout: { + findMany: vi.fn(), + createMany: vi.fn(), + findFirst: vi.fn(), + }, + workoutRoute: { + createMany: vi.fn(), + }, + $transaction: vi.fn(async (fn: unknown) => { + // Same shape as the real Prisma client — invoke the supplied + // callback with the mocked client itself so `tx.workout.createMany` + // calls land on the same vi.fn instances. + if (typeof fn === "function") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (fn as any)((prisma as unknown as { workout: unknown })); + } + }), + }, +})); + +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("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../batch/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: "marc", role: "USER" as const }, +}; + +function makeRequest( + body: unknown, + opts: { + contentLength?: number | null; + headers?: Record; + } = {}, +): NextRequest { + const baseHeaders: Record = { + "content-type": "application/json", + ...(opts.headers ?? {}), + }; + const serialized = JSON.stringify(body); + if (opts.contentLength !== null) { + baseHeaders["content-length"] = String( + opts.contentLength ?? serialized.length, + ); + } + return new NextRequest("http://localhost/api/workouts/batch", { + method: "POST", + headers: baseHeaders, + body: serialized, + }); +} + +function validWorkout(externalId: string, overrides: Record = {}) { + return { + sportType: "running", + startedAt: "2026-05-14T06:30:00.000Z", + endedAt: "2026-05-14T07:15:00.000Z", + source: "APPLE_HEALTH", + externalId, + ...overrides, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 60, + resetAt: Date.now() + 60_000, + }); + vi.mocked(prisma.workout.findMany).mockResolvedValue([]); + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 }); + vi.mocked(prisma.workout.findFirst).mockResolvedValue(null); + vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 }); +}); + +describe("POST /api/workouts/batch — auth + size + rate-limit", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await POST(makeRequest({ workouts: [validWorkout("a")] })); + expect(res.status).toBe(401); + }); + + it("returns 413 when Content-Length exceeds the 5 MB ceiling", async () => { + const res = await POST( + makeRequest( + { workouts: [validWorkout("uuid-1")] }, + { contentLength: 6 * 1024 * 1024 }, + ), + ); + expect(res.status).toBe(413); + const body = (await res.json()) as { + error: string; + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("workout.batch.payload_too_large"); + }); + + it("returns 429 when the rate-limit gate blocks the call", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await POST(makeRequest({ workouts: [validWorkout("uuid-1")] })); + expect(res.status).toBe(429); + // We must NOT have queried Prisma after a rate-limit miss — the + // gate's whole point is to short-circuit before the DB. + expect(prisma.workout.createMany).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/workouts/batch — validation", () => { + it("returns 400 when the workouts array exceeds the per-batch cap", async () => { + const workouts = Array.from({ length: 101 }, (_, i) => + validWorkout(`uuid-${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"); + }); + + it("returns 400 when the Zod schema rejects the payload", async () => { + const res = await POST( + makeRequest({ + workouts: [ + { + // Missing required fields — Zod must reject. + sportType: "running", + }, + ], + }), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { + error: string; + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("workout.batch.invalid"); + }); + + it("returns 400 when a route exceeds the 20 000-point cap", async () => { + // 20 001 points — schema must reject before the handler reaches + // its DB layer. We assert no Prisma query fires. + 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: [ + validWorkout("uuid-route-cap", { + route: { geometry: { type: "LineString", coordinates } }, + }), + ], + }, + // Skip the Content-Length check — 20k coordinates serialised + // is well under 5 MB and the request as built has the real + // header anyway. The test asserts that the SCHEMA cap fires + // even when the byte cap doesn't. + { contentLength: null }, + ), + ); + expect(res.status).toBe(400); + expect(prisma.workout.createMany).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/workouts/batch — per-entry status envelope", () => { + it("marks every fresh entry as inserted", async () => { + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 2 }); + const res = await POST( + makeRequest({ + workouts: [ + validWorkout("uuid-fresh-1"), + validWorkout("uuid-fresh-2"), + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + processed: number; + inserted: number; + duplicates: number; + entries: Array<{ status: string }>; + }; + }; + expect(body.data.processed).toBe(2); + expect(body.data.inserted).toBe(2); + expect(body.data.duplicates).toBe(0); + expect(body.data.entries.every((e) => e.status === "inserted")).toBe(true); + }); + + it("flags entries already in the DB as duplicate without re-inserting", async () => { + vi.mocked(prisma.workout.findMany).mockResolvedValue([ + { source: "APPLE_HEALTH", externalId: "uuid-existing" }, + ] as never); + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); + const res = await POST( + makeRequest({ + workouts: [ + validWorkout("uuid-existing"), + validWorkout("uuid-fresh"), + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + inserted: number; + duplicates: number; + entries: Array<{ index: number; status: string }>; + }; + }; + expect(body.data.inserted).toBe(1); + expect(body.data.duplicates).toBe(1); + expect(body.data.entries[0]?.status).toBe("duplicate"); + expect(body.data.entries[1]?.status).toBe("inserted"); + }); + + it("downgrades inserted statuses to duplicate when createMany.count < attempted (race)", async () => { + // Pre-flight findMany returns empty (no obvious duplicates), so + // every entry is initially marked "inserted". createMany then + // returns count=1 — the other batch already won the race for one + // row. The race-reconciliation block must downgrade exactly one + // "inserted" status to "duplicate" so the envelope sums match the + // aggregate counts (the v1.4.25 W10 fix-C invariant). + vi.mocked(prisma.workout.findMany).mockResolvedValue([]); + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); + const res = await POST( + makeRequest({ + workouts: [ + validWorkout("uuid-race-1"), + validWorkout("uuid-race-2"), + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + inserted: number; + duplicates: number; + entries: Array<{ status: string }>; + }; + }; + const insertedEntries = body.data.entries.filter( + (e) => e.status === "inserted", + ).length; + const duplicateEntries = body.data.entries.filter( + (e) => e.status === "duplicate", + ).length; + expect(insertedEntries).toBe(body.data.inserted); + expect(duplicateEntries).toBe(body.data.duplicates); + expect(body.data.inserted).toBe(1); + expect(body.data.duplicates).toBe(1); + }); +}); + +describe("POST /api/workouts/batch — nested route attachment", () => { + it("creates a WorkoutRoute row for a workout that ships a route", async () => { + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); + // Post-insert lookup returns the newly-inserted workout id so the + // route can be attached by FK. + vi.mocked(prisma.workout.findMany).mockImplementation((async (args: { + select?: { id?: true; source?: true; externalId?: true }; + }) => { + if (args.select?.id) { + return [ + { + id: "wkt-fresh-id", + source: "APPLE_HEALTH", + externalId: "uuid-with-route", + }, + ]; + } + return []; + }) as never); + + const res = await POST( + makeRequest({ + workouts: [ + validWorkout("uuid-with-route", { + route: { + geometry: { + type: "LineString", + coordinates: [ + [11.077, 49.452], + [11.078, 49.453], + ], + }, + }, + }), + ], + }), + ); + expect(res.status).toBe(200); + expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0]; + const rows = (call as { data: Array<{ workoutId: string }> }).data; + expect(rows[0]?.workoutId).toBe("wkt-fresh-id"); + }); +}); diff --git a/tests/integration/workout-batch-create.test.ts b/tests/integration/workout-batch-create.test.ts new file mode 100644 index 000000000..bc90748af --- /dev/null +++ b/tests/integration/workout-batch-create.test.ts @@ -0,0 +1,409 @@ +/** + * 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 admitted (W10 fix-C parity) + * + * 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"; + +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); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +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; + route?: { + geometry: { type: "LineString"; coordinates: [number, number][] }; + sampleTimestamps?: Array<{ t: string; speedMs?: number; hr?: number }>; + }; +} + +function makeRequest( + body: { workouts: WorkoutFixture[] }, + opts: { + idempotencyKey?: string; + bearer?: string; + contentLength?: number | null; + } = {}, +): NextRequest { + const headers: Record = { + "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, + }); +} + +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"); + + 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); + }); + + it("inserts a 100-workout batch with a mix of routed and routeless entries", async () => { + const { POST } = await import("@/app/api/workouts/batch/route"); + + 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("dedupes a re-posted batch into duplicate status without inserting twice", async () => { + const { POST } = await import("@/app/api/workouts/batch/route"); + + const body = { + workouts: [ + baseWorkout("hk-uuid-dup-1"), + baseWorkout("hk-uuid-dup-2"), + ], + }; + + const first = await POST(makeRequest(body)); + expect(first.status).toBe(200); + const firstJson = (await first.json()) as { data: { inserted: number } }; + expect(firstJson.data.inserted).toBe(2); + + 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("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("admits a narrow-scope Bearer token (W10 fix-C parity)", async () => { + const { POST } = await import("@/app/api/workouts/batch/route"); + + // Drop the cookie session so the Bearer path is the ONLY auth in + // play. A narrow-scope token (no wildcard) MUST still admit on a + // route that declares no required permission — that's the W10 + // fix-C correctness fix the iOS narrow-scope sync depends on. + cookieJar.delete("healthlog_session"); + + const rawToken = "hlk_narrow_scope_workout_ingest_token_value_a1b2c3d4e5"; + await getPrismaClient().apiToken.create({ + data: { + userId: TEST_USER_ID, + name: "narrow-scope-workouts", + tokenHash: hashToken(rawToken), + permissions: ["workouts:ingest"], // NO wildcard + }, + }); + headerJar.set("authorization", `Bearer ${rawToken}`); + + const res = await POST( + makeRequest( + { workouts: [baseWorkout("hk-uuid-narrow-1")] }, + { bearer: rawToken }, + ), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { data: { inserted: number } }; + expect(json.data.inserted).toBe(1); + }); +});