Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ jobs:
src/__tests__/integration/AppPageComponents.test.tsx
src/__tests__/integration/CausesFilterUrlSync.test.tsx
src/__tests__/components/CauseCard.test.tsx
src/__tests__/hooks/useCampaignEvents.test.ts

- name: Upload coverage report to Codecov
uses: codecov/codecov-action@v4
Expand Down
297 changes: 297 additions & 0 deletions src/__tests__/hooks/useCampaignEvents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
import { renderHook, act } from "@testing-library/react";
import { useCampaignEvents, type CampaignEvent } from "@/hooks/useCampaignEvents";

jest.mock("@/hooks/useWindowVisibility", () => ({
useWindowVisibility: jest.fn(),
}));

import { useWindowVisibility } from "@/hooks/useWindowVisibility";

const mockUseWindowVisibility = useWindowVisibility as jest.MockedFunction<
typeof useWindowVisibility
>;

interface TestEvent extends CampaignEvent {
type: string;
}

function makeEvent(id: string): TestEvent {
return { id, type: "test" };
}

beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockUseWindowVisibility.mockReturnValue(true);
});

afterEach(() => {
jest.useRealTimers();
});

describe("useCampaignEvents", () => {
it("calls fetchEvents on mount and delivers unseen events", async () => {
const onUnseenEvents = jest.fn();
const fetchEvents = jest.fn().mockResolvedValue({
events: [makeEvent("1"), makeEvent("2")],
cursor: "cur1",
});

renderHook(() =>
useCampaignEvents({
campaignId: 1,
fetchEvents,
onUnseenEvents,
pollIntervalMs: 1000,
}),
);

await act(async () => {
await jest.advanceTimersByTimeAsync(0);
});

expect(fetchEvents).toHaveBeenCalledWith({ campaignId: 1, cursor: undefined });
expect(onUnseenEvents).toHaveBeenCalledWith([makeEvent("1"), makeEvent("2")]);
});

it("deduplicates events across polls", async () => {
const onUnseenEvents = jest.fn();
const fetchEvents = jest
.fn()
.mockResolvedValueOnce({ events: [makeEvent("1"), makeEvent("2")], cursor: "c1" })
.mockResolvedValueOnce({ events: [makeEvent("2"), makeEvent("3")], cursor: "c2" });

renderHook(() =>
useCampaignEvents({
campaignId: 1,
fetchEvents,
onUnseenEvents,
pollIntervalMs: 1000,
}),
);

await act(async () => {
await jest.advanceTimersByTimeAsync(0);
});

expect(onUnseenEvents).toHaveBeenCalledTimes(1);
expect(onUnseenEvents).toHaveBeenCalledWith([makeEvent("1"), makeEvent("2")]);

await act(async () => {
await jest.advanceTimersByTimeAsync(1000);
});

expect(onUnseenEvents).toHaveBeenCalledTimes(2);
expect(onUnseenEvents).toHaveBeenLastCalledWith([makeEvent("3")]);
});

it("calls onError when fetchEvents throws", async () => {
const onError = jest.fn();
const fetchEvents = jest.fn().mockRejectedValue(new Error("rpc down"));

renderHook(() =>
useCampaignEvents({
campaignId: 1,
fetchEvents,
onUnseenEvents: jest.fn(),
pollIntervalMs: 1000,
onError,
}),
);

await act(async () => {
await jest.advanceTimersByTimeAsync(0);
});

expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(expect.any(Error));
});

it("does not poll when enabled is false", async () => {
const fetchEvents = jest.fn().mockResolvedValue({ events: [], cursor: undefined });

renderHook(() =>
useCampaignEvents({
campaignId: 1,
enabled: false,
fetchEvents,
onUnseenEvents: jest.fn(),
pollIntervalMs: 1000,
}),
);

await act(async () => {
await jest.advanceTimersByTimeAsync(0);
});

expect(fetchEvents).not.toHaveBeenCalled();
});

it("does not poll when window is not visible", async () => {
mockUseWindowVisibility.mockReturnValue(false);
const fetchEvents = jest.fn().mockResolvedValue({ events: [], cursor: undefined });

renderHook(() =>
useCampaignEvents({
campaignId: 1,
fetchEvents,
onUnseenEvents: jest.fn(),
pollIntervalMs: 1000,
}),
);

await act(async () => {
await jest.advanceTimersByTimeAsync(0);
});

expect(fetchEvents).not.toHaveBeenCalled();
});

it("resets state when campaignId changes", async () => {
const onUnseenEvents = jest.fn();
const fetchEvents = jest
.fn()
.mockResolvedValueOnce({ events: [makeEvent("1")], cursor: "c1" })
.mockResolvedValueOnce({ events: [makeEvent("1")], cursor: "c2" });

const { rerender } = renderHook(
({ campaignId }) =>
useCampaignEvents({
campaignId,
fetchEvents,
onUnseenEvents,
pollIntervalMs: 1000,
}),
{ initialProps: { campaignId: 1 } },
);

await act(async () => {
await jest.advanceTimersByTimeAsync(0);
});

expect(onUnseenEvents).toHaveBeenCalledWith([makeEvent("1")]);
onUnseenEvents.mockClear();

rerender({ campaignId: 2 });

await act(async () => {
await jest.advanceTimersByTimeAsync(0);
});

expect(onUnseenEvents).toHaveBeenCalledWith([makeEvent("1")]);
});

it("cleans up interval on unmount", async () => {
const fetchEvents = jest.fn().mockResolvedValue({ events: [], cursor: undefined });

const { unmount } = renderHook(() =>
useCampaignEvents({
campaignId: 1,
fetchEvents,
onUnseenEvents: jest.fn(),
pollIntervalMs: 1000,
}),
);

await act(async () => {
await jest.advanceTimersByTimeAsync(0);
});

unmount();

await act(async () => {
await jest.advanceTimersByTimeAsync(1000);
});

expect(fetchEvents).toHaveBeenCalledTimes(1);
});

it("does not poll when campaignId is 0", async () => {
const fetchEvents = jest.fn().mockResolvedValue({ events: [], cursor: undefined });

renderHook(() =>
useCampaignEvents({
campaignId: 0,
fetchEvents,
onUnseenEvents: jest.fn(),
pollIntervalMs: 1000,
}),
);

await act(async () => {
await jest.advanceTimersByTimeAsync(0);
});

expect(fetchEvents).not.toHaveBeenCalled();
});

it("does not call onUnseenEvents when result is null", async () => {
const onUnseenEvents = jest.fn();
const fetchEvents = jest.fn().mockResolvedValue(null);

renderHook(() =>
useCampaignEvents({
campaignId: 1,
fetchEvents,
onUnseenEvents,
pollIntervalMs: 1000,
}),
);

await act(async () => {
await jest.advanceTimersByTimeAsync(0);
});

expect(onUnseenEvents).not.toHaveBeenCalled();
});

it("does not deliver events after cancellation", async () => {
const onUnseenEvents = jest.fn();
const fetchEvents = jest.fn().mockResolvedValue({ events: [makeEvent("1")], cursor: "c1" });

const { unmount } = renderHook(() =>
useCampaignEvents({
campaignId: 1,
fetchEvents,
onUnseenEvents,
pollIntervalMs: 1000,
}),
);

unmount();

await act(async () => {
await jest.advanceTimersByTimeAsync(0);
});

expect(onUnseenEvents).not.toHaveBeenCalled();
});

it("uses cursor from previous poll", async () => {
const fetchEvents = jest
.fn()
.mockResolvedValueOnce({ events: [makeEvent("1")], cursor: "cursor_a" })
.mockResolvedValueOnce({ events: [makeEvent("2")], cursor: "cursor_b" });

renderHook(() =>
useCampaignEvents({
campaignId: 1,
fetchEvents,
onUnseenEvents: jest.fn(),
pollIntervalMs: 1000,
}),
);

await act(async () => {
await jest.advanceTimersByTimeAsync(0);
});

expect(fetchEvents).toHaveBeenCalledWith({ campaignId: 1, cursor: undefined });

await act(async () => {
await jest.advanceTimersByTimeAsync(1000);
});

expect(fetchEvents).toHaveBeenCalledWith({ campaignId: 1, cursor: "cursor_a" });
});
});
93 changes: 93 additions & 0 deletions src/hooks/useCampaignEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"use client";

import { useEffect, useRef } from "react";
import { useWindowVisibility } from "./useWindowVisibility";

const USE_MOCKS = typeof process !== "undefined" && process.env.NEXT_PUBLIC_USE_MOCKS === "true";

export interface CampaignEvent {
id: string;
}

export interface UseCampaignEventsOptions<TEvent extends CampaignEvent> {
campaignId: number;
enabled?: boolean;
fetchEvents: (params: {
campaignId: number;
cursor?: string;
}) => Promise<{ events: TEvent[]; cursor?: string } | null | undefined>;
onUnseenEvents: (unseenEvents: TEvent[]) => void;
pollIntervalMs: number;
useMocksCheck?: boolean;
onError?: (error: unknown) => void;
}

export function useCampaignEvents<TEvent extends CampaignEvent>({
campaignId,
enabled = true,
fetchEvents,
onUnseenEvents,
pollIntervalMs,
useMocksCheck = false,
onError,
}: UseCampaignEventsOptions<TEvent>): void {
const isVisible = useWindowVisibility();
const seenEventIdsRef = useRef<Set<string>>(new Set());
const cursorRef = useRef<string | undefined>(undefined);
const onUnseenEventsRef = useRef(onUnseenEvents);
const onErrorRef = useRef(onError);

useEffect(() => {
onUnseenEventsRef.current = onUnseenEvents;
}, [onUnseenEvents]);

useEffect(() => {
onErrorRef.current = onError;
}, [onError]);

useEffect(() => {
seenEventIdsRef.current = new Set();
cursorRef.current = undefined;
}, [campaignId]);

useEffect(() => {
if (!enabled || !campaignId || (useMocksCheck && USE_MOCKS) || !isVisible) {
return;
}

let cancelled = false;

const poll = async () => {
try {
const result = await fetchEvents({
campaignId,
cursor: cursorRef.current,
});
if (!result || cancelled) return;

cursorRef.current = result.cursor;

const unseen = result.events.filter((event) => !seenEventIdsRef.current.has(event.id));
for (const event of unseen) {
seenEventIdsRef.current.add(event.id);
}

if (unseen.length > 0) {
onUnseenEventsRef.current(unseen);
}
} catch (error) {
onErrorRef.current?.(error);
}
};

void poll();
const intervalId = window.setInterval(() => {
void poll();
}, pollIntervalMs);

return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [campaignId, enabled, isVisible]);
}
Loading
Loading