import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; import { useDeepLink } from "../useDeepLink"; import * as api from "../../api"; import type { ProjectInfo } from "../../api"; vi.mock("../../api", () => ({ fetchTaskDetail: vi.fn(), })); const mockFetchTaskDetail = vi.mocked(api.fetchTaskDetail); describe("useDeepLink", () => { const originalLocation = window.location; const originalReplaceState = window.history.replaceState; const defaultProject: ProjectInfo = { id: "proj_123", name: "Project 123", path: "/repo-123", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "", }; const otherProject: ProjectInfo = { id: "proj_456", name: "Project 456", path: "/repo-456", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "", }; beforeEach(() => { vi.clearAllMocks(); window.history.replaceState = vi.fn(); Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/"), }); mockFetchTaskDetail.mockResolvedValue({ id: "FN-123" } as never); }); afterEach(() => { Object.defineProperty(window, "location", { configurable: true, value: originalLocation, }); window.history.replaceState = originalReplaceState; }); function renderUseDeepLink(overrides: Partial[0]> = {}) { const openTaskDetail = vi.fn(); const closeTaskDetail = vi.fn(); const setCurrentProject = vi.fn(); const addToast = vi.fn(); const options: Parameters[0] = { projectId: defaultProject.id, projects: [defaultProject, otherProject], projectsLoading: false, currentProject: defaultProject, setCurrentProject, addToast, openTaskDetail, closeTaskDetail, ...overrides, }; const hook = renderHook(() => useDeepLink(options)); return { ...hook, options, openTaskDetail, closeTaskDetail, setCurrentProject, addToast }; } it("does nothing when no task param is present", async () => { renderUseDeepLink(); await waitFor(() => { expect(mockFetchTaskDetail).not.toHaveBeenCalled(); }); }); it("fetches and opens task detail for task deep-link", async () => { Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?task=FN-123"), }); const { openTaskDetail } = renderUseDeepLink(); await waitFor(() => { expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123"); expect(openTaskDetail).toHaveBeenCalledTimes(1); }); }); it("switches project and uses project param for task fetch", async () => { Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?project=proj_456&task=FN-999"), }); const { setCurrentProject } = renderUseDeepLink(); await waitFor(() => { expect(setCurrentProject).toHaveBeenCalledWith(otherProject); expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-999", "proj_456"); }); }); it("shows toast and skips fetch for unknown project param", async () => { Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?project=missing&task=FN-123"), }); const { addToast } = renderUseDeepLink(); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Project 'missing' not found", "error"); }); expect(mockFetchTaskDetail).not.toHaveBeenCalled(); }); it("waits for projects to load before resolving deep links", async () => { Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?project=proj_456&task=FN-123"), }); renderUseDeepLink({ projectsLoading: true, currentProject: null, }); await waitFor(() => { expect(mockFetchTaskDetail).not.toHaveBeenCalled(); }); }); it("cleans task query param when deep-linked modal closes", async () => { Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?project=proj_123&task=FN-123"), }); const { result, closeTaskDetail } = renderUseDeepLink(); await waitFor(() => { expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123"); }); result.current.handleDetailClose(); expect(window.history.replaceState).toHaveBeenCalledWith( null, "", "/?project=proj_123", ); expect(closeTaskDetail).toHaveBeenCalledTimes(1); }); it("prevents duplicate fetches when rerendering after project switch", async () => { Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?project=proj_456&task=FN-777"), }); const addToast = vi.fn(); const setCurrentProject = vi.fn(); const openTaskDetail = vi.fn(); const closeTaskDetail = vi.fn(); const { rerender } = renderHook( (props: Parameters[0]) => useDeepLink(props), { initialProps: { projectId: defaultProject.id, projects: [defaultProject, otherProject], projectsLoading: false, currentProject: defaultProject, setCurrentProject, addToast, openTaskDetail, closeTaskDetail, }, }, ); await waitFor(() => { expect(mockFetchTaskDetail).toHaveBeenCalledTimes(1); }); rerender({ projectId: otherProject.id, projects: [defaultProject, otherProject], projectsLoading: false, currentProject: otherProject, setCurrentProject, addToast, openTaskDetail, closeTaskDetail, }); await waitFor(() => { expect(mockFetchTaskDetail).toHaveBeenCalledTimes(1); }); }); });