Kuma/tests/courses/publish-drafts-actions.test.ts
2026-09-15 14:00:25 +02:00

321 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/src/lib/prisma", () => ({
prisma: {
course: {
findMany: vi.fn(),
updateManyAndReturn: vi.fn(),
},
},
}));
vi.mock("@/src/lib/auth/getUser", () => ({
getUserData: vi.fn(),
isManager: (u: { role: string }) => u?.role === "manager",
isEmployee: (u: { role: string }) => u?.role === "employee",
// Spiegelt die echte Implementierung: für Employees ist der Manager
// EmployeeData.createdBy — nicht etwa ein managerId-Feld, das es nicht gibt.
getEffectiveManagerId: (u: { role: string; id: string; createdBy?: string | null }) =>
u?.role === "manager" ? u.id : (u?.createdBy ?? null),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
import { prisma } from "@/src/lib/prisma";
import { getUserData } from "@/src/lib/auth/getUser";
import {
getPublishableDrafts,
publishDraftCourses,
} from "@/src/modules/courses/actions/publish-drafts-actions";
const findMany = vi.mocked(prisma.course.findMany);
const updateManyAndReturn = vi.mocked(prisma.course.updateManyAndReturn);
const mockGetUserData = vi.mocked(getUserData);
const MANAGER = { id: "mgr-1", role: "manager" };
/** Ein vollständiger, veröffentlichbarer Entwurf. Einzelne Felder pro Test überschreibbar. */
const completeDraft = (overrides: Record<string, unknown> = {}) => ({
id: "c1",
name: "Yoga Basics",
sport: ["yoga"],
level: "any",
date: new Date(2026, 9, 2),
timeFrom: "18:00",
timeTo: "19:00",
trainers: ["t1"],
room: "room-1",
description: "",
coverImage: null,
maxParticipants: 12,
price: 15,
isStandingOrder: false,
frequency: null,
weekdays: [],
endDate: null,
parentCourseId: null,
status: "draft",
createdBy: "mgr-1",
...overrides,
});
/**
* Simuliert updateManyAndReturn: die DB gibt nur die Zeilen zurück, die dieses
* Statement tatsächlich geändert hat. Wer nicht in updatedIds steht, wurde
* zwischenzeitlich von anderer Seite verändert und bleibt unberührt.
*/
const mockUpdateReturning = (updatedIds: string[]) => {
updateManyAndReturn.mockResolvedValueOnce(updatedIds.map((id) => ({ id })) as never);
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("getPublishableDrafts", () => {
it("lehnt nicht angemeldete Nutzer ab", async () => {
mockGetUserData.mockResolvedValueOnce(null as never);
const result = await getPublishableDrafts();
expect(result.success).toBe(false);
expect(findMany).not.toHaveBeenCalled();
});
it("lehnt Employees ohne edit-Berechtigung ab", async () => {
mockGetUserData.mockResolvedValueOnce({
id: "emp-1",
role: "employee",
createdBy: "mgr-1",
permissions: { courses: { edit: false } },
} as never);
const result = await getPublishableDrafts();
expect(result.success).toBe(false);
expect(findMany).not.toHaveBeenCalled();
});
it("fragt nur eigene Entwurf-Instanzen ab, keine Dauerauftrag-Templates", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([] as never);
await getPublishableDrafts({
dateFrom: new Date(2026, 9, 1),
dateTo: new Date(2026, 9, 31),
});
expect(findMany.mock.calls[0][0]?.where).toEqual({
createdBy: "mgr-1",
status: "draft",
isStandingOrder: false,
date: { gte: new Date(2026, 9, 1), lte: new Date(2026, 9, 31) },
});
});
it("setzt keine Datums-Bedingung, wenn kein Zeitraum übergeben wird", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([] as never);
await getPublishableDrafts();
expect(findMany.mock.calls[0][0]?.where).toEqual({
createdBy: "mgr-1",
status: "draft",
isStandingOrder: false,
});
});
it("lässt die obere Grenze offen, wenn nur dateFrom gesetzt ist", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([] as never);
await getPublishableDrafts({ dateFrom: new Date(2026, 8, 15) });
expect(findMany.mock.calls[0][0]?.where).toEqual({
createdBy: "mgr-1",
status: "draft",
isStandingOrder: false,
date: { gte: new Date(2026, 8, 15) },
});
});
it("markiert vollständige Entwürfe als veröffentlichbar", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([completeDraft()] as never);
const result = await getPublishableDrafts();
expect(result.success).toBe(true);
expect(result.drafts[0].missingFields).toEqual([]);
});
it("benennt fehlende Pflichtfelder auf Deutsch", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([
completeDraft({ name: "", room: "", price: undefined }),
] as never);
const result = await getPublishableDrafts();
expect(result.drafts[0].missingFields).toContain("Name");
expect(result.drafts[0].missingFields).toContain("Raum");
expect(result.drafts[0].missingFields).toContain("Preis");
});
it("kennzeichnet Instanzen eines Dauerauftrags als Serie", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([
completeDraft({ parentCourseId: "template-1" }),
completeDraft({ id: "c2", parentCourseId: null }),
] as never);
const result = await getPublishableDrafts();
expect(result.drafts[0].isSeriesInstance).toBe(true);
expect(result.drafts[1].isSeriesInstance).toBe(false);
});
});
describe("publishDraftCourses", () => {
it("lehnt nicht angemeldete Nutzer ab", async () => {
mockGetUserData.mockResolvedValueOnce(null as never);
const result = await publishDraftCourses(["c1"]);
expect(result.success).toBe(false);
expect(updateManyAndReturn).not.toHaveBeenCalled();
});
it("lehnt eine leere Auswahl ab", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
const result = await publishDraftCourses([]);
expect(result.success).toBe(false);
expect(findMany).not.toHaveBeenCalled();
});
it("scoped die Abfrage auf eigene Entwürfe", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([] as never);
await publishDraftCourses(["c1", "fremd"]);
expect(findMany.mock.calls[0][0]?.where).toEqual({
id: { in: ["c1", "fremd"] },
createdBy: "mgr-1",
status: "draft",
isStandingOrder: false,
});
});
it("veröffentlicht vollständige Entwürfe", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([completeDraft()] as never);
mockUpdateReturning(["c1"]);
const result = await publishDraftCourses(["c1"]);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.published).toBe(1);
expect(result.publishedIds).toEqual(["c1"]);
expect(result.skipped).toEqual([]);
});
it("überspringt unvollständige Entwürfe statt sie zu veröffentlichen", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([
completeDraft(),
completeDraft({ id: "c2", name: "Zumba", room: "" }),
] as never);
mockUpdateReturning(["c1"]);
const result = await publishDraftCourses(["c1", "c2"]);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.published).toBe(1);
expect(result.publishedIds).toEqual(["c1"]);
expect(result.skipped).toHaveLength(1);
expect(result.skipped[0].name).toBe("Zumba");
expect(result.skipped[0].reason).toContain("Raum");
});
it("meldet nur Kurse als veröffentlicht, die dieses Update wirklich geändert hat", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([
completeDraft(),
completeDraft({ id: "c2", name: "Pilates" }),
] as never);
// c2 wurde zwischen Prüfung und Update von anderer Seite verändert (z.B.
// parallel veröffentlicht oder abgesagt) — das Update erfasst nur c1.
mockUpdateReturning(["c1"]);
const result = await publishDraftCourses(["c1", "c2"]);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.published).toBe(1);
expect(result.publishedIds).toEqual(["c1"]);
// c2 darf weder in publishedIds stehen noch mitgezählt werden.
expect(result.publishedIds).not.toContain("c2");
expect(result.vanished).toBe(1);
});
it("schreibt nur eigene Entwurf-Einzelkurse, nie Dauerauftrag-Templates", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([completeDraft()] as never);
mockUpdateReturning(["c1"]);
await publishDraftCourses(["c1"]);
// Das Update muss dieselben Bedingungen tragen wie die Auswahl-Abfrage:
// ohne status "draft" könnte ein zwischenzeitlich abgesagter Kurs wieder
// live gehen, ohne isStandingOrder: false ein parallel konvertiertes
// Dauerauftrag-Template veröffentlicht werden, das nie in der Kursliste
// erscheint.
expect(updateManyAndReturn.mock.calls[0][0]?.where).toEqual({
id: { in: ["c1"] },
createdBy: "mgr-1",
status: "draft",
isStandingOrder: false,
});
});
it("zählt IDs, die die Abfrage nicht zurückgibt, als verschwunden", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([completeDraft()] as never);
mockUpdateReturning(["c1"]);
const result = await publishDraftCourses(["c1", "geloescht", "fremd"]);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.vanished).toBe(2);
});
it("schreibt gar nicht, wenn kein Kurs veröffentlichbar ist", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([completeDraft({ name: "", room: "" })] as never);
const result = await publishDraftCourses(["c1"]);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.published).toBe(0);
expect(updateManyAndReturn).not.toHaveBeenCalled();
});
it("erlaubt Employees mit edit-Berechtigung das Veröffentlichen im Namen des Managers", async () => {
mockGetUserData.mockResolvedValueOnce({
id: "emp-1",
role: "employee",
createdBy: "mgr-1",
permissions: { courses: { edit: true } },
} as never);
findMany.mockResolvedValueOnce([completeDraft()] as never);
mockUpdateReturning(["c1"]);
const result = await publishDraftCourses(["c1"]);
expect(result.success).toBe(true);
expect(findMany.mock.calls[0][0]?.where).toMatchObject({ createdBy: "mgr-1" });
});
});