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

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

const mockFetchLiveBounty = fetchLiveBounty as jest.MockedFunction<typeof fetchLiveBounty>;

const mockLiveBounty: Bounty = {
id: 'bounty-123',
title: 'Test Bounty',
amount: '100',
asset: 'XLM',
status: 'claimed',
claimedBy: 'user-1',
sponsor: 'sponsor-1',
issueUrl: 'https://github.com/org/repo/issues/1',
createdAt: '2026-10-01T00:00:00Z',
updatedAt: '2026-10-01T00:00:00Z',
};

const mockFallbackBounty: Bounty = {
id: 'bounty-123',
title: 'Test Bounty Fallback',
amount: '100',
asset: 'XLM',
status: 'open',
claimedBy: undefined,
sponsor: 'sponsor-1',
issueUrl: 'https://github.com/org/repo/issues/1',
createdAt: '2026-10-01T00:00:00Z',
updatedAt: '2026-10-01T00:00:00Z',
};

describe('useBountyStatus', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('preserves live bounty data and sets error on transient poll failure (#571)', async () => {
// 1. Initial poll succeeds with live data (claimed)
mockFetchLiveBounty.mockResolvedValueOnce({
data: mockLiveBounty,
source: 'live',
});

const onStatusChange = jest.fn();

const { result } = renderHook(() =>
useBountyStatus({
bountyId: 'bounty-123',
fallbackBounty: mockFallbackBounty,
onStatusChange,
}),
);

await waitFor(() => {
expect(result.current.status).toBe('claimed');
expect(result.current.source).toBe('live');
});

expect(onStatusChange).toHaveBeenCalledTimes(1);
expect(onStatusChange).toHaveBeenCalledWith('claimed');
onStatusChange.mockClear();

// 2. Subsequent poll fails with a network error
mockFetchLiveBounty.mockRejectedValueOnce(new Error('Network drop'));

await act(async () => {
await result.current.refetch();
});

// Verify: status is still claimed, onStatusChange was not called with fallback open status, and error is set
expect(result.current.status).toBe('claimed');
expect(result.current.bounty).toEqual(mockLiveBounty);
expect(result.current.source).toBe('live');
expect(result.current.error).toBeDefined();
expect(result.current.error?.message).toBe('Network drop');
expect(onStatusChange).not.toHaveBeenCalled();
});

it('falls back to mock data on initial fetch failure when fallbackBounty is provided', async () => {
mockFetchLiveBounty.mockRejectedValueOnce(new Error('Backend offline'));

const { result } = renderHook(() =>
useBountyStatus({
bountyId: 'bounty-123',
fallbackBounty: mockFallbackBounty,
}),
);

await waitFor(() => {
expect(result.current.status).toBe('open');
expect(result.current.source).toBe('mock');
expect(result.current.bounty).toEqual(mockFallbackBounty);
});
});
});
26 changes: 23 additions & 3 deletions src/hooks/useBountyStatus.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useRef } from 'react';
import { useSmartPolling } from './useSmartPolling';
import { fetchBounty } from '@/lib/api';
import { fetchBounty, fetchLiveBounty } from '@/lib/api';
import type { Bounty, BountyStatus } from '@/types/bounty';

interface UseBountyStatusOptions {
Expand Down Expand Up @@ -28,6 +29,14 @@ export function useBountyStatus({
enabled = true,
onStatusChange,
}: UseBountyStatusOptions): UseBountyStatusResult {
const hasLiveRef = useRef(false);
const lastBountyIdRef = useRef(bountyId);

if (lastBountyIdRef.current !== bountyId) {
lastBountyIdRef.current = bountyId;
hasLiveRef.current = false;
}

const {
data,
isLoading,
Expand All @@ -37,8 +46,19 @@ export function useBountyStatus({
isBackingOff,
} = useSmartPolling<{ data: Bounty | undefined; source: 'live' | 'mock' }>({
fetchFn: async () => {
const result = await fetchBounty(bountyId, fallbackBounty);
return result;
if (hasLiveRef.current) {
return await fetchLiveBounty(bountyId);
}
try {
const result = await fetchLiveBounty(bountyId);
hasLiveRef.current = true;
return result;
} catch (err) {
if (fallbackBounty !== undefined) {
return { data: fallbackBounty, source: 'mock' };
}
throw err;
}
},
interval,
enabled,
Expand Down
83 changes: 73 additions & 10 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type RawUserProfile,
} from "./adapters";
import type { Bounty, Milestone, MaintenancePool, ReputationProfile } from "@/types";
import { buildBountyQueryString, type BountyQuery } from "./bounty-query";

export class ApiUnavailableError extends Error {}

Expand Down Expand Up @@ -39,14 +40,20 @@ function logFetchError(path: string, kind: "network" | "http" | "parse", detail:
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
let res: Response;
try {
res = await fetch(`${API_BASE_URL}${path}`, {
cache: "no-store",
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
signal: init?.signal ?? timeout,
});
} catch (err) {
if (err instanceof DOMException && err.name === "TimeoutError") {
logFetchError(path, "network", "Request timed out");
throw new ApiUnavailableError(`Request to ${path} timed out`);
}
logFetchError(path, "network", err instanceof Error ? err.message : String(err));
throw new ApiUnavailableError(`Network error on ${path}`);
}
Expand Down Expand Up @@ -88,6 +95,22 @@ async function dedupedFetch<T>(
return promise;
}

export function parseRetryAfter(header: string): number | undefined {
const trimmed = header.trim();
if (/^\d+$/.test(trimmed)) {
const sec = parseInt(trimmed, 10);
return Number.isFinite(sec) && sec >= 0 ? sec : undefined;
}
if (!trimmed.includes("GMT") && !trimmed.includes(",")) {
return undefined;
}
const dateMs = Date.parse(trimmed);
if (!Number.isNaN(dateMs)) {
return Math.max(0, Math.ceil((dateMs - Date.now()) / 1000));
}
return undefined;
}

/**
* Client-side call that attaches the signed-in user's JWT (if any) and
* surfaces backend error bodies instead of silently falling back — used for
Expand Down Expand Up @@ -125,14 +148,14 @@ export async function apiRequest<T>(
// --- Rate-limit handling (#44) ---
if (res.status === 429) {
const retryAfter = res.headers.get("Retry-After");
const seconds = retryAfter ? parseInt(retryAfter, 10) : NaN;
const waitMsg = Number.isFinite(seconds)
const seconds = retryAfter ? parseRetryAfter(retryAfter) : undefined;
const waitMsg = seconds !== undefined
? ` Please wait ${seconds} second${seconds === 1 ? "" : "s"} before trying again.`
: "";
throw new ApiRequestError(
`You're doing that too fast.${waitMsg}` || `Rate limited. Please try again later.`,
`You're doing that too fast.${waitMsg}`,
429,
Number.isFinite(seconds) ? seconds : undefined,
seconds,
);
}

Expand Down Expand Up @@ -167,22 +190,40 @@ export function apiPost<T>(path: string, body?: unknown): Promise<T> {
* into the flat shapes the UI renders, falling back to mock data (already in
* the target shape) when the backend is unreachable.
*/
export async function fetchBounties(fallback: Bounty[]): Promise<FetchResult<Bounty[]>> {
/**
* `query`, if given, is forwarded to the backend as query params (#28) so
* this is ready for server-side filtering once `/bounties` supports it —
* today the backend ignores unknown params and returns the full unfiltered
* collection, which callers must still run through
* `applyBountyQuery`/`filterBounties` themselves (see src/lib/bounty-query.ts)
* to get correct behavior against both live and mock-fallback data.
*/
export async function fetchBounties(
fallback: Bounty[],
query?: BountyQuery,
): Promise<FetchResult<Bounty[]>> {
const qs = query ? buildBountyQueryString(query) : "";
try {
const raw = await request<RawBounty[]>("/bounties");
const raw = await request<RawBounty[]>(`/bounties${qs}`);
return { data: raw.map(adaptBounty), source: "live" };
} catch {
return { data: fallback, source: "mock" };
}
}

export async function fetchLiveBounty(
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 fetchLiveBounty(id);
} catch {
return { data: fallback, source: "mock" };
}
Expand Down Expand Up @@ -226,11 +267,33 @@ export async function fetchReputationByUsername(
}
}

export async function fetchReputationHandles(fallback: string[]): Promise<FetchResult<string[]>> {
/**
* Handles eligible to appear in the sitemap — i.e. profiles whose owner has
* opted into search-engine indexing.
*
* The filter is the enforcement point for the privacy policy documented in
* src/lib/seo-policy.ts: without it, calling the /users endpoint to enumerate
* handles builds a crawlable directory of who earns what, tied to real GitHub
* identities. Anything other than an explicit `isProfilePublic: true` is
* excluded, so the endpoint omitting the field (as it does today) means
* nothing is indexed rather than everything.
*/
export async function fetchIndexableReputationHandles(
fallback: string[],
): Promise<FetchResult<string[]>> {
try {
const users = await request<(RawUserProfile & { id: string })[]>("/users");
return { data: users.map((user) => user.username).filter(Boolean), source: "live" };
return {
data: users
.filter((user) => user.isProfilePublic === true)
.map((user) => user.username)
.filter(Boolean),
source: "live",
};
} catch {
// The mock fixtures represent contributors who have opted in, so local
// development exercises the same code path production will take once the
// backend ships the flag.
return { data: fallback, source: "mock" };
}
}