Skip to content
Closed
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
2 changes: 1 addition & 1 deletion src/components/common/ActiveStakesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ function StakeRow({ stake, isPendingUnstake, onUnstake }: StakeRowProps) {
const expired = useMemo(() => isExpired(stake.lockExpiresAt), [stake.lockExpiresAt]);
const countdown = useMemo(
() => (expired ? 'Unlocked' : formatCountdown(stake.lockExpiresAt)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[expired, stake.lockExpiresAt]
);
const lockDate = new Date(stake.lockExpiresAt).toLocaleDateString(undefined, {
Expand Down
40 changes: 7 additions & 33 deletions src/components/common/BuyCooldownCountdown.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import { Timer } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
computeRemainingCooldownSeconds,
formatCooldownDuration,
} from '@/utils/buyCooldown.utils';
import { useBuyCooldownCountdown } from '@/hooks/useBuyCooldownCountdown';

export interface BuyCooldownCountdownProps {
/** Timestamp (ms epoch, seconds epoch, or ISO string) after which the authenticated user can next buy this key. */
Expand All @@ -25,38 +22,15 @@ export const BuyCooldownCountdown: React.FC<BuyCooldownCountdownProps> = ({
onExpire,
className,
}) => {
const [remainingSeconds, setRemainingSeconds] = useState<number>(() =>
computeRemainingCooldownSeconds(nextBuyAllowedAt)
const { remainingSeconds, formattedRemaining } = useBuyCooldownCountdown(
nextBuyAllowedAt,
onExpire
);

useEffect(() => {
const initialRemaining = computeRemainingCooldownSeconds(nextBuyAllowedAt);
setRemainingSeconds(initialRemaining);

if (initialRemaining <= 0) {
onExpire?.();
return;
}

const intervalId = setInterval(() => {
const rem = computeRemainingCooldownSeconds(nextBuyAllowedAt);
setRemainingSeconds(rem);

if (rem <= 0) {
clearInterval(intervalId);
onExpire?.();
}
}, 1000);

return () => clearInterval(intervalId);
}, [nextBuyAllowedAt, onExpire]);

if (remainingSeconds <= 0 || !nextBuyAllowedAt) {
return null;
}

const formattedTime = formatCooldownDuration(remainingSeconds);

return (
<div
className={cn(
Expand All @@ -66,12 +40,12 @@ export const BuyCooldownCountdown: React.FC<BuyCooldownCountdownProps> = ({
data-testid="buy-cooldown-countdown"
role="status"
aria-live="polite"
aria-label={`Next buy available in ${formattedTime}`}
aria-label={`Next buy available in ${formattedRemaining}`}
>
<Timer className="size-3.5 shrink-0 text-sky-400" />
<span data-testid="buy-cooldown-text">
Next buy available in{' '}
<span className="font-mono tabular-nums">{formattedTime}</span>
<span className="font-mono tabular-nums">{formattedRemaining}</span>
</span>
</div>
);
Expand Down
123 changes: 123 additions & 0 deletions src/hooks/__tests__/useBuyCooldownCountdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useBuyCooldownCountdown } from '@/hooks/useBuyCooldownCountdown';

const NOW_SEC = 1_700_000_000;

describe('useBuyCooldownCountdown (#915)', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW_SEC * 1000);
});

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

it('is inactive with no cooldown data and starts no timer', () => {
const { result } = renderHook(() => useBuyCooldownCountdown(null));

expect(result.current.isActive).toBe(false);
expect(result.current.remainingSeconds).toBe(0);
expect(vi.getTimerCount()).toBe(0);
});

it('is inactive when the cooldown has already expired and starts no timer', () => {
const { result } = renderHook(() =>
useBuyCooldownCountdown(NOW_SEC - 10)
);

expect(result.current.isActive).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});

it('reports the remaining time formatted for display', () => {
const { result } = renderHook(() => useBuyCooldownCountdown(NOW_SEC + 272));

expect(result.current.isActive).toBe(true);
expect(result.current.remainingSeconds).toBe(272);
expect(result.current.formattedRemaining).toBe('4m 32s');
});

it('accepts a millisecond epoch expiry', () => {
const { result } = renderHook(() =>
useBuyCooldownCountdown((NOW_SEC + 65) * 1000)
);

expect(result.current.remainingSeconds).toBe(65);
expect(result.current.formattedRemaining).toBe('1m 05s');
});

it('deactivates on the tick that crosses zero so the buy action re-enables', () => {
const { result } = renderHook(() => useBuyCooldownCountdown(NOW_SEC + 2));

expect(result.current.isActive).toBe(true);
expect(result.current.formattedRemaining).toBe('2s');

act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current.isActive).toBe(true);
expect(result.current.formattedRemaining).toBe('1s');

act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current.isActive).toBe(false);
expect(result.current.remainingSeconds).toBe(0);
});

it('calls onExpire exactly once and stops ticking', () => {
const onExpire = vi.fn();
renderHook(() => useBuyCooldownCountdown(NOW_SEC + 1, onExpire));

act(() => {
vi.advanceTimersByTime(5000);
});

expect(onExpire).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});

it('does not call onExpire when the cooldown is already over on mount', () => {
const onExpire = vi.fn();
renderHook(() => useBuyCooldownCountdown(NOW_SEC - 1, onExpire));

act(() => {
vi.advanceTimersByTime(5000);
});

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

it('does not restart the interval when an inline onExpire callback changes identity', () => {
renderHook(() => useBuyCooldownCountdown(NOW_SEC + 10, () => {}));

const timerCountAfterMount = vi.getTimerCount();
act(() => {
vi.advanceTimersByTime(3000);
});

expect(vi.getTimerCount()).toBe(timerCountAfterMount);
});

it('recomputes against a new expiry when the cooldown value changes', () => {
const { result, rerender } = renderHook(
({ nextBuyAllowedAt }: { nextBuyAllowedAt: number | null }) =>
useBuyCooldownCountdown(nextBuyAllowedAt),
{ wrapper: undefined, initialProps: { nextBuyAllowedAt: NOW_SEC + 10 } }
);

expect(result.current.remainingSeconds).toBe(10);

rerender({ nextBuyAllowedAt: NOW_SEC + 120 });

expect(result.current.remainingSeconds).toBe(120);
expect(result.current.formattedRemaining).toBe('2m 00s');

rerender({ nextBuyAllowedAt: null });

expect(result.current.isActive).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
});
126 changes: 126 additions & 0 deletions src/hooks/__tests__/useKeyBuyCooldown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useKeyBuyCooldown } from '@/hooks/useKeyBuyCooldown';
import { courseService } from '@/services/course.service';
import { queryKeys } from '@/lib/queryKeys';

vi.mock('@/services/course.service', () => ({
courseService: {
getKeyBuyCooldown: vi.fn(),
},
}));

const mockGetKeyBuyCooldown = vi.mocked(courseService.getKeyBuyCooldown);

describe('useKeyBuyCooldown (#915)', () => {
let queryClient: QueryClient;

beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
mockGetKeyBuyCooldown.mockReset();
});

afterEach(() => {
queryClient.clear();
});

const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

it('reads the cooldown expiry for the connected wallet and the key', async () => {
const nextBuyAllowedAt = 1_700_000_272;
mockGetKeyBuyCooldown.mockResolvedValue({
keyId: 'key-1',
wallet: 'wallet-1',
nextBuyAllowedAt,
cooldownLedgers: 12,
});

const { result } = renderHook(
() => useKeyBuyCooldown('key-1', 'wallet-1'),
{ wrapper }
);

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(mockGetKeyBuyCooldown).toHaveBeenCalledWith('key-1', 'wallet-1');
expect(result.current.data?.nextBuyAllowedAt).toBe(nextBuyAllowedAt);

const query = queryClient
.getQueryCache()
.find({ queryKey: queryKeys.creators.buyCooldown('key-1', 'wallet-1') });
expect(query).toBeDefined();
expect(query?.options.staleTime).toBe(15_000);
});

it('treats a null cooldown as a successful "no cooldown" answer', async () => {
mockGetKeyBuyCooldown.mockResolvedValue(null);

const { result } = renderHook(
() => useKeyBuyCooldown('key-1', 'wallet-1'),
{ wrapper }
);

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data).toBeNull();
expect(result.current.isError).toBe(false);
});

it('does not fetch until a wallet is connected, then fetches on connect', async () => {
mockGetKeyBuyCooldown.mockResolvedValue({ nextBuyAllowedAt: null });

const { result, rerender } = renderHook(
({ wallet }: { wallet: string | undefined }) =>
useKeyBuyCooldown('key-1', wallet),
{ wrapper, initialProps: { wallet: undefined as string | undefined } }
);

expect(result.current.fetchStatus).toBe('idle');
expect(mockGetKeyBuyCooldown).not.toHaveBeenCalled();

// Wallet connects while the key page is open.
rerender({ wallet: 'wallet-1' });

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockGetKeyBuyCooldown).toHaveBeenCalledWith('key-1', 'wallet-1');
});

it('reads again for a different wallet instead of reusing the previous cache entry', async () => {
mockGetKeyBuyCooldown.mockResolvedValue({ nextBuyAllowedAt: 1_700_000_272 });

const { result, rerender } = renderHook(
({ wallet }: { wallet: string }) => useKeyBuyCooldown('key-1', wallet),
{ wrapper, initialProps: { wallet: 'wallet-1' } }
);

await waitFor(() => expect(result.current.isSuccess).toBe(true));

rerender({ wallet: 'wallet-2' });

await waitFor(() =>
expect(mockGetKeyBuyCooldown).toHaveBeenCalledWith('key-1', 'wallet-2')
);
expect(
queryClient
.getQueryCache()
.find({ queryKey: queryKeys.creators.buyCooldown('key-1', 'wallet-1') })
).toBeDefined();
expect(
queryClient
.getQueryCache()
.find({ queryKey: queryKeys.creators.buyCooldown('key-1', 'wallet-2') })
).toBeDefined();
});

it('does not fetch when the key id is empty', () => {
renderHook(() => useKeyBuyCooldown('', 'wallet-1'), { wrapper });

expect(mockGetKeyBuyCooldown).not.toHaveBeenCalled();
});
});
Loading
Loading