From fa9a1403094e996540acd5af93f51200dba3a0a6 Mon Sep 17 00:00:00 2001 From: "erwin.bause" <103743786+BauZee@users.noreply.github.com> Date: Fri, 29 May 2026 22:26:25 +0200 Subject: [PATCH 1/6] feat: add ResourceState type and state field to UserSearchFilter --- src/data/internal/common/ResourceState.ts | 14 ++++++++++++++ .../internal/search-filter/UserSearchFilter.ts | 9 +++++++++ 2 files changed, 23 insertions(+) create mode 100644 src/data/internal/common/ResourceState.ts diff --git a/src/data/internal/common/ResourceState.ts b/src/data/internal/common/ResourceState.ts new file mode 100644 index 00000000..4daa7ab2 --- /dev/null +++ b/src/data/internal/common/ResourceState.ts @@ -0,0 +1,14 @@ +const RESOURCE_STATES = ["ACTIVE", "INACTIVE_BY_USER", "INACTIVE_BY_RESTRICTED_PLAN"] as const; +export type ResourceState = (typeof RESOURCE_STATES)[number]; +export type PatchResourceState = "ACTIVE" | "INACTIVE_BY_USER"; + +export function parseResourceState(state?: string | null): ResourceState { + switch (state) { + case "ACTIVE": + case "INACTIVE_BY_USER": + case "INACTIVE_BY_RESTRICTED_PLAN": + return state; + default: + return "ACTIVE"; + } +} diff --git a/src/data/internal/search-filter/UserSearchFilter.ts b/src/data/internal/search-filter/UserSearchFilter.ts index 930e4d33..342025b0 100644 --- a/src/data/internal/search-filter/UserSearchFilter.ts +++ b/src/data/internal/search-filter/UserSearchFilter.ts @@ -8,6 +8,11 @@ import type { SearchFilterArguments } from "@/data/internal/search/SearchFilterA import { parseProductState, mapToBackendState } from "@/data/internal/product/ProductState.ts"; import { parseShopType, mapToBackendShopType } from "@/data/internal/shop/ShopType.ts"; import { FILTER_DEFAULTS } from "@/lib/filterDefaults.ts"; +import { + parseResourceState, + type ResourceState, + type PatchResourceState, +} from "@/data/internal/common/ResourceState.ts"; export type UserSearchFilter = { readonly userId: string; @@ -15,6 +20,7 @@ export type UserSearchFilter = { readonly name: string; readonly enhancedSearchDescription?: string; readonly notifications: boolean; + readonly state: ResourceState; readonly search: SearchFilterArguments; readonly created: Date; readonly updated: Date; @@ -30,6 +36,7 @@ export type UserSearchFilterPatchData = { readonly name?: string; readonly enhancedSearchDescription?: string | null; readonly notifications?: boolean; + readonly state?: PatchResourceState; readonly search?: SearchFilterArguments; }; @@ -106,6 +113,7 @@ export function mapToInternalUserSearchFilter(data: UserSearchFilterData): UserS name: data.name, enhancedSearchDescription: data.enhancedSearchDescription, notifications: data.notifications, + state: parseResourceState(data.state), search: mapProductSearchDataToSearchFilterArguments(data.search), created: new Date(data.created), updated: new Date(data.updated), @@ -148,6 +156,7 @@ export function mapToBackendPatchUserSearchFilter( name: data.name, enhancedSearchDescription: data.enhancedSearchDescription, notifications: data.notifications, + state: data.state, search: data.search ? mapSearchFilterArgumentsToProductSearchData(data.search) : undefined, }; } From 8bddce7d4f96b3dfefce2de17144d51fe8201396 Mon Sep 17 00:00:00 2001 From: "erwin.bause" <103743786+BauZee@users.noreply.github.com> Date: Fri, 29 May 2026 22:26:41 +0200 Subject: [PATCH 2/6] feat: add useWatchlistStateMutation hook --- .../watchlist/useWatchlistStateMutation.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/hooks/watchlist/useWatchlistStateMutation.ts diff --git a/src/hooks/watchlist/useWatchlistStateMutation.ts b/src/hooks/watchlist/useWatchlistStateMutation.ts new file mode 100644 index 00000000..b47e965c --- /dev/null +++ b/src/hooks/watchlist/useWatchlistStateMutation.ts @@ -0,0 +1,41 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { patchWatchlistProduct } from "@/client"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { useApiError } from "@/hooks/common/useApiError.ts"; +import { mapToInternalApiError } from "@/data/internal/hooks/ApiError.ts"; + +export function useWatchlistStateMutation(shopId: string, shopsProductId: string) { + const queryClient = useQueryClient(); + const { getErrorMessage } = useApiError(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (active: boolean) => { + const result = await patchWatchlistProduct({ + path: { shopId, shopsProductId }, + body: { state: active ? "ACTIVE" : "INACTIVE_BY_USER" }, + }); + + if (result.error) { + if (result.response?.status === 401) { + toast.info(t("watchlist.loginRequired")); + return; + } + throw new Error(getErrorMessage(mapToInternalApiError(result.error))); + } + + return result.data; + }, + onError: (e) => { + console.error("Error mutating watchlist state:", e); + toast.error(e.message || t("watchlist.loadingError")); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["watchlist"] }), + queryClient.invalidateQueries({ queryKey: ["search"] }), + ]); + }, + }); +} From 065dd53a3fc144039a13870bff953cc9d583d34b Mon Sep 17 00:00:00 2001 From: "erwin.bause" <103743786+BauZee@users.noreply.github.com> Date: Fri, 29 May 2026 22:27:48 +0200 Subject: [PATCH 3/6] feat: add toggles UI to SearchFilterCard --- .../search-filters/SearchFilterCard.tsx | 107 +++++++++++++++--- src/i18n/locales/de/translation.json | 6 + src/i18n/locales/en/translation.json | 6 + 3 files changed, 105 insertions(+), 14 deletions(-) diff --git a/src/components/search-filters/SearchFilterCard.tsx b/src/components/search-filters/SearchFilterCard.tsx index 1958599b..3921b118 100644 --- a/src/components/search-filters/SearchFilterCard.tsx +++ b/src/components/search-filters/SearchFilterCard.tsx @@ -1,5 +1,15 @@ import { useTranslation } from "react-i18next"; -import { Bell, BellRing, Copy, ScanSearch, Search, Settings2, Trash2 } from "lucide-react"; +import { + Bell, + BellRing, + Copy, + Pause, + Play, + ScanSearch, + Search, + Settings2, + Trash2, +} from "lucide-react"; import { Badge } from "@/components/ui/badge.tsx"; import { Button } from "@/components/ui/button.tsx"; import { Card } from "@/components/ui/card.tsx"; @@ -61,20 +71,31 @@ export function SearchFilterCard({ ? t("searchFilters.notificationsOn") : t("searchFilters.notificationsOff"); + const isActive = filter.state === "ACTIVE"; + const isRestrictedByPlan = filter.state === "INACTIVE_BY_RESTRICTED_PLAN"; + const stateToggleLabel = isActive ? t("searchFilters.deactivate") : t("searchFilters.activate"); + return (
-
-

{filter.name}

- +

{filter.name}

+
+ {intlFormatDistance(filter.created, new Date(), { locale: i18n.language, })} + {filter.state === "INACTIVE_BY_USER" && ( + + {t("searchFilters.stateInactiveByUser")} + + )} + {isRestrictedByPlan && ( + + {t("searchFilters.stateInactiveByPlan")} + + )}
{search.q && (

@@ -88,6 +109,38 @@ export function SearchFilterCard({ )}

+ + + + + + {isRestrictedByPlan + ? t("searchFilters.stateInactiveByPlanTooltip") + : stateToggleLabel} + + - + + + + + + + {isRestrictedByPlan && ( + + {t("searchFilters.matchingProductsInactive")} + + )} +
); diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 4e2f3199..e7be8e6a 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -1310,6 +1310,11 @@ }, "notificationsOn": "Benachrichtigungen aktiv", "notificationsOff": "Keine Benachrichtigungen", + "activate": "Aktivieren", + "deactivate": "Pausieren", + "stateInactiveByUser": "Pausiert", + "stateInactiveByPlan": "Gesperrt", + "stateInactiveByPlanTooltip": "Dieser Suchauftrag ist aufgrund Ihres Abonnements inaktiv.", "loadingError": "Die Suchaufträge konnten nicht geladen werden. Bitte versuchen Sie es später erneut.", "noResults": { "title": "Keine Suchaufträge", @@ -1323,6 +1328,7 @@ "totalElements_one": "{{count}} Suchauftrag", "totalElements_other": "{{count}} Suchaufträge", "matchingProducts": "Alle Suchtreffer anzeigen", + "matchingProductsInactive": "Suchauftrag ist inaktiv – keine Treffer verfügbar.", "searchPlaceholder": "Suchaufträge durchsuchen …", "showDetails": "Details anzeigen", "showResults": "Jetzt suchen", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 03220b4b..1cfb0aae 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -1308,6 +1308,11 @@ }, "notificationsOn": "Notifications active", "notificationsOff": "Notifications inactive", + "activate": "Activate", + "deactivate": "Pause", + "stateInactiveByUser": "Paused", + "stateInactiveByPlan": "Locked", + "stateInactiveByPlanTooltip": "This search filter is inactive due to your subscription plan.", "loadingError": "Search filters could not be loaded. Please try again later.", "noResults": { "title": "No Search Filters", @@ -1320,6 +1325,7 @@ }, "totalElements": "{{count}} search filter(s)", "matchingProducts": "Show all matches", + "matchingProductsInactive": "Search filter is inactive – no matches available.", "searchPlaceholder": "Search filters...", "showDetails": "Show details", "showResults": "Search now", From 9f6fecaa4e1a5c414bc13ae14df398b4e41c80d6 Mon Sep 17 00:00:00 2001 From: "erwin.bause" <103743786+BauZee@users.noreply.github.com> Date: Fri, 29 May 2026 22:30:05 +0200 Subject: [PATCH 4/6] test: add and extend tests --- .../CreateSearchFilterWizard.test.tsx | 1 + .../__tests__/SearchFilterCard.test.tsx | 122 +++++++++++++++ .../common/__tests__/ResourceState.test.ts | 30 ++++ .../__tests__/UserSearchFilter.test.ts | 31 ++++ .../useWatchlistStateMutation.test.ts | 147 ++++++++++++++++++ 5 files changed, 331 insertions(+) create mode 100644 src/data/internal/common/__tests__/ResourceState.test.ts create mode 100644 src/hooks/watchlist/__tests__/useWatchlistStateMutation.test.ts diff --git a/src/components/search-filters/__tests__/CreateSearchFilterWizard.test.tsx b/src/components/search-filters/__tests__/CreateSearchFilterWizard.test.tsx index d0ff29d4..7ae7a489 100644 --- a/src/components/search-filters/__tests__/CreateSearchFilterWizard.test.tsx +++ b/src/components/search-filters/__tests__/CreateSearchFilterWizard.test.tsx @@ -67,6 +67,7 @@ const mockFilter: UserSearchFilter = { id: "filter-1", name: "Barock Möbel", notifications: false, + state: "ACTIVE", search: { q: "Tisch" }, created: new Date("2024-01-01"), updated: new Date("2024-03-01"), diff --git a/src/components/search-filters/__tests__/SearchFilterCard.test.tsx b/src/components/search-filters/__tests__/SearchFilterCard.test.tsx index d7283618..d79cee86 100644 --- a/src/components/search-filters/__tests__/SearchFilterCard.test.tsx +++ b/src/components/search-filters/__tests__/SearchFilterCard.test.tsx @@ -26,6 +26,7 @@ const mockFilter: UserSearchFilter = { id: "filter-1", name: "Barock Möbel", notifications: false, + state: "ACTIVE", search: { q: "Tisch" }, created: new Date("2024-01-01T00:00:00Z"), updated: new Date("2024-03-01T00:00:00Z"), @@ -157,4 +158,125 @@ describe("SearchFilterCard", () => { }), ); }); + + describe("resource state", () => { + it("renders pause button when filter is ACTIVE", async () => { + await act(() => { + renderWithRouter(); + }); + expect(screen.getByRole("button", { name: /Pausieren/i })).toBeInTheDocument(); + }); + + it("renders activate button when filter is INACTIVE_BY_USER", async () => { + const filter = { ...mockFilter, state: "INACTIVE_BY_USER" as const }; + await act(() => { + renderWithRouter(); + }); + expect(screen.getByRole("button", { name: /Aktivieren/i })).toBeInTheDocument(); + }); + + it("shows Pausiert badge when filter is INACTIVE_BY_USER", async () => { + const filter = { ...mockFilter, state: "INACTIVE_BY_USER" as const }; + await act(() => { + renderWithRouter(); + }); + expect(screen.getByText("Pausiert")).toBeInTheDocument(); + }); + + it("shows Gesperrt badge when filter is INACTIVE_BY_RESTRICTED_PLAN", async () => { + const filter = { ...mockFilter, state: "INACTIVE_BY_RESTRICTED_PLAN" as const }; + await act(() => { + renderWithRouter(); + }); + expect(screen.getByText("Gesperrt")).toBeInTheDocument(); + }); + + it("disables state toggle when INACTIVE_BY_RESTRICTED_PLAN", async () => { + const filter = { ...mockFilter, state: "INACTIVE_BY_RESTRICTED_PLAN" as const }; + await act(() => { + renderWithRouter(); + }); + expect(screen.getByRole("button", { name: /Aktivieren/i })).toBeDisabled(); + }); + + it("calls mutate with INACTIVE_BY_USER when pausing an ACTIVE filter", async () => { + await act(() => { + renderWithRouter(); + }); + const pauseBtn = screen.getByRole("button", { name: /Pausieren/i }); + await act(() => { + fireEvent.click(pauseBtn); + }); + expect(mockUpdateMutate).toHaveBeenCalledWith( + expect.objectContaining({ + id: "filter-1", + patch: { state: "INACTIVE_BY_USER" }, + }), + ); + }); + + it("calls mutate with ACTIVE when activating an INACTIVE_BY_USER filter", async () => { + const filter = { ...mockFilter, state: "INACTIVE_BY_USER" as const }; + await act(() => { + renderWithRouter(); + }); + const activateBtn = screen.getByRole("button", { name: /Aktivieren/i }); + await act(() => { + fireEvent.click(activateBtn); + }); + expect(mockUpdateMutate).toHaveBeenCalledWith( + expect.objectContaining({ + id: "filter-1", + patch: { state: "ACTIVE" }, + }), + ); + }); + + it("shows no state badge when filter is ACTIVE", async () => { + await act(() => { + renderWithRouter(); + }); + expect(screen.queryByText("Pausiert")).not.toBeInTheDocument(); + expect(screen.queryByText("Gesperrt")).not.toBeInTheDocument(); + }); + + it("enables matching products button when filter is INACTIVE_BY_USER", async () => { + const filter = { ...mockFilter, state: "INACTIVE_BY_USER" as const }; + await act(() => { + renderWithRouter(); + }); + expect( + screen.getByRole("link", { name: /Alle Suchtreffer anzeigen/i }), + ).toBeInTheDocument(); + }); + + it("disables matching products button when filter is INACTIVE_BY_RESTRICTED_PLAN", async () => { + const filter = { ...mockFilter, state: "INACTIVE_BY_RESTRICTED_PLAN" as const }; + await act(() => { + renderWithRouter(); + }); + expect( + screen.getByRole("button", { name: /Alle Suchtreffer anzeigen/i }), + ).toBeDisabled(); + }); + + it("disables bell button when filter is INACTIVE_BY_USER", async () => { + const filter = { ...mockFilter, state: "INACTIVE_BY_USER" as const }; + await act(() => { + renderWithRouter(); + }); + expect( + screen.getByRole("button", { name: /Keine Benachrichtigungen/i }), + ).toBeDisabled(); + }); + + it("enables matching products button when filter is ACTIVE", async () => { + await act(() => { + renderWithRouter(); + }); + expect( + screen.getByRole("link", { name: /Alle Suchtreffer anzeigen/i }), + ).toBeInTheDocument(); + }); + }); }); diff --git a/src/data/internal/common/__tests__/ResourceState.test.ts b/src/data/internal/common/__tests__/ResourceState.test.ts new file mode 100644 index 00000000..8382b034 --- /dev/null +++ b/src/data/internal/common/__tests__/ResourceState.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { parseResourceState } from "../ResourceState.ts"; + +describe("parseResourceState", () => { + it("returns ACTIVE for 'ACTIVE'", () => { + expect(parseResourceState("ACTIVE")).toBe("ACTIVE"); + }); + + it("returns INACTIVE_BY_USER for 'INACTIVE_BY_USER'", () => { + expect(parseResourceState("INACTIVE_BY_USER")).toBe("INACTIVE_BY_USER"); + }); + + it("returns INACTIVE_BY_RESTRICTED_PLAN for 'INACTIVE_BY_RESTRICTED_PLAN'", () => { + expect(parseResourceState("INACTIVE_BY_RESTRICTED_PLAN")).toBe( + "INACTIVE_BY_RESTRICTED_PLAN", + ); + }); + + it("returns ACTIVE for undefined", () => { + expect(parseResourceState(undefined)).toBe("ACTIVE"); + }); + + it("returns ACTIVE for null", () => { + expect(parseResourceState(null)).toBe("ACTIVE"); + }); + + it("returns ACTIVE for unknown string", () => { + expect(parseResourceState("UNKNOWN")).toBe("ACTIVE"); + }); +}); diff --git a/src/data/internal/search-filter/__tests__/UserSearchFilter.test.ts b/src/data/internal/search-filter/__tests__/UserSearchFilter.test.ts index e44794ef..e036e2b9 100644 --- a/src/data/internal/search-filter/__tests__/UserSearchFilter.test.ts +++ b/src/data/internal/search-filter/__tests__/UserSearchFilter.test.ts @@ -66,6 +66,27 @@ describe("mapToInternalUserSearchFilter", () => { const result = mapToInternalUserSearchFilter(baseFilterData); expect(result.enhancedSearchDescription).toBeUndefined(); }); + + it("maps state ACTIVE", () => { + const result = mapToInternalUserSearchFilter({ ...baseFilterData, state: "ACTIVE" }); + expect(result.state).toBe("ACTIVE"); + }); + + it("maps state INACTIVE_BY_USER", () => { + const result = mapToInternalUserSearchFilter({ + ...baseFilterData, + state: "INACTIVE_BY_USER", + }); + expect(result.state).toBe("INACTIVE_BY_USER"); + }); + + it("maps state INACTIVE_BY_RESTRICTED_PLAN", () => { + const result = mapToInternalUserSearchFilter({ + ...baseFilterData, + state: "INACTIVE_BY_RESTRICTED_PLAN", + }); + expect(result.state).toBe("INACTIVE_BY_RESTRICTED_PLAN"); + }); }); describe("mapProductSearchDataToSearchFilterArguments", () => { @@ -170,6 +191,16 @@ describe("mapToBackendPatchUserSearchFilter", () => { const result = mapToBackendPatchUserSearchFilter({ search: { q: "Lampe" } }); expect(result.search?.productQuery).toBe("Lampe"); }); + + it("maps state when provided", () => { + const result = mapToBackendPatchUserSearchFilter({ state: "INACTIVE_BY_USER" }); + expect(result.state).toBe("INACTIVE_BY_USER"); + }); + + it("omits state when not provided", () => { + const result = mapToBackendPatchUserSearchFilter({ name: "Test" }); + expect(result.state).toBeUndefined(); + }); }); describe("isDefaultOrEmpty behaviour in mapToBackendCreateUserSearchFilter", () => { diff --git a/src/hooks/watchlist/__tests__/useWatchlistStateMutation.test.ts b/src/hooks/watchlist/__tests__/useWatchlistStateMutation.test.ts new file mode 100644 index 00000000..79b2578d --- /dev/null +++ b/src/hooks/watchlist/__tests__/useWatchlistStateMutation.test.ts @@ -0,0 +1,147 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useWatchlistStateMutation } from "../useWatchlistStateMutation.ts"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement } from "react"; + +const mockPatchWatchlistProduct = vi.hoisted(() => vi.fn()); +const mockGetErrorMessage = vi.hoisted(() => vi.fn()); +const mockToast = vi.hoisted(() => ({ + info: vi.fn(), + error: vi.fn(), +})); + +vi.mock("@/client", () => ({ + patchWatchlistProduct: mockPatchWatchlistProduct, +})); + +vi.mock("sonner", () => ({ + toast: mockToast, +})); + +vi.mock("@/hooks/common/useApiError.ts", () => ({ + useApiError: () => ({ + getErrorMessage: mockGetErrorMessage, + }), +})); + +vi.mock("@/data/internal/hooks/ApiError.ts", () => ({ + mapToInternalApiError: (error: unknown) => error, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe("useWatchlistStateMutation", () => { + let queryClient: QueryClient; + const shopId = "test-shop-id"; + const shopsProductId = "test-product-id"; + + const createWrapper = () => { + return ({ children }: { children: React.ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children); + }; + + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + mockGetErrorMessage.mockImplementation((error) => error?.message || "Unknown error"); + }); + + it("sends ACTIVE when activating", async () => { + mockPatchWatchlistProduct.mockResolvedValue({ data: {}, error: null }); + + const { result } = renderHook(() => useWatchlistStateMutation(shopId, shopsProductId), { + wrapper: createWrapper(), + }); + + result.current.mutate(true); + + await waitFor(() => { + expect(mockPatchWatchlistProduct).toHaveBeenCalledWith({ + path: { shopId, shopsProductId }, + body: { state: "ACTIVE" }, + }); + }); + }); + + it("sends INACTIVE_BY_USER when deactivating", async () => { + mockPatchWatchlistProduct.mockResolvedValue({ data: {}, error: null }); + + const { result } = renderHook(() => useWatchlistStateMutation(shopId, shopsProductId), { + wrapper: createWrapper(), + }); + + result.current.mutate(false); + + await waitFor(() => { + expect(mockPatchWatchlistProduct).toHaveBeenCalledWith({ + path: { shopId, shopsProductId }, + body: { state: "INACTIVE_BY_USER" }, + }); + }); + }); + + it("shows info toast on 401", async () => { + mockPatchWatchlistProduct.mockResolvedValue({ + data: null, + error: { message: "Unauthorized" }, + response: { status: 401 }, + }); + + const { result } = renderHook(() => useWatchlistStateMutation(shopId, shopsProductId), { + wrapper: createWrapper(), + }); + + result.current.mutate(true); + + await waitFor(() => { + expect(mockToast.info).toHaveBeenCalledWith("watchlist.loginRequired"); + }); + }); + + it("invalidates watchlist and search queries on success", async () => { + mockPatchWatchlistProduct.mockResolvedValue({ data: {}, error: null }); + + const invalidateQueriesSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const { result } = renderHook(() => useWatchlistStateMutation(shopId, shopsProductId), { + wrapper: createWrapper(), + }); + + result.current.mutate(true); + + await waitFor(() => { + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ["watchlist"] }); + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ["search"] }); + }); + }); + + it("shows error toast on failure", async () => { + const errorMessage = "Server error"; + mockPatchWatchlistProduct.mockResolvedValue({ + data: null, + error: { message: errorMessage }, + response: { status: 500 }, + }); + mockGetErrorMessage.mockReturnValue(errorMessage); + + const { result } = renderHook(() => useWatchlistStateMutation(shopId, shopsProductId), { + wrapper: createWrapper(), + }); + + result.current.mutate(true); + + await waitFor(() => { + expect(mockToast.error).toHaveBeenCalled(); + }); + }); +}); From b777f53432fe9c432f6bae75d8dc97e69679c02a Mon Sep 17 00:00:00 2001 From: "erwin.bause" <103743786+BauZee@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:44:17 +0200 Subject: [PATCH 5/6] fix: allow reactivation of system-restricted search filters --- src/components/search-filters/SearchFilterCard.tsx | 8 ++------ src/hooks/search-filters/useUpdateUserSearchFilter.ts | 4 ++++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/search-filters/SearchFilterCard.tsx b/src/components/search-filters/SearchFilterCard.tsx index 3921b118..31bbca4e 100644 --- a/src/components/search-filters/SearchFilterCard.tsx +++ b/src/components/search-filters/SearchFilterCard.tsx @@ -117,7 +117,7 @@ export function SearchFilterCard({ size="icon" className="size-10 text-muted-foreground" aria-label={stateToggleLabel} - disabled={updateFilter.isPending || isRestrictedByPlan} + disabled={updateFilter.isPending} onClick={() => updateFilter.mutate({ id: filter.id, @@ -135,11 +135,7 @@ export function SearchFilterCard({
- - {isRestrictedByPlan - ? t("searchFilters.stateInactiveByPlanTooltip") - : stateToggleLabel} - + {stateToggleLabel} diff --git a/src/hooks/search-filters/useUpdateUserSearchFilter.ts b/src/hooks/search-filters/useUpdateUserSearchFilter.ts index 9586d7f0..7453ce9e 100644 --- a/src/hooks/search-filters/useUpdateUserSearchFilter.ts +++ b/src/hooks/search-filters/useUpdateUserSearchFilter.ts @@ -1,4 +1,5 @@ import { updateUserSearchFilter } from "@/client"; +import { toast } from "sonner"; import { mapToBackendPatchUserSearchFilter, mapToInternalUserSearchFilter, @@ -39,5 +40,8 @@ export function useUpdateUserSearchFilter(): UseMutationResult< queryClient.invalidateQueries({ queryKey: ["userSearchFilters"] }); queryClient.invalidateQueries({ queryKey: ["userSearchFilter", data.id] }); }, + onError: (error) => { + toast.error(error.message); + }, }); } From c42b501e619d29b1e5ffb3f328f9a3bc51999e9d Mon Sep 17 00:00:00 2001 From: "erwin.bause" <103743786+BauZee@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:44:49 +0200 Subject: [PATCH 6/6] test: update and extend tests for search filter reactivation --- .../__tests__/SearchFilterCard.test.tsx | 20 +++++++++++++++++-- .../useUpdateUserSearchFilter.test.ts | 16 +++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/components/search-filters/__tests__/SearchFilterCard.test.tsx b/src/components/search-filters/__tests__/SearchFilterCard.test.tsx index d79cee86..815e28b6 100644 --- a/src/components/search-filters/__tests__/SearchFilterCard.test.tsx +++ b/src/components/search-filters/__tests__/SearchFilterCard.test.tsx @@ -191,12 +191,28 @@ describe("SearchFilterCard", () => { expect(screen.getByText("Gesperrt")).toBeInTheDocument(); }); - it("disables state toggle when INACTIVE_BY_RESTRICTED_PLAN", async () => { + it("enables state toggle when INACTIVE_BY_RESTRICTED_PLAN", async () => { const filter = { ...mockFilter, state: "INACTIVE_BY_RESTRICTED_PLAN" as const }; await act(() => { renderWithRouter(); }); - expect(screen.getByRole("button", { name: /Aktivieren/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /Aktivieren/i })).not.toBeDisabled(); + }); + + it("calls mutate with ACTIVE when activating an INACTIVE_BY_RESTRICTED_PLAN filter", async () => { + const filter = { ...mockFilter, state: "INACTIVE_BY_RESTRICTED_PLAN" as const }; + await act(() => { + renderWithRouter(); + }); + await act(() => { + fireEvent.click(screen.getByRole("button", { name: /Aktivieren/i })); + }); + expect(mockUpdateMutate).toHaveBeenCalledWith( + expect.objectContaining({ + id: "filter-1", + patch: { state: "ACTIVE" }, + }), + ); }); it("calls mutate with INACTIVE_BY_USER when pausing an ACTIVE filter", async () => { diff --git a/src/hooks/search-filters/__tests__/useUpdateUserSearchFilter.test.ts b/src/hooks/search-filters/__tests__/useUpdateUserSearchFilter.test.ts index 56c62735..36653238 100644 --- a/src/hooks/search-filters/__tests__/useUpdateUserSearchFilter.test.ts +++ b/src/hooks/search-filters/__tests__/useUpdateUserSearchFilter.test.ts @@ -6,12 +6,14 @@ import { createElement } from "react"; const mockUpdateUserSearchFilter = vi.hoisted(() => vi.fn()); const mockGetErrorMessage = vi.hoisted(() => vi.fn(() => "Fehler")); const mockInvalidateQueries = vi.hoisted(() => vi.fn()); +const mockToastError = vi.hoisted(() => vi.fn()); vi.mock("@/client", () => ({ updateUserSearchFilter: mockUpdateUserSearchFilter })); vi.mock("@/hooks/common/useApiError", () => ({ useApiError: () => ({ getErrorMessage: mockGetErrorMessage }), })); vi.mock("@/data/internal/hooks/ApiError", () => ({ mapToInternalApiError: (e: unknown) => e })); +vi.mock("sonner", () => ({ toast: { error: mockToastError } })); vi.mock("@tanstack/react-query", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) }; @@ -108,4 +110,18 @@ describe("useUpdateUserSearchFilter", () => { await waitFor(() => expect(result.current.isError).toBe(true)); }); + + it("shows error toast when API returns error", async () => { + mockUpdateUserSearchFilter.mockResolvedValue({ data: null, error: { status: 409 } }); + + const { result } = renderHook(() => useUpdateUserSearchFilter(), { + wrapper: createWrapper(), + }); + + await act(async () => { + result.current.mutate({ id: "filter-1", patch: { state: "ACTIVE" } }); + }); + + await waitFor(() => expect(mockToastError).toHaveBeenCalledWith("Fehler")); + }); });