perfect-postcode/frontend/src/hooks/useClickedListings.test.ts
2026-07-12 15:10:26 +01:00

98 lines
3.5 KiB
TypeScript

import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
create: vi.fn(),
getFullList: vi.fn(),
authStore: {
isValid: true,
record: { id: 'user-1' } as { id: string } | null,
onChange: () => () => {},
},
}));
vi.mock('../lib/pocketbase', () => ({
default: {
authStore: mocks.authStore,
collection: () => ({ getFullList: mocks.getFullList, create: mocks.create }),
},
}));
import { useClickedListings } from './useClickedListings';
describe('useClickedListings', () => {
beforeEach(() => {
mocks.create.mockReset().mockResolvedValue({});
mocks.getFullList.mockReset().mockResolvedValue([]);
mocks.authStore.isValid = true;
mocks.authStore.record = { id: 'user-1' };
});
afterEach(() => {
vi.clearAllMocks();
});
it('hydrates the visited set from PocketBase for the signed-in user', async () => {
mocks.getFullList.mockResolvedValue([{ url: 'https://example.com/a' }]);
const { result } = renderHook(() => useClickedListings());
await waitFor(() => expect(result.current.clickedUrls.has('https://example.com/a')).toBe(true));
});
it('records a click optimistically and persists it', async () => {
const { result } = renderHook(() => useClickedListings());
await waitFor(() => expect(mocks.getFullList).toHaveBeenCalled());
act(() => result.current.markClicked('https://example.com/b'));
// Visible immediately. No need to await the network round-trip.
expect(result.current.clickedUrls.has('https://example.com/b')).toBe(true);
expect(mocks.create).toHaveBeenCalledWith({
user: 'user-1',
url: 'https://example.com/b',
});
});
it('produces a new Set instance for a new url so deck.gl re-colours', async () => {
const { result } = renderHook(() => useClickedListings());
await waitFor(() => expect(mocks.getFullList).toHaveBeenCalled());
const before = result.current.clickedUrls;
act(() => result.current.markClicked('https://example.com/c'));
expect(result.current.clickedUrls).not.toBe(before);
});
it('does not re-persist an already visited url', async () => {
mocks.getFullList.mockResolvedValue([{ url: 'https://example.com/a' }]);
const { result } = renderHook(() => useClickedListings());
await waitFor(() => expect(result.current.clickedUrls.has('https://example.com/a')).toBe(true));
const before = result.current.clickedUrls;
act(() => result.current.markClicked('https://example.com/a'));
expect(mocks.create).not.toHaveBeenCalled();
expect(result.current.clickedUrls).toBe(before); // identity stable → no needless recompute
});
it('ignores empty urls', async () => {
const { result } = renderHook(() => useClickedListings());
await waitFor(() => expect(mocks.getFullList).toHaveBeenCalled());
act(() => result.current.markClicked(''));
act(() => result.current.markClicked(undefined));
expect(mocks.create).not.toHaveBeenCalled();
expect(result.current.clickedUrls.size).toBe(0);
});
it('recolours for anonymous users in-memory but does not persist', async () => {
mocks.authStore.isValid = false;
mocks.authStore.record = null;
const { result } = renderHook(() => useClickedListings());
act(() => result.current.markClicked('https://example.com/d'));
expect(result.current.clickedUrls.has('https://example.com/d')).toBe(true);
expect(mocks.create).not.toHaveBeenCalled();
});
});