diff --git a/apps/web/src/stores/connection-store.ts b/apps/web/src/stores/connection-store.ts new file mode 100644 index 00000000..cb9159e9 --- /dev/null +++ b/apps/web/src/stores/connection-store.ts @@ -0,0 +1,86 @@ +import { create } from "zustand"; + +type ConnectionStatus = "connected" | "disconnected" | "reconnected" | "offline"; + +interface ConnectionState { + status: ConnectionStatus; + failedSince: number | null; + lastHealthCheck: number | null; + + setDisconnected: () => void; + setOffline: () => void; + setOnline: () => void; + checkHealth: () => Promise; + startPolling: () => void; + stopPolling: () => void; + refreshStaleData: () => Promise; +} + +let pollingInterval: ReturnType | null = null; + +export const useConnectionStore = create((set, get) => ({ + status: "connected", + failedSince: null, + lastHealthCheck: null, + + setDisconnected: () => { + const current = get(); + if (current.status === "disconnected") return; + set({ + status: "disconnected", + failedSince: current.failedSince ?? Date.now(), + }); + }, + + setOffline: () => { + set({ status: "offline", failedSince: get().failedSince ?? Date.now() }); + }, + + setOnline: () => { + if (get().status !== "offline") return; + set({ status: "disconnected" }); + }, + + checkHealth: async () => { + try { + const res = await fetch("/api/v1/health"); + if (res.ok) { + const current = get().status; + if (current === "disconnected" || current === "offline") { + set({ status: "reconnected", lastHealthCheck: Date.now(), failedSince: null }); + } else { + set({ lastHealthCheck: Date.now() }); + } + } + } catch { + if (get().status === "connected") { + get().setDisconnected(); + } + } + }, + + startPolling: () => { + if (pollingInterval) return; + pollingInterval = setInterval(() => { + get().checkHealth(); + }, 3000); + }, + + stopPolling: () => { + if (pollingInterval) { + clearInterval(pollingInterval); + pollingInterval = null; + } + }, + + refreshStaleData: async () => { + const { useSettingsStore } = await import("@/stores/settings-store"); + const { useFeaturesStore } = await import("@/stores/features-store"); + + useSettingsStore.setState({ loaded: false }); + await Promise.allSettled([ + useSettingsStore.getState().fetch(), + useFeaturesStore.getState().refresh(), + ]); + }, +})); diff --git a/tests/unit/web/connection-store.test.ts b/tests/unit/web/connection-store.test.ts new file mode 100644 index 00000000..601d435a --- /dev/null +++ b/tests/unit/web/connection-store.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +import { useConnectionStore } from "@/stores/connection-store"; + +function okHealth() { + return Promise.resolve(new Response(JSON.stringify({ status: "healthy" }), { status: 200 })); +} + +function failHealth() { + return Promise.reject(new TypeError("Failed to fetch")); +} + +describe("connection-store", () => { + beforeEach(() => { + vi.useFakeTimers(); + useConnectionStore.setState({ + status: "connected", + failedSince: null, + lastHealthCheck: null, + }); + fetchMock.mockReset(); + }); + + afterEach(() => { + useConnectionStore.getState().stopPolling(); + vi.useRealTimers(); + }); + + it("starts in connected state", () => { + expect(useConnectionStore.getState().status).toBe("connected"); + }); + + it("transitions to disconnected on setDisconnected", () => { + useConnectionStore.getState().setDisconnected(); + const state = useConnectionStore.getState(); + expect(state.status).toBe("disconnected"); + expect(state.failedSince).toBeTypeOf("number"); + }); + + it("does not overwrite failedSince on repeated setDisconnected calls", () => { + useConnectionStore.getState().setDisconnected(); + const first = useConnectionStore.getState().failedSince; + useConnectionStore.getState().setDisconnected(); + expect(useConnectionStore.getState().failedSince).toBe(first); + }); + + it("transitions to offline on setOffline", () => { + useConnectionStore.getState().setOffline(); + expect(useConnectionStore.getState().status).toBe("offline"); + }); + + it("transitions from offline to disconnected on setOnline", () => { + useConnectionStore.getState().setOffline(); + useConnectionStore.getState().setOnline(); + expect(useConnectionStore.getState().status).toBe("disconnected"); + }); + + it("checkHealth transitions disconnected → reconnected on success", async () => { + fetchMock.mockImplementation(okHealth); + useConnectionStore.getState().setDisconnected(); + await useConnectionStore.getState().checkHealth(); + expect(useConnectionStore.getState().status).toBe("reconnected"); + expect(useConnectionStore.getState().lastHealthCheck).toBeTypeOf("number"); + }); + + it("checkHealth stays disconnected on failure", async () => { + fetchMock.mockImplementation(failHealth); + useConnectionStore.getState().setDisconnected(); + await useConnectionStore.getState().checkHealth(); + expect(useConnectionStore.getState().status).toBe("disconnected"); + }); + + it("checkHealth is a no-op when already connected", async () => { + fetchMock.mockImplementation(okHealth); + await useConnectionStore.getState().checkHealth(); + expect(useConnectionStore.getState().status).toBe("connected"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("startPolling is idempotent", () => { + useConnectionStore.getState().setDisconnected(); + useConnectionStore.getState().startPolling(); + useConnectionStore.getState().startPolling(); + fetchMock.mockImplementation(failHealth); + vi.advanceTimersByTime(3000); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("stopPolling clears the interval", () => { + useConnectionStore.getState().setDisconnected(); + fetchMock.mockImplementation(failHealth); + useConnectionStore.getState().startPolling(); + useConnectionStore.getState().stopPolling(); + vi.advanceTimersByTime(6000); + expect(fetchMock).not.toHaveBeenCalled(); + }); +});