mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
235 lines
8.2 KiB
TypeScript
235 lines
8.2 KiB
TypeScript
/**
|
|
* The cycle writes name the starts they fold in and give back (ios #116
|
|
* item 9).
|
|
*
|
|
* A start logged a few days before an existing one is the same period entered
|
|
* in the other order, so the later start is folded in. Taking the earlier one
|
|
* back gives the later one back. Both used to reach only the audit row; the
|
|
* client could refetch the grid but could not say which date moved. These
|
|
* pin the answers: `absorbed` on the start, `restored` on the PATCH that
|
|
* clears a start day's flow, and the opt-in 200 on the two deletes, with the
|
|
* 204 unchanged for a client that does not ask.
|
|
*/
|
|
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.API_TOKEN_HMAC_KEY ??=
|
|
"test-hmac-key-cycle-absorbed-integration-32-bytes-min-1234567890";
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
const USER_ID = "user-cycle-absorbed";
|
|
|
|
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: USER_ID,
|
|
username: "cycle-absorbed",
|
|
email: "cycle-absorbed@example.test",
|
|
gender: "FEMALE",
|
|
},
|
|
});
|
|
const session = await getPrismaClient().session.create({
|
|
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
});
|
|
|
|
function request(
|
|
path: string,
|
|
method: string,
|
|
body?: unknown,
|
|
headers: Record<string, string> = {},
|
|
): NextRequest {
|
|
return new NextRequest(`http://localhost${path}`, {
|
|
method,
|
|
headers: { "content-type": "application/json", ...headers },
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
async function startPeriod(date: string): Promise<{
|
|
cycle: { id: string; startDate: string } | null;
|
|
absorbed: { cycleId: string; startDate: string } | null;
|
|
}> {
|
|
const { POST } = await import("@/app/api/cycle/period/route");
|
|
const res = await POST(
|
|
request("/api/cycle/period", "POST", {
|
|
action: "start",
|
|
date,
|
|
loggedAt: `${date}T08:00:00.000Z`,
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
return (await res.json()).data;
|
|
}
|
|
|
|
async function cycleIdOn(date: string): Promise<string> {
|
|
const row = await getPrismaClient().menstrualCycle.findFirstOrThrow({
|
|
where: { userId: USER_ID, startDate: date },
|
|
select: { id: true },
|
|
});
|
|
return row.id;
|
|
}
|
|
|
|
const PREFER = { prefer: "return=representation" };
|
|
|
|
describe("absorbed and restored cycle starts", () => {
|
|
it("names the later start a start folds in, and null when it folds none", async () => {
|
|
const first = await startPeriod("2026-09-17");
|
|
expect(first.absorbed).toBeNull();
|
|
const later = await cycleIdOn("2026-09-17");
|
|
|
|
const earlier = await startPeriod("2026-09-14");
|
|
expect(earlier.absorbed).toEqual({
|
|
cycleId: later,
|
|
startDate: "2026-09-17",
|
|
});
|
|
});
|
|
|
|
it("gives the folded start back on a cycle delete with Prefer, as 200", async () => {
|
|
await startPeriod("2026-09-17");
|
|
const later = await cycleIdOn("2026-09-17");
|
|
await startPeriod("2026-09-14");
|
|
const earlier = await cycleIdOn("2026-09-14");
|
|
|
|
const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route");
|
|
const res = await DELETE(
|
|
request(`/api/cycle/cycles/${earlier}`, "DELETE", undefined, PREFER),
|
|
{ params: Promise.resolve({ id: earlier }) },
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("preference-applied")).toBe("return=representation");
|
|
expect((await res.json()).data).toEqual({
|
|
removedCycleId: earlier,
|
|
restored: [{ cycleId: later, startDate: "2026-09-17" }],
|
|
});
|
|
// The calendar shows 17 Sep again.
|
|
const live = await getPrismaClient().menstrualCycle.findMany({
|
|
where: { userId: USER_ID, deletedAt: null },
|
|
select: { startDate: true },
|
|
});
|
|
expect(live.map((c) => c.startDate)).toEqual(["2026-09-17"]);
|
|
});
|
|
|
|
it("keeps answering 204 without a body when the request does not ask", async () => {
|
|
await startPeriod("2026-09-17");
|
|
await startPeriod("2026-09-14");
|
|
const earlier = await cycleIdOn("2026-09-14");
|
|
|
|
const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route");
|
|
const res = await DELETE(
|
|
request(`/api/cycle/cycles/${earlier}`, "DELETE"),
|
|
{ params: Promise.resolve({ id: earlier }) },
|
|
);
|
|
expect(res.status).toBe(204);
|
|
expect(res.headers.get("preference-applied")).toBeNull();
|
|
expect(await res.text()).toBe("");
|
|
});
|
|
|
|
it("answers a repeated delete with nothing removed and nothing restored", async () => {
|
|
await startPeriod("2026-09-17");
|
|
const id = await cycleIdOn("2026-09-17");
|
|
const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route");
|
|
await DELETE(request(`/api/cycle/cycles/${id}`, "DELETE"), {
|
|
params: Promise.resolve({ id }),
|
|
});
|
|
const again = await DELETE(
|
|
request(`/api/cycle/cycles/${id}`, "DELETE", undefined, PREFER),
|
|
{ params: Promise.resolve({ id }) },
|
|
);
|
|
expect(again.status).toBe(200);
|
|
expect((await again.json()).data).toEqual({
|
|
removedCycleId: null,
|
|
restored: [],
|
|
});
|
|
});
|
|
|
|
it("gives the folded start back on a day-log delete with Prefer", async () => {
|
|
await startPeriod("2026-09-17");
|
|
const later = await cycleIdOn("2026-09-17");
|
|
await startPeriod("2026-09-14");
|
|
const earlier = await cycleIdOn("2026-09-14");
|
|
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
|
|
where: { userId: USER_ID, date: "2026-09-14" },
|
|
select: { id: true },
|
|
});
|
|
|
|
const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route");
|
|
const res = await DELETE(
|
|
request(`/api/cycle/day-logs/${dayLog.id}`, "DELETE", undefined, {
|
|
prefer: "handling=lenient, return=representation",
|
|
}),
|
|
{ params: Promise.resolve({ id: dayLog.id }) },
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect((await res.json()).data).toEqual({
|
|
removedCycleId: earlier,
|
|
restored: [{ cycleId: later, startDate: "2026-09-17" }],
|
|
});
|
|
});
|
|
|
|
it("names the start given back when an edit clears the flow on a start day", async () => {
|
|
await startPeriod("2026-09-17");
|
|
const later = await cycleIdOn("2026-09-17");
|
|
await startPeriod("2026-09-14");
|
|
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
|
|
where: { userId: USER_ID, date: "2026-09-14" },
|
|
select: { id: true },
|
|
});
|
|
|
|
const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
|
|
const res = await PATCH(
|
|
request(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", { flow: "NONE" }),
|
|
{ params: Promise.resolve({ id: dayLog.id }) },
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const data = (await res.json()).data;
|
|
expect(data.restored).toEqual([
|
|
{ cycleId: later, startDate: "2026-09-17" },
|
|
]);
|
|
expect(data.id).toBe(dayLog.id);
|
|
});
|
|
|
|
it("answers an edit that removes no start with an empty list", async () => {
|
|
await startPeriod("2026-09-17");
|
|
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
|
|
where: { userId: USER_ID, date: "2026-09-17" },
|
|
select: { id: true },
|
|
});
|
|
const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
|
|
const res = await PATCH(
|
|
request(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", { flow: "HEAVY" }),
|
|
{ params: Promise.resolve({ id: dayLog.id }) },
|
|
);
|
|
expect((await res.json()).data.restored).toEqual([]);
|
|
});
|
|
});
|