mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +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
|
// 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 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[] = [];
|
let publishedIds: string[] = [];
|
||||||
if (publishableIds.length > 0) {
|
if (publishableIds.length > 0) {
|
||||||
// In einer Transaktion, damit das nachgelagerte findMany nur die Zeilen
|
// updateManyAndReturn liefert genau die Zeilen zurück, die DIESES Statement
|
||||||
// sieht, die dieses updateMany erfasst hat. status erneut im where:
|
// geändert hat. Ein nachgelagertes findMany auf status "published" würde
|
||||||
// zwischen findMany und updateMany kann der Kurs in einem parallelen
|
// dagegen auch Kurse zurückgeben, die ein paralleler Request zwischen dem
|
||||||
// Request veröffentlicht oder abgesagt worden sein.
|
// findMany oben und hier veröffentlicht hat — die würden wir uns fälschlich
|
||||||
publishedIds = await prisma.$transaction(async (tx) => {
|
// zuschreiben und an den Client melden. status: "draft" im where sorgt dafür,
|
||||||
await tx.course.updateMany({
|
// dass ein zwischenzeitlich abgesagter Kurs nicht doch noch live geht.
|
||||||
where: { id: { in: publishableIds }, createdBy: auth.managerId, status: "draft" },
|
const updated = await prisma.course.updateManyAndReturn({
|
||||||
data: { status: "published" },
|
where: { id: { in: publishableIds }, createdBy: auth.managerId, status: "draft" },
|
||||||
});
|
data: { status: "published" },
|
||||||
|
select: { id: true },
|
||||||
const updated = await tx.course.findMany({
|
|
||||||
where: { id: { in: publishableIds }, createdBy: auth.managerId, status: "published" },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
return updated.map((c) => c.id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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");
|
revalidatePath("/courses");
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,16 @@ function PublishDraftsDialogBody({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
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 () => {
|
(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -115,10 +125,14 @@ function PublishDraftsDialogBody({
|
||||||
);
|
);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
} else {
|
} else {
|
||||||
|
clearDrafts();
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) toast.error("Entwürfe konnten nicht geladen werden.");
|
if (!cancelled) {
|
||||||
|
clearDrafts();
|
||||||
|
toast.error("Entwürfe konnten nicht geladen werden.");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setIsLoading(false);
|
if (!cancelled) setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -289,11 +289,6 @@ const CourseListView = ({
|
||||||
].join("-");
|
].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
|
// Der Dialog übernimmt den Datumsfilter des Nutzers, falls gesetzt. Ohne
|
||||||
// Filter bleibt "bis" leer = keine obere Grenze, es werden also alle
|
// Filter bleibt "bis" leer = keine obere Grenze, es werden also alle
|
||||||
// kommenden Entwürfe gezeigt. Bewusst NICHT loadedDateTo: das ist der
|
// kommenden Entwürfe gezeigt. Bewusst NICHT loadedDateTo: das ist der
|
||||||
|
|
@ -438,7 +433,13 @@ const CourseListView = ({
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</CourseFilter>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setPublishDialogOpen(true)}
|
onClick={() => setPublishDialogOpen(true)}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,8 @@ vi.mock("@/src/lib/prisma", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
course: {
|
course: {
|
||||||
findMany: vi.fn(),
|
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(),
|
getUserData: vi.fn(),
|
||||||
isManager: (u: { role: string }) => u?.role === "manager",
|
isManager: (u: { role: string }) => u?.role === "manager",
|
||||||
isEmployee: (u: { role: string }) => u?.role === "employee",
|
isEmployee: (u: { role: string }) => u?.role === "employee",
|
||||||
getEffectiveManagerId: (u: { role: string; id: string; managerId?: string }) =>
|
// Spiegelt die echte Implementierung: für Employees ist der Manager
|
||||||
u?.role === "manager" ? u.id : (u?.managerId ?? null),
|
// 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", () => ({
|
vi.mock("next/cache", () => ({
|
||||||
|
|
@ -30,8 +31,7 @@ import {
|
||||||
} from "@/src/modules/courses/actions/publish-drafts-actions";
|
} from "@/src/modules/courses/actions/publish-drafts-actions";
|
||||||
|
|
||||||
const findMany = vi.mocked(prisma.course.findMany);
|
const findMany = vi.mocked(prisma.course.findMany);
|
||||||
const updateMany = vi.mocked(prisma.course.updateMany);
|
const updateManyAndReturn = vi.mocked(prisma.course.updateManyAndReturn);
|
||||||
const transaction = vi.mocked(prisma.$transaction);
|
|
||||||
const mockGetUserData = vi.mocked(getUserData);
|
const mockGetUserData = vi.mocked(getUserData);
|
||||||
|
|
||||||
const MANAGER = { id: "mgr-1", role: "manager" };
|
const MANAGER = { id: "mgr-1", role: "manager" };
|
||||||
|
|
@ -61,17 +61,13 @@ const completeDraft = (overrides: Record<string, unknown> = {}) => ({
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** $transaction mit Callback ausführen, als liefe sie gegen die echte DB. */
|
/**
|
||||||
const runTransaction = (updatedIds: string[]) => {
|
* Simuliert updateManyAndReturn: die DB gibt nur die Zeilen zurück, die dieses
|
||||||
transaction.mockImplementationOnce(async (cb: unknown) => {
|
* Statement tatsächlich geändert hat. Wer nicht in updatedIds steht, wurde
|
||||||
const tx = {
|
* zwischenzeitlich von anderer Seite verändert und bleibt unberührt.
|
||||||
course: {
|
*/
|
||||||
updateMany: vi.fn().mockResolvedValue({ count: updatedIds.length }),
|
const mockUpdateReturning = (updatedIds: string[]) => {
|
||||||
findMany: vi.fn().mockResolvedValue(updatedIds.map((id) => ({ id }))),
|
updateManyAndReturn.mockResolvedValueOnce(updatedIds.map((id) => ({ id })) as never);
|
||||||
},
|
|
||||||
};
|
|
||||||
return (cb as (t: typeof tx) => Promise<string[]>)(tx);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -90,7 +86,7 @@ describe("getPublishableDrafts", () => {
|
||||||
mockGetUserData.mockResolvedValueOnce({
|
mockGetUserData.mockResolvedValueOnce({
|
||||||
id: "emp-1",
|
id: "emp-1",
|
||||||
role: "employee",
|
role: "employee",
|
||||||
managerId: "mgr-1",
|
createdBy: "mgr-1",
|
||||||
permissions: { courses: { edit: false } },
|
permissions: { courses: { edit: false } },
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
|
@ -185,7 +181,7 @@ describe("publishDraftCourses", () => {
|
||||||
mockGetUserData.mockResolvedValueOnce(null as never);
|
mockGetUserData.mockResolvedValueOnce(null as never);
|
||||||
const result = await publishDraftCourses(["c1"]);
|
const result = await publishDraftCourses(["c1"]);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(transaction).not.toHaveBeenCalled();
|
expect(updateManyAndReturn).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lehnt eine leere Auswahl ab", async () => {
|
it("lehnt eine leere Auswahl ab", async () => {
|
||||||
|
|
@ -212,7 +208,7 @@ describe("publishDraftCourses", () => {
|
||||||
it("veröffentlicht vollständige Entwürfe", async () => {
|
it("veröffentlicht vollständige Entwürfe", async () => {
|
||||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||||
findMany.mockResolvedValueOnce([completeDraft()] as never);
|
findMany.mockResolvedValueOnce([completeDraft()] as never);
|
||||||
runTransaction(["c1"]);
|
mockUpdateReturning(["c1"]);
|
||||||
|
|
||||||
const result = await publishDraftCourses(["c1"]);
|
const result = await publishDraftCourses(["c1"]);
|
||||||
|
|
||||||
|
|
@ -229,7 +225,7 @@ describe("publishDraftCourses", () => {
|
||||||
completeDraft(),
|
completeDraft(),
|
||||||
completeDraft({ id: "c2", name: "Zumba", room: "" }),
|
completeDraft({ id: "c2", name: "Zumba", room: "" }),
|
||||||
] as never);
|
] as never);
|
||||||
runTransaction(["c1"]);
|
mockUpdateReturning(["c1"]);
|
||||||
|
|
||||||
const result = await publishDraftCourses(["c1", "c2"]);
|
const result = await publishDraftCourses(["c1", "c2"]);
|
||||||
|
|
||||||
|
|
@ -242,10 +238,47 @@ describe("publishDraftCourses", () => {
|
||||||
expect(result.skipped[0].reason).toContain("Raum");
|
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 () => {
|
it("zählt IDs, die die Abfrage nicht zurückgibt, als verschwunden", async () => {
|
||||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||||
findMany.mockResolvedValueOnce([completeDraft()] as never);
|
findMany.mockResolvedValueOnce([completeDraft()] as never);
|
||||||
runTransaction(["c1"]);
|
mockUpdateReturning(["c1"]);
|
||||||
|
|
||||||
const result = await publishDraftCourses(["c1", "geloescht", "fremd"]);
|
const result = await publishDraftCourses(["c1", "geloescht", "fremd"]);
|
||||||
|
|
||||||
|
|
@ -263,19 +296,18 @@ describe("publishDraftCourses", () => {
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (!result.success) return;
|
if (!result.success) return;
|
||||||
expect(result.published).toBe(0);
|
expect(result.published).toBe(0);
|
||||||
expect(transaction).not.toHaveBeenCalled();
|
expect(updateManyAndReturn).not.toHaveBeenCalled();
|
||||||
expect(updateMany).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("erlaubt Employees mit edit-Berechtigung das Veröffentlichen im Namen des Managers", async () => {
|
it("erlaubt Employees mit edit-Berechtigung das Veröffentlichen im Namen des Managers", async () => {
|
||||||
mockGetUserData.mockResolvedValueOnce({
|
mockGetUserData.mockResolvedValueOnce({
|
||||||
id: "emp-1",
|
id: "emp-1",
|
||||||
role: "employee",
|
role: "employee",
|
||||||
managerId: "mgr-1",
|
createdBy: "mgr-1",
|
||||||
permissions: { courses: { edit: true } },
|
permissions: { courses: { edit: true } },
|
||||||
} as never);
|
} as never);
|
||||||
findMany.mockResolvedValueOnce([completeDraft()] as never);
|
findMany.mockResolvedValueOnce([completeDraft()] as never);
|
||||||
runTransaction(["c1"]);
|
mockUpdateReturning(["c1"]);
|
||||||
|
|
||||||
const result = await publishDraftCourses(["c1"]);
|
const result = await publishDraftCourses(["c1"]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue