2026-06-06 19:29:46 -07:00
|
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
|
import { act, cleanup, render } from "@testing-library/react";
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
|
import { buildEventsUrl, RealtimeEventsProvider } from "./RealtimeEventsProvider";
|
|
|
|
|
|
|
|
|
|
const mockState = vi.hoisted(() => ({
|
|
|
|
|
user: {
|
|
|
|
|
id: 1,
|
|
|
|
|
username: "admin",
|
|
|
|
|
email: "admin@example.com",
|
|
|
|
|
role: "admin",
|
|
|
|
|
permissions: [],
|
|
|
|
|
download_allowed: true,
|
|
|
|
|
},
|
|
|
|
|
pageActivity: {
|
|
|
|
|
isVisible: true,
|
|
|
|
|
isFocused: true,
|
|
|
|
|
isFrozen: false,
|
|
|
|
|
canPollDashboard: true,
|
|
|
|
|
canApplyRealtimeUpdates: true,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("@/hooks/useAuth", () => ({
|
|
|
|
|
useAuth: () => ({
|
|
|
|
|
user: mockState.user,
|
|
|
|
|
profile: null,
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("@/hooks/usePageActivity", () => ({
|
|
|
|
|
usePageActivity: () => mockState.pageActivity,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("react-router", () => ({
|
|
|
|
|
useLocation: () => ({ pathname: "/" }),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
class FakeWebSocket {
|
|
|
|
|
static CONNECTING = 0;
|
|
|
|
|
static OPEN = 1;
|
|
|
|
|
static CLOSED = 3;
|
|
|
|
|
static instances: FakeWebSocket[] = [];
|
|
|
|
|
|
|
|
|
|
onopen: (() => void) | null = null;
|
|
|
|
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
|
|
|
onerror: (() => void) | null = null;
|
|
|
|
|
onclose: (() => void) | null = null;
|
|
|
|
|
readyState = FakeWebSocket.CONNECTING;
|
|
|
|
|
|
|
|
|
|
constructor(public url: string) {
|
|
|
|
|
FakeWebSocket.instances.push(this);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
send() {}
|
|
|
|
|
|
|
|
|
|
close() {
|
|
|
|
|
this.readyState = FakeWebSocket.CLOSED;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
emitClose() {
|
|
|
|
|
this.readyState = FakeWebSocket.CLOSED;
|
|
|
|
|
this.onclose?.();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-22 20:26:11 -04:00
|
|
|
|
|
|
|
|
describe("buildEventsUrl", () => {
|
|
|
|
|
it("includes auth token and websocket scheme", () => {
|
|
|
|
|
expect(
|
|
|
|
|
buildEventsUrl("token-123", {
|
|
|
|
|
protocol: "https:",
|
|
|
|
|
host: "example.com",
|
|
|
|
|
}),
|
|
|
|
|
).toBe("wss://example.com/api/v1/events/ws?token=token-123");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("omits the query string when no token is available", () => {
|
|
|
|
|
expect(
|
|
|
|
|
buildEventsUrl(null, {
|
|
|
|
|
protocol: "http:",
|
|
|
|
|
host: "localhost:5173",
|
|
|
|
|
}),
|
|
|
|
|
).toBe("ws://localhost:5173/api/v1/events/ws");
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-06 19:29:46 -07:00
|
|
|
|
|
|
|
|
describe("RealtimeEventsProvider", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
FakeWebSocket.instances = [];
|
|
|
|
|
vi.useFakeTimers();
|
|
|
|
|
vi.stubGlobal("WebSocket", FakeWebSocket);
|
|
|
|
|
mockState.pageActivity = {
|
|
|
|
|
isVisible: true,
|
|
|
|
|
isFocused: true,
|
|
|
|
|
isFrozen: false,
|
|
|
|
|
canPollDashboard: true,
|
|
|
|
|
canApplyRealtimeUpdates: true,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
cleanup();
|
|
|
|
|
vi.useRealTimers();
|
|
|
|
|
vi.unstubAllGlobals();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("ignores stale close events from intentionally closed sockets", () => {
|
|
|
|
|
const queryClient = new QueryClient({
|
|
|
|
|
defaultOptions: {
|
|
|
|
|
queries: { retry: false },
|
|
|
|
|
mutations: { retry: false },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const view = render(
|
|
|
|
|
<QueryClientProvider client={queryClient}>
|
|
|
|
|
<RealtimeEventsProvider>
|
|
|
|
|
<div />
|
|
|
|
|
</RealtimeEventsProvider>
|
|
|
|
|
</QueryClientProvider>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
|
|
|
const firstSocket = FakeWebSocket.instances[0];
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
mockState.pageActivity = {
|
|
|
|
|
...mockState.pageActivity,
|
|
|
|
|
canApplyRealtimeUpdates: false,
|
|
|
|
|
};
|
|
|
|
|
view.rerender(
|
|
|
|
|
<QueryClientProvider client={queryClient}>
|
|
|
|
|
<RealtimeEventsProvider>
|
|
|
|
|
<div />
|
|
|
|
|
</RealtimeEventsProvider>
|
|
|
|
|
</QueryClientProvider>,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
mockState.pageActivity = {
|
|
|
|
|
...mockState.pageActivity,
|
|
|
|
|
canApplyRealtimeUpdates: true,
|
|
|
|
|
};
|
|
|
|
|
view.rerender(
|
|
|
|
|
<QueryClientProvider client={queryClient}>
|
|
|
|
|
<RealtimeEventsProvider>
|
|
|
|
|
<div />
|
|
|
|
|
</RealtimeEventsProvider>
|
|
|
|
|
</QueryClientProvider>,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(FakeWebSocket.instances).toHaveLength(2);
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
firstSocket?.emitClose();
|
|
|
|
|
vi.advanceTimersByTime(1_000);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(FakeWebSocket.instances).toHaveLength(2);
|
|
|
|
|
});
|
|
|
|
|
});
|