Kuma/tests/courses/course-spots.test.ts
2026-10-02 01:28:49 +02:00

184 lines
6.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import {
assignSpots,
extendSpotHolds,
getCourseSpots,
getHeldSpotIds,
holdSpot,
spotHolderKey,
} from "@/src/modules/courses/lib/course-spots";
import type { FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan";
const db = {
room: { findUnique: vi.fn() },
courseBooking: { findMany: vi.fn(), findFirst: vi.fn() },
courseSpotHold: {
findMany: vi.fn(),
findUnique: vi.fn(),
deleteMany: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
},
};
const spot = (id: string, label: string): FloorPlanItem => ({
id,
type: "reformer",
x: 1,
y: 1.5,
rotation: 0,
label,
});
const spots = [spot("s1", "1"), spot("s2", "2"), spot("s3", "3")];
beforeEach(() => {
vi.clearAllMocks();
db.courseBooking.findMany.mockResolvedValue([]);
db.courseBooking.findFirst.mockResolvedValue(null);
db.courseSpotHold.findMany.mockResolvedValue([]);
db.courseSpotHold.deleteMany.mockResolvedValue({ count: 0 });
db.courseSpotHold.create.mockResolvedValue({ id: "hold-1" });
});
describe("getCourseSpots", () => {
it("liest den Raum gar nicht erst, wenn der Kurs keine Platzwahl hat", async () => {
expect(await getCourseSpots(db as never, { spotBooking: false, room: "room-1" })).toBeNull();
expect(db.room.findUnique).not.toHaveBeenCalled();
});
it("behandelt einen Raum ohne Geräte wie einen Kurs ohne Platzwahl", async () => {
db.room.findUnique.mockResolvedValue({ floorPlan: null });
expect(await getCourseSpots(db as never, { spotBooking: true, room: "room-1" })).toBeNull();
});
it("liefert die buchbaren Geräte des Raums", async () => {
db.room.findUnique.mockResolvedValue({
floorPlan: { width: 8, depth: 6, items: [spot("s2", "2"), spot("s1", "1")] },
});
const result = await getCourseSpots(db as never, { spotBooking: true, room: "room-1" });
expect(result?.map((s) => s.id)).toEqual(["s1", "s2"]);
});
});
describe("assignSpots", () => {
const assign = (params: { count: number; requestedSpotIds: string[]; holdIds?: string[] }) =>
assignSpots(db as never, { courseId: "c1", spots, holdIds: [], ...params });
it("gibt die Wunschplätze, wenn sie frei sind", async () => {
const result = await assign({ count: 2, requestedSpotIds: ["s3", "s2"], holdIds: ["h"] });
expect(result).toEqual([
{ spotId: "s2", spotLabel: "Reformer 2", changed: false },
{ spotId: "s3", spotLabel: "Reformer 3", changed: false },
]);
});
it("weicht auf einen freien Platz aus, den niemand gerade im Checkout hält", async () => {
db.courseBooking.findMany.mockResolvedValue([{ spotIds: ["s1"] }]);
db.courseSpotHold.findMany.mockResolvedValue([{ spotId: "s2" }]);
const result = await assign({ count: 1, requestedSpotIds: ["s1"], holdIds: ["hold-1"] });
expect(result).toEqual([{ spotId: "s3", spotLabel: "Reformer 3", changed: true }]);
// Die eigenen Holds zählen nicht als "von jemand anderem gehalten".
expect(db.courseSpotHold.findMany.mock.calls[0][0].where.id).toEqual({ notIn: ["hold-1"] });
});
it("behält freie Wunschplätze und füllt nur den fehlenden auf", async () => {
db.courseBooking.findMany.mockResolvedValue([{ spotIds: ["s1"] }]);
const result = await assign({ count: 2, requestedSpotIds: ["s1", "s2"] });
expect(result?.map((s) => [s.spotId, s.changed])).toEqual([
["s2", false],
["s3", true],
]);
});
it("nimmt notfalls einen gehaltenen Platz - bezahlt schlägt Checkout", async () => {
db.courseBooking.findMany.mockResolvedValue([{ spotIds: ["s1", "s3"] }]);
db.courseSpotHold.findMany.mockResolvedValue([{ spotId: "s2" }]);
const result = await assign({ count: 1, requestedSpotIds: ["s1"] });
expect(result?.[0].spotId).toBe("s2");
});
it("liefert null, wenn nicht genug Plätze frei sind", async () => {
db.courseBooking.findMany.mockResolvedValue([{ spotIds: ["s1", "s2"] }]);
expect(await assign({ count: 2, requestedSpotIds: ["s3"] })).toBeNull();
});
it("weist bei alten Sessions ohne Wunschplatz die ersten freien zu", async () => {
const result = await assign({ count: 1, requestedSpotIds: [] });
expect(result).toEqual([{ spotId: "s1", spotLabel: "Reformer 1", changed: false }]);
});
});
describe("holdSpot", () => {
const params = {
courseId: "c1",
spotId: "s1",
holderKey: spotHolderKey({ userId: "u1" }),
expiresAt: new Date(Date.now() + 30 * 60_000),
};
it("räumt nur abgelaufene Holds weg - eigene Holds anderer Checkouts bleiben", async () => {
const result = await holdSpot(db as never, params);
expect(result).toEqual({ held: true, holdId: "hold-1" });
const where = db.courseSpotHold.deleteMany.mock.calls[0][0].where;
expect(where).toEqual({ courseId: "c1", expiresAt: { lte: expect.any(Date) } });
});
it("hält keinen Platz, der schon bezahlt ist", async () => {
db.courseBooking.findFirst.mockResolvedValue({ id: "b1" });
const result = await holdSpot(db as never, params);
expect(result.held).toBe(false);
expect(db.courseSpotHold.create).not.toHaveBeenCalled();
});
it("verliert gegen einen gleichzeitigen Checkout für denselben Platz", async () => {
db.courseSpotHold.create.mockRejectedValue(
Object.assign(new Error("Unique constraint failed"), { code: "P2002" })
);
db.courseSpotHold.findUnique.mockResolvedValue({ holderKey: "user:jemand-anderes" });
const result = await holdSpot(db as never, params);
expect(result).toMatchObject({
held: false,
reason: expect.stringContaining("jemand anderem"),
});
});
it("sagt der Person, dass ihre eigene Zahlung für den Platz noch läuft", async () => {
db.courseSpotHold.create.mockRejectedValue(
Object.assign(new Error("Unique constraint failed"), { code: "P2002" })
);
db.courseSpotHold.findUnique.mockResolvedValue({ holderKey: "user:u1" });
const result = await holdSpot(db as never, params);
expect(result).toMatchObject({ held: false, reason: expect.stringContaining("Ihre Zahlung") });
});
});
describe("Hold-Status", () => {
it("markiert einen verlängerten Hold als abgeschlossenen Checkout", async () => {
const until = new Date(Date.now() + 14 * 86_400_000);
await extendSpotHolds(db as never, ["hold-1"], until);
expect(db.courseSpotHold.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["hold-1"] } },
data: { expiresAt: until, status: "awaiting_payment" },
});
});
it("blendet für die Person nur ihre offenen Checkouts aus", async () => {
await getHeldSpotIds(db as never, "c1", { exceptHolderKey: "user:u1" });
expect(db.courseSpotHold.findMany.mock.calls[0][0].where.NOT).toEqual({
holderKey: "user:u1",
status: "checkout",
});
});
});
describe("spotHolderKey", () => {
it("normalisiert Gast-E-Mails, damit derselbe Gast seinen alten Hold ersetzt", () => {
expect(spotHolderKey({ guestEmail: " Gast@Example.com " })).toBe("guest:gast@example.com");
});
});