mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
see comments
This commit is contained in:
parent
a26e2a6245
commit
94e632681e
4 changed files with 98 additions and 51 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue