import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core"; import { cleanup, fireEvent, render, screen, waitFor, within, } from "@testing-library/react"; import type { PropsWithChildren } from "react"; // Hoisted doubles for the trash/projects hooks the view reads through. const mocks = rs.hoisted(() => { class MockTrashNotFoundError extends Error { constructor(message: string) { super(message); this.name = "TrashNotFoundError"; } } class MockRestoreConflictError extends Error { constructor( message: string, readonly detail: string, ) { super(message); this.name = "RestoreConflictError"; } } return { documents: [] as TrashDocument[], documentsTotal: null as number | null, documentsHasNextPage: false, retentionDays: 30 as number | undefined, activeProjects: [] as Project[], fetchNextTrashPage: rs.fn(), restoreMutate: rs.fn(), purgeMutate: rs.fn(), emptyMutate: rs.fn(), MockTrashNotFoundError, MockRestoreConflictError, }; }); rs.mock("@/core/trash", () => { // Stable identity across renders: the view flattens ``data.pages``. const trashResult = { data: { pages: [ { documents: [] as TrashDocument[], total: 0, limit: 100, offset: 0, }, ], }, isLoading: false, isError: false, hasNextPage: false, isFetchingNextPage: false, fetchNextPage: mocks.fetchNextTrashPage, refetch: rs.fn(), }; return { TrashNotFoundError: mocks.MockTrashNotFoundError, RestoreConflictError: mocks.MockRestoreConflictError, useInfiniteTrashDocuments: () => { trashResult.data.pages[0]!.documents = mocks.documents; trashResult.data.pages[0]!.total = mocks.documentsTotal ?? mocks.documents.length; trashResult.hasNextPage = mocks.documentsHasNextPage; return trashResult; }, useRestoreDocument: () => ({ mutate: mocks.restoreMutate, isPending: false, }), usePurgeDocument: () => ({ mutate: mocks.purgeMutate, isPending: false, }), useEmptyTrash: () => ({ mutate: mocks.emptyMutate, isPending: false, }), }; }); rs.mock("@/core/projects", () => ({ PROJECTS_CONFIG_DEFAULT: { instructions_max_bytes: 8192, trash_retention_days: 30, }, useProjectsConfig: () => ({ data: mocks.retentionDays === undefined ? undefined : { instructions_max_bytes: 8192, trash_retention_days: mocks.retentionDays, }, }), useProjects: () => ({ data: mocks.activeProjects, isLoading: false, }), })); import { TrashView } from "@/components/workspace/trash/trash-view"; import { I18nProvider } from "@/core/i18n/context"; import type { Project } from "@/core/projects/types"; import type { TrashDocument } from "@/core/trash/types"; function Wrapper({ children }: PropsWithChildren) { return {children}; } function makeTrashDocument( overrides: Partial = {}, ): TrashDocument { return { id: "doc-1", name: "q3-report.pdf", size_bytes: 2048, sha256: "abc", content_missing: false, source_thread_id: null, source_kind: null, source_name: null, created_at: "2026-09-01T00:00:00Z", updated_at: "2026-09-01T00:00:00Z", trashed_at: new Date(Date.now() - 10 * 86_400_000).toISOString(), trash_origin: { project_id: "proj-1", project_name: "Alpha" }, ...overrides, }; } beforeEach(() => { mocks.documents = []; mocks.documentsTotal = null; mocks.documentsHasNextPage = false; mocks.retentionDays = 30; mocks.activeProjects = []; }); afterEach(() => { cleanup(); rs.clearAllMocks(); }); describe("TrashView", () => { it("renders rows with the origin project and remaining retention", () => { mocks.documents = [makeTrashDocument()]; render(, { wrapper: Wrapper }); expect(screen.getByText("q3-report.pdf")).toBeDefined(); expect(screen.getByText(/from Alpha/)).toBeDefined(); expect(screen.getByText(/20 days left/)).toBeDefined(); expect(screen.getByRole("button", { name: /Restore/ })).toBeDefined(); expect( screen.getByRole("button", { name: /Delete permanently/ }), ).toBeDefined(); }); it("shows the empty state without the empty-trash action", () => { render(, { wrapper: Wrapper }); expect(screen.getByText("Trash is empty.")).toBeDefined(); expect(screen.queryByRole("button", { name: "Empty trash" })).toBeNull(); }); it("offers the project picker when a targetless restore 404s, then retries with the picked project", async () => { mocks.documents = [makeTrashDocument()]; mocks.activeProjects = [ { id: "proj-2", name: "Beta", instructions: "", presentation: {}, status: "active", created_at: "2026-09-01T00:00:00Z", updated_at: "2026-09-01T00:00:00Z", }, ]; mocks.restoreMutate.mockImplementation( ( input: { documentId: string; projectId?: string }, options: { onError?: (error: Error) => void; onSuccess?: (result: unknown) => void; }, ) => { if (input.projectId === undefined) { options.onError?.( new mocks.MockTrashNotFoundError("Trash document not found"), ); } }, ); render(, { wrapper: Wrapper }); fireEvent.click(screen.getByRole("button", { name: /Restore/ })); await waitFor(() => { expect(screen.getByText("Choose a project")).toBeDefined(); }); expect( screen.getByText(/original project is gone or archived/), ).toBeDefined(); fireEvent.click(screen.getByRole("button", { name: "Beta" })); expect(mocks.restoreMutate).toHaveBeenLastCalledWith( { documentId: "doc-1", projectId: "proj-2" }, expect.objectContaining({}), ); }); it("keeps the row and skips the picker on a content_missing restore conflict", async () => { mocks.documents = [makeTrashDocument()]; mocks.restoreMutate.mockImplementation( ( _input: { documentId: string }, options: { onError?: (error: Error) => void }, ) => { options.onError?.( new mocks.MockRestoreConflictError( "content_missing", "content_missing", ), ); }, ); render(, { wrapper: Wrapper }); fireEvent.click(screen.getByRole("button", { name: /Restore/ })); await waitFor(() => { expect(mocks.restoreMutate).toHaveBeenCalled(); }); // No picker for a conflict — the row simply stays in trash. expect(screen.queryByText("Choose a project")).toBeNull(); expect(screen.getByText("q3-report.pdf")).toBeDefined(); }); it("requires the irreversible confirmation before purging one document", () => { mocks.documents = [makeTrashDocument()]; mocks.purgeMutate.mockImplementation( (_id: string, options: { onSuccess?: () => void }) => { options.onSuccess?.(); }, ); render(, { wrapper: Wrapper }); fireEvent.click(screen.getByRole("button", { name: /Delete permanently/ })); expect( screen.getByText(/will be permanently deleted. This cannot be undone./), ).toBeDefined(); expect(mocks.purgeMutate).not.toHaveBeenCalled(); fireEvent.click( within(screen.getByRole("dialog")).getByRole("button", { name: "Delete permanently", }), ); expect(mocks.purgeMutate).toHaveBeenCalledWith( "doc-1", expect.objectContaining({}), ); }); it("requires its own confirmation before emptying the trash", () => { mocks.documents = [makeTrashDocument(), makeTrashDocument({ id: "doc-2" })]; render(, { wrapper: Wrapper }); fireEvent.click(screen.getByTestId("trash-empty-button")); expect( screen.getByText( "2 documents will be permanently deleted. This cannot be undone.", ), ).toBeDefined(); expect(mocks.emptyMutate).not.toHaveBeenCalled(); const dialog = screen.getByRole("dialog"); fireEvent.click( within(dialog).getByRole("button", { name: "Empty trash" }), ); expect(mocks.emptyMutate).toHaveBeenCalled(); }); it("paginates: loaded-vs-total plus a Load more that fetches the next page", () => { mocks.documents = [makeTrashDocument()]; mocks.documentsTotal = 3; mocks.documentsHasNextPage = true; render(, { wrapper: Wrapper }); expect(screen.getByText("Showing 1 of 3")).toBeDefined(); fireEvent.click(screen.getByTestId("trash-load-more")); expect(mocks.fetchNextTrashPage).toHaveBeenCalledTimes(1); }); it("counts the server total, not just the loaded rows, in the empty-trash confirmation", () => { mocks.documents = [makeTrashDocument()]; mocks.documentsTotal = 5; mocks.documentsHasNextPage = true; render(, { wrapper: Wrapper }); fireEvent.click(screen.getByTestId("trash-empty-button")); expect( screen.getByText( "5 documents will be permanently deleted. This cannot be undone.", ), ).toBeDefined(); }); it("computes the remaining retention from the configured window", () => { // Trashed 10 days ago with a 7-day window: already past expiry. mocks.retentionDays = 7; mocks.documents = [makeTrashDocument()]; render(, { wrapper: Wrapper }); expect(screen.getByText(/Less than a day left/)).toBeDefined(); expect(screen.queryByText(/20 days left/)).toBeNull(); }); it("falls back to the 30-day default when the config endpoint is unavailable", () => { mocks.retentionDays = undefined; mocks.documents = [makeTrashDocument()]; render(, { wrapper: Wrapper }); expect(screen.getByText(/20 days left/)).toBeDefined(); }); });