From 207d281285432e28cf347c0316ba2d7b1df0b60e Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Mon, 2 Nov 2026 15:25:25 +0000 Subject: [PATCH 1/3] Add useExportConfig hook Signed-off-by: Gabriel Costa --- src/hooks/useExportConfig.test.ts | 157 +++++++++++++++++++++++++++++ src/hooks/useExportConfig.ts | 90 +++++++++++++++++ src/i18n/locales/en-US/common.json | 4 +- src/i18n/locales/es-ES/common.json | 4 +- src/i18n/locales/pt-BR/common.json | 4 +- 5 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useExportConfig.test.ts create mode 100644 src/hooks/useExportConfig.ts diff --git a/src/hooks/useExportConfig.test.ts b/src/hooks/useExportConfig.test.ts new file mode 100644 index 00000000..920b5a84 --- /dev/null +++ b/src/hooks/useExportConfig.test.ts @@ -0,0 +1,157 @@ +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 { toast } from "sonner"; + +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("sonner", () => ({ toast: { success: vi.fn(), error: 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(); + + await act(() => result.current.exportAll({ types: ["tools"] })); + + expect(mockExportConfig).toHaveBeenCalledWith({ types: ["tools"] }, expect.any(AbortSignal)); + expect(mockDownloadJson).toHaveBeenCalledWith( + EXPORT, + "contextforge-export-2026-11-02T10-00-00-000Z.json", + ); + expect(toast.success).toHaveBeenCalledWith("Configuration exported"); + 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("surfaces the backend detail on failure and skips the download", async () => { + mockExportConfig.mockRejectedValueOnce( + new ApiError(403, { detail: "Insufficient permissions" }, "HTTP 403"), + ); + const { result } = renderHook(); + + await act(() => result.current.exportAll()); + + expect(result.current.error).toBe("Insufficient permissions"); + expect(toast.error).toHaveBeenCalledWith("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); + await pending; + expect(mockDownloadJson).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useExportConfig.ts b/src/hooks/useExportConfig.ts new file mode 100644 index 00000000..b85b770b --- /dev/null +++ b/src/hooks/useExportConfig.ts @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useIntl } from "react-intl"; +import { toast } from "sonner"; + +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"; + +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) => { + 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; + downloadJson(data, exportFilename()); + toast.success(intl.formatMessage({ id: "importExport.export.success" })); + } catch (err) { + if (controller.signal.aborted) return; + const message = parseApiError( + err, + intl.formatMessage({ id: "importExport.export.failed" }), + ); + setError(message); + toast.error(message); + } 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..dd7c1f77 100644 --- a/src/i18n/locales/en-US/common.json +++ b/src/i18n/locales/en-US/common.json @@ -67,5 +67,7 @@ "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.success": "Configuration exported", + "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..ce7846a3 100644 --- a/src/i18n/locales/es-ES/common.json +++ b/src/i18n/locales/es-ES/common.json @@ -67,5 +67,7 @@ "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.success": "Configuración exportada", + "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..05781882 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -67,5 +67,7 @@ "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.success": "Configuração exportada", + "importExport.export.failed": "Falha ao exportar" } From 547b46fb9649c9c98508057952b3bc1e73cefb31 Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Tue, 3 Nov 2026 14:11:32 +0000 Subject: [PATCH 2/3] remove admin router endpoints Signed-off-by: Gabriel Costa --- src/hooks/useExportConfig.test.ts | 23 ++++++++++++++--------- src/hooks/useExportConfig.ts | 27 ++++++++++++++------------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/hooks/useExportConfig.test.ts b/src/hooks/useExportConfig.test.ts index 920b5a84..d90ff7be 100644 --- a/src/hooks/useExportConfig.test.ts +++ b/src/hooks/useExportConfig.test.ts @@ -2,7 +2,6 @@ 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 { toast } from "sonner"; import { ApiError } from "@/api/client"; import { importExportApi, type ConfigExport } from "@/api/importExport"; @@ -14,7 +13,6 @@ import { useExportConfig } from "./useExportConfig"; vi.mock("@/auth/useAuth", () => ({ useAuth: vi.fn() })); vi.mock("@/lib/download", () => ({ downloadJson: vi.fn() })); -vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock("@/api/importExport", () => ({ importExportApi: { exportConfig: vi.fn(), exportSelective: vi.fn() }, })); @@ -69,14 +67,18 @@ describe("useExportConfig", () => { mockExportConfig.mockResolvedValueOnce(EXPORT); const { result } = renderHook(); - await act(() => result.current.exportAll({ types: ["tools"] })); + 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(toast.success).toHaveBeenCalledWith("Configuration exported"); expect(result.current.isExporting).toBe(false); expect(result.current.error).toBeNull(); }); @@ -101,7 +103,7 @@ describe("useExportConfig", () => { mockExportConfig.mockReturnValueOnce(new Promise((r) => (resolve = r))); const { result } = renderHook(); - let pending!: Promise; + let pending!: Promise; act(() => { pending = result.current.exportAll(); }); @@ -120,10 +122,13 @@ describe("useExportConfig", () => { ); const { result } = renderHook(); - await act(() => result.current.exportAll()); + let returned!: boolean; + await act(async () => { + returned = await result.current.exportAll(); + }); + expect(returned).toBe(false); expect(result.current.error).toBe("Insufficient permissions"); - expect(toast.error).toHaveBeenCalledWith("Insufficient permissions"); expect(mockDownloadJson).not.toHaveBeenCalled(); expect(result.current.isExporting).toBe(false); }); @@ -142,7 +147,7 @@ describe("useExportConfig", () => { mockExportConfig.mockReturnValueOnce(new Promise((r) => (resolve = r))); const { result, unmount } = renderHook(); - let pending!: Promise; + let pending!: Promise; act(() => { pending = result.current.exportAll(); }); @@ -151,7 +156,7 @@ describe("useExportConfig", () => { expect(signal.aborted).toBe(true); resolve(EXPORT); - await pending; + expect(await pending).toBe(false); expect(mockDownloadJson).not.toHaveBeenCalled(); }); }); diff --git a/src/hooks/useExportConfig.ts b/src/hooks/useExportConfig.ts index b85b770b..381ad562 100644 --- a/src/hooks/useExportConfig.ts +++ b/src/hooks/useExportConfig.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useIntl } from "react-intl"; -import { toast } from "sonner"; import { importExportApi, @@ -12,14 +11,19 @@ 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; + exportAll: (filters?: ExportFilters) => Promise; /** Export only the picked entities; downloads the result as JSON. */ exportSelected: ( selection: EntitySelection, opts?: { includeDependencies?: boolean }, - ) => Promise; + ) => Promise; isExporting: boolean; error: string | null; canExport: boolean; @@ -39,7 +43,7 @@ export function useExportConfig(): ExportConfigState { useEffect(() => () => abortRef.current?.abort(), []); const run = useCallback( - async (fetchExport: (signal: AbortSignal) => Promise) => { + async (fetchExport: (signal: AbortSignal) => Promise): Promise => { abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; @@ -47,17 +51,14 @@ export function useExportConfig(): ExportConfigState { setError(null); try { const data = await fetchExport(controller.signal); - if (controller.signal.aborted) return; + if (controller.signal.aborted) return false; downloadJson(data, exportFilename()); - toast.success(intl.formatMessage({ id: "importExport.export.success" })); + return true; } catch (err) { - if (controller.signal.aborted) return; - const message = parseApiError( - err, - intl.formatMessage({ id: "importExport.export.failed" }), - ); - setError(message); - toast.error(message); + 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) { From 9e3a7ff31e58572b17812abbfc491697b58ade30 Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Thu, 5 Nov 2026 11:30:23 +0000 Subject: [PATCH 3/3] Add tests Signed-off-by: Gabriel Costa --- src/hooks/useExportConfig.test.ts | 41 ++++++++++++++++++++++++++++++ src/i18n/locales/en-US/common.json | 1 - src/i18n/locales/es-ES/common.json | 1 - src/i18n/locales/pt-BR/common.json | 1 - 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/hooks/useExportConfig.test.ts b/src/hooks/useExportConfig.test.ts index d90ff7be..c40094cb 100644 --- a/src/hooks/useExportConfig.test.ts +++ b/src/hooks/useExportConfig.test.ts @@ -116,6 +116,47 @@ describe("useExportConfig", () => { 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"), diff --git a/src/i18n/locales/en-US/common.json b/src/i18n/locales/en-US/common.json index dd7c1f77..e98c45a8 100644 --- a/src/i18n/locales/en-US/common.json +++ b/src/i18n/locales/en-US/common.json @@ -68,6 +68,5 @@ "common.required": "*", "common.team.label": "Team", "common.team.placeholder": "Select a team", - "importExport.export.success": "Configuration exported", "importExport.export.failed": "Export failed" } diff --git a/src/i18n/locales/es-ES/common.json b/src/i18n/locales/es-ES/common.json index ce7846a3..38f8376e 100644 --- a/src/i18n/locales/es-ES/common.json +++ b/src/i18n/locales/es-ES/common.json @@ -68,6 +68,5 @@ "common.required": "*", "common.team.label": "Equipo", "common.team.placeholder": "Seleccione un equipo", - "importExport.export.success": "Configuración exportada", "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 05781882..5475771f 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -68,6 +68,5 @@ "common.required": "*", "common.team.label": "Equipe", "common.team.placeholder": "Selecione uma equipe", - "importExport.export.success": "Configuração exportada", "importExport.export.failed": "Falha ao exportar" }