import { act, renderHook } from "@testing-library/react"; import { IconInbox } from "@tabler/icons-react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PresetOption } from "./search-bar"; import { useSavedPresetActions } from "./use-saved-preset-actions"; import { useSavedPresets, type SavedPreset } from "./use-saved-presets"; const mockToast = vi.fn(); vi.mock("./use-saved-presets", () => ({ useSavedPresets: vi.fn(), })); vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: mockToast }), })); const QUERY = "assignee:@me is:open"; const REPO = "kdlbs/kandev"; const FIRST_WORKSPACE_ID = "workspace-1"; const SECOND_WORKSPACE_ID = "workspace-2"; const savedPreset: SavedPreset = { id: "saved-1", kind: "issue", label: "Assigned in Kandev", customQuery: QUERY, repoFilter: REPO, createdAt: "2026-07-17T00:00:00Z", isDefault: false, }; const prPreset: PresetOption = { value: "review_requested", label: "Review requested", filter: "review-requested:@me is:open", group: "inbox", icon: IconInbox, }; const issuePreset: PresetOption = { value: "assigned", label: "Assigned", filter: QUERY, group: "inbox", icon: IconInbox, }; type SavedPresetStore = ReturnType; type Options = Parameters[0]; function makeStore(overrides: Partial = {}): SavedPresetStore { return { presets: [], save: vi.fn(async () => null), remove: vi.fn(async () => false), setDefault: vi.fn(async () => false), ...overrides, } as SavedPresetStore; } function renderActions( overrides: Partial = {}, savedPresetStore = makeStore({ presets: [savedPreset] }), ) { const setProgrammaticSelection = vi.fn(); const setQueryImmediate = vi.fn(); const setRepoFilter = vi.fn(); const markSearchInteracted = vi.fn(); const options: Options = { workspaceId: FIRST_WORKSPACE_ID, selection: { kind: "issue", source: "saved", id: savedPreset.id }, customQuery: QUERY, resolvedPrPresets: [prPreset], resolvedIssuePresets: [issuePreset], setProgrammaticSelection, setQueryImmediate, setRepoFilter, savedPresetStore, markSearchInteracted, ...overrides, }; const rendered = renderHook((currentOptions: Options) => useSavedPresetActions(currentOptions), { initialProps: options, }); return { ...rendered, rerender: (nextOverrides: Partial = {}) => rendered.rerender({ ...options, ...nextOverrides }), setProgrammaticSelection, setQueryImmediate, setRepoFilter, markSearchInteracted, }; } async function expectLatestPresetCatalogAfterDelete() { let finishRemove!: (removed: boolean) => void; const remove = vi.fn( () => new Promise((resolve) => { finishRemove = resolve; }), ); const refreshedIssuePreset: PresetOption = { ...issuePreset, value: "mentioned", label: "Mentioned", filter: "mentions:@me is:open", }; const { result, rerender, setProgrammaticSelection, setQueryImmediate } = renderActions( {}, makeStore({ presets: [savedPreset], remove }), ); let deletion!: Promise; act(() => { deletion = result.current.onDeleteSaved(savedPreset.id); }); rerender({ resolvedIssuePresets: [refreshedIssuePreset] }); await act(async () => { finishRemove(true); await deletion; }); expect(setProgrammaticSelection).toHaveBeenCalledWith({ kind: "issue", source: "preset", id: refreshedIssuePreset.value, }); expect(setQueryImmediate).toHaveBeenCalledWith(refreshedIssuePreset.filter); } async function expectStaleSaveOutcomeIgnored() { let finishSave!: () => void; const save = vi.fn( () => new Promise((resolve) => { finishSave = () => resolve(savedPreset); }), ); const { result, rerender, markSearchInteracted, setProgrammaticSelection, setQueryImmediate, setRepoFilter, } = renderActions({}, makeStore({ save })); let mutation!: Promise; act(() => { mutation = result.current.onConfirmSave(savedPreset.label, savedPreset.repoFilter); }); rerender({ workspaceId: SECOND_WORKSPACE_ID }); await act(async () => { finishSave(); await mutation; }); expect(markSearchInteracted).not.toHaveBeenCalled(); expect(setProgrammaticSelection).not.toHaveBeenCalled(); expect(setQueryImmediate).not.toHaveBeenCalled(); expect(setRepoFilter).not.toHaveBeenCalled(); } async function expectStaleDeleteOutcomeIgnored() { let finishRemove!: () => void; const remove = vi.fn( () => new Promise((resolve) => { finishRemove = () => resolve(true); }), ); const { result, rerender, markSearchInteracted, setProgrammaticSelection, setQueryImmediate, setRepoFilter, } = renderActions({}, makeStore({ remove })); let mutation!: Promise; act(() => { mutation = result.current.onDeleteSaved(savedPreset.id); }); rerender({ workspaceId: SECOND_WORKSPACE_ID }); await act(async () => { finishRemove(); await mutation; }); expect(markSearchInteracted).not.toHaveBeenCalled(); expect(setProgrammaticSelection).not.toHaveBeenCalled(); expect(setQueryImmediate).not.toHaveBeenCalled(); expect(setRepoFilter).not.toHaveBeenCalled(); } async function expectStaleToggleDefaultOutcomeIgnored() { let finishSetDefault!: () => void; const setDefault = vi.fn( () => new Promise((resolve) => { finishSetDefault = () => resolve(true); }), ); const { result, rerender, markSearchInteracted } = renderActions( {}, makeStore({ presets: [savedPreset], setDefault }), ); let mutation!: Promise; act(() => { mutation = result.current.onToggleSavedDefault(savedPreset); }); rerender({ workspaceId: SECOND_WORKSPACE_ID }); await act(async () => { finishSetDefault(); await mutation; }); expect(markSearchInteracted).not.toHaveBeenCalled(); expect(result.current.defaultMutationPendingId).toBeNull(); } async function expectDefaultPendingScopedToWorkspace() { let finishFirst!: () => void; let finishSecond!: () => void; const setDefault = vi.fn( () => new Promise((resolve) => { const finish = () => resolve(true); if (setDefault.mock.calls.length === 1) finishFirst = finish; else finishSecond = finish; }), ); const { result, rerender, markSearchInteracted } = renderActions({}, makeStore({ setDefault })); let firstMutation!: Promise; act(() => { firstMutation = result.current.onToggleSavedDefault(savedPreset); }); expect(result.current.defaultMutationPendingId).toBe(savedPreset.id); rerender({ workspaceId: SECOND_WORKSPACE_ID }); expect(result.current.defaultMutationPendingId).toBeNull(); let secondMutation!: Promise; act(() => { secondMutation = result.current.onToggleSavedDefault(savedPreset); }); expect(setDefault).toHaveBeenCalledTimes(2); expect(result.current.defaultMutationPendingId).toBe(savedPreset.id); await act(async () => { finishFirst(); await firstMutation; }); expect(markSearchInteracted).not.toHaveBeenCalled(); expect(result.current.defaultMutationPendingId).toBe(savedPreset.id); await act(async () => { finishSecond(); await secondMutation; }); expect(markSearchInteracted).toHaveBeenCalledOnce(); expect(result.current.defaultMutationPendingId).toBeNull(); } beforeEach(() => { vi.mocked(useSavedPresets).mockReset(); mockToast.mockReset(); }); describe("useSavedPresetActions save actions", () => { it("ignores a completed save after changing workspace", expectStaleSaveOutcomeIgnored); it("saves the current query, commits it, selects it, and applies its repository", async () => { const save = vi.fn(async () => savedPreset); const store = makeStore({ presets: [savedPreset], save }); const { result, setProgrammaticSelection, setQueryImmediate, setRepoFilter, markSearchInteracted, } = renderActions({ selection: { kind: "issue", source: "preset", id: "assigned" } }, store); await act(async () => result.current.onConfirmSave("Assigned in Kandev", REPO)); expect(useSavedPresets).not.toHaveBeenCalled(); expect(markSearchInteracted).toHaveBeenCalledOnce(); expect(save).toHaveBeenCalledWith({ kind: "issue", label: "Assigned in Kandev", customQuery: QUERY, repoFilter: REPO, }); expect(save.mock.invocationCallOrder[0]).toBeLessThan( markSearchInteracted.mock.invocationCallOrder[0], ); expect(setProgrammaticSelection).toHaveBeenCalledWith({ kind: "issue", source: "saved", id: savedPreset.id, }); expect(setRepoFilter).toHaveBeenCalledWith(REPO); expect(setQueryImmediate).toHaveBeenCalledWith(QUERY); expect(result.current.savedPresets).toEqual([savedPreset]); }); it("leaves selection and repository unchanged when saving returns null", async () => { const save = vi.fn(async () => null); const { result, setProgrammaticSelection, setQueryImmediate, setRepoFilter, markSearchInteracted, } = renderActions({}, makeStore({ save })); await act(async () => result.current.onConfirmSave("Unavailable", REPO)); expect(markSearchInteracted).not.toHaveBeenCalled(); expect(save).toHaveBeenCalledWith({ kind: "issue", label: "Unavailable", customQuery: QUERY, repoFilter: REPO, }); expect(setProgrammaticSelection).not.toHaveBeenCalled(); expect(setQueryImmediate).not.toHaveBeenCalled(); expect(setRepoFilter).not.toHaveBeenCalled(); }); it("reports a save persistence failure without selecting the optimistic preset", async () => { const failure = Promise.reject(new Error("settings down")); void failure.catch(() => undefined); const save = vi.fn(() => failure); const { result, setProgrammaticSelection, markSearchInteracted } = renderActions( {}, makeStore({ save }), ); await act(async () => result.current.onConfirmSave("Unavailable", REPO)); expect(mockToast).toHaveBeenCalledWith({ description: "Failed to save saved query", variant: "error", }); expect(setProgrammaticSelection).not.toHaveBeenCalled(); expect(markSearchInteracted).not.toHaveBeenCalled(); }); }); describe("useSavedPresetActions delete actions", () => { it("ignores a completed delete after changing workspace", expectStaleDeleteOutcomeIgnored); it("deletes the active saved query and selects the first same-kind preset", async () => { const remove = vi.fn(async () => true); const { result, setProgrammaticSelection, setQueryImmediate, setRepoFilter, markSearchInteracted, } = renderActions({}, makeStore({ presets: [savedPreset], remove })); await act(async () => result.current.onDeleteSaved(savedPreset.id)); expect(markSearchInteracted).toHaveBeenCalledOnce(); expect(remove).toHaveBeenCalledWith(savedPreset.id); expect(remove.mock.invocationCallOrder[0]).toBeLessThan( markSearchInteracted.mock.invocationCallOrder[0], ); expect(remove.mock.invocationCallOrder[0]).toBeLessThan( setProgrammaticSelection.mock.invocationCallOrder[0], ); expect(setProgrammaticSelection).toHaveBeenCalledWith({ kind: "issue", source: "preset", id: issuePreset.value, }); expect(setQueryImmediate).toHaveBeenCalledWith(issuePreset.filter); expect(setRepoFilter).toHaveBeenCalledWith(""); }); it( "uses the latest preset catalog when an active deletion finishes", expectLatestPresetCatalogAfterDelete, ); it("deletes an inactive saved query without changing the active search", async () => { const remove = vi.fn(async () => true); const { result, setProgrammaticSelection, setQueryImmediate, setRepoFilter, markSearchInteracted, } = renderActions( { selection: { kind: "issue", source: "saved", id: "saved-active" } }, makeStore({ presets: [savedPreset], remove }), ); await act(async () => result.current.onDeleteSaved(savedPreset.id)); expect(markSearchInteracted).toHaveBeenCalledOnce(); expect(remove).toHaveBeenCalledWith(savedPreset.id); expect(setProgrammaticSelection).not.toHaveBeenCalled(); expect(setQueryImmediate).not.toHaveBeenCalled(); expect(setRepoFilter).not.toHaveBeenCalled(); }); it("does not mark search interaction when the saved query no longer exists", async () => { const remove = vi.fn(async () => false); const { result, markSearchInteracted } = renderActions({}, makeStore({ remove })); await act(async () => result.current.onDeleteSaved(savedPreset.id)); expect(markSearchInteracted).not.toHaveBeenCalled(); }); it("reports a delete persistence failure", async () => { const failure = Promise.reject(new Error("settings down")); void failure.catch(() => undefined); const remove = vi.fn(() => failure); const { result, setProgrammaticSelection, setQueryImmediate, setRepoFilter, markSearchInteracted, } = renderActions({}, makeStore({ remove })); await act(async () => result.current.onDeleteSaved(savedPreset.id)); expect(mockToast).toHaveBeenCalledWith({ description: "Failed to delete saved query", variant: "error", }); expect(setProgrammaticSelection).not.toHaveBeenCalled(); expect(setQueryImmediate).not.toHaveBeenCalled(); expect(setRepoFilter).not.toHaveBeenCalled(); expect(markSearchInteracted).not.toHaveBeenCalled(); }); }); describe("useSavedPresetActions default deletion", () => { it("deletes an inactive default without changing the active search", async () => { const inactiveDefault = { ...savedPreset, isDefault: true }; const remove = vi.fn(async () => true); const { result, setProgrammaticSelection, setQueryImmediate, setRepoFilter, markSearchInteracted, } = renderActions( { selection: { kind: "issue", source: "saved", id: "saved-active" } }, makeStore({ presets: [inactiveDefault], remove }), ); await act(async () => result.current.onDeleteSaved(inactiveDefault.id)); expect(markSearchInteracted).toHaveBeenCalledOnce(); expect(remove).toHaveBeenCalledWith(inactiveDefault.id); expect(setProgrammaticSelection).not.toHaveBeenCalled(); expect(setQueryImmediate).not.toHaveBeenCalled(); expect(setRepoFilter).not.toHaveBeenCalled(); }); }); describe("useSavedPresetActions default actions", () => { it("scopes pending default toggles to their workspace", expectDefaultPendingScopedToWorkspace); it( "ignores a completed default toggle after changing workspace", expectStaleToggleDefaultOutcomeIgnored, ); it("keeps action handlers stable across unchanged renders", () => { const { result, rerender } = renderActions(); const initialConfirm = result.current.onConfirmSave; const initialDelete = result.current.onDeleteSaved; const initialToggle = result.current.onToggleSavedDefault; rerender(); expect(result.current.onConfirmSave).toBe(initialConfirm); expect(result.current.onDeleteSaved).toBe(initialDelete); expect(result.current.onToggleSavedDefault).toBe(initialToggle); }); it("does not mark search interaction when the default mutation is unavailable", async () => { const { result, markSearchInteracted } = renderActions(); await act(async () => { await result.current.onToggleSavedDefault(savedPreset); }); expect(markSearchInteracted).not.toHaveBeenCalled(); expect(result.current.defaultMutationPendingId).toBeNull(); }); it("ignores a second default toggle while the first is pending", async () => { let finish!: () => void; const setDefault = vi.fn( () => new Promise((resolve) => { finish = () => resolve(true); }), ); const { result } = renderActions({}, makeStore({ presets: [savedPreset], setDefault })); const toggle = result.current.onToggleSavedDefault; let firstMutation!: Promise; let secondMutation!: Promise; act(() => { firstMutation = toggle(savedPreset); secondMutation = toggle(savedPreset); }); expect(setDefault).toHaveBeenCalledOnce(); await act(async () => { finish(); await Promise.all([firstMutation, secondMutation]); }); }); it("sets a future default without changing the current search and exposes pending state", async () => { let finish!: () => void; const setDefault = vi.fn( () => new Promise((resolve) => { finish = () => resolve(true); }), ); const store = makeStore({ presets: [savedPreset], setDefault }); const { result, setProgrammaticSelection, setQueryImmediate, setRepoFilter, markSearchInteracted, } = renderActions({}, store); let mutation!: Promise; act(() => { mutation = result.current.onToggleSavedDefault(savedPreset); }); expect(markSearchInteracted).not.toHaveBeenCalled(); expect(setDefault).toHaveBeenCalledWith("issue", savedPreset.id); expect(result.current.defaultMutationPendingId).toBe(savedPreset.id); expect(setProgrammaticSelection).not.toHaveBeenCalled(); expect(setQueryImmediate).not.toHaveBeenCalled(); expect(setRepoFilter).not.toHaveBeenCalled(); await act(async () => { finish(); await mutation; }); expect(markSearchInteracted).toHaveBeenCalledOnce(); expect(result.current.defaultMutationPendingId).toBeNull(); }); it("clears an existing default and reports persistence failure", async () => { const currentDefault = { ...savedPreset, isDefault: true }; const setDefault = vi.fn(async () => { throw new Error("settings down"); }); const { result, markSearchInteracted } = renderActions( {}, makeStore({ presets: [currentDefault], setDefault }), ); await act(async () => { await result.current.onToggleSavedDefault(currentDefault); }); expect(setDefault).toHaveBeenCalledWith("issue", null); expect(mockToast).toHaveBeenCalledWith({ description: "Failed to update default view", variant: "error", }); expect(markSearchInteracted).not.toHaveBeenCalled(); expect(result.current.defaultMutationPendingId).toBeNull(); }); });