diff --git a/country-explorer/package-lock.json b/country-explorer/package-lock.json index 9a54d00..370b12c 100644 --- a/country-explorer/package-lock.json +++ b/country-explorer/package-lock.json @@ -453,448 +453,6 @@ "node": ">=18" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -3703,7 +3261,6 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/country-explorer/src/api/countries.ts b/country-explorer/src/api/countries.ts index 31de31e..290ca24 100644 --- a/country-explorer/src/api/countries.ts +++ b/country-explorer/src/api/countries.ts @@ -2,8 +2,6 @@ import type { CountriesApiResponse, Country, CountryApiResponse } from '../types const BASE_URL = 'https://countries.dev'; -// Thrown for a failed request: a non-2xx response (status holds the real -// HTTP status) or a network-level failure (status 0). export class ApiError extends Error { status: number; @@ -49,7 +47,7 @@ async function fetchJson(url: string): Promise { message = String((body as { error: unknown }).error); } } catch { - // Response body wasn't JSON; fall back to the status text above. + // ignore, use the status text above } throw new ApiError(response.status, message); } @@ -57,7 +55,7 @@ async function fetchJson(url: string): Promise { return response.json() as Promise; } -// GET /region/{region} — every country in a region, e.g. "europe". +// GET /region/{region} export async function getCountriesByRegion(region: string): Promise { const raw = await fetchJson( `${BASE_URL}/region/${encodeURIComponent(region)}`, @@ -66,7 +64,7 @@ export async function getCountriesByRegion(region: string): Promise { return raw.map(toCountry); } -// GET /alpha/{code} — a single country by ISO alpha-2 or alpha-3 code. +// GET /alpha/{code} export async function getCountryByAlpha(code: string): Promise { const raw = await fetchJson(`${BASE_URL}/alpha/${encodeURIComponent(code)}`); diff --git a/country-explorer/src/components/App.tsx b/country-explorer/src/components/App.tsx index d0ff4e6..4327891 100644 --- a/country-explorer/src/components/App.tsx +++ b/country-explorer/src/components/App.tsx @@ -13,19 +13,16 @@ import ErrorMessage from './ErrorMessage'; import NavigationControls from './NavigationControls'; import FavoritesList from './FavoritesList'; -// sessionStorage keys for the display choices this component persists. const REGION_STORAGE_KEY = 'selectedRegion'; const SORT_STORAGE_KEY = 'sortOption'; function App() { const { countries, isLoading, isError, error, refetch } = useCountries(); const { isFavorite, addFavorite, removeFavorite } = useFavorites(); - // Lazily read any saved choice on first render, so the page opens already - // showing what the user had before reloading within the same session. const [selectedRegion, setSelectedRegion] = useState( () => readSessionValue(REGION_STORAGE_KEY) ?? '', ); - // The fetched countries are European, so filter by their geographic subregion. + // countries are all European, so "region" here means subregion const getRegion = (country: (typeof countries)[number]) => country.subregion?.trim() || country.region?.trim() || 'Unknown region'; const regions = [...new Set(countries.map(getRegion))].sort(); @@ -38,15 +35,11 @@ function App() { : countries; const [sortOption, setSortOption] = useState(() => { const stored = readSessionValue(SORT_STORAGE_KEY); - // A missing or no-longer-valid stored value falls back to the default. return isSortOption(stored) ? stored : 'name-asc'; }); - // A fresh, sorted copy — filteredCountries (and the cached query data it - // derives from) is left untouched. const sortedCountries = sortCountries(filteredCountries, sortOption); - // Favorites are drawn from the full, unfiltered country list (not - // filteredCountries/sortedCountries) so a favorite from another region - // still shows up here even while a region filter is active. + // favorites are drawn from the full list, not the filtered one, so a + // favorite from another region still shows up here const favoriteCountries = sortCountries( countries.filter((country) => isFavorite(country.code)), sortOption, @@ -59,8 +52,6 @@ function App() { (country) => country.code === activeCountryCode, ); - // Persist the display choices (never the fetched country/API data) so a - // reload within the same browser session restores them. useEffect(() => { writeSessionValue(REGION_STORAGE_KEY, selectedRegion); }, [selectedRegion]); @@ -86,17 +77,15 @@ function App() { } } - // Selecting a favorite clears the region filter first: the favorite may - // belong to a region other than the one currently filtered to, and - // selectedCountry/CountrySelector/NavigationControls are all derived from - // sortedCountries, so the code has to be present there to actually show up. function selectFavorite(code: string) { + // clear the region filter first, since the favorite might be in a + // different region than the one currently filtered to setSelectedRegion(''); setSelectedCountryCode(code); } - // Keep the selection valid when data arrives or the available countries change. - // Store the fallback so a removed country is not reselected if it returns later. + // correct the selection during render if it's no longer valid (e.g. the + // country list just changed), instead of flashing the old selection first if (selectedCountryCode !== activeCountryCode) { setSelectedCountryCode(activeCountryCode); } diff --git a/country-explorer/src/components/ErrorMessage.tsx b/country-explorer/src/components/ErrorMessage.tsx index c597af9..6416a43 100644 --- a/country-explorer/src/components/ErrorMessage.tsx +++ b/country-explorer/src/components/ErrorMessage.tsx @@ -1,18 +1,8 @@ interface ErrorMessageProps { - /** User-friendly explanation of what went wrong (e.g. an ApiError message). */ message: string; - /** Called when the user asks to try the request again. */ onRetry: () => void; } -/** - * Generic error state for a failed API request. Rendered as an assertive - * live region so assistive technology announces the failure immediately, - * and always offers a way to retry instead of leaving the user stuck. - * - * Intended usage: pass `error instanceof ApiError ? error.message : '...'` - * from `useCountries` as `message`, and its `refetch` as `onRetry`. - */ function ErrorMessage({ message, onRetry }: ErrorMessageProps) { return (
diff --git a/country-explorer/src/components/Loading.tsx b/country-explorer/src/components/Loading.tsx index 3a853d6..2be988b 100644 --- a/country-explorer/src/components/Loading.tsx +++ b/country-explorer/src/components/Loading.tsx @@ -1,13 +1,7 @@ interface LoadingProps { - /** Accessible status text; defaults to a generic loading message. */ message?: string; } -/** - * Generic loading indicator for any async data fetch (e.g. `useCountries`). - * Rendered as a polite live region so assistive technology announces the - * status without interrupting whatever the user is doing. - */ function Loading({ message = 'Loading…' }: LoadingProps) { return (

diff --git a/country-explorer/src/hooks/useCountries.test.tsx b/country-explorer/src/hooks/useCountries.test.tsx new file mode 100644 index 0000000..f56d74a --- /dev/null +++ b/country-explorer/src/hooks/useCountries.test.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ApiError, getCountriesByRegion } from '../api/countries'; +import { mockCountries } from '../test/fixtures'; +import { useCountries } from './useCountries'; + +// Mock only getCountriesByRegion, keep the real ApiError export +vi.mock('../api/countries', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCountriesByRegion: vi.fn(), + }; +}); + +const mockedGetCountriesByRegion = vi.mocked(getCountriesByRegion); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useCountries', () => { + it('reports a loading state before the request resolves', () => { + mockedGetCountriesByRegion.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useCountries(), { wrapper: createWrapper() }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.countries).toEqual([]); + }); + + it('returns the countries on a successful response', async () => { + mockedGetCountriesByRegion.mockResolvedValue(mockCountries); + + const { result } = renderHook(() => useCountries(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.countries).toEqual(mockCountries); + expect(result.current.isError).toBe(false); + }); + + it('reports an error when the request fails', async () => { + mockedGetCountriesByRegion.mockRejectedValue(new ApiError(500, 'Server error')); + + const { result } = renderHook(() => useCountries(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.countries).toEqual([]); + expect(result.current.error).toBeInstanceOf(ApiError); + }); +}); diff --git a/country-explorer/src/hooks/useCountries.ts b/country-explorer/src/hooks/useCountries.ts index 0c39578..619bae9 100644 --- a/country-explorer/src/hooks/useCountries.ts +++ b/country-explorer/src/hooks/useCountries.ts @@ -3,9 +3,6 @@ import { getCountriesByRegion } from '../api/countries'; const DEFAULT_REGION = 'europe'; -// Centralizes the query key so it's built the same way everywhere it's -// needed (e.g. a future manual refetch/invalidate call), instead of a -// duplicated inline array. export const countriesQueryKey = (region: string) => ['countries', region] as const; export function useCountries(region: string = DEFAULT_REGION) { @@ -19,8 +16,6 @@ export function useCountries(region: string = DEFAULT_REGION) { isLoading, isError, error, - // Exposed so a caller can wire it up as the retry action for an - // ErrorMessage component when a request fails. refetch, }; } diff --git a/country-explorer/src/hooks/useFavorites.ts b/country-explorer/src/hooks/useFavorites.ts index 2b74e04..294b95b 100644 --- a/country-explorer/src/hooks/useFavorites.ts +++ b/country-explorer/src/hooks/useFavorites.ts @@ -1,14 +1,8 @@ import { useCallback, useEffect, useState } from 'react'; import { readLocalValue, writeLocalValue } from '../utils/localStorage'; -// sessionStorage/localStorage keys in this app are all namespaced by the -// wrapper in utils/localStorage.ts; this is just the per-value suffix. const FAVORITES_STORAGE_KEY = 'favoriteCountryCodes'; -// A stored value is only trusted if it parses as a JSON array of non-empty -// strings. Anything else — nothing saved yet, corrupted JSON, or a value -// that isn't an array of strings — is treated as "no favorites saved" so a -// broken or tampered-with value never crashes the app. function readStoredFavorites(): string[] { const stored = readLocalValue(FAVORITES_STORAGE_KEY); if (!stored) return []; @@ -23,28 +17,14 @@ function readStoredFavorites(): string[] { } } -/** - * Tracks which countries the user has marked as favorites, identified by - * their stable country code (the alpha-3 code already used as `Country.code` - * everywhere else in the app). - * - * Only the country codes are persisted to localStorage — never the fetched - * country data itself — so favorites are retained after a reload and after - * the browser is closed and reopened. - */ export function useFavorites() { - // Lazily read any saved favorites on first render, so the app opens - // already showing what the user had saved before. const [favoriteCodes, setFavoriteCodes] = useState(() => readStoredFavorites()); - // Re-persist whenever the favorite list changes (add, remove, or the - // initial load being replaced by a correction). useEffect(() => { writeLocalValue(FAVORITES_STORAGE_KEY, JSON.stringify(favoriteCodes)); }, [favoriteCodes]); const addFavorite = useCallback((code: string) => { - // Guard against duplicates instead of relying on callers to check first. setFavoriteCodes((current) => (current.includes(code) ? current : [...current, code])); }, []); diff --git a/country-explorer/src/test/fixtures.ts b/country-explorer/src/test/fixtures.ts new file mode 100644 index 0000000..2104da1 --- /dev/null +++ b/country-explorer/src/test/fixtures.ts @@ -0,0 +1,28 @@ +import type { Country } from '../types/country'; + +export const mockCountries: Country[] = [ + { + code: 'NOR', + name: 'Norway', + capital: 'Oslo', + region: 'Europe', + subregion: 'Northern Europe', + population: 5379475, + area: 323802, + flagUrl: 'https://example.com/flags/no.svg', + currencies: ['Norwegian krone (NOK)'], + languages: ['Norwegian', 'Sámi'], + }, + { + code: 'SWE', + name: 'Sweden', + capital: 'Stockholm', + region: 'Europe', + subregion: 'Northern Europe', + population: 10353442, + area: 450295, + flagUrl: 'https://example.com/flags/se.svg', + currencies: ['Swedish krona (SEK)'], + languages: ['Swedish'], + }, +]; diff --git a/country-explorer/src/test/setup.ts b/country-explorer/src/test/setup.ts index 044da4b..5fcf45e 100644 --- a/country-explorer/src/test/setup.ts +++ b/country-explorer/src/test/setup.ts @@ -1,15 +1,10 @@ -// Extends Vitest's `expect` with jest-dom's DOM matchers (toBeInTheDocument, -// toHaveTextContent, etc.) for every test file, via the `setupFiles` entry -// in vite.config.ts. import '@testing-library/jest-dom/vitest'; -import { afterEach } from 'vitest'; +import { afterEach, vi } from 'vitest'; import { cleanup } from '@testing-library/react'; -// React Testing Library only auto-unmounts between tests when it detects a -// global `afterEach` (e.g. Vitest's `globals: true`). Globals are off here, -// so register cleanup explicitly — otherwise elements from an earlier test's -// render are still in the DOM when the next test queries it. afterEach(() => { + // no globals: true, so RTL won't auto-cleanup between tests cleanup(); + vi.resetAllMocks(); }); diff --git a/country-explorer/src/types/country.ts b/country-explorer/src/types/country.ts index 5b027f1..658f427 100644 --- a/country-explorer/src/types/country.ts +++ b/country-explorer/src/types/country.ts @@ -26,7 +26,6 @@ export interface CountryApiResponse { // Successful response from /region/{region}. export type CountriesApiResponse = CountryApiResponse[]; -// Internal country model used by application components. export interface Country { code: string; name: string; diff --git a/country-explorer/src/utils/localStorage.ts b/country-explorer/src/utils/localStorage.ts index 06569a0..454b515 100644 --- a/country-explorer/src/utils/localStorage.ts +++ b/country-explorer/src/utils/localStorage.ts @@ -1,11 +1,3 @@ -/** - * Thin, defensive wrapper around localStorage for small, user-created state - * that should persist across browser sessions (e.g. favorites) — never the - * fetched country data itself. Keys are namespaced so they don't collide - * with anything else on the page, and every call is wrapped in a try/catch - * since storage can be unavailable or full (e.g. private browsing, quota - * exceeded) — a failed read/write should never crash the app. - */ const NAMESPACE = 'country-explorer'; function storageKey(key: string): string { @@ -24,6 +16,6 @@ export function writeLocalValue(key: string, value: string): void { try { window.localStorage.setItem(storageKey(key), value); } catch { - // Storage unavailable or full — the choice just won't persist. + // storage unavailable or full } } diff --git a/country-explorer/src/utils/sessionStorage.ts b/country-explorer/src/utils/sessionStorage.ts index fa187c2..33d456d 100644 --- a/country-explorer/src/utils/sessionStorage.ts +++ b/country-explorer/src/utils/sessionStorage.ts @@ -1,11 +1,3 @@ -/** - * Thin, defensive wrapper around sessionStorage for small UI display - * choices (region filter, sort option) — never the fetched country data - * itself. Keys are namespaced so they don't collide with anything else on - * the page, and every call is wrapped in a try/catch since storage can be - * unavailable or full (e.g. private browsing, quota exceeded) — a failed - * read/write should never crash the app. - */ const NAMESPACE = 'country-explorer'; function storageKey(key: string): string { @@ -24,6 +16,6 @@ export function writeSessionValue(key: string, value: string): void { try { window.sessionStorage.setItem(storageKey(key), value); } catch { - // Storage unavailable or full — the choice just won't persist. + // storage unavailable or full } } diff --git a/country-explorer/src/utils/sortCountries.ts b/country-explorer/src/utils/sortCountries.ts index c209966..8ee362e 100644 --- a/country-explorer/src/utils/sortCountries.ts +++ b/country-explorer/src/utils/sortCountries.ts @@ -2,29 +2,21 @@ import type { Country } from '../types/country'; export type SortOption = 'name-asc' | 'population-desc'; -// Drives both the