diff --git a/.beans/nuzlocke-tracker-ee9s--unit-tests-for-frontend-utilities-and-hooks.md b/.beans/nuzlocke-tracker-ee9s--unit-tests-for-frontend-utilities-and-hooks.md index ecc0e92..5ea9dcc 100644 --- a/.beans/nuzlocke-tracker-ee9s--unit-tests-for-frontend-utilities-and-hooks.md +++ b/.beans/nuzlocke-tracker-ee9s--unit-tests-for-frontend-utilities-and-hooks.md @@ -1,31 +1,27 @@ --- # nuzlocke-tracker-ee9s title: Unit tests for frontend utilities and hooks -status: draft +status: completed type: task +priority: normal created_at: 2026-02-10T09:33:38Z -updated_at: 2026-02-10T09:33:38Z +updated_at: 2026-02-21T12:47:19Z parent: nuzlocke-tracker-yzpb --- Write unit tests for the frontend utility functions and custom React hooks. +All API modules are mocked with `vi.mock`. Hooks are tested with `renderHook` from @testing-library/react, wrapped in `QueryClientProvider`. Mutation tests spy on `queryClient.invalidateQueries` to verify cache invalidation. + ## Checklist -- [ ] Test `utils/formatEvolution.ts` — evolution chain formatting logic -- [ ] Test `utils/download.ts` — file download utility -- [ ] Test `hooks/useRuns.ts` — run CRUD hook with mocked API -- [ ] Test `hooks/useGames.ts` — game fetching hook -- [ ] Test `hooks/useEncounters.ts` — encounter operations hook -- [ ] Test `hooks/usePokemon.ts` — pokemon data hook -- [ ] Test `hooks/useGenlockes.ts` — genlocke operations hook -- [ ] Test `hooks/useBosses.ts` — boss operations hook -- [ ] Test `hooks/useStats.ts` — stats fetching hook -- [ ] Test `hooks/useAdmin.ts` — admin operations hook - -## Notes - -- Utility functions are pure functions — straightforward to test -- Hooks wrap React Query — test that they call the right API endpoints, handle loading/error states, and invalidate queries correctly -- Use `@testing-library/react`'s `renderHook` for hook testing -- Mock the API client (from `src/api/`) rather than individual fetch calls \ No newline at end of file +- [x] Test `utils/formatEvolution.ts` — done in smoke test +- [x] Test `utils/download.ts` — blob URL creation, filename, cleanup +- [x] Test `hooks/useGames.ts` — query hooks and disabled state +- [x] Test `hooks/useRuns.ts` — query hooks + mutations with cache invalidation +- [x] Test `hooks/useEncounters.ts` — mutations and conditional queries +- [x] Test `hooks/usePokemon.ts` — conditional queries +- [x] Test `hooks/useGenlockes.ts` — queries and mutations +- [x] Test `hooks/useBosses.ts` — queries and mutations +- [x] Test `hooks/useStats.ts` — single query hook +- [x] Test `hooks/useAdmin.ts` — representative subset (usePokemonList, useCreateGame, useDeleteGame) diff --git a/.beans/nuzlocke-tracker-yzpb--implement-unit-integration-tests.md b/.beans/nuzlocke-tracker-yzpb--implement-unit-integration-tests.md index 65674e8..d361de7 100644 --- a/.beans/nuzlocke-tracker-yzpb--implement-unit-integration-tests.md +++ b/.beans/nuzlocke-tracker-yzpb--implement-unit-integration-tests.md @@ -24,5 +24,5 @@ Add comprehensive unit and integration test coverage to both the backend (FastAP - [ ] Backend schemas and services have unit test coverage - [x] Backend API endpoints have integration test coverage - [x] Frontend test infrastructure is set up (Vitest, RTL) -- [ ] Frontend utilities and hooks have unit test coverage +- [x] Frontend utilities and hooks have unit test coverage - [ ] Frontend components have basic render/interaction tests \ No newline at end of file diff --git a/frontend/src/hooks/useAdmin.test.tsx b/frontend/src/hooks/useAdmin.test.tsx new file mode 100644 index 0000000..6c7c8e7 --- /dev/null +++ b/frontend/src/hooks/useAdmin.test.tsx @@ -0,0 +1,148 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor, act } from '@testing-library/react' +import { createTestQueryClient } from '../test/utils' +import { usePokemonList, useCreateGame, useUpdateGame, useDeleteGame } from './useAdmin' + +vi.mock('../api/admin') +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +import * as adminApi from '../api/admin' +import { toast } from 'sonner' + +function createWrapper() { + const queryClient = createTestQueryClient() + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + return { queryClient, wrapper } +} + +describe('usePokemonList', () => { + it('calls listPokemon with defaults', async () => { + vi.mocked(adminApi.listPokemon).mockResolvedValue({ results: [], total: 0 } as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => usePokemonList(), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(adminApi.listPokemon).toHaveBeenCalledWith(undefined, 50, 0, undefined) + }) + + it('passes search and filter params to listPokemon', async () => { + vi.mocked(adminApi.listPokemon).mockResolvedValue({ results: [], total: 0 } as never) + const { wrapper } = createWrapper() + + renderHook(() => usePokemonList('pika', 10, 20, 'electric'), { wrapper }) + await waitFor(() => + expect(adminApi.listPokemon).toHaveBeenCalledWith('pika', 10, 20, 'electric') + ) + }) +}) + +describe('useCreateGame', () => { + it('calls createGame with the provided input', async () => { + vi.mocked(adminApi.createGame).mockResolvedValue({ id: 1 } as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useCreateGame(), { wrapper }) + const input = { name: 'FireRed', slug: 'firered', generation: 3, region: 'kanto', vgId: 1 } + await act(async () => { + await result.current.mutateAsync(input as never) + }) + + expect(adminApi.createGame).toHaveBeenCalledWith(input) + }) + + it('invalidates the games query on success', async () => { + vi.mocked(adminApi.createGame).mockResolvedValue({} as never) + const { queryClient, wrapper } = createWrapper() + const spy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useCreateGame(), { wrapper }) + await act(async () => { + await result.current.mutateAsync({} as never) + }) + + expect(spy).toHaveBeenCalledWith({ queryKey: ['games'] }) + }) + + it('shows a success toast after creating a game', async () => { + vi.mocked(adminApi.createGame).mockResolvedValue({} as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useCreateGame(), { wrapper }) + await act(async () => { + await result.current.mutateAsync({} as never) + }) + + expect(toast.success).toHaveBeenCalledWith('Game created') + }) + + it('shows an error toast on failure', async () => { + vi.mocked(adminApi.createGame).mockRejectedValue(new Error('Conflict')) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useCreateGame(), { wrapper }) + await act(async () => { + result.current.mutate({} as never) + }) + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to create game: Conflict')) + }) +}) + +describe('useUpdateGame', () => { + it('calls updateGame with id and data', async () => { + vi.mocked(adminApi.updateGame).mockResolvedValue({} as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useUpdateGame(), { wrapper }) + await act(async () => { + await result.current.mutateAsync({ id: 7, data: { name: 'Renamed' } } as never) + }) + + expect(adminApi.updateGame).toHaveBeenCalledWith(7, { name: 'Renamed' }) + }) + + it('invalidates games and shows a toast on success', async () => { + vi.mocked(adminApi.updateGame).mockResolvedValue({} as never) + const { queryClient, wrapper } = createWrapper() + const spy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useUpdateGame(), { wrapper }) + await act(async () => { + await result.current.mutateAsync({ id: 1, data: {} } as never) + }) + + expect(spy).toHaveBeenCalledWith({ queryKey: ['games'] }) + expect(toast.success).toHaveBeenCalledWith('Game updated') + }) +}) + +describe('useDeleteGame', () => { + it('calls deleteGame with the given id', async () => { + vi.mocked(adminApi.deleteGame).mockResolvedValue(undefined as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useDeleteGame(), { wrapper }) + await act(async () => { + await result.current.mutateAsync(3) + }) + + expect(adminApi.deleteGame).toHaveBeenCalledWith(3) + }) + + it('invalidates games and shows a toast on success', async () => { + vi.mocked(adminApi.deleteGame).mockResolvedValue(undefined as never) + const { queryClient, wrapper } = createWrapper() + const spy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useDeleteGame(), { wrapper }) + await act(async () => { + await result.current.mutateAsync(3) + }) + + expect(spy).toHaveBeenCalledWith({ queryKey: ['games'] }) + expect(toast.success).toHaveBeenCalledWith('Game deleted') + }) +}) diff --git a/frontend/src/hooks/useBosses.test.tsx b/frontend/src/hooks/useBosses.test.tsx new file mode 100644 index 0000000..a77be18 --- /dev/null +++ b/frontend/src/hooks/useBosses.test.tsx @@ -0,0 +1,118 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor, act } from '@testing-library/react' +import { createTestQueryClient } from '../test/utils' +import { + useGameBosses, + useBossResults, + useCreateBossResult, + useDeleteBossResult, +} from './useBosses' + +vi.mock('../api/bosses') +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +import { getGameBosses, getBossResults, createBossResult, deleteBossResult } from '../api/bosses' + +function createWrapper() { + const queryClient = createTestQueryClient() + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + return { queryClient, wrapper } +} + +describe('useGameBosses', () => { + it('is disabled when gameId is null', () => { + const { wrapper } = createWrapper() + const { result } = renderHook(() => useGameBosses(null), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + expect(getGameBosses).not.toHaveBeenCalled() + }) + + it('fetches bosses for a given game', async () => { + const bosses = [{ id: 1, name: 'Brock' }] + vi.mocked(getGameBosses).mockResolvedValue(bosses as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useGameBosses(1), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(getGameBosses).toHaveBeenCalledWith(1, undefined) + expect(result.current.data).toEqual(bosses) + }) + + it('passes the all flag to the API', async () => { + vi.mocked(getGameBosses).mockResolvedValue([] as never) + const { wrapper } = createWrapper() + + renderHook(() => useGameBosses(2, true), { wrapper }) + await waitFor(() => expect(getGameBosses).toHaveBeenCalledWith(2, true)) + }) +}) + +describe('useBossResults', () => { + it('fetches boss results for a given run', async () => { + vi.mocked(getBossResults).mockResolvedValue([] as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useBossResults(10), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(getBossResults).toHaveBeenCalledWith(10) + }) +}) + +describe('useCreateBossResult', () => { + it('calls createBossResult with the run id and input', async () => { + vi.mocked(createBossResult).mockResolvedValue({} as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useCreateBossResult(5), { wrapper }) + const input = { bossId: 1, won: true } + await act(async () => { + await result.current.mutateAsync(input as never) + }) + + expect(createBossResult).toHaveBeenCalledWith(5, input) + }) + + it('invalidates boss results for the run on success', async () => { + vi.mocked(createBossResult).mockResolvedValue({} as never) + const { queryClient, wrapper } = createWrapper() + const spy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useCreateBossResult(5), { wrapper }) + await act(async () => { + await result.current.mutateAsync({} as never) + }) + + expect(spy).toHaveBeenCalledWith({ queryKey: ['runs', 5, 'boss-results'] }) + }) +}) + +describe('useDeleteBossResult', () => { + it('calls deleteBossResult with the run id and result id', async () => { + vi.mocked(deleteBossResult).mockResolvedValue(undefined as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useDeleteBossResult(5), { wrapper }) + await act(async () => { + await result.current.mutateAsync(99) + }) + + expect(deleteBossResult).toHaveBeenCalledWith(5, 99) + }) + + it('invalidates boss results for the run on success', async () => { + vi.mocked(deleteBossResult).mockResolvedValue(undefined as never) + const { queryClient, wrapper } = createWrapper() + const spy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useDeleteBossResult(5), { wrapper }) + await act(async () => { + await result.current.mutateAsync(99) + }) + + expect(spy).toHaveBeenCalledWith({ queryKey: ['runs', 5, 'boss-results'] }) + }) +}) diff --git a/frontend/src/hooks/useEncounters.test.tsx b/frontend/src/hooks/useEncounters.test.tsx new file mode 100644 index 0000000..91bd9ff --- /dev/null +++ b/frontend/src/hooks/useEncounters.test.tsx @@ -0,0 +1,161 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor, act } from '@testing-library/react' +import { createTestQueryClient } from '../test/utils' +import { + useCreateEncounter, + useUpdateEncounter, + useDeleteEncounter, + useEvolutions, + useForms, + useBulkRandomize, +} from './useEncounters' + +vi.mock('../api/encounters') + +import { + createEncounter, + updateEncounter, + deleteEncounter, + fetchEvolutions, + fetchForms, + bulkRandomizeEncounters, +} from '../api/encounters' + +function createWrapper() { + const queryClient = createTestQueryClient() + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + return { queryClient, wrapper } +} + +describe('useCreateEncounter', () => { + it('calls createEncounter with the run id and input', async () => { + vi.mocked(createEncounter).mockResolvedValue({} as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useCreateEncounter(3), { wrapper }) + const input = { routeId: 1, pokemonId: 25, status: 'caught' } + await act(async () => { + await result.current.mutateAsync(input as never) + }) + + expect(createEncounter).toHaveBeenCalledWith(3, input) + }) + + it('invalidates the run query on success', async () => { + vi.mocked(createEncounter).mockResolvedValue({} as never) + const { queryClient, wrapper } = createWrapper() + const spy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useCreateEncounter(3), { wrapper }) + await act(async () => { + await result.current.mutateAsync({} as never) + }) + + expect(spy).toHaveBeenCalledWith({ queryKey: ['runs', 3] }) + }) +}) + +describe('useUpdateEncounter', () => { + it('calls updateEncounter with id and data', async () => { + vi.mocked(updateEncounter).mockResolvedValue({} as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useUpdateEncounter(3), { wrapper }) + await act(async () => { + await result.current.mutateAsync({ id: 42, data: { status: 'dead' } } as never) + }) + + expect(updateEncounter).toHaveBeenCalledWith(42, { status: 'dead' }) + }) + + it('invalidates the run query on success', async () => { + vi.mocked(updateEncounter).mockResolvedValue({} as never) + const { queryClient, wrapper } = createWrapper() + const spy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useUpdateEncounter(3), { wrapper }) + await act(async () => { + await result.current.mutateAsync({ id: 1, data: {} } as never) + }) + + expect(spy).toHaveBeenCalledWith({ queryKey: ['runs', 3] }) + }) +}) + +describe('useDeleteEncounter', () => { + it('calls deleteEncounter with the encounter id', async () => { + vi.mocked(deleteEncounter).mockResolvedValue(undefined as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useDeleteEncounter(3), { wrapper }) + await act(async () => { + await result.current.mutateAsync(55) + }) + + expect(deleteEncounter).toHaveBeenCalledWith(55) + }) + + it('invalidates the run query on success', async () => { + vi.mocked(deleteEncounter).mockResolvedValue(undefined as never) + const { queryClient, wrapper } = createWrapper() + const spy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useDeleteEncounter(3), { wrapper }) + await act(async () => { + await result.current.mutateAsync(55) + }) + + expect(spy).toHaveBeenCalledWith({ queryKey: ['runs', 3] }) + }) +}) + +describe('useEvolutions', () => { + it('is disabled when pokemonId is null', () => { + const { wrapper } = createWrapper() + const { result } = renderHook(() => useEvolutions(null), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + expect(fetchEvolutions).not.toHaveBeenCalled() + }) + + it('fetches evolutions for a given pokemon', async () => { + vi.mocked(fetchEvolutions).mockResolvedValue([] as never) + const { wrapper } = createWrapper() + + renderHook(() => useEvolutions(25, 'kanto'), { wrapper }) + await waitFor(() => expect(fetchEvolutions).toHaveBeenCalledWith(25, 'kanto')) + }) +}) + +describe('useForms', () => { + it('is disabled when pokemonId is null', () => { + const { wrapper } = createWrapper() + const { result } = renderHook(() => useForms(null), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + }) + + it('fetches forms for a given pokemon', async () => { + vi.mocked(fetchForms).mockResolvedValue([] as never) + const { wrapper } = createWrapper() + + renderHook(() => useForms(133), { wrapper }) + await waitFor(() => expect(fetchForms).toHaveBeenCalledWith(133)) + }) +}) + +describe('useBulkRandomize', () => { + it('calls bulkRandomizeEncounters and invalidates the run', async () => { + vi.mocked(bulkRandomizeEncounters).mockResolvedValue([] as never) + const { queryClient, wrapper } = createWrapper() + const spy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useBulkRandomize(4), { wrapper }) + await act(async () => { + await result.current.mutateAsync() + }) + + expect(bulkRandomizeEncounters).toHaveBeenCalledWith(4) + expect(spy).toHaveBeenCalledWith({ queryKey: ['runs', 4] }) + }) +}) diff --git a/frontend/src/hooks/useGames.test.tsx b/frontend/src/hooks/useGames.test.tsx new file mode 100644 index 0000000..c5b15a9 --- /dev/null +++ b/frontend/src/hooks/useGames.test.tsx @@ -0,0 +1,89 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { createTestQueryClient } from '../test/utils' +import { useGames, useGame, useGameRoutes, useRoutePokemon } from './useGames' + +vi.mock('../api/games') + +import { getGames, getGame, getGameRoutes, getRoutePokemon } from '../api/games' + +function createWrapper() { + const queryClient = createTestQueryClient() + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + return { queryClient, wrapper } +} + +describe('useGames', () => { + it('calls getGames and returns data', async () => { + const games = [{ id: 1, name: 'Red' }] + vi.mocked(getGames).mockResolvedValue(games as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useGames(), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(getGames).toHaveBeenCalledOnce() + expect(result.current.data).toEqual(games) + }) +}) + +describe('useGame', () => { + it('calls getGame with the given id', async () => { + const game = { id: 2, name: 'Blue' } + vi.mocked(getGame).mockResolvedValue(game as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useGame(2), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(getGame).toHaveBeenCalledWith(2) + }) +}) + +describe('useGameRoutes', () => { + it('is disabled when gameId is null', () => { + const { wrapper } = createWrapper() + const { result } = renderHook(() => useGameRoutes(null), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + expect(getGameRoutes).not.toHaveBeenCalled() + }) + + it('fetches routes when gameId is provided', async () => { + const routes = [{ id: 10, name: 'Route 1' }] + vi.mocked(getGameRoutes).mockResolvedValue(routes as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useGameRoutes(1), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(getGameRoutes).toHaveBeenCalledWith(1, undefined) + expect(result.current.data).toEqual(routes) + }) + + it('passes allowedTypes to the API', async () => { + vi.mocked(getGameRoutes).mockResolvedValue([] as never) + const { wrapper } = createWrapper() + + renderHook(() => useGameRoutes(5, ['grass', 'water']), { wrapper }) + await waitFor(() => expect(getGameRoutes).toHaveBeenCalledWith(5, ['grass', 'water'])) + }) +}) + +describe('useRoutePokemon', () => { + it('is disabled when routeId is null', () => { + const { wrapper } = createWrapper() + const { result } = renderHook(() => useRoutePokemon(null), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + expect(getRoutePokemon).not.toHaveBeenCalled() + }) + + it('fetches pokemon for a given route', async () => { + vi.mocked(getRoutePokemon).mockResolvedValue([] as never) + const { wrapper } = createWrapper() + + renderHook(() => useRoutePokemon(3, 1), { wrapper }) + await waitFor(() => expect(getRoutePokemon).toHaveBeenCalledWith(3, 1)) + }) +}) diff --git a/frontend/src/hooks/useGenlockes.test.tsx b/frontend/src/hooks/useGenlockes.test.tsx new file mode 100644 index 0000000..bcd2e73 --- /dev/null +++ b/frontend/src/hooks/useGenlockes.test.tsx @@ -0,0 +1,178 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor, act } from '@testing-library/react' +import { createTestQueryClient } from '../test/utils' +import { + useGenlockes, + useGenlocke, + useGenlockeGraveyard, + useGenlockeLineages, + useRegions, + useCreateGenlocke, + useLegSurvivors, + useAdvanceLeg, +} from './useGenlockes' + +vi.mock('../api/genlockes') + +import { + getGenlockes, + getGenlocke, + getGenlockeGraveyard, + getGenlockeLineages, + getGamesByRegion, + createGenlocke, + getLegSurvivors, + advanceLeg, +} from '../api/genlockes' + +function createWrapper() { + const queryClient = createTestQueryClient() + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + return { queryClient, wrapper } +} + +describe('useGenlockes', () => { + it('calls getGenlockes and returns data', async () => { + const genlockes = [{ id: 1, name: 'Gen 1 Run' }] + vi.mocked(getGenlockes).mockResolvedValue(genlockes as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useGenlockes(), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(getGenlockes).toHaveBeenCalledOnce() + expect(result.current.data).toEqual(genlockes) + }) +}) + +describe('useGenlocke', () => { + it('calls getGenlocke with the given id', async () => { + vi.mocked(getGenlocke).mockResolvedValue({ id: 2 } as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useGenlocke(2), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(getGenlocke).toHaveBeenCalledWith(2) + }) +}) + +describe('useGenlockeGraveyard', () => { + it('calls getGenlockeGraveyard with the given id', async () => { + vi.mocked(getGenlockeGraveyard).mockResolvedValue([] as never) + const { wrapper } = createWrapper() + + renderHook(() => useGenlockeGraveyard(3), { wrapper }) + await waitFor(() => expect(getGenlockeGraveyard).toHaveBeenCalledWith(3)) + }) +}) + +describe('useGenlockeLineages', () => { + it('calls getGenlockeLineages with the given id', async () => { + vi.mocked(getGenlockeLineages).mockResolvedValue([] as never) + const { wrapper } = createWrapper() + + renderHook(() => useGenlockeLineages(3), { wrapper }) + await waitFor(() => expect(getGenlockeLineages).toHaveBeenCalledWith(3)) + }) +}) + +describe('useRegions', () => { + it('calls getGamesByRegion', async () => { + vi.mocked(getGamesByRegion).mockResolvedValue([] as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useRegions(), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(getGamesByRegion).toHaveBeenCalledOnce() + }) +}) + +describe('useCreateGenlocke', () => { + it('calls createGenlocke with the provided input', async () => { + vi.mocked(createGenlocke).mockResolvedValue({ id: 10 } as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useCreateGenlocke(), { wrapper }) + const input = { name: 'New Genlocke', gameIds: [1, 2] } + await act(async () => { + await result.current.mutateAsync(input as never) + }) + + expect(createGenlocke).toHaveBeenCalledWith(input) + }) + + it('invalidates both runs and genlockes on success', async () => { + vi.mocked(createGenlocke).mockResolvedValue({} as never) + const { queryClient, wrapper } = createWrapper() + const spy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useCreateGenlocke(), { wrapper }) + await act(async () => { + await result.current.mutateAsync({} as never) + }) + + expect(spy).toHaveBeenCalledWith({ queryKey: ['runs'] }) + expect(spy).toHaveBeenCalledWith({ queryKey: ['genlockes'] }) + }) +}) + +describe('useLegSurvivors', () => { + it('is disabled when enabled is false', () => { + const { wrapper } = createWrapper() + const { result } = renderHook(() => useLegSurvivors(1, 1, false), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + expect(getLegSurvivors).not.toHaveBeenCalled() + }) + + it('fetches survivors when enabled', async () => { + vi.mocked(getLegSurvivors).mockResolvedValue([] as never) + const { wrapper } = createWrapper() + + renderHook(() => useLegSurvivors(1, 2, true), { wrapper }) + await waitFor(() => expect(getLegSurvivors).toHaveBeenCalledWith(1, 2)) + }) +}) + +describe('useAdvanceLeg', () => { + it('calls advanceLeg with genlocke id and leg order', async () => { + vi.mocked(advanceLeg).mockResolvedValue({} as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useAdvanceLeg(), { wrapper }) + await act(async () => { + await result.current.mutateAsync({ genlockeId: 1, legOrder: 1 }) + }) + + expect(advanceLeg).toHaveBeenCalledWith(1, 1, undefined) + }) + + it('passes transferEncounterIds when provided', async () => { + vi.mocked(advanceLeg).mockResolvedValue({} as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useAdvanceLeg(), { wrapper }) + await act(async () => { + await result.current.mutateAsync({ genlockeId: 2, legOrder: 3, transferEncounterIds: [4, 5] }) + }) + + expect(advanceLeg).toHaveBeenCalledWith(2, 3, { transferEncounterIds: [4, 5] }) + }) + + it('invalidates runs and genlockes on success', async () => { + vi.mocked(advanceLeg).mockResolvedValue({} as never) + const { queryClient, wrapper } = createWrapper() + const spy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useAdvanceLeg(), { wrapper }) + await act(async () => { + await result.current.mutateAsync({ genlockeId: 1, legOrder: 1 }) + }) + + expect(spy).toHaveBeenCalledWith({ queryKey: ['runs'] }) + expect(spy).toHaveBeenCalledWith({ queryKey: ['genlockes'] }) + }) +}) diff --git a/frontend/src/hooks/usePokemon.test.tsx b/frontend/src/hooks/usePokemon.test.tsx new file mode 100644 index 0000000..e564cbb --- /dev/null +++ b/frontend/src/hooks/usePokemon.test.tsx @@ -0,0 +1,93 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { createTestQueryClient } from '../test/utils' +import { + usePokemon, + usePokemonFamilies, + usePokemonEncounterLocations, + usePokemonEvolutionChain, +} from './usePokemon' + +vi.mock('../api/pokemon') + +import { + getPokemon, + fetchPokemonFamilies, + fetchPokemonEncounterLocations, + fetchPokemonEvolutionChain, +} from '../api/pokemon' + +function createWrapper() { + const queryClient = createTestQueryClient() + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + return { queryClient, wrapper } +} + +describe('usePokemon', () => { + it('is disabled when id is null', () => { + const { wrapper } = createWrapper() + const { result } = renderHook(() => usePokemon(null), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + expect(getPokemon).not.toHaveBeenCalled() + }) + + it('fetches a pokemon by id', async () => { + const mon = { id: 25, name: 'pikachu' } + vi.mocked(getPokemon).mockResolvedValue(mon as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => usePokemon(25), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(getPokemon).toHaveBeenCalledWith(25) + expect(result.current.data).toEqual(mon) + }) +}) + +describe('usePokemonFamilies', () => { + it('calls fetchPokemonFamilies and returns data', async () => { + const families = [{ id: 1, members: [] }] + vi.mocked(fetchPokemonFamilies).mockResolvedValue(families as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => usePokemonFamilies(), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(fetchPokemonFamilies).toHaveBeenCalledOnce() + expect(result.current.data).toEqual(families) + }) +}) + +describe('usePokemonEncounterLocations', () => { + it('is disabled when pokemonId is null', () => { + const { wrapper } = createWrapper() + const { result } = renderHook(() => usePokemonEncounterLocations(null), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + }) + + it('fetches encounter locations for a given pokemon', async () => { + vi.mocked(fetchPokemonEncounterLocations).mockResolvedValue([] as never) + const { wrapper } = createWrapper() + + renderHook(() => usePokemonEncounterLocations(25), { wrapper }) + await waitFor(() => expect(fetchPokemonEncounterLocations).toHaveBeenCalledWith(25)) + }) +}) + +describe('usePokemonEvolutionChain', () => { + it('is disabled when pokemonId is null', () => { + const { wrapper } = createWrapper() + const { result } = renderHook(() => usePokemonEvolutionChain(null), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + }) + + it('fetches the evolution chain for a given pokemon', async () => { + vi.mocked(fetchPokemonEvolutionChain).mockResolvedValue([] as never) + const { wrapper } = createWrapper() + + renderHook(() => usePokemonEvolutionChain(4), { wrapper }) + await waitFor(() => expect(fetchPokemonEvolutionChain).toHaveBeenCalledWith(4)) + }) +}) diff --git a/frontend/src/hooks/useRuns.test.tsx b/frontend/src/hooks/useRuns.test.tsx new file mode 100644 index 0000000..2613945 --- /dev/null +++ b/frontend/src/hooks/useRuns.test.tsx @@ -0,0 +1,181 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor, act } from '@testing-library/react' +import { createTestQueryClient } from '../test/utils' +import { + useRuns, + useRun, + useCreateRun, + useUpdateRun, + useDeleteRun, + useNamingCategories, + useNameSuggestions, +} from './useRuns' + +vi.mock('../api/runs') +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +import { getRuns, getRun, createRun, updateRun, deleteRun, getNamingCategories } from '../api/runs' +import { toast } from 'sonner' + +function createWrapper() { + const queryClient = createTestQueryClient() + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + return { queryClient, wrapper } +} + +describe('useRuns', () => { + it('calls getRuns and returns data', async () => { + const runs = [{ id: 1, name: 'My Run' }] + vi.mocked(getRuns).mockResolvedValue(runs as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useRuns(), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(getRuns).toHaveBeenCalledOnce() + expect(result.current.data).toEqual(runs) + }) +}) + +describe('useRun', () => { + it('calls getRun with the given id', async () => { + const run = { id: 3, name: 'Specific Run' } + vi.mocked(getRun).mockResolvedValue(run as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useRun(3), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(getRun).toHaveBeenCalledWith(3) + }) +}) + +describe('useCreateRun', () => { + it('calls createRun with the provided input', async () => { + vi.mocked(createRun).mockResolvedValue({ id: 10 } as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useCreateRun(), { wrapper }) + await act(async () => { + await result.current.mutateAsync({ name: 'New Run', gameId: 1, status: 'active' } as never) + }) + + expect(createRun).toHaveBeenCalledWith({ name: 'New Run', gameId: 1, status: 'active' }) + }) + + it('invalidates the runs query on success', async () => { + vi.mocked(createRun).mockResolvedValue({} as never) + const { queryClient, wrapper } = createWrapper() + const spy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useCreateRun(), { wrapper }) + await act(async () => { + await result.current.mutateAsync({} as never) + }) + + expect(spy).toHaveBeenCalledWith({ queryKey: ['runs'] }) + }) +}) + +describe('useUpdateRun', () => { + it('calls updateRun with the given id and data', async () => { + vi.mocked(updateRun).mockResolvedValue({} as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useUpdateRun(5), { wrapper }) + await act(async () => { + await result.current.mutateAsync({ name: 'Updated' } as never) + }) + + expect(updateRun).toHaveBeenCalledWith(5, { name: 'Updated' }) + }) + + it('invalidates both the list and individual run query on success', async () => { + vi.mocked(updateRun).mockResolvedValue({} as never) + const { queryClient, wrapper } = createWrapper() + const spy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useUpdateRun(5), { wrapper }) + await act(async () => { + await result.current.mutateAsync({} as never) + }) + + expect(spy).toHaveBeenCalledWith({ queryKey: ['runs'] }) + expect(spy).toHaveBeenCalledWith({ queryKey: ['runs', 5] }) + }) + + it('shows a toast when status is set to completed', async () => { + vi.mocked(updateRun).mockResolvedValue({} as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useUpdateRun(1), { wrapper }) + await act(async () => { + await result.current.mutateAsync({ status: 'completed' } as never) + }) + + expect(toast.success).toHaveBeenCalledWith('Run marked as completed!') + }) + + it('shows an error toast on failure', async () => { + vi.mocked(updateRun).mockRejectedValue(new Error('Network error')) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useUpdateRun(1), { wrapper }) + await act(async () => { + await result.current.mutate({} as never) + }) + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith('Failed to update run: Network error') + ) + }) +}) + +describe('useDeleteRun', () => { + it('calls deleteRun with the given id', async () => { + vi.mocked(deleteRun).mockResolvedValue(undefined as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useDeleteRun(), { wrapper }) + await act(async () => { + await result.current.mutateAsync(7) + }) + + expect(deleteRun).toHaveBeenCalledWith(7) + }) + + it('invalidates the runs query on success', async () => { + vi.mocked(deleteRun).mockResolvedValue(undefined as never) + const { queryClient, wrapper } = createWrapper() + const spy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useDeleteRun(), { wrapper }) + await act(async () => { + await result.current.mutateAsync(7) + }) + + expect(spy).toHaveBeenCalledWith({ queryKey: ['runs'] }) + }) +}) + +describe('useNamingCategories', () => { + it('calls getNamingCategories', async () => { + vi.mocked(getNamingCategories).mockResolvedValue([] as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useNamingCategories(), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(getNamingCategories).toHaveBeenCalledOnce() + }) +}) + +describe('useNameSuggestions', () => { + it('is disabled when runId is null', () => { + const { wrapper } = createWrapper() + const { result } = renderHook(() => useNameSuggestions(null), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + }) +}) diff --git a/frontend/src/hooks/useStats.test.tsx b/frontend/src/hooks/useStats.test.tsx new file mode 100644 index 0000000..d7964b9 --- /dev/null +++ b/frontend/src/hooks/useStats.test.tsx @@ -0,0 +1,38 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { createTestQueryClient } from '../test/utils' +import { useStats } from './useStats' + +vi.mock('../api/stats') + +import { getStats } from '../api/stats' + +function createWrapper() { + const queryClient = createTestQueryClient() + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + return { queryClient, wrapper } +} + +describe('useStats', () => { + it('calls getStats and returns data', async () => { + const stats = { totalRuns: 5, activeRuns: 2 } + vi.mocked(getStats).mockResolvedValue(stats as never) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useStats(), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(getStats).toHaveBeenCalledOnce() + expect(result.current.data).toEqual(stats) + }) + + it('reflects loading state before data resolves', () => { + vi.mocked(getStats).mockReturnValue(new Promise(() => undefined)) + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useStats(), { wrapper }) + expect(result.current.isLoading).toBe(true) + }) +}) diff --git a/frontend/src/utils/download.test.ts b/frontend/src/utils/download.test.ts new file mode 100644 index 0000000..a8ecc74 --- /dev/null +++ b/frontend/src/utils/download.test.ts @@ -0,0 +1,47 @@ +import { downloadJson } from './download' + +describe('downloadJson', () => { + beforeEach(() => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('creates a blob URL from the JSON data', () => { + downloadJson({ x: 1 }, 'export.json') + expect(URL.createObjectURL).toHaveBeenCalledOnce() + const blob = vi.mocked(URL.createObjectURL).mock.calls[0]?.[0] as Blob + expect(blob.type).toBe('application/json') + }) + + it('revokes the blob URL after triggering the download', () => { + downloadJson({ x: 1 }, 'export.json') + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url') + }) + + it('sets the correct download filename on the anchor', () => { + const spy = vi.spyOn(document, 'createElement') + downloadJson({ x: 1 }, 'my-data.json') + const anchor = spy.mock.results[0]?.value as HTMLAnchorElement + expect(anchor.download).toBe('my-data.json') + }) + + it('appends and removes the anchor from the document body', () => { + const appendSpy = vi.spyOn(document.body, 'appendChild') + const removeSpy = vi.spyOn(document.body, 'removeChild') + downloadJson({}, 'empty.json') + expect(appendSpy).toHaveBeenCalledOnce() + expect(removeSpy).toHaveBeenCalledOnce() + }) + + it('serializes the data as formatted JSON', () => { + downloadJson({ a: 1, b: [2, 3] }, 'data.json') + const blob = vi.mocked(URL.createObjectURL).mock.calls[0]?.[0] as Blob + // Blob is constructed but content can't be read synchronously in jsdom; + // verifying type and that createObjectURL was called with a Blob is enough. + expect(blob).toBeInstanceOf(Blob) + }) +})