Kuma/tests/courses/course-spots.test.ts
2026-10-02 00:56:16 +02:00

193 lines
6.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import {
assignSpot,
extendSpotHold,
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("assignSpot", () => {
it("gibt den Wunschplatz, wenn er frei ist", async () => {
const result = await assignSpot(db as never, {
courseId: "c1",
spots,
requestedSpotId: "s2",
holdId: "hold-1",
});
expect(result).toEqual({ spotId: "s2", spotLabel: "Reformer 2", changed: false });
});
it("weicht auf einen freien Platz aus, den niemand gerade im Checkout hält", async () => {
db.courseBooking.findMany.mockResolvedValue([{ spotId: "s1" }]);
db.courseSpotHold.findMany.mockResolvedValue([{ spotId: "s2" }]);
const result = await assignSpot(db as never, {
courseId: "c1",
spots,
requestedSpotId: "s1",
holdId: "hold-1",
});
expect(result).toEqual({ spotId: "s3", spotLabel: "Reformer 3", changed: true });
// Der eigene Hold zählt nicht als "von jemand anderem gehalten".
expect(db.courseSpotHold.findMany.mock.calls[0][0].where.id).toEqual({ not: "hold-1" });
});
it("nimmt notfalls einen gehaltenen Platz - bezahlt schlägt Checkout", async () => {
db.courseBooking.findMany.mockResolvedValue([{ spotId: "s1" }, { spotId: "s3" }]);
db.courseSpotHold.findMany.mockResolvedValue([{ spotId: "s2" }]);
const result = await assignSpot(db as never, {
courseId: "c1",
spots,
requestedSpotId: "s1",
holdId: null,
});
expect(result?.spotId).toBe("s2");
});
it("liefert null, wenn alle Plätze bezahlt sind", async () => {
db.courseBooking.findMany.mockResolvedValue(spots.map((s) => ({ spotId: s.id })));
const result = await assignSpot(db as never, {
courseId: "c1",
spots,
requestedSpotId: "s1",
holdId: null,
});
expect(result).toBeNull();
});
it("weist bei alten Sessions ohne Wunschplatz den ersten freien zu", async () => {
const result = await assignSpot(db as never, {
courseId: "c1",
spots,
requestedSpotId: null,
holdId: null,
});
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 extendSpotHold(db as never, "hold-1", until);
expect(db.courseSpotHold.updateMany).toHaveBeenCalledWith({
where: { id: "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");
});
});