zerobyte/app/client/hooks/__tests__/use-server-events.test.tsx
Nico 611640b32b
fix: split display path and query base path (#714)
* fix: split display path and query base path

#709

* test(frontend): shared render utils

* fix(file-tree): add missing path segments

* chore: bump Bun version

* fix: select kind for synthetic folders
2026-03-29 12:30:35 +02:00

131 lines
3.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { useEffect, useState } from "react";
import { cleanup, createTestQueryClient, render, screen } from "~/test/test-utils";
import { useServerEvents } from "../use-server-events";
class MockEventSource {
static instances: MockEventSource[] = [];
public onerror: ((event: Event) => void) | null = null;
public close = mock(() => {});
private listeners = new Map<string, Set<(event: Event) => void>>();
constructor(public url: string) {
MockEventSource.instances.push(this);
}
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
const listeners = this.listeners.get(type) ?? new Set<(event: Event) => void>();
const callback = typeof listener === "function" ? listener : (event: Event) => listener.handleEvent(event);
listeners.add(callback);
this.listeners.set(type, listeners);
}
emit(type: string, data: unknown) {
const event = new MessageEvent(type, {
data: JSON.stringify(data),
});
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
static reset() {
MockEventSource.instances = [];
}
}
const originalEventSource = globalThis.EventSource;
const originalConsoleInfo = console.info;
const originalConsoleError = console.error;
const ConnectionConsumer = ({ enabled = true }: { enabled?: boolean }) => {
useServerEvents({ enabled });
return null;
};
const BackupCompletedListener = ({ scheduleId }: { scheduleId: string }) => {
const { addEventListener } = useServerEvents();
const [status, setStatus] = useState("pending");
useEffect(() => {
const abortController = new AbortController();
addEventListener(
"backup:completed",
(event) => {
if (event.scheduleId === scheduleId) {
setStatus(event.status);
}
},
{ signal: abortController.signal },
);
return () => abortController.abort();
}, [addEventListener, scheduleId]);
return <div>{status}</div>;
};
describe("useServerEvents", () => {
beforeEach(() => {
MockEventSource.reset();
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
console.info = mock(() => {});
console.error = mock(() => {});
});
afterEach(() => {
cleanup();
globalThis.EventSource = originalEventSource;
console.info = originalConsoleInfo;
console.error = originalConsoleError;
MockEventSource.reset();
});
test("shares one EventSource across consumers and invalidates queries once on backup completion", async () => {
const queryClient = createTestQueryClient();
const invalidateQueries = mock(async () => undefined);
const refetchQueries = mock(async () => undefined);
queryClient.invalidateQueries = invalidateQueries as typeof queryClient.invalidateQueries;
queryClient.refetchQueries = refetchQueries as typeof queryClient.refetchQueries;
render(
<>
<ConnectionConsumer />
<BackupCompletedListener scheduleId="0b9c940b" />
</>,
{ queryClient },
);
expect(MockEventSource.instances).toHaveLength(1);
MockEventSource.instances[0]?.emit("backup:completed", {
organizationId: "default-org",
scheduleId: "0b9c940b",
volumeName: "synology",
repositoryName: "swiss-backup",
status: "success",
});
expect(await screen.findByText("success")).toBeTruthy();
expect(invalidateQueries).toHaveBeenCalledTimes(1);
expect(refetchQueries).not.toHaveBeenCalled();
cleanup();
expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1);
});
test("waits to subscribe until enabled", () => {
const queryClient = createTestQueryClient();
const view = render(<ConnectionConsumer enabled={false} />, { queryClient });
expect(MockEventSource.instances).toHaveLength(0);
view.rerender(<ConnectionConsumer />);
expect(MockEventSource.instances).toHaveLength(1);
expect(MockEventSource.instances[0]?.url).toBe("/api/v1/events");
});
});