mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add connection state store with health polling
This commit is contained in:
@@ -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<void>;
|
||||||
|
startPolling: () => void;
|
||||||
|
stopPolling: () => void;
|
||||||
|
refreshStaleData: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
export const useConnectionStore = create<ConnectionState>((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(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user