bulk operations fixes

- Frontend: implémente bulkDelete, bulkArchive, bulkRestore
- Backend: adapts ticket DTOs pour bulk operations
- Infrastructure: MCP server pour gestion bulk tickets
- Tests: coverage frontend + backend
This commit is contained in:
2026-07-28 20:38:07 +02:00
parent 73c6081ab8
commit bd4f76ae74
19 changed files with 597 additions and 35 deletions

View File

@ -178,6 +178,44 @@ describe("MockTicketGateway", () => {
});
});
it("bulk updates status and reports partial failures in request order", async () => {
const a = await ticket.create(PROJECT_ID, { title: "A" });
const b = await ticket.create(PROJECT_ID, { title: "B" });
const out = await ticket.bulkUpdateStatus(
PROJECT_ID,
[a.ref, "#404", b.ref],
"QA",
);
expect(out.items.map((item) => [item.ref, item.ok])).toEqual([
[a.ref, true],
["#404", false],
[b.ref, true],
]);
expect((await ticket.read(PROJECT_ID, a.ref)).status).toBe("QA");
expect((await ticket.read(PROJECT_ID, b.ref)).status).toBe("QA");
});
it("bulk deletes existing tickets and keeps missing refs as item failures", async () => {
const a = await ticket.create(PROJECT_ID, { title: "A" });
const b = await ticket.create(PROJECT_ID, { title: "B" });
const out = await ticket.bulkDelete(PROJECT_ID, [a.ref, "#404", b.ref]);
expect(out.items.map((item) => [item.ref, item.ok])).toEqual([
[a.ref, true],
["#404", false],
[b.ref, true],
]);
await expect(ticket.read(PROJECT_ID, a.ref)).rejects.toMatchObject({
code: "NOT_FOUND",
});
await expect(ticket.read(PROJECT_ID, b.ref)).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("links and unlinks tickets, bumping the version", async () => {
const a = await ticket.create(PROJECT_ID, { title: "a" });
await ticket.create(PROJECT_ID, { title: "b" });
@ -692,6 +730,56 @@ describe("TicketsView", () => {
expect(await screen.findByText("No tickets.")).toBeTruthy();
});
it("selects several rows and changes their status from the bulk action bar", async () => {
const a = await ticket.create(PROJECT_ID, { title: "Bulk A" });
const b = await ticket.create(PROJECT_ID, { title: "Bulk B" });
await ticket.create(PROJECT_ID, { title: "Unselected" });
const bulkSpy = vi.spyOn(ticket, "bulkUpdateStatus");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
expect(screen.getByRole("toolbar", { name: "bulk ticket actions" })).toBeTruthy();
fireEvent.click(screen.getByLabelText("Changer le statut"));
fireEvent.click(await screen.findByRole("option", { name: "QA" }));
await waitFor(() =>
expect(bulkSpy).toHaveBeenCalledWith(PROJECT_ID, [a.ref, b.ref], "QA"),
);
await waitFor(async () => {
expect((await ticket.read(PROJECT_ID, a.ref)).status).toBe("QA");
expect((await ticket.read(PROJECT_ID, b.ref)).status).toBe("QA");
});
expect(screen.queryByRole("toolbar", { name: "bulk ticket actions" })).toBeNull();
});
it("selects several rows and deletes them after bulk confirmation", async () => {
const a = await ticket.create(PROJECT_ID, { title: "Delete A" });
const b = await ticket.create(PROJECT_ID, { title: "Delete B" });
await ticket.create(PROJECT_ID, { title: "Keep me" });
const bulkSpy = vi.spyOn(ticket, "bulkDelete");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
fireEvent.click(screen.getByRole("button", { name: "Supprimer" }));
const confirm = await screen.findByRole("dialog", {
name: "confirmer la suppression groupée",
});
fireEvent.click(within(confirm).getByLabelText("confirm bulk delete"));
await waitFor(() =>
expect(bulkSpy).toHaveBeenCalledWith(PROJECT_ID, [a.ref, b.ref]),
);
await waitFor(() => expect(screen.queryByText("Delete A")).toBeNull());
expect(screen.queryByText("Delete B")).toBeNull();
expect(screen.getByText("Keep me")).toBeTruthy();
});
it("cancelling the delete confirmation keeps the ticket (#6)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "Keeper" });
const delSpy = vi.spyOn(ticket, "delete");