feat(tickets): suppression d'un ticket (backend + frontend)
Ajoute la suppression complète d'un ticket (#6). Backend Rust : - port IssueStore::delete (NotFound si absent) + event IssueDeleted{freed_sprint} - FsIssueStore::delete : supprime .ideai/tickets/<N>/ et l'index sous lock - use case DeleteIssue + adaptations des tests sprint/ticket_assistant au port - commande Tauri ticket_delete + câblage events/state/lib Frontend : - gateway delete (ports, adapter ticket + mock, domain) - useTicketDetail : retrait de la liste et fermeture du détail via event issueDeleted - intégration TicketDetail + tests Tests verts : application/issue_usecases (6), infrastructure/issue_store (7), app-tauri --lib (56), frontend vitest (503), npm run build (exit 0). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -14,7 +14,7 @@ import {
|
||||
MockSystemGateway,
|
||||
MockTicketGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { Agent } from "@/domain";
|
||||
import type { Agent, DomainEvent } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { TicketsView } from "./TicketsView";
|
||||
import { TicketDetail } from "./TicketDetail";
|
||||
@ -108,6 +108,34 @@ describe("MockTicketGateway", () => {
|
||||
).rejects.toMatchObject({ message: expect.stringContaining("version conflict") });
|
||||
});
|
||||
|
||||
it("deletes a ticket, removing it and emitting issueDeleted with freedSprint (#6)", async () => {
|
||||
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "S" });
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Doomed" });
|
||||
await ticket.setTicketSprint(PROJECT_ID, t.ref, "s1", t.version);
|
||||
const events: DomainEvent[] = [];
|
||||
await system.onDomainEvent((e) => events.push(e));
|
||||
|
||||
await ticket.delete(PROJECT_ID, t.ref);
|
||||
|
||||
// Removed from the store (NotFound afterwards)…
|
||||
await expect(ticket.read(PROJECT_ID, t.ref)).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
// …and the sprint it was released from is reported on the event.
|
||||
expect(events).toContainEqual({
|
||||
type: "issueDeleted",
|
||||
projectId: PROJECT_ID,
|
||||
issueRef: t.ref,
|
||||
freedSprint: "s1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects deleting an unknown ticket with NotFound (#6)", async () => {
|
||||
await expect(ticket.delete(PROJECT_ID, "#404")).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" });
|
||||
@ -425,6 +453,69 @@ describe("TicketsView", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes a ticket from the detail: confirmation ⇒ row gone + detail closed (#6)", async () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Deletable" });
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByText("Deletable"));
|
||||
const detail = await screen.findByRole("dialog", {
|
||||
name: `ticket ${t.ref}`,
|
||||
});
|
||||
|
||||
// Open the confirmation, then confirm the deletion.
|
||||
fireEvent.click(within(detail).getByText("Supprimer"));
|
||||
const confirm = await screen.findByRole("dialog", {
|
||||
name: "confirmer la suppression",
|
||||
});
|
||||
fireEvent.click(within(confirm).getByLabelText("confirm delete"));
|
||||
|
||||
// The gateway removed the ticket…
|
||||
await waitFor(() =>
|
||||
expect(ticket.read(PROJECT_ID, t.ref)).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
}),
|
||||
);
|
||||
// …the detail closed (via the `issueDeleted` event → `deleted` flag)…
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: `ticket ${t.ref}` }),
|
||||
).toBeNull(),
|
||||
);
|
||||
// …and the row disappeared from the list (event-driven refresh).
|
||||
await waitFor(() => expect(screen.queryByText("Deletable")).toBeNull());
|
||||
expect(await screen.findByText("No tickets.")).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");
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByText("Keeper"));
|
||||
const detail = await screen.findByRole("dialog", {
|
||||
name: `ticket ${t.ref}`,
|
||||
});
|
||||
|
||||
fireEvent.click(within(detail).getByText("Supprimer"));
|
||||
const confirm = await screen.findByRole("dialog", {
|
||||
name: "confirmer la suppression",
|
||||
});
|
||||
fireEvent.click(within(confirm).getByLabelText("cancel delete"));
|
||||
|
||||
// The confirmation is dismissed, delete was never called, ticket stays.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "confirmer la suppression" }),
|
||||
).toBeNull(),
|
||||
);
|
||||
expect(delSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: `ticket ${t.ref}` }),
|
||||
).toBeTruthy();
|
||||
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
||||
expect(fresh.title).toBe("Keeper");
|
||||
});
|
||||
|
||||
it("copies a delegation context prompt (F7)", async () => {
|
||||
const writeText = stubClipboard();
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Delegate me" });
|
||||
|
||||
Reference in New Issue
Block a user