From 92ce6997925eded5d4db4ee24ff84cd7bf3cb96b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 21:25:36 +0200 Subject: [PATCH] test(workouts): drive a hand-entered workout through the real routes Builds the entry the form sends, posts it through the batch route, reads it back from the list, re-posts it as a duplicate, and deletes it; a synced workout is refused. Refs #1085 --- .../integration/workout-manual-entry.test.ts | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 tests/integration/workout-manual-entry.test.ts diff --git a/tests/integration/workout-manual-entry.test.ts b/tests/integration/workout-manual-entry.test.ts new file mode 100644 index 000000000..4ae50381f --- /dev/null +++ b/tests/integration/workout-manual-entry.test.ts @@ -0,0 +1,201 @@ +/** + * A workout entered by hand, end to end against a real Postgres. + * + * The web form builds its entry with `buildManualWorkoutEntry` and posts it + * to `POST /api/workouts/batch`; this suite drives that exact entry through + * the real route and reads it back through `GET /api/workouts`, then deletes + * it through `DELETE /api/workouts/{id}`. Pins: + * + * - the entry lands as `MANUAL`, with the start, duration and metres the + * form computed, and the list serves it; + * - a second submit of the same form is a `duplicate`, not a second row; + * - delete removes a hand-entered workout and refuses a synced one. + */ +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"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const TEST_USER_ID = "user-workout-manual-entry"; + +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), +})); + +const { POST: postBatch } = await import("@/app/api/workouts/batch/route"); +const { GET: listWorkouts } = await import("@/app/api/workouts/route"); +const { DELETE: deleteWorkout } = await import("@/app/api/workouts/[id]/route"); +const { buildManualWorkoutEntry, newManualWorkoutExternalId } = await import( + "@/lib/workouts/manual-entry" +); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "workout-manual", + email: "workout-manual@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); +}); + +function post(body: unknown): Promise { + return postBatch( + new NextRequest("http://localhost/api/workouts/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +function list(): Promise { + return listWorkouts(new NextRequest("http://localhost/api/workouts")); +} + +function del(id: string): Promise { + return deleteWorkout( + new NextRequest(`http://localhost/api/workouts/${id}`, { + method: "DELETE", + }), + { params: Promise.resolve({ id }) }, + ); +} + +/** The entry the form sends for a 45-minute imperial run, yesterday 07:30 Berlin. */ +function formEntry(externalId: string) { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + const day = yesterday.toISOString().slice(0, 10); + const built = buildManualWorkoutEntry( + { + sportType: "running", + start: `${day}T07:30`, + hours: "0", + minutes: "45", + distance: "5", + energyKcal: "410", + }, + { + timezone: "Europe/Berlin", + unitPreference: "imperial", + externalId, + now: new Date(), + }, + ); + if (!built.ok) throw new Error(JSON.stringify(built.errors)); + return built.entry; +} + +describe("a workout entered by hand", () => { + it("lands through the batch route as MANUAL and is served by the list", async () => { + const entry = formEntry(newManualWorkoutExternalId()); + + const res = await post({ workouts: [entry] }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.entries).toEqual([{ index: 0, status: "inserted" }]); + + const listRes = await list(); + expect(listRes.status).toBe(200); + const { data } = await listRes.json(); + expect(data.workouts).toHaveLength(1); + const row = data.workouts[0]; + expect(row).toMatchObject({ + sportType: "running", + startedAt: entry.startedAt, + endedAt: entry.endedAt, + durationSec: 45 * 60, + // 5 mi, stored in metres to a tenth. + distanceM: 8046.7, + activeEnergyKcal: 410, + source: "MANUAL", + externalId: entry.externalId, + }); + }); + + it("stores a second submit of the same form once", async () => { + const entry = formEntry(newManualWorkoutExternalId()); + + await post({ workouts: [entry] }); + const again = await post({ workouts: [entry] }); + + expect((await again.json()).data.entries).toEqual([ + { index: 0, status: "duplicate" }, + ]); + expect( + await getPrismaClient().workout.count({ + where: { userId: TEST_USER_ID }, + }), + ).toBe(1); + }); + + it("can be deleted, and is gone from the list", async () => { + await post({ workouts: [formEntry(newManualWorkoutExternalId())] }); + const stored = await getPrismaClient().workout.findFirstOrThrow({ + where: { userId: TEST_USER_ID }, + }); + + const res = await del(stored.id); + expect(res.status).toBe(200); + expect((await res.json()).data).toEqual({ deleted: true }); + + const { data } = await (await list()).json(); + expect(data.workouts).toHaveLength(0); + }); + + it("refuses to delete a synced workout and leaves it in place", async () => { + const synced = await getPrismaClient().workout.create({ + data: { + userId: TEST_USER_ID, + sportType: "cycling", + startedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), + endedAt: new Date(Date.now() - 60 * 60 * 1000), + durationSec: 3600, + source: "APPLE_HEALTH", + externalId: "hk-sync-1", + }, + }); + + const res = await del(synced.id); + + expect(res.status).toBe(409); + expect( + await getPrismaClient().workout.count({ where: { id: synced.id } }), + ).toBe(1); + }); +});