mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The manual form sends one manual: id per opening and resends it after an edit. The batch route now treats a MANUAL entry with a manual: id as an overwrite, the way stats: measurements are: the stored row takes the new values and the entry reports "updated", or "duplicate" when nothing changed. Records keyed on the workout are removed under the personal-records lock and re-derived. Every other id stays first-write-wins. The status enum gains "updated".
276 lines
8.5 KiB
TypeScript
276 lines
8.5 KiB
TypeScript
/**
|
|
* 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;
|
|
* - a submit after an edit is `updated` and the one row carries the edit,
|
|
* while a synced id with changed values stays first-write-wins;
|
|
* - 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<Response> {
|
|
return postBatch(
|
|
new NextRequest("http://localhost/api/workouts/batch", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
);
|
|
}
|
|
|
|
function list(): Promise<Response> {
|
|
return listWorkouts(new NextRequest("http://localhost/api/workouts"));
|
|
}
|
|
|
|
function del(id: string): Promise<Response> {
|
|
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("updates the stored row when the same form is sent again with an edit", async () => {
|
|
const prisma = getPrismaClient();
|
|
const entry = formEntry(newManualWorkoutExternalId());
|
|
await post({ workouts: [entry] });
|
|
const stored = await prisma.workout.findFirstOrThrow({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
// A record the first values set; the edit must not leave it behind.
|
|
await prisma.personalRecord.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
metricType: "WALKING_RUNNING_DISTANCE",
|
|
metricSlot: "longest_distance_run",
|
|
direction: "MAX",
|
|
value: 8046.7,
|
|
unit: "m",
|
|
achievedAt: stored.startedAt,
|
|
source: "MANUAL",
|
|
externalId: entry.externalId,
|
|
},
|
|
});
|
|
|
|
const edited = { ...entry, totalDistanceM: 6000, totalEnergyKcal: 380 };
|
|
const res = await post({ workouts: [edited] });
|
|
|
|
expect((await res.json()).data.entries).toEqual([
|
|
{ index: 0, status: "updated" },
|
|
]);
|
|
const rows = await prisma.workout.findMany({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({
|
|
id: stored.id,
|
|
totalDistanceM: 6000,
|
|
totalEnergyKcal: 380,
|
|
durationSec: 45 * 60,
|
|
});
|
|
expect(
|
|
await prisma.personalRecord.count({
|
|
where: { userId: TEST_USER_ID, externalId: entry.externalId },
|
|
}),
|
|
).toBe(0);
|
|
|
|
// The same edit once more changes nothing and says so.
|
|
const again = await post({ workouts: [edited] });
|
|
expect((await again.json()).data.entries).toEqual([
|
|
{ index: 0, status: "duplicate" },
|
|
]);
|
|
});
|
|
|
|
it("keeps a synced workout first-write-wins under the same resend", async () => {
|
|
const prisma = getPrismaClient();
|
|
const entry = {
|
|
...formEntry("hk-uuid-1"),
|
|
source: "APPLE_HEALTH" as const,
|
|
externalId: "8F14E45F-CEEA-467A-9575-0F5D6C5A1B2C",
|
|
};
|
|
await post({ workouts: [entry] });
|
|
|
|
const res = await post({
|
|
workouts: [{ ...entry, totalDistanceM: 1234 }],
|
|
});
|
|
|
|
expect((await res.json()).data.entries).toEqual([
|
|
{ index: 0, status: "duplicate" },
|
|
]);
|
|
const row = await prisma.workout.findFirstOrThrow({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(row.totalDistanceM).not.toBe(1234);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|