Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions frontend/editor/src/core/api/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import apiClient from "@app/services/apiClient";
import { getSimulatedAppConfig } from "@app/testing/serverExperienceSimulations";
import type { AppConfig } from "@app/types/appConfig";
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";

/** Unauthenticated and unreachable both mean "assume login is on". */
export const DEFAULT_APP_CONFIG: AppConfig = { enableLogin: true };
Expand All @@ -23,6 +24,35 @@ export async function fetchAppConfig(): Promise<AppConfig> {
}
}

export type EndpointAvailabilityMap = Record<
string,
EndpointAvailabilityDetails
>;

/**
* Fires on app load before auth settles, so a 401 must not trigger the global
* login redirect. Callers treat a failure as "assume enabled".
*/
export async function fetchEndpointsAvailability(): Promise<EndpointAvailabilityMap> {
const response = await apiClient.get<EndpointAvailabilityMap>(
"/api/v1/config/endpoints-availability",
{ suppressErrorToast: true, skipAuthRedirect: true },
);
return Object.fromEntries(
Object.entries(response.data).map(([name, detail]) => [
name,
{ enabled: detail?.enabled ?? true, reason: detail?.reason ?? null },
]),
);
}

export async function fetchEndpointEnabled(endpoint: string): Promise<boolean> {
const response = await apiClient.get<boolean>(
`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`,
);
return response.data;
}

export interface FooterInfo {
analyticsEnabled?: boolean;
termsAndConditions?: string;
Expand Down
149 changes: 149 additions & 0 deletions frontend/editor/src/core/hooks/useEndpointConfig.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider";
import {
useEndpointEnabled,
useMultipleEndpointsEnabled,
} from "@app/hooks/useEndpointConfig";
import {
fetchEndpointEnabled,
fetchEndpointsAvailability,
} from "@app/api/config";

vi.mock("@app/api/config", () => ({
fetchEndpointEnabled: vi.fn(),
fetchEndpointsAvailability: vi.fn(),
}));

const mockOne = vi.mocked(fetchEndpointEnabled);
const mockAll = vi.mocked(fetchEndpointsAvailability);

describe("useEndpointEnabled", () => {
beforeEach(() => vi.clearAllMocks());

it("reports null while loading, then the server's answer", async () => {
mockOne.mockResolvedValue(false);

const { result } = renderHook(() => useEndpointEnabled("ocr-pdf"), {
wrapper: TestQueryProvider,
});

expect(result.current.enabled).toBeNull();
await waitFor(() => expect(result.current.enabled).toBe(false));
});

it("stays null on failure rather than claiming disabled", async () => {
mockOne.mockRejectedValue(new Error("boom"));

const { result } = renderHook(() => useEndpointEnabled("ocr-pdf"), {
wrapper: TestQueryProvider,
});

await waitFor(() => expect(result.current.error).toBe("boom"));
expect(result.current.enabled).toBeNull();
});

it("does not fetch without an endpoint", () => {
const { result } = renderHook(() => useEndpointEnabled(""), {
wrapper: TestQueryProvider,
});

expect(result.current.loading).toBe(false);
expect(mockOne).not.toHaveBeenCalled();
});
});

describe("useMultipleEndpointsEnabled", () => {
beforeEach(() => vi.clearAllMocks());

it("projects the shared map onto the requested endpoints", async () => {
mockAll.mockResolvedValue({
"ocr-pdf": { enabled: false, reason: "DEPENDENCY" },
"add-stamp": { enabled: true, reason: null },
});

const { result } = renderHook(
() => useMultipleEndpointsEnabled(["ocr-pdf"]),
{ wrapper: TestQueryProvider },
);

await waitFor(() =>
expect(result.current.endpointStatus).toEqual({ "ocr-pdf": false }),
);
expect(result.current.endpointDetails["ocr-pdf"].reason).toBe("DEPENDENCY");
});

it("serves every consumer from one request", async () => {
mockAll.mockResolvedValue({ "ocr-pdf": { enabled: true, reason: null } });

const { result } = renderHook(
() => ({
a: useMultipleEndpointsEnabled(["ocr-pdf"]),
b: useMultipleEndpointsEnabled(["ocr-pdf", "add-stamp"]),
}),
{ wrapper: TestQueryProvider },
);

await waitFor(() => expect(result.current.a.loading).toBe(false));
expect(mockAll).toHaveBeenCalledTimes(1);
});

it("treats unknown endpoints as enabled", async () => {
mockAll.mockResolvedValue({});

const { result } = renderHook(
() => useMultipleEndpointsEnabled(["brand-new-tool"]),
{ wrapper: TestQueryProvider },
);

await waitFor(() =>
expect(result.current.endpointStatus).toEqual({ "brand-new-tool": true }),
);
});

it("falls back to enabled when the check fails", async () => {
mockAll.mockRejectedValue(
Object.assign(new Error("unauthorised"), { response: { status: 401 } }),
);

const { result } = renderHook(
() => useMultipleEndpointsEnabled(["ocr-pdf", "add-stamp"]),
{ wrapper: TestQueryProvider },
);

await waitFor(() =>
expect(result.current.endpointStatus).toEqual({
"ocr-pdf": true,
"add-stamp": true,
}),
);
// The fallback is the answer, so no retry.
expect(mockAll).toHaveBeenCalledTimes(1);
});

it("does not fetch for an empty endpoint list", () => {
const { result } = renderHook(() => useMultipleEndpointsEnabled([]), {
wrapper: TestQueryProvider,
});

expect(result.current.loading).toBe(false);
expect(mockAll).not.toHaveBeenCalled();
});

it("refetches when a JWT becomes available", async () => {
mockAll.mockResolvedValue({ "ocr-pdf": { enabled: true, reason: null } });

const { result } = renderHook(
() => useMultipleEndpointsEnabled(["ocr-pdf"]),
{ wrapper: TestQueryProvider },
);
await waitFor(() => expect(result.current.loading).toBe(false));

await act(async () => {
window.dispatchEvent(new CustomEvent("jwt-available"));
await new Promise((resolve) => setTimeout(resolve, 0));
});

await waitFor(() => expect(mockAll).toHaveBeenCalledTimes(2));
});
});
Loading
Loading