see comments

This commit is contained in:
Rajpreet0 2026-09-15 13:53:27 +02:00
parent a26e2a6245
commit 94e632681e
4 changed files with 98 additions and 51 deletions

View file

@ -240,29 +240,29 @@ export async function publishDraftCourses(courseIds: string[]) {
}
// IDs, die die DB-Abfrage gar nicht zurückgegeben hat (gelöscht, fremd oder
// inzwischen nicht mehr "draft"), sind ebenfalls übersprungen.
// inzwischen nicht mehr "draft"), sind übersprungen.
const foundIds = new Set(courses.map((c) => c.id));
const vanishedCount = courseIds.filter((id) => !foundIds.has(id)).length;
let vanishedCount = courseIds.filter((id) => !foundIds.has(id)).length;
let publishedIds: string[] = [];
if (publishableIds.length > 0) {
// In einer Transaktion, damit das nachgelagerte findMany nur die Zeilen
// sieht, die dieses updateMany erfasst hat. status erneut im where:
// zwischen findMany und updateMany kann der Kurs in einem parallelen
// Request veröffentlicht oder abgesagt worden sein.
publishedIds = await prisma.$transaction(async (tx) => {
await tx.course.updateMany({
where: { id: { in: publishableIds }, createdBy: auth.managerId, status: "draft" },
data: { status: "published" },
});
const updated = await tx.course.findMany({
where: { id: { in: publishableIds }, createdBy: auth.managerId, status: "published" },
select: { id: true },
});
return updated.map((c) => c.id);
// updateManyAndReturn liefert genau die Zeilen zurück, die DIESES Statement
// geändert hat. Ein nachgelagertes findMany auf status "published" würde
// dagegen auch Kurse zurückgeben, die ein paralleler Request zwischen dem
// findMany oben und hier veröffentlicht hat — die würden wir uns fälschlich
// zuschreiben und an den Client melden. status: "draft" im where sorgt dafür,
// dass ein zwischenzeitlich abgesagter Kurs nicht doch noch live geht.
const updated = await prisma.course.updateManyAndReturn({
where: { id: { in: publishableIds }, createdBy: auth.managerId, status: "draft" },
data: { status: "published" },
select: { id: true },
});
publishedIds = updated.map((c) => c.id);
// Differenz = Kurse, die zwischen Prüfung und Update von woanders
// verändert wurden. Sie zählen als übersprungen, nicht als veröffentlicht.
vanishedCount += publishableIds.length - publishedIds.length;
}
revalidatePath("/courses");

View file

@ -97,6 +97,16 @@ function PublishDraftsDialogBody({
useEffect(() => {
let cancelled = false;
// Schlägt das Laden fehl, muss der alte Stand weg: publishDraftCourses
// bekommt den Zeitraum nicht mit und würde einen noch gültigen Entwurf aus
// dem VORHERIGEN Zeitraum anstandslos veröffentlichen, wenn er nach dem
// Fehler ausgewählt im Dialog stehen bleibt.
const clearDrafts = () => {
setDrafts([]);
setSelectedIds(new Set());
setPage(0);
};
(async () => {
setIsLoading(true);
try {
@ -115,10 +125,14 @@ function PublishDraftsDialogBody({
);
setPage(0);
} else {
clearDrafts();
toast.error(result.error);
}
} catch {
if (!cancelled) toast.error("Entwürfe konnten nicht geladen werden.");
if (!cancelled) {
clearDrafts();
toast.error("Entwürfe konnten nicht geladen werden.");
}
} finally {
if (!cancelled) setIsLoading(false);
}

View file

@ -289,11 +289,6 @@ const CourseListView = ({
].join("-");
};
// Steuert nur, OB der Veröffentlichen-Button erscheint. Bewusst ohne Zahl am
// Button: hier sind nur die geladenen Kurse bekannt, der Dialog zeigt aber
// alle kommenden Entwürfe — eine Zahl hier würde der Zahl dort widersprechen.
const hasDrafts = courses.some((c) => c.status === "draft" && !isPastCourse(c.date));
// Der Dialog übernimmt den Datumsfilter des Nutzers, falls gesetzt. Ohne
// Filter bleibt "bis" leer = keine obere Grenze, es werden also alle
// kommenden Entwürfe gezeigt. Bewusst NICHT loadedDateTo: das ist der
@ -438,7 +433,13 @@ const CourseListView = ({
)}
</Button>
</CourseFilter>
{canEdit && hasDrafts && (
{/* Bewusst nicht an die geladenen Kurse gekoppelt: die Liste zeigt nur
ein Datumsfenster (initial heute ±14 Tage), der Dialog dagegen alle
kommenden Entwürfe. Ein Entwurf außerhalb des Fensters würde den
Button sonst verstecken — und bei leerem Fenster fehlt auch
"Mehr laden", der Nutzer käme gar nicht mehr an seine Entwürfe.
Der Dialog hat für "nichts gefunden" einen eigenen Leerzustand. */}
{canEdit && (
<Button
variant="outline"
onClick={() => setPublishDialogOpen(true)}

View file

@ -4,9 +4,8 @@ vi.mock("@/src/lib/prisma", () => ({
prisma: {
course: {
findMany: vi.fn(),
updateMany: vi.fn(),
updateManyAndReturn: vi.fn(),
},
$transaction: vi.fn(),
},
}));
@ -14,8 +13,10 @@ vi.mock("@/src/lib/auth/getUser", () => ({
getUserData: vi.fn(),
isManager: (u: { role: string }) => u?.role === "manager",
isEmployee: (u: { role: string }) => u?.role === "employee",
getEffectiveManagerId: (u: { role: string; id: string; managerId?: string }) =>
u?.role === "manager" ? u.id : (u?.managerId ?? null),
// 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", () => ({
@ -30,8 +31,7 @@ import {
} from "@/src/modules/courses/actions/publish-drafts-actions";
const findMany = vi.mocked(prisma.course.findMany);
const updateMany = vi.mocked(prisma.course.updateMany);
const transaction = vi.mocked(prisma.$transaction);
const updateManyAndReturn = vi.mocked(prisma.course.updateManyAndReturn);
const mockGetUserData = vi.mocked(getUserData);
const MANAGER = { id: "mgr-1", role: "manager" };
@ -61,17 +61,13 @@ const completeDraft = (overrides: Record<string, unknown> = {}) => ({
...overrides,
});
/** $transaction mit Callback ausführen, als liefe sie gegen die echte DB. */
const runTransaction = (updatedIds: string[]) => {
transaction.mockImplementationOnce(async (cb: unknown) => {
const tx = {
course: {
updateMany: vi.fn().mockResolvedValue({ count: updatedIds.length }),
findMany: vi.fn().mockResolvedValue(updatedIds.map((id) => ({ id }))),
},
};
return (cb as (t: typeof tx) => Promise<string[]>)(tx);
});
/**
* 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(() => {
@ -90,7 +86,7 @@ describe("getPublishableDrafts", () => {
mockGetUserData.mockResolvedValueOnce({
id: "emp-1",
role: "employee",
managerId: "mgr-1",
createdBy: "mgr-1",
permissions: { courses: { edit: false } },
} as never);
@ -185,7 +181,7 @@ describe("publishDraftCourses", () => {
mockGetUserData.mockResolvedValueOnce(null as never);
const result = await publishDraftCourses(["c1"]);
expect(result.success).toBe(false);
expect(transaction).not.toHaveBeenCalled();
expect(updateManyAndReturn).not.toHaveBeenCalled();
});
it("lehnt eine leere Auswahl ab", async () => {
@ -212,7 +208,7 @@ describe("publishDraftCourses", () => {
it("veröffentlicht vollständige Entwürfe", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([completeDraft()] as never);
runTransaction(["c1"]);
mockUpdateReturning(["c1"]);
const result = await publishDraftCourses(["c1"]);
@ -229,7 +225,7 @@ describe("publishDraftCourses", () => {
completeDraft(),
completeDraft({ id: "c2", name: "Zumba", room: "" }),
] as never);
runTransaction(["c1"]);
mockUpdateReturning(["c1"]);
const result = await publishDraftCourses(["c1", "c2"]);
@ -242,10 +238,47 @@ describe("publishDraftCourses", () => {
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("veröffentlicht nur Kurse, die noch den Status draft haben", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([completeDraft()] as never);
mockUpdateReturning(["c1"]);
await publishDraftCourses(["c1"]);
// Ohne status: "draft" im where könnte ein zwischenzeitlich abgesagter
// Kurs wieder live gehen.
expect(updateManyAndReturn.mock.calls[0][0]?.where).toEqual({
id: { in: ["c1"] },
createdBy: "mgr-1",
status: "draft",
});
});
it("zählt IDs, die die Abfrage nicht zurückgibt, als verschwunden", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([completeDraft()] as never);
runTransaction(["c1"]);
mockUpdateReturning(["c1"]);
const result = await publishDraftCourses(["c1", "geloescht", "fremd"]);
@ -263,19 +296,18 @@ describe("publishDraftCourses", () => {
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.published).toBe(0);
expect(transaction).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled();
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",
managerId: "mgr-1",
createdBy: "mgr-1",
permissions: { courses: { edit: true } },
} as never);
findMany.mockResolvedValueOnce([completeDraft()] as never);
runTransaction(["c1"]);
mockUpdateReturning(["c1"]);
const result = await publishDraftCourses(["c1"]);