diff --git a/src/hooks/useExportConfig.test.ts b/src/hooks/useExportConfig.test.ts new file mode 100644 index 00000000..c40094cb --- /dev/null +++ b/src/hooks/useExportConfig.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook as rtlRenderHook } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { IntlProvider } from "react-intl"; + +import { ApiError } from "@/api/client"; +import { importExportApi, type ConfigExport } from "@/api/importExport"; +import { useAuth } from "@/auth/useAuth"; +import enMessages from "@/i18n/locales/en-US"; +import { downloadJson } from "@/lib/download"; + +import { useExportConfig } from "./useExportConfig"; + +vi.mock("@/auth/useAuth", () => ({ useAuth: vi.fn() })); +vi.mock("@/lib/download", () => ({ downloadJson: vi.fn() })); +vi.mock("@/api/importExport", () => ({ + importExportApi: { exportConfig: vi.fn(), exportSelective: vi.fn() }, +})); + +const mockUseAuth = vi.mocked(useAuth); +const mockExportConfig = vi.mocked(importExportApi.exportConfig); +const mockExportSelective = vi.mocked(importExportApi.exportSelective); +const mockDownloadJson = vi.mocked(downloadJson); + +const EXPORT: ConfigExport = { + version: "2025-06-18", + exported_at: "2026-11-02T10:00:00Z", + exported_by: "admin@example.com", + entities: { tools: [] }, +}; + +const wrapper = ({ children }: { children: ReactNode }) => + createElement( + IntlProvider, + { locale: "en", defaultLocale: "en", messages: enMessages }, + children, + ); + +const renderHook = () => rtlRenderHook(() => useExportConfig(), { wrapper }); + +function grant(...perms: string[]) { + mockUseAuth.mockReturnValue({ + hasPermission: (perm: string) => perms.includes(perm), + } as unknown as ReturnType); +} + +describe("useExportConfig", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-11-02T10:00:00.000Z")); + grant("admin.export"); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("canExport follows the admin.export permission", () => { + expect(renderHook().result.current.canExport).toBe(true); + + grant(); + expect(renderHook().result.current.canExport).toBe(false); + }); + + it("exportAll fetches with filters and downloads a timestamped file", async () => { + mockExportConfig.mockResolvedValueOnce(EXPORT); + const { result } = renderHook(); + + let returned!: boolean; + await act(async () => { + returned = await result.current.exportAll({ types: ["tools"] }); + }); + + expect(returned).toBe(true); + + expect(mockExportConfig).toHaveBeenCalledWith({ types: ["tools"] }, expect.any(AbortSignal)); + expect(mockDownloadJson).toHaveBeenCalledWith( + EXPORT, + "contextforge-export-2026-11-02T10-00-00-000Z.json", + ); + expect(result.current.isExporting).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it("exportSelected passes selection and options through", async () => { + mockExportSelective.mockResolvedValueOnce(EXPORT); + const { result } = renderHook(); + + await act(() => + result.current.exportSelected({ servers: ["s1"] }, { includeDependencies: false }), + ); + + expect(mockExportSelective).toHaveBeenCalledWith( + { servers: ["s1"] }, + { includeDependencies: false, signal: expect.any(AbortSignal) }, + ); + expect(mockDownloadJson).toHaveBeenCalledOnce(); + }); + + it("reports isExporting while the request is in flight", async () => { + let resolve!: (value: ConfigExport) => void; + mockExportConfig.mockReturnValueOnce(new Promise((r) => (resolve = r))); + const { result } = renderHook(); + + let pending!: Promise; + act(() => { + pending = result.current.exportAll(); + }); + expect(result.current.isExporting).toBe(true); + + await act(async () => { + resolve(EXPORT); + await pending; + }); + expect(result.current.isExporting).toBe(false); + }); + + it("a second call aborts the first and owns isExporting until it settles", async () => { + let resolveFirst!: (value: ConfigExport) => void; + let resolveSecond!: (value: ConfigExport) => void; + mockExportConfig + .mockReturnValueOnce(new Promise((r) => (resolveFirst = r))) + .mockReturnValueOnce(new Promise((r) => (resolveSecond = r))); + const { result } = renderHook(); + + let first!: Promise; + let second!: Promise; + act(() => { + first = result.current.exportAll(); + }); + act(() => { + second = result.current.exportAll(); + }); + const firstSignal = mockExportConfig.mock.calls[0][1] as AbortSignal; + const secondSignal = mockExportConfig.mock.calls[1][1] as AbortSignal; + expect(firstSignal.aborted).toBe(true); + expect(secondSignal.aborted).toBe(false); + + // The aborted call answering late must not clear the loading state. + let firstReturned!: boolean; + await act(async () => { + resolveFirst(EXPORT); + firstReturned = await first; + }); + expect(firstReturned).toBe(false); + expect(result.current.isExporting).toBe(true); + expect(mockDownloadJson).not.toHaveBeenCalled(); + + let secondReturned!: boolean; + await act(async () => { + resolveSecond(EXPORT); + secondReturned = await second; + }); + expect(secondReturned).toBe(true); + expect(result.current.isExporting).toBe(false); + expect(mockDownloadJson).toHaveBeenCalledOnce(); + }); + + it("surfaces the backend detail on failure and skips the download", async () => { + mockExportConfig.mockRejectedValueOnce( + new ApiError(403, { detail: "Insufficient permissions" }, "HTTP 403"), + ); + const { result } = renderHook(); + + let returned!: boolean; + await act(async () => { + returned = await result.current.exportAll(); + }); + + expect(returned).toBe(false); + expect(result.current.error).toBe("Insufficient permissions"); + expect(mockDownloadJson).not.toHaveBeenCalled(); + expect(result.current.isExporting).toBe(false); + }); + + it("falls back to the localized message when the error has no detail", async () => { + mockExportConfig.mockRejectedValueOnce(new Error("network")); + const { result } = renderHook(); + + await act(() => result.current.exportAll()); + + expect(result.current.error).toBe("Export failed"); + }); + + it("aborts the in-flight request on unmount and never downloads", async () => { + let resolve!: (value: ConfigExport) => void; + mockExportConfig.mockReturnValueOnce(new Promise((r) => (resolve = r))); + const { result, unmount } = renderHook(); + + let pending!: Promise; + act(() => { + pending = result.current.exportAll(); + }); + const signal = mockExportConfig.mock.calls[0][1] as AbortSignal; + unmount(); + expect(signal.aborted).toBe(true); + + resolve(EXPORT); + expect(await pending).toBe(false); + expect(mockDownloadJson).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useExportConfig.ts b/src/hooks/useExportConfig.ts new file mode 100644 index 00000000..381ad562 --- /dev/null +++ b/src/hooks/useExportConfig.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useIntl } from "react-intl"; + +import { + importExportApi, + type ConfigExport, + type EntitySelection, + type ExportFilters, +} from "@/api/importExport"; +import { useAuth } from "@/auth/useAuth"; +import { downloadJson } from "@/lib/download"; +import { parseApiError } from "@/lib/errorUtils"; + +/** + * Requests + state only. Actions resolve true once the file is downloaded, + * false on failure (`error` set) or abort; feedback (toasts etc.) is the + * caller's job. + */ +export interface ExportConfigState { + /** Full export, optionally filtered; downloads the result as JSON. */ + exportAll: (filters?: ExportFilters) => Promise; + /** Export only the picked entities; downloads the result as JSON. */ + exportSelected: ( + selection: EntitySelection, + opts?: { includeDependencies?: boolean }, + ) => Promise; + isExporting: boolean; + error: string | null; + canExport: boolean; +} + +/** `contextforge-export-2026-11-02T10-00-00-000Z.json` — colons/dots are unsafe in filenames. */ +const exportFilename = () => + `contextforge-export-${new Date().toISOString().replace(/[:.]/g, "-")}.json`; + +export function useExportConfig(): ExportConfigState { + const intl = useIntl(); + const { hasPermission } = useAuth(); + const [isExporting, setExporting] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + useEffect(() => () => abortRef.current?.abort(), []); + + const run = useCallback( + async (fetchExport: (signal: AbortSignal) => Promise): Promise => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setExporting(true); + setError(null); + try { + const data = await fetchExport(controller.signal); + if (controller.signal.aborted) return false; + downloadJson(data, exportFilename()); + return true; + } catch (err) { + if (!controller.signal.aborted) { + setError(parseApiError(err, intl.formatMessage({ id: "importExport.export.failed" }))); + } + return false; + } finally { + // A superseding call owns the loading state from here on. + if (abortRef.current === controller) { + abortRef.current = null; + setExporting(false); + } + } + }, + [intl], + ); + + const exportAll = useCallback( + (filters?: ExportFilters) => run((signal) => importExportApi.exportConfig(filters, signal)), + [run], + ); + + const exportSelected = useCallback( + (selection: EntitySelection, opts?: { includeDependencies?: boolean }) => + run((signal) => importExportApi.exportSelective(selection, { ...opts, signal })), + [run], + ); + + return { + exportAll, + exportSelected, + isExporting, + error, + canExport: hasPermission("admin.export"), + }; +} diff --git a/src/i18n/locales/en-US/common.json b/src/i18n/locales/en-US/common.json index 7adc5b98..e98c45a8 100644 --- a/src/i18n/locales/en-US/common.json +++ b/src/i18n/locales/en-US/common.json @@ -67,5 +67,6 @@ "common.errorBoundary.reload": "Reload page", "common.required": "*", "common.team.label": "Team", - "common.team.placeholder": "Select a team" + "common.team.placeholder": "Select a team", + "importExport.export.failed": "Export failed" } diff --git a/src/i18n/locales/es-ES/common.json b/src/i18n/locales/es-ES/common.json index a19c62c6..38f8376e 100644 --- a/src/i18n/locales/es-ES/common.json +++ b/src/i18n/locales/es-ES/common.json @@ -67,5 +67,6 @@ "common.errorBoundary.reload": "Recargar página", "common.required": "*", "common.team.label": "Equipo", - "common.team.placeholder": "Seleccione un equipo" + "common.team.placeholder": "Seleccione un equipo", + "importExport.export.failed": "Error al exportar" } diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 75d3900c..5475771f 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -67,5 +67,6 @@ "common.errorBoundary.reload": "Recarregar página", "common.required": "*", "common.team.label": "Equipe", - "common.team.placeholder": "Selecione uma equipe" + "common.team.placeholder": "Selecione uma equipe", + "importExport.export.failed": "Falha ao exportar" }