Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
99 changes: 56 additions & 43 deletions frontend/src/core/contexts/AppConfigContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,29 +28,49 @@ describe("AppConfigContext", () => {
<AppConfigProvider>{children}</AppConfigProvider>
);

/**
* Helper to mock API responses for app-config and info-status
*/
const mockApiResponses = (config: any, delay = 0) => {
vi.mocked(apiClient.get).mockImplementation(async (url: string) => {
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay));
}

if (url === "/api/v1/config/app-config") {
return { status: 200, data: config };
}
if (url === "/api/v1/info/status") {
return { status: 200, data: { status: "UP" } };
}
return { status: 404, data: {} };
});
};

it("should fetch and provide app config on non-auth pages", async () => {
const mockConfig = {
enableLogin: false,
appNameNavbar: "Stirling PDF",
languages: ["en-US", "en-GB"],
};

vi.mocked(apiClient.get).mockResolvedValueOnce({
status: 200,
data: mockConfig,
} as any);
// Use a small delay to ensure we can catch the loading state
mockApiResponses(mockConfig, 10);

const { result } = renderHook(() => useAppConfig(), { wrapper });

// Initially loading
expect(result.current.loading).toBe(true);
expect(result.current.config).toBeNull();

await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.config).toEqual(mockConfig);
expect(result.current.error).toBeNull();
});
await waitFor(
() => {
expect(result.current.loading).toBe(false);
expect(result.current.config).toEqual(mockConfig);
expect(result.current.error).toBeNull();
},
{ timeout: 1000 },
);

expect(apiClient.get).toHaveBeenCalledWith("/api/v1/config/app-config", {
suppressErrorToast: true,
Expand Down Expand Up @@ -80,7 +100,7 @@ describe("AppConfigContext", () => {
const mockError = Object.assign(new Error("Unauthorized"), {
response: { status: 401, data: {} },
});
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
vi.mocked(apiClient.get).mockRejectedValue(mockError);

const { result } = renderHook(() => useAppConfig(), { wrapper });

Expand All @@ -95,11 +115,9 @@ describe("AppConfigContext", () => {
const errorMessage = "Network error occurred";
const mockError = new Error(errorMessage);
// Network errors don't have response property
// Mock rejection for all retry attempts (default is 3 attempts)
vi.mocked(apiClient.get)
.mockRejectedValueOnce(mockError)
.mockRejectedValueOnce(mockError)
.mockRejectedValueOnce(mockError);
// Mock rejection for all retry attempts (default is 0 retries in test if not specified,
// but the component might still catch it)
vi.mocked(apiClient.get).mockRejectedValue(mockError);

const { result } = renderHook(() => useAppConfig(), { wrapper });

Expand Down Expand Up @@ -171,24 +189,25 @@ describe("AppConfigContext", () => {
enableAnalytics: true,
};

// First call returns initial config
vi.mocked(apiClient.get).mockResolvedValueOnce({
status: 200,
data: initialConfig,
} as any);
// Setup implementation to return different configs on subsequent calls
let callCount = 0;
vi.mocked(apiClient.get).mockImplementation(async (url: string) => {
if (url === "/api/v1/config/app-config") {
callCount++;
return {
status: 200,
data: callCount === 1 ? initialConfig : updatedConfig,
};
}
return { status: 200, data: { status: "UP" } };
});

const { result } = renderHook(() => useAppConfig(), { wrapper });

await waitFor(() => {
expect(result.current.config).toEqual(initialConfig);
});

// Setup second call for refetch
vi.mocked(apiClient.get).mockResolvedValueOnce({
status: 200,
data: updatedConfig,
} as any);

// Trigger jwt-available event wrapped in act
await act(async () => {
window.dispatchEvent(new CustomEvent("jwt-available"));
Expand All @@ -200,7 +219,8 @@ describe("AppConfigContext", () => {
expect(result.current.config).toEqual(updatedConfig);
});

expect(apiClient.get).toHaveBeenCalledTimes(2);
// 2 logical fetches * 2 calls each = 4 total calls
expect(apiClient.get).toHaveBeenCalledTimes(4);
});

it("should provide refetch function", async () => {
Expand All @@ -209,10 +229,7 @@ describe("AppConfigContext", () => {
appNameNavbar: "Test App",
};

vi.mocked(apiClient.get).mockResolvedValue({
status: 200,
data: mockConfig,
} as any);
mockApiResponses(mockConfig);

const { result } = renderHook(() => useAppConfig(), { wrapper });

Expand All @@ -225,27 +242,25 @@ describe("AppConfigContext", () => {
await result.current.refetch();
});

expect(apiClient.get).toHaveBeenCalledTimes(2);
// 2 logical fetches * 2 calls each = 4 total calls
expect(apiClient.get).toHaveBeenCalledTimes(4);
});

it("should not fetch twice without force flag", async () => {
const mockConfig = {
enableLogin: false,
};

vi.mocked(apiClient.get).mockResolvedValue({
status: 200,
data: mockConfig,
} as any);
mockApiResponses(mockConfig);

const { result } = renderHook(() => useAppConfig(), { wrapper });

await waitFor(() => {
expect(result.current.config).toEqual(mockConfig);
});

// Should only be called once (no duplicate fetches)
expect(apiClient.get).toHaveBeenCalledTimes(1);
// Should only be called twice (one logical fetch = /app-config + /info/status)
expect(apiClient.get).toHaveBeenCalledTimes(2);
});

it("should handle initial config prop", async () => {
Expand All @@ -254,6 +269,8 @@ describe("AppConfigContext", () => {
appNameNavbar: "Initial App",
};

mockApiResponses({ ...initialConfig, fromApi: true });

const customWrapper = ({ children }: { children: ReactNode }) => (
<AppConfigProvider initialConfig={initialConfig}>
{children}
Expand All @@ -275,11 +292,7 @@ describe("AppConfigContext", () => {

it("should use suppressErrorToast for all config requests", async () => {
const mockConfig = { enableLogin: true };

vi.mocked(apiClient.get).mockResolvedValueOnce({
status: 200,
data: mockConfig,
} as any);
mockApiResponses(mockConfig);

renderHook(() => useAppConfig(), { wrapper });

Expand Down
44 changes: 28 additions & 16 deletions frontend/src/core/contexts/AppConfigContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,20 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
const [error, setError] = useState<string | null>(null);
// Track how many times we've attempted to fetch. useRef avoids re-renders that can trigger loops.
const fetchCountRef = React.useRef(0);
const [hasResolvedConfig, setHasResolvedConfig] = useState(
// Use a Ref for hasResolvedConfig to avoid re-creating fetchConfig when it changes.
// This prevents unnecessary re-renders and potential infinite loops in consumers.
const hasResolvedConfigRef = React.useRef(
Boolean(initialConfig) && !isBlockingMode,
);
const [hasResolvedConfig, setHasResolvedConfigState] = useState(
hasResolvedConfigRef.current,
);

const setHasResolvedConfig = (val: boolean) => {
hasResolvedConfigRef.current = val;
setHasResolvedConfigState(val);
};

const [loading, setLoading] = useState(!hasResolvedConfig);

const onConfigLoadedRef = React.useRef(onConfigLoaded);
Expand All @@ -87,7 +98,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
// Mark that we've attempted a fetch to prevent repeated auto-fetch loops
fetchCountRef.current += 1;

const shouldBlockUI = !hasResolvedConfig || isBlockingMode;
const shouldBlockUI = !hasResolvedConfigRef.current || isBlockingMode;
if (shouldBlockUI) {
setLoading(true);
}
Expand All @@ -114,21 +125,22 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
console.log("[AppConfig] Fetching app config...");
}

// apiClient automatically adds JWT header if available via interceptors
// Always suppress error toast - we handle 401 errors locally
console.debug("[AppConfig] Fetching app config", {
attempt,
force,
path: window.location.pathname,
});
const response = await apiClient.get<AppConfig>(
"/api/v1/config/app-config",
{
// Parallelize app-config and status calls to minimize initialization time
const [configResponse] = await Promise.all([
apiClient.get<AppConfig>("/api/v1/config/app-config", {
suppressErrorToast: true,
skipAuthRedirect: true,
} as any,
);
const data = response.data;
} as any),
Comment thread
balazs-szucs marked this conversation as resolved.
Outdated
// Background probe for status to warm up the connection/cache
apiClient
.get("/api/v1/info/status", {
suppressErrorToast: true,
skipAuthRedirect: true,
} as any)
.catch(() => null),
]);

const data = configResponse.data;

console.debug("[AppConfig] Config fetched successfully:", data);
console.debug(
Expand Down Expand Up @@ -195,7 +207,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({

setLoading(false);
},
[hasResolvedConfig, isBlockingMode, maxRetries, initialDelay],
[isBlockingMode, maxRetries, initialDelay],
);

const { isAuthPage } = useJwtConfigSync(fetchConfig);
Expand Down
Loading
Loading