Add useDictionaries hook wiring api + transform
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useDictionaries } from "./useDictionaries.js";
|
||||
import { ApiClientProvider } from "@/shared/api/provider.js";
|
||||
import type { ApiClient } from "@/shared/api/client.js";
|
||||
import type { IRawDictionaries } from "./types.js";
|
||||
|
||||
const raw: IRawDictionaries = {
|
||||
regions: [{ world_region_id: 500374, title: { ru: "Россия" } }],
|
||||
countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }],
|
||||
cities: [
|
||||
{ code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } },
|
||||
],
|
||||
airports: [
|
||||
{ code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } },
|
||||
],
|
||||
};
|
||||
|
||||
function makeClient(
|
||||
impl: (path: string) => Promise<unknown>,
|
||||
): ApiClient {
|
||||
return { get: vi.fn(impl) } as unknown as ApiClient;
|
||||
}
|
||||
|
||||
function wrapper(client: ApiClient) {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<ApiClientProvider client={client}>{children}</ApiClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useDictionaries", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("starts in a loading state with no data and no error", () => {
|
||||
const client = makeClient(async () => []);
|
||||
const { result } = renderHook(() => useDictionaries("ru"), {
|
||||
wrapper: wrapper(client),
|
||||
});
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.dictionaries).toBeNull();
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves to a populated IDictionaries after fetch", async () => {
|
||||
const client = makeClient(async (path: string) => {
|
||||
if (path.includes("world_regions")) return raw.regions;
|
||||
if (path.includes("countries")) return raw.countries;
|
||||
if (path.includes("cities")) return raw.cities;
|
||||
if (path.includes("airports")) return raw.airports;
|
||||
throw new Error("unexpected");
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDictionaries("ru"), {
|
||||
wrapper: wrapper(client),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.dictionaries).not.toBeNull();
|
||||
expect(result.current.dictionaries!.cities.map((c) => c.code)).toEqual(["MOW"]);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("sets error and keeps dictionaries null on fetch rejection", async () => {
|
||||
const client = makeClient(async () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDictionaries("ru"), {
|
||||
wrapper: wrapper(client),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.dictionaries).toBeNull();
|
||||
expect(result.current.error?.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("does not call setState after unmount during an in-flight fetch", async () => {
|
||||
let resolveFetch: (v: unknown) => void = () => {};
|
||||
const deferred = new Promise((r) => {
|
||||
resolveFetch = r;
|
||||
});
|
||||
const client = makeClient(async () => deferred);
|
||||
|
||||
const warn = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { unmount } = renderHook(() => useDictionaries("ru"), {
|
||||
wrapper: wrapper(client),
|
||||
});
|
||||
|
||||
unmount();
|
||||
resolveFetch([]);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(warn).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("unmounted"),
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Loads the four dictionary endpoints and returns the transformed, ready-to-use
|
||||
* IDictionaries object. Mounts once per session (the map is client-only and
|
||||
* only mounts once). Not intended for multiple concurrent consumers.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useApiClient } from "@/shared/api/provider.js";
|
||||
import { fetchDictionaries } from "./api.js";
|
||||
import { transformDictionaries } from "./transform.js";
|
||||
import type { IDictionariesState } from "./types.js";
|
||||
|
||||
export function useDictionaries(lang: string): IDictionariesState {
|
||||
const client = useApiClient();
|
||||
const [state, setState] = useState<IDictionariesState>({
|
||||
dictionaries: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetchDictionaries(client)
|
||||
.then((raw) => {
|
||||
if (cancelled) return;
|
||||
const dictionaries = transformDictionaries(raw, lang);
|
||||
setState({ dictionaries, loading: false, error: null });
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelled) return;
|
||||
setState({ dictionaries: null, loading: false, error: err });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, lang]);
|
||||
|
||||
return state;
|
||||
}
|
||||
Reference in New Issue
Block a user