Skip to content
Open
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
114 changes: 114 additions & 0 deletions src/hooks/useBountyStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { renderHook, act } from "@testing-library/react";
import { useBountyStatus } from "./useBountyStatus";
import { fetchBountyLive } from "@/lib/api";
import type { Bounty } from "@/types/bounty";

jest.mock("@/lib/api", () => ({
fetchBountyLive: jest.fn(),
fetchBounty: jest.fn(),
}));

const mockFetchBountyLive = fetchBountyLive as jest.MockedFunction<typeof fetchBountyLive>;

const fallbackBounty: Bounty = {
id: "bounty-1",
title: "Test Bounty",
description: "Initial description",
status: "open",
rewardAmount: "100",
rewardToken: "USDC",
creatorAddress: "GCREATOR",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};

const liveClaimedBounty: Bounty = {
...fallbackBounty,
status: "claimed",
claimedBy: "alice",
};

describe("useBountyStatus (#571)", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
});

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

it("retains last live data, sets error, and does not fire false onStatusChange on transient poll failure", async () => {
const onStatusChange = jest.fn();

// 1. First poll succeeds with live data (claimed)
mockFetchBountyLive.mockResolvedValueOnce({
data: liveClaimedBounty,
source: "live",
});

const { result } = renderHook(() =>
useBountyStatus({
bountyId: "bounty-1",
fallbackBounty,
interval: 3000,
onStatusChange,
}),
);

// Let the first poll resolve
await act(async () => {
await Promise.resolve();
});

expect(result.current.status).toBe("claimed");
expect(result.current.bounty?.claimedBy).toBe("alice");
expect(result.current.source).toBe("live");
expect(result.current.error).toBeNull();
expect(onStatusChange).toHaveBeenCalledTimes(1);
expect(onStatusChange).toHaveBeenLastCalledWith("claimed");

// 2. Next poll suffers a transient network failure mid-session
const networkError = new Error("Network request failed: 503 Service Unavailable");
mockFetchBountyLive.mockRejectedValueOnce(networkError);

await act(async () => {
jest.advanceTimersByTime(3000);
await Promise.resolve();
});

// 3. Assertions:
// - status must still be claimed (not swapped back to fallback "open")
expect(result.current.status).toBe("claimed");
expect(result.current.bounty?.claimedBy).toBe("alice");
expect(result.current.source).toBe("live");
// - error must be surfaced so UI can render retry prompt
expect(result.current.error).toEqual(networkError);
// - onStatusChange must NOT be called with the stale fallback status
expect(onStatusChange).toHaveBeenCalledTimes(1);
expect(onStatusChange).not.toHaveBeenCalledWith("open");
});

it("uses fallbackBounty on initial load when live fetch fails immediately", async () => {
const onStatusChange = jest.fn();
mockFetchBountyLive.mockRejectedValueOnce(new Error("Initial offline"));

const { result } = renderHook(() =>
useBountyStatus({
bountyId: "bounty-1",
fallbackBounty,
onStatusChange,
}),
);

await act(async () => {
await Promise.resolve();
});

expect(result.current.status).toBe("open");
expect(result.current.source).toBe("mock");
expect(result.current.bounty?.id).toBe("bounty-1");
expect(result.current.error).toBeNull();
expect(onStatusChange).toHaveBeenCalledWith("open");
});
});
68 changes: 53 additions & 15 deletions src/hooks/useBountyStatus.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useRef, useCallback, useEffect } from 'react';
import { useSmartPolling } from './useSmartPolling';
import { fetchBounty } from '@/lib/api';
import { fetchBountyLive } from '@/lib/api';
import type { Bounty, BountyStatus } from '@/types/bounty';

interface UseBountyStatusOptions {
Expand Down Expand Up @@ -28,6 +29,54 @@ export function useBountyStatus({
enabled = true,
onStatusChange,
}: UseBountyStatusOptions): UseBountyStatusResult {
const lastLiveBountyIdRef = useRef<string | null>(null);

const fallbackRef = useRef(fallbackBounty);
useEffect(() => {
fallbackRef.current = fallbackBounty;
});

const onStatusChangeRef = useRef(onStatusChange);
useEffect(() => {
onStatusChangeRef.current = onStatusChange;
});

const fetchFn = useCallback(async () => {
try {
const result = await fetchBountyLive(bountyId);
lastLiveBountyIdRef.current = bountyId;
return result;
} catch (err) {
// Only return static fallback before any live data has been observed for this bounty
const hasObservedLive = lastLiveBountyIdRef.current === bountyId;
if (!hasObservedLive && fallbackRef.current !== undefined) {
return { data: fallbackRef.current, source: 'mock' as const };
}
throw err;
}
}, [bountyId]);

const compareFn = useCallback(
(
a: { data: Bounty | undefined; source: 'live' | 'mock' } | null,
b: { data: Bounty | undefined; source: 'live' | 'mock' } | null,
) => {
const aStatus = a?.data?.status;
const bStatus = b?.data?.status;
return aStatus === bStatus && a?.data?.claimedBy === b?.data?.claimedBy;
},
[],
);

const handleDataChange = useCallback(
(result: { data: Bounty | undefined; source: 'live' | 'mock' }) => {
if (onStatusChangeRef.current && result?.data?.status) {
onStatusChangeRef.current(result.data.status);
}
},
[],
);

const {
data,
isLoading,
Expand All @@ -36,25 +85,14 @@ export function useBountyStatus({
isPolling,
isBackingOff,
} = useSmartPolling<{ data: Bounty | undefined; source: 'live' | 'mock' }>({
fetchFn: async () => {
const result = await fetchBounty(bountyId, fallbackBounty);
return result;
},
fetchFn,
interval,
enabled,
backoffMultiplier: 1.5,
maxBackoff: 30000,
unchangedThreshold: 3,
compareFn: (a, b) => {
const aStatus = a?.data?.status;
const bStatus = b?.data?.status;
return aStatus === bStatus && a?.data?.claimedBy === b?.data?.claimedBy;
},
onDataChange: (result) => {
if (onStatusChange && result?.data?.status) {
onStatusChange(result.data.status);
}
},
compareFn,
onDataChange: handleDataChange,
});

return {
Expand Down
10 changes: 8 additions & 2 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,13 +195,19 @@ export async function fetchBounties(
}
}

export async function fetchBountyLive(
id: string,
): Promise<FetchResult<Bounty>> {
const raw = await request<RawBounty>(`/bounties/${id}`);
return { data: adaptBounty(raw), source: "live" };
}

export async function fetchBounty(
id: string,
fallback: Bounty | undefined,
): Promise<FetchResult<Bounty | undefined>> {
try {
const raw = await request<RawBounty>(`/bounties/${id}`);
return { data: adaptBounty(raw), source: "live" };
return await fetchBountyLive(id);
} catch {
return { data: fallback, source: "mock" };
}
Expand Down