diff --git a/apps/frontend/src/components/MaintenanceBanner/index.tsx b/apps/frontend/src/components/MaintenanceBanner/index.tsx index 7e3d62e16..382b980e3 100644 --- a/apps/frontend/src/components/MaintenanceBanner/index.tsx +++ b/apps/frontend/src/components/MaintenanceBanner/index.tsx @@ -40,7 +40,7 @@ export const MaintenanceBanner: FC = () => { }; return ( -
+
diff --git a/apps/frontend/src/hooks/useMaintenanceStatus.test.ts b/apps/frontend/src/hooks/useMaintenanceStatus.test.ts new file mode 100644 index 000000000..92ccfa005 --- /dev/null +++ b/apps/frontend/src/hooks/useMaintenanceStatus.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { useMaintenanceStore } from "../stores/maintenanceStore"; +import { useMaintenanceStatus } from "./useMaintenanceStatus"; + +const { getMaintenanceStatus } = vi.hoisted(() => ({ getMaintenanceStatus: vi.fn() })); +vi.mock("../services/api/maintenance.service", () => ({ getMaintenanceStatus })); + +afterEach(() => { + vi.useRealTimers(); + useMaintenanceStore.getState().reset(); +}); + +it("refetches on every 5-minute poll and stops after unmount", async () => { + vi.useFakeTimers(); + // Real responses take time; the store only records lastFetched once they arrive. + getMaintenanceStatus.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve({ is_maintenance_active: false, maintenance_details: null }), 200)) + ); + + const { unmount } = renderHook(() => useMaintenanceStatus()); + expect(getMaintenanceStatus).toHaveBeenCalledTimes(1); + + await act(() => vi.advanceTimersByTimeAsync(5 * 60 * 1000)); + expect(getMaintenanceStatus).toHaveBeenCalledTimes(2); + + unmount(); + await act(() => vi.advanceTimersByTimeAsync(10 * 60 * 1000)); + expect(getMaintenanceStatus).toHaveBeenCalledTimes(2); +}); diff --git a/apps/frontend/src/layouts/index.test.tsx b/apps/frontend/src/layouts/index.test.tsx new file mode 100644 index 000000000..fb1995a89 --- /dev/null +++ b/apps/frontend/src/layouts/index.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import "../test/i18n"; +import { useMaintenanceStore } from "../stores/maintenanceStore"; +import { API_BASE_URL, server } from "../test/msw-server"; + +const widgetMode = vi.hoisted(() => ({ value: true })); + +// Only the maintenance wiring is under test; stub the layout's unrelated chrome and hooks. +vi.mock("../components/Navbar", () => ({ Navbar: () => null })); +vi.mock("../components/Footer", () => ({ Footer: () => null })); +vi.mock("../components/Stepper", () => ({ default: () => null })); +vi.mock("../hooks/useInitTokenBalances", () => ({ useInitTokenBalances: () => undefined })); +vi.mock("../hooks/useStepper", () => ({ useStepper: () => ({ steps: [] }) })); +vi.mock("../hooks/useWidgetMode", () => ({ useWidgetMode: () => widgetMode.value })); +vi.mock("../hooks/ramp/useIsQuoteComponentDisplayed", () => ({ useIsQuoteComponentDisplayed: () => false })); + +import { BaseLayout } from "./index"; + +describe("BaseLayout", () => { + beforeEach(() => { + server.use( + http.get(`${API_BASE_URL}/maintenance/status`, () => + HttpResponse.json({ + is_maintenance_active: true, + maintenance_details: { + end_datetime: "2026-10-02T12:00:00Z", + message: "Ramps are paused while we upgrade.", + start_datetime: "2026-10-02T10:00:00Z", + title: "Scheduled maintenance" + } + }) + ) + ); + }); + + afterEach(() => { + useMaintenanceStore.getState().reset(); + }); + + it("fetches the maintenance status on mount and shows an active maintenance banner in the widget", async () => { + widgetMode.value = true; + + render(} />); + + expect(await screen.findByText("Scheduled maintenance")).toBeInTheDocument(); + }); + + it("keeps the maintenance banner off the marketing pages", async () => { + widgetMode.value = false; + + render(} />); + + await waitFor(() => expect(useMaintenanceStore.getState().maintenanceStatus?.is_maintenance_active).toBe(true)); + expect(screen.queryByText("Scheduled maintenance")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/layouts/index.tsx b/apps/frontend/src/layouts/index.tsx index 6deb7ae8e..56217c4ad 100644 --- a/apps/frontend/src/layouts/index.tsx +++ b/apps/frontend/src/layouts/index.tsx @@ -1,13 +1,13 @@ -import { FC, ReactNode, useEffect } from "react"; +import { FC, ReactNode } from "react"; import { Footer } from "../components/Footer"; import { MaintenanceBanner } from "../components/MaintenanceBanner"; import { Navbar } from "../components/Navbar"; import Stepper from "../components/Stepper"; import { useIsQuoteComponentDisplayed } from "../hooks/ramp/useIsQuoteComponentDisplayed"; import { useInitTokenBalances } from "../hooks/useInitTokenBalances"; +import { useMaintenanceStatus } from "../hooks/useMaintenanceStatus"; import { useStepper } from "../hooks/useStepper"; import { useWidgetMode } from "../hooks/useWidgetMode"; -import { useFetchMaintenanceStatus } from "../stores/maintenanceStore"; interface BaseLayoutProps { main: ReactNode; @@ -21,7 +21,7 @@ export const BaseLayout: FC = ({ main, modals }) => { const isQuoteComponentDisplayed = useIsQuoteComponentDisplayed(); useInitTokenBalances(); - useFetchMaintenanceStatus(); + useMaintenanceStatus(); const isStepperHidden = isWidgetMode && isQuoteComponentDisplayed; @@ -29,9 +29,9 @@ export const BaseLayout: FC = ({ main, modals }) => { <> {modals} - {isWidgetMode && ( <> +
{isStepperHidden ?
: }
diff --git a/apps/frontend/src/stores/maintenanceStore.ts b/apps/frontend/src/stores/maintenanceStore.ts index fb994a434..08a1d06e9 100644 --- a/apps/frontend/src/stores/maintenanceStore.ts +++ b/apps/frontend/src/stores/maintenanceStore.ts @@ -14,7 +14,8 @@ interface MaintenanceStore { reset: () => void; } -const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds +// Below the hook's 5-minute poll: lastFetched lands after the response, so an equal window skips every other poll. +const CACHE_DURATION = 60 * 1000; export const useMaintenanceStore = create((set, get) => ({ clearError: () => set({ error: null }),