Files
BanGUI/frontend/src/providers/__tests__/providerComposition.test.tsx
Lukas 2fea513c9c docs: make provider dependency chain explicit with documentation and tests
This addresses issue #19 by making the implicit provider dependency order
explicit and order-sensitive.

Changes:
1. Created PROVIDER_ORDER.md - comprehensive documentation explaining:
   - The provider hierarchy from outermost to innermost
   - Why each provider must be at its position
   - Order-sensitive pitfalls and what would break
   - Guidelines for adding new providers in the future

2. Added provider composition tests (providerComposition.test.tsx):
   - 13 comprehensive tests validating provider order and dependencies
   - Tests verify all providers mount correctly
   - Tests check that hooks only work inside correct providers
   - Tests validate async initialization (AuthProvider, TimezoneProvider)
   - Tests verify theme persistence and notification propagation

3. Updated App.tsx with inline documentation:
   - Added detailed provider order contract in JSDoc header
   - Inline comments explaining each provider's position
   - Reference to PROVIDER_ORDER.md for detailed rationale

4. Updated Web-Development.md:
   - Added new section 5.5 'Provider Order Contract'
   - Documents provider hierarchy and rationale
   - Links to comprehensive provider documentation
   - References regression test suite

All tests pass. TypeScript compilation succeeds. Build succeeds.
The provider order is now explicit and future refactors can validate
compliance through the regression test suite.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 09:30:22 +02:00

404 lines
13 KiB
TypeScript

/**
* Provider Composition Tests
*
* Validates that providers are nested in the correct order and that their
* dependencies are satisfied. These tests ensure that the provider hierarchy
* contract is maintained across refactors.
*
* Key invariants tested:
* - All providers mount without crashing
* - Providers are accessible from their descendant components
* - Order-dependent initialization (auth, timezone) works correctly
* - Theme persistence works across re-renders
* - Notifications and errors propagate correctly
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { type ReactElement } from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { AuthProvider } from "../AuthProvider";
import { ThemeProvider, useThemeMode } from "../ThemeProvider";
import { TimezoneProvider } from "../TimezoneProvider";
import { useTimezone } from "../../hooks/useTimezone";
import { NotificationProvider, useNotification } from "../../services/notificationService";
import * as clientModule from "../../api/client";
// ---------------------------------------------------------------------------
// Fixture: Test component that consumes all providers
// ---------------------------------------------------------------------------
function AllProvidersConsumer(): ReactElement {
const { colorMode } = useThemeMode();
const notification = useNotification();
const timezone = useTimezone();
return (
<div>
<div data-testid="theme">{colorMode}</div>
<div data-testid="timezone">{timezone}</div>
<button
type="button"
onClick={() => notification.success("Test notification")}
data-testid="notify-btn"
>
Notify
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Test Suite: Provider Composition
// ---------------------------------------------------------------------------
describe("Provider Composition Contract", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
vi.clearAllMocks();
vi.restoreAllMocks();
// Mock window.matchMedia for theme preference
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: vi.fn().mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
});
});
afterEach(() => {
vi.clearAllMocks();
});
// -----------------------------------------------------------------------
// Test 1: ThemeProvider must be the outermost provider
// -----------------------------------------------------------------------
it("ThemeProvider supplies theme to AppContents for FluentProvider", () => {
function InnerApp(): ReactElement {
const { colorMode } = useThemeMode();
return <div data-testid="color-output">{colorMode}</div>;
}
render(
<ThemeProvider>
<InnerApp />
</ThemeProvider>,
);
const colorOutput = screen.getByTestId("color-output");
expect(colorOutput.textContent).toMatch(/light|dark/);
});
it("FluentProvider must receive theme from useThemeMode", () => {
function ConsumerComponent(): ReactElement {
return <div data-testid="fluent-content">Fluent content rendered</div>;
}
const { container } = render(
<ThemeProvider>
<FluentProvider theme={webLightTheme}>
<ConsumerComponent />
</FluentProvider>
</ThemeProvider>,
);
expect(screen.getByTestId("fluent-content")).toBeInTheDocument();
// Verify FluentProvider rendered (check for Fluent-specific class)
expect(container.querySelector("[class*='fui']")).toBeTruthy();
});
// -----------------------------------------------------------------------
// Test 2: NotificationProvider must be after FluentProvider but before
// error boundaries and content
// -----------------------------------------------------------------------
it("NotificationProvider makes useNotification available to children", () => {
function NotificationConsumer(): ReactElement {
const notification = useNotification();
return (
<button
type="button"
onClick={() => notification.success("Test")}
data-testid="notify-btn"
>
Notify
</button>
);
}
render(
<ThemeProvider>
<FluentProvider theme={webLightTheme}>
<NotificationProvider>
<NotificationConsumer />
</NotificationProvider>
</FluentProvider>
</ThemeProvider>,
);
expect(screen.getByTestId("notify-btn")).toBeInTheDocument();
});
it("throws error when useNotification is called outside NotificationProvider", () => {
function BadComponent(): ReactElement {
useNotification();
return <div>This should not render</div>;
}
expect(() => {
render(
<ThemeProvider>
<FluentProvider theme={webLightTheme}>
<BadComponent />
</FluentProvider>
</ThemeProvider>,
);
}).toThrow("useNotification must be used within NotificationProvider");
});
// -----------------------------------------------------------------------
// Test 3: AuthProvider must be inside BrowserRouter
// -----------------------------------------------------------------------
it("AuthProvider must be inside BrowserRouter (uses useNavigate internally)", () => {
// This test verifies that AuthProvider can be placed inside BrowserRouter
// by confirming it doesn't throw when nested correctly
render(
<MemoryRouter initialEntries={["/"]}>
<FluentProvider theme={webLightTheme}>
<AuthProvider>
<Routes>
<Route path="/" element={<div>Home</div>} />
</Routes>
</AuthProvider>
</FluentProvider>
</MemoryRouter>,
);
expect(screen.getByText("Home")).toBeInTheDocument();
});
it("throws error when AuthProvider is outside BrowserRouter", () => {
function RootComponent(): ReactElement {
return (
<AuthProvider>
<div>Content</div>
</AuthProvider>
);
}
expect(() => {
render(
<FluentProvider theme={webLightTheme}>
<RootComponent />
</FluentProvider>,
);
}).toThrow(/useNavigate|useLocation|outside/i);
});
// -----------------------------------------------------------------------
// Test 4: TimezoneProvider must be inside authenticated context
// -----------------------------------------------------------------------
it("TimezoneProvider provides timezone to consumers", () => {
function TimezoneConsumer(): ReactElement {
const timezone = useTimezone();
return <div data-testid="tz-output">{timezone}</div>;
}
render(
<TimezoneProvider>
<TimezoneConsumer />
</TimezoneProvider>,
);
const tzOutput = screen.getByTestId("tz-output");
expect(tzOutput.textContent).toBeTruthy();
});
it("defaults to UTC when timezone data is loading or unavailable", async () => {
function TimezoneDisplay(): ReactElement {
const timezone = useTimezone();
return <div data-testid="tz-display">{timezone}</div>;
}
render(
<TimezoneProvider>
<TimezoneDisplay />
</TimezoneProvider>,
);
// Should default to UTC while loading
const tzDisplay = screen.getByTestId("tz-display");
expect(tzDisplay.textContent).toMatch(/UTC/);
});
// -----------------------------------------------------------------------
// Test 5: Provider initialization order is respected
// -----------------------------------------------------------------------
it("renders all providers without errors when properly nested", () => {
render(
<ThemeProvider>
<FluentProvider theme={webLightTheme}>
<NotificationProvider>
<MemoryRouter initialEntries={["/"]}>
<AuthProvider>
<TimezoneProvider>
<AllProvidersConsumer />
</TimezoneProvider>
</AuthProvider>
</MemoryRouter>
</NotificationProvider>
</FluentProvider>
</ThemeProvider>,
);
expect(screen.getByTestId("theme")).toBeInTheDocument();
expect(screen.getByTestId("timezone")).toBeInTheDocument();
expect(screen.getByTestId("notify-btn")).toBeInTheDocument();
});
// -----------------------------------------------------------------------
// Test 6: Async provider initialization (AuthProvider)
// -----------------------------------------------------------------------
it("AuthProvider shows loading state while validating session", async () => {
vi.spyOn(clientModule, "setUnauthorizedHandler").mockImplementation(
(_handler: (() => void) | null) => {
// Mock implementation
},
);
sessionStorage.setItem("bangui_authenticated", "true");
render(
<MemoryRouter initialEntries={["/"]}>
<FluentProvider theme={webLightTheme}>
<AuthProvider>
<Routes>
<Route path="/" element={<div>Authenticated content</div>} />
</Routes>
</AuthProvider>
</FluentProvider>
</MemoryRouter>,
);
// AuthProvider should eventually render authenticated content
await waitFor(() => {
expect(screen.queryByText("Authenticated content")).toBeInTheDocument();
});
});
// -----------------------------------------------------------------------
// Test 7: Theme persistence across provider remounts
// -----------------------------------------------------------------------
it("persists theme selection across re-renders", () => {
localStorage.setItem("bangui_theme", "dark");
const { rerender } = render(
<ThemeProvider>
<div data-testid="theme-display">
<ThemeDisplay />
</div>
</ThemeProvider>,
);
expect(screen.getByTestId("theme-color")).toHaveTextContent("dark");
// Re-render should preserve theme
rerender(
<ThemeProvider>
<div data-testid="theme-display">
<ThemeDisplay />
</div>
</ThemeProvider>,
);
expect(screen.getByTestId("theme-color")).toHaveTextContent("dark");
});
// -----------------------------------------------------------------------
// Test 8: Error boundary receives notifications
// -----------------------------------------------------------------------
it("NotificationProvider is accessible from error scenarios", () => {
// This tests that NotificationProvider is positioned correctly
// so that error boundaries and other error handlers can use it
let serviceWorks = false;
function CaptureNotification(): ReactElement {
const notification = useNotification();
// Just verify it's callable - don't call it as it mutates state
serviceWorks = typeof notification.success === "function";
return <div>Captured notification service</div>;
}
render(
<ThemeProvider>
<FluentProvider theme={webLightTheme}>
<NotificationProvider>
<CaptureNotification />
</NotificationProvider>
</FluentProvider>
</ThemeProvider>,
);
expect(serviceWorks).toBe(true);
});
// -----------------------------------------------------------------------
// Test 9: Full app provider stack renders without errors
// -----------------------------------------------------------------------
it("complete App provider stack mounts successfully", async () => {
// Mock the session validation
vi.spyOn(clientModule, "setUnauthorizedHandler").mockImplementation(
(_handler: (() => void) | null) => {
// Mock implementation
},
);
// Note: This tests basic mounting; full App integration is tested in App.test.tsx
render(
<ThemeProvider>
<FluentProvider theme={webLightTheme}>
<NotificationProvider>
<MemoryRouter initialEntries={["/"]}>
<AuthProvider>
<Routes>
<Route path="/" element={<div>Dashboard</div>} />
</Routes>
</AuthProvider>
</MemoryRouter>
</NotificationProvider>
</FluentProvider>
</ThemeProvider>,
);
await waitFor(() => {
expect(screen.queryByText("Dashboard")).toBeInTheDocument();
});
});
});
// ---------------------------------------------------------------------------
// Helper Components
// ---------------------------------------------------------------------------
function ThemeDisplay(): ReactElement {
const { colorMode } = useThemeMode();
return <span data-testid="theme-color">{colorMode}</span>;
}