mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
321 lines
11 KiB
TypeScript
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" });
|
|
});
|
|
});
|