deer-flow/frontend/tests/unit/components/workspace/thread-sidebar-item-delete-gating.dom.test.tsx
Wenchao An c3adc51ec9
fix(frontend): confirm sidebar chat deletion (#5406)
* fix(frontend): confirm sidebar chat deletion

* fix(frontend): preserve chat deletion retries after partial cleanup

* fix(frontend): improve chat deletion failure feedback
2026-09-14 06:46:29 +08:00

102 lines
3.3 KiB
TypeScript

import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type { ReactNode } from "react";
import { SidebarProvider } from "@/components/ui/sidebar";
import { ThreadSidebarItem } from "@/components/workspace/recent-chat-list";
import { ThreadDeleteDialogProvider } from "@/components/workspace/thread-delete-dialog";
import { AuthProvider } from "@/core/auth/AuthProvider";
import type { User } from "@/core/auth/types";
import { DEFAULT_LOCALE } from "@/core/i18n";
import { I18nProvider } from "@/core/i18n/context";
rs.mock("next/navigation", () => ({
useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }),
usePathname: () => "/workspace",
useSearchParams: () => new URLSearchParams(),
useParams: () => ({}),
}));
function makeUser(permissions: string[] | undefined): User {
return {
id: "user-1",
email: "user@example.test",
system_role: "user",
...(permissions === undefined ? {} : { permissions }),
} as User;
}
function makeThread() {
return {
thread_id: "thread-1",
title: "A thread",
updated_at: "2026-01-01T00:00:00Z",
} as never;
}
function renderItem(user: User): ReturnType<typeof render> {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const tree: ReactNode = (
<I18nProvider initialLocale={DEFAULT_LOCALE}>
<QueryClientProvider client={queryClient}>
<AuthProvider initialUser={user}>
<SidebarProvider>
<ThreadDeleteDialogProvider>
<ThreadSidebarItem thread={makeThread()} isActive={false} />
</ThreadDeleteDialogProvider>
</SidebarProvider>
</AuthProvider>
</QueryClientProvider>
</I18nProvider>
);
return render(tree);
}
/** Opens the row's "more" dropdown — the Delete item lives inside it.
* Radix triggers open on pointerdown, not click (verified in happy-dom). */
async function openRowMenu(): Promise<void> {
const trigger = await screen.findByRole("button", { name: /more/i });
fireEvent.pointerDown(trigger, { button: 0, pointerType: "mouse" });
fireEvent.click(trigger);
}
afterEach(() => {
rs.restoreAllMocks();
cleanup();
});
describe("ThreadSidebarItem delete-menu gating (threads:delete)", () => {
it("offers Delete for a role holding threads:delete", async () => {
renderItem(makeUser(["threads:read", "threads:delete"]));
await openRowMenu();
expect(await screen.findByText("Delete")).not.toBeNull();
});
it("omits Delete for a role denied threads:delete, keeping the other menu actions", async () => {
renderItem(makeUser(["threads:read"]));
await openRowMenu();
// Other actions survive the gating…
expect(await screen.findByText("Rename")).not.toBeNull();
// …and Delete is gone. Anchor via waitFor on Rename first so the menu is
// provably open before asserting absence.
await waitFor(() => {
expect(screen.queryByText("Delete")).toBeNull();
});
});
it("offers Delete for an unresolved permission list (pre-Phase-4 backend)", async () => {
renderItem(makeUser(undefined));
await openRowMenu();
expect(await screen.findByText("Delete")).not.toBeNull();
});
});