diff --git a/Docs/Architekture.md b/Docs/Architekture.md index bc8d4dd..1051a04 100644 --- a/Docs/Architekture.md +++ b/Docs/Architekture.md @@ -949,11 +949,57 @@ Shared TypeScript interfaces and type aliases. Purely declarative — no runtime React context providers for application-wide concerns. +**Provider Ordering and Compile-Time Validation** + +Provider order is **order-sensitive** and enforced at compile-time through TypeScript discriminated unions. The required order (outermost to innermost) is: + +1. `ThemeProvider` — must be outermost; provides theme context to `AppContents` +2. `FluentProvider` — supplies Fluent UI theme and design tokens to all Fluent UI consumers +3. `NotificationProvider` — provides notification service; must wrap error boundaries +4. `ErrorBoundary` — catches catastrophic errors at the top level +5. `BrowserRouter` — enables client-side routing +6. `NavigationCancellationProvider` — manages route-aware request cancellation using `useLocation()` +7. `AuthProvider` — validates session on mount; must be inside BrowserRouter (uses `useNavigate()`) +8. `TimezoneProvider` — fetches timezone after auth; wraps protected routes only + +**Compile-Time Validation:** + +A type-safe builder pattern (`ProviderCompositionBuilder`) in `providerComposition.tsx` enforces this order using TypeScript's discriminated unions. The builder prevents adding providers out of order at compile-time: + +```tsx +const tree = createProviderComposition() + .withTheme({ children }) + .withFluent(theme) // ✓ Must come after withTheme + .withNotification() // ✓ Must come after withFluent + .withErrorBoundary() // ✓ Correct order enforced + .withBrowserRouter() + .withNavigationCancellation() + .withAuth() + .build(routes); +``` + +Attempting to add providers out of order results in TypeScript errors (no runtime overhead). + +**Runtime Validation (Development):** + +A runtime validator (`providerOrderValidator.tsx`) provides fallback validation for development: + +- `validateProviderPosition()` — checks if a provider is correctly nested +- `validateProvidersExist()` — ensures required providers are in the tree +- `hasProvider()` — queries provider presence +- `useProviderValidation()` — development-only hook that warns if required providers are missing + +See `src/providers/PROVIDER_ORDER.md` for detailed dependency rationale. + +**Provider Reference:** + | Provider | Purpose | |---|---| | `AuthProvider` | Holds authentication state; exposes `isAuthenticated`, `login()`, and `logout()` via `useAuth()`. Synchronizes logout events across browser tabs in real-time using the BroadcastChannel API (with storage event fallback for older browsers). When a user logs out in any tab, all other open tabs immediately reflect the logout state without requiring a page refresh. | | `TimezoneProvider` | Reads the configured IANA timezone from the backend and supplies it to all children via `useTimezone()` | | `ThemeProvider` | Manages light/dark theme selection, supplies the active Fluent UI theme to `FluentProvider` | +| `NotificationProvider` | Provides notification service via `useNotification()` hook; must wrap error boundaries so they can display error notifications | +| `NavigationCancellationProvider` | Detects route changes and automatically aborts pending API requests; call `useNavigationAbortSignal()` to get an `AbortSignal` that lives for the current route | #### Theme (`src/theme/`) diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 146d792..62aee29 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -1,52 +1,3 @@ -## [IMPORTANT] Error response schema inconsistent - -**Where found** - -- Different handlers return different response shapes -- Fail2Ban errors: `{ "error_code": "...", "detail": "..." }` -- Validation errors: `{ "detail": [...] }` -- Not found errors: `{ "detail": "...", "error_code": "..." }` - -**Why this is needed** - -Frontend must normalize multiple shapes, making error handling fragile and error-prone. - -**Goal** - -Unify all error responses to single schema. - -**What to do** - -1. Define canonical error response: - ```python - class ErrorResponse(BaseModel): - error_code: str - message: str - status: int - details: dict | None = None - ``` - -2. Update all handlers to return this format -3. Update frontend to expect unified schema - -**Possible traps and issues** - -- Backward compatibility with old clients -- FastAPI's built-in handlers may override custom -- Rich detail structures need accommodation - -**Docs changes needed** - -- Update API documentation with unified error schema -- Add error code reference table - -**Doc references** - -- `Docs/API.md` (error codes) -- `backend/app/main.py` (exception handlers) - ---- - ## [IMPORTANT] Provider ordering fragility (Frontend) **Where found** diff --git a/frontend/eslint-rules/index.js b/frontend/eslint-rules/index.js new file mode 100644 index 0000000..6de5266 --- /dev/null +++ b/frontend/eslint-rules/index.js @@ -0,0 +1,14 @@ +/** + * BanGUI Custom ESLint Rules + * + * This module exports all custom ESLint rules used to enforce + * coding standards and best practices in the BanGUI frontend. + */ + +import providerOrderRule from "./provider-order.js"; + +export default { + rules: { + "provider-order": providerOrderRule, + }, +}; diff --git a/frontend/eslint-rules/provider-order.js b/frontend/eslint-rules/provider-order.js new file mode 100644 index 0000000..720c16f --- /dev/null +++ b/frontend/eslint-rules/provider-order.js @@ -0,0 +1,172 @@ +/** + * ESLint Rule: no-incorrect-provider-order + * + * Validates that React context providers are nested in the correct order + * according to the BanGUI provider dependency contract. + * + * Provider Order (enforced by this rule): + * 1. ThemeProvider (OUTERMOST — must be first) + * 2. FluentProvider (wraps all Fluent UI consumers) + * 3. NotificationProvider (must wrap error boundaries) + * 4. ErrorBoundary (top-level error boundary) + * 5. BrowserRouter (enables routing) + * 6. NavigationCancellationProvider (manages route cancellation) + * 7. AuthProvider (session validation) + * 8. TimezoneProvider (INNERMOST — wraps protected routes only) + */ + +const PROVIDER_ORDER = [ + "ThemeProvider", + "FluentProvider", + "NotificationProvider", + "ErrorBoundary", + "BrowserRouter", + "NavigationCancellationProvider", + "AuthProvider", + "TimezoneProvider", +]; + +/** + * Get the provider name from a JSX element + */ +function getProviderName(node) { + if ( + node.name && + node.name.type === "JSXIdentifier" && + PROVIDER_ORDER.includes(node.name.name) + ) { + return node.name.name; + } + return null; +} + +/** + * Get the position of a provider in the ordering sequence + */ +function getProviderPosition(name) { + return PROVIDER_ORDER.indexOf(name); +} + +/** + * Check if a JSX element is a provider + */ +function isProvider(node) { + return getProviderName(node) !== null; +} + +/** + * Extract all JSX provider elements from a component tree + */ +function extractProviderStack(node) { + const stack = []; + let current = node; + + while (current) { + if (!current.openingElement) break; + + const providerName = getProviderName(current.openingElement); + + if (providerName) { + stack.push({ + name: providerName, + node: current.openingElement, + }); + } + + // Find the next JSX element by looking at children + if (current.children && current.children.length > 0) { + const firstChild = current.children[0]; + + if (firstChild && firstChild.type === "JSXElement") { + current = firstChild; + } else { + break; + } + } else { + break; + } + } + + return stack; +} + +export default { + meta: { + type: "problem", + docs: { + description: + "Enforce correct nesting order of React context providers in BanGUI", + category: "Best Practices", + recommended: "error", + url: "https://github.com/bangui/frontend/blob/main/src/providers/PROVIDER_ORDER.md", + }, + fixable: undefined, + schema: [], + messages: { + incorrectOrder: + "Provider '{{ outer }}' (position {{ outerPos }}) must wrap '{{ inner }}' (position {{ innerPos }}). Current nesting: {{ outer }} → {{ inner }}", + missingParent: + "Provider '{{ current }}' (position {{ currentPos }}) requires parent provider '{{ required }}' (position {{ requiredPos }}). Expected: {{ expected }}", + }, + }, + create(context) { + return { + JSXElement(node) { + // Skip non-provider elements + if (!node.openingElement || !isProvider(node.openingElement)) { + return; + } + + // Extract the provider stack from this element + const stack = extractProviderStack(node); + + if (stack.length < 2) { + // No nesting to validate + return; + } + + // Check ordering from outermost to innermost + for (let i = 0; i < stack.length - 1; i++) { + const outer = stack[i]; + const inner = stack[i + 1]; + + const outerPos = getProviderPosition(outer.name); + const innerPos = getProviderPosition(inner.name); + + // Inner provider must have a higher position (come later in the sequence) + if (innerPos <= outerPos) { + context.report({ + node: inner.node, + messageId: "incorrectOrder", + data: { + outer: outer.name, + outerPos: String(outerPos), + inner: inner.name, + innerPos: String(innerPos), + }, + }); + } + } + + // Check that all providers before the first one in the stack are present + const firstProvider = stack[0]; + const firstPos = getProviderPosition(firstProvider.name); + + if (firstPos > 0) { + const required = PROVIDER_ORDER[firstPos - 1]; + context.report({ + node: firstProvider.node, + messageId: "missingParent", + data: { + current: firstProvider.name, + currentPos: String(firstPos), + required, + requiredPos: String(firstPos - 1), + expected: PROVIDER_ORDER.slice(0, firstPos).join(" → "), + }, + }); + } + }, + }; + }, +}; diff --git a/frontend/eslint.config.ts b/frontend/eslint.config.ts index 07ab321..852172b 100644 --- a/frontend/eslint.config.ts +++ b/frontend/eslint.config.ts @@ -3,9 +3,10 @@ import tseslint from "typescript-eslint"; import reactHooks from "eslint-plugin-react-hooks"; import reactPlugin from "eslint-plugin-react"; import prettierConfig from "eslint-config-prettier"; +import bangguiRulesModule from "./eslint-rules/index.js"; export default tseslint.config( - { ignores: ["dist", "eslint.config.ts", ".vite"] }, + { ignores: ["dist", "eslint.config.ts", ".vite", "eslint-rules"] }, { extends: [js.configs.recommended, ...tseslint.configs.strictTypeChecked], files: ["**/*.{ts,tsx}"], @@ -18,6 +19,7 @@ export default tseslint.config( plugins: { react: reactPlugin, "react-hooks": reactHooks, + bangui: bangguiRulesModule, }, settings: { react: { @@ -34,6 +36,7 @@ export default tseslint.config( "@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/explicit-function-return-type": "warn", "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + "bangui/provider-order": "warn", }, }, prettierConfig, diff --git a/frontend/src/providers/__tests__/providerCompositionBuilder.test.tsx b/frontend/src/providers/__tests__/providerCompositionBuilder.test.tsx new file mode 100644 index 0000000..235e3de --- /dev/null +++ b/frontend/src/providers/__tests__/providerCompositionBuilder.test.tsx @@ -0,0 +1,290 @@ +/** + * Tests for Provider Composition Builder + * + * Validates that the provider composition builder enforces correct ordering + * at compile-time through TypeScript's discriminated union types. + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { webLightTheme } from "@fluentui/react-components"; +import { createProviderComposition } from "../providerComposition"; +import { useThemeMode } from "../ThemeProvider"; +import { useNotification } from "../../services/notificationService"; + +// --------------------------------------------------------------------------- +// Test Suite: Provider Composition Builder +// --------------------------------------------------------------------------- + +describe("ProviderCompositionBuilder", () => { + beforeEach(() => { + sessionStorage.clear(); + localStorage.clear(); + vi.clearAllMocks(); + + // 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: Builder creates valid provider tree without TypeScript errors + // ----------------------------------------------------------------------- + + it("creates a valid provider tree with complete composition", () => { + function TestComponent(): React.JSX.Element { + const { colorMode } = useThemeMode(); + return
{colorMode}
; + } + + const composition = createProviderComposition() + .withTheme({ children: }) + .withFluent(webLightTheme) + .withNotification() + .withErrorBoundary() + .withBrowserRouter() + .withNavigationCancellation() + .withAuth() + .build(
Routes go here
); + + const { container } = render(composition); + expect(container).toBeTruthy(); + }); + + // ----------------------------------------------------------------------- + // Test 2: Builder enforces ThemeProvider must be first + // ----------------------------------------------------------------------- + + it("requires withTheme to be called first", () => { + // This test is primarily a compile-time check + // If TypeScript allows calling withFluent without withTheme first, + // this test would fail (but TypeScript would prevent it) + + const builder = createProviderComposition(); + // @ts-expect-error withFluent should not be available on initial builder + builder.withFluent(webLightTheme); + }); + + // ----------------------------------------------------------------------- + // Test 3: Builder enforces provider ordering + // ----------------------------------------------------------------------- + + it("ensures FluentProvider comes after ThemeProvider", () => { + const builder = createProviderComposition().withTheme({ children:
}); + // withFluent should now be available after withTheme + const nextBuilder = builder.withFluent(webLightTheme); + expect(nextBuilder).toBeTruthy(); + }); + + it("ensures NotificationProvider comes after FluentProvider", () => { + const builder = createProviderComposition() + .withTheme({ children:
}) + .withFluent(webLightTheme); + // withNotification should now be available + const nextBuilder = builder.withNotification(); + expect(nextBuilder).toBeTruthy(); + }); + + // ----------------------------------------------------------------------- + // Test 4: Builder provides typed access to state + // ----------------------------------------------------------------------- + + it("allows building with intermediate providers (no Timezone)", () => { + function TestComponent(): React.JSX.Element { + const { colorMode } = useThemeMode(); + const notification = useNotification(); + + return ( +
+
{colorMode}
+ +
+ ); + } + + const tree = createProviderComposition() + .withTheme({ children: }) + .withFluent(webLightTheme) + .withNotification() + .withErrorBoundary() + .withBrowserRouter() + .withNavigationCancellation() + .withAuth() + .build(
Routes
); + + render(tree); + expect(screen.getByTestId("routes")).toBeInTheDocument(); + }); + + // ----------------------------------------------------------------------- + // Test 5: Builder supports optional TimezoneProvider + // ----------------------------------------------------------------------- + + it("allows adding TimezoneProvider as the final step", () => { + function ProtectedContent(): React.JSX.Element { + return
Protected
; + } + + const tree = createProviderComposition() + .withTheme({ children: }) + .withFluent(webLightTheme) + .withNotification() + .withErrorBoundary() + .withBrowserRouter() + .withNavigationCancellation() + .withAuth() + .withTimezone({ children:
Content
}) + .buildWithTimezone(); + + const { container } = render(tree); + expect(container).toBeTruthy(); + }); + + // ----------------------------------------------------------------------- + // Test 6: Verify all providers are included in final tree + // ----------------------------------------------------------------------- + + it("includes all providers in correct nesting order", () => { + function TestComponent(): React.JSX.Element { + const notification = useNotification(); + + // NotificationProvider should be accessible + const canUseNotification = notification !== undefined; + + return ( +
+
{canUseNotification ? "yes" : "no"}
+
+ ); + } + + // The builder composition creates the full provider tree + const tree = createProviderComposition() + .withTheme({ + children: + }) + .withFluent(webLightTheme) + .withNotification() + .withErrorBoundary() + .withBrowserRouter() + .withNavigationCancellation() + .withAuth() + .build(
Routes go here
); + + // Render the composition and verify at least one provider works + const { container } = render(tree); + expect(container).toBeTruthy(); + expect(container.querySelector('[class*="fui"]')).toBeTruthy(); // FluentProvider renders + }); + + // ----------------------------------------------------------------------- + // Test 7: Type safety prevents incorrect call sequences + // ----------------------------------------------------------------------- + + it("prevents calling methods out of order (TypeScript compile-time check)", () => { + const builder = createProviderComposition() + .withTheme({ children:
}) + .withFluent(webLightTheme); + + // This would be a compile error in real code: + // @ts-expect-error withTheme should not be available after withFluent + builder.withTheme({ children:
}); + + // This should work: + const nextBuilder = builder.withNotification(); + expect(nextBuilder).toBeTruthy(); + }); + + // ----------------------------------------------------------------------- + // Test 8: Builder state is immutable + // ----------------------------------------------------------------------- + + it("creates new builder instances without mutating previous state", () => { + const builder1 = createProviderComposition(); + const builder2 = builder1.withTheme({ children:
}); + const builder3 = builder2.withFluent(webLightTheme); + + // builder1 should still be in initial state + // @ts-expect-error builder1 should not have withFluent available + expect(() => builder1.withFluent(webLightTheme)).toBeDefined(); + + // builder2 should be in state 1 + const builder2_next = builder2.withFluent(webLightTheme); + expect(builder2_next).toBeTruthy(); + + // builder3 should be in state 2 + const builder3_next = builder3.withNotification(); + expect(builder3_next).toBeTruthy(); + }); + + // ----------------------------------------------------------------------- + // Test 9: Verify builder doesn't allow skipping intermediate steps + // ----------------------------------------------------------------------- + + it("prevents skipping providers in the sequence", () => { + const builder = createProviderComposition().withTheme({ children:
}); + + // Cannot call withNotification without withFluent first + // @ts-expect-error withNotification should not be available yet + expect(() => builder.withNotification()).toBeDefined(); + + // Must call withFluent first + const nextBuilder = builder.withFluent(webLightTheme); + expect(nextBuilder).toBeTruthy(); + }); + + // ----------------------------------------------------------------------- + // Test 10: Integration — Full composition with all steps + // ----------------------------------------------------------------------- + + it("supports full composition path with all providers including Timezone", () => { + function FullContent(): React.JSX.Element { + const { colorMode } = useThemeMode(); + const notification = useNotification(); + + return ( +
+
{colorMode}
+ +
+ ); + } + + const composition = createProviderComposition() + .withTheme({ children: }) + .withFluent(webLightTheme) + .withNotification() + .withErrorBoundary() + .withBrowserRouter() + .withNavigationCancellation() + .withAuth() + .withTimezone({ children:
TZ Content
}) + .buildWithTimezone(); + + const { container } = render(composition); + expect(container).toBeTruthy(); + }); +}); diff --git a/frontend/src/providers/__tests__/providerOrderValidator.test.tsx b/frontend/src/providers/__tests__/providerOrderValidator.test.tsx new file mode 100644 index 0000000..396a1cd --- /dev/null +++ b/frontend/src/providers/__tests__/providerOrderValidator.test.tsx @@ -0,0 +1,359 @@ +/** + * Tests for Provider Order Validator + * + * Validates runtime checking of provider ordering through context tracking. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render } from "@testing-library/react"; +import { + validateProviderPosition, + validateProvidersExist, + hasProvider, + getCurrentProviders, + createProviderTracker, + useProviderValidation, +} from "../providerOrderValidator"; + +describe("ProviderOrderValidator", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ----------------------------------------------------------------------- + // Test 1: validateProviderPosition detects missing parent providers + // ----------------------------------------------------------------------- + + it("throws error when provider parent is missing", () => { + function InvalidComponent(): React.JSX.Element { + validateProviderPosition("AuthProvider", 6); + return
Should not render
; + } + + expect(() => { + render(); + }).toThrow(/missing required parent providers/i); + }); + + it("allows ThemeProvider at position 0 without parent tracking", () => { + function ValidThemeComponent(): React.JSX.Element { + validateProviderPosition("ThemeProvider", 0); + return
Theme OK
; + } + + const { getByTestId } = render(); + expect(getByTestId("theme")).toBeInTheDocument(); + }); + + // ----------------------------------------------------------------------- + // Test 2: validateProvidersExist checks for required providers + // ----------------------------------------------------------------------- + + it("throws error when required providers are missing", () => { + function NeedsProviders(): React.JSX.Element { + validateProvidersExist(["AuthProvider", "NotificationProvider"]); + return
Should not render
; + } + + expect(() => { + render(); + }).toThrow(/missing required providers/i); + }); + + it("allows validation when tracking context exists with all required providers", () => { + function ValidatedComponent(): React.JSX.Element { + validateProvidersExist(["ThemeProvider"]); + return
Valid
; + } + + const composition = ( +
+ {createProviderTracker("ThemeProvider", 0, )} +
+ ); + + const { getByTestId } = render(composition); + expect(getByTestId("valid")).toBeInTheDocument(); + }); + + // ----------------------------------------------------------------------- + // Test 3: hasProvider checks provider presence + // ----------------------------------------------------------------------- + + it("returns true when provider is present", () => { + let result = false; + + function CheckProvider(): React.JSX.Element { + result = hasProvider("ThemeProvider"); + return
Check done
; + } + + render( + createProviderTracker("ThemeProvider", 0, ) + ); + + expect(result).toBe(true); + }); + + it("returns false when provider is not present", () => { + let result = true; + + function CheckProvider(): React.JSX.Element { + result = hasProvider("TimezoneProvider"); + return
Check done
; + } + + render( + createProviderTracker("ThemeProvider", 0, ) + ); + + expect(result).toBe(false); + }); + + // ----------------------------------------------------------------------- + // Test 4: getCurrentProviders returns providers in order + // ----------------------------------------------------------------------- + + it("returns empty array when no providers are tracked", () => { + let providers: string[] = []; + + function CheckProviders(): React.JSX.Element { + const result = getCurrentProviders(); + providers = result; + return
Check done
; + } + + render(); + expect(providers).toEqual([]); + }); + + it("returns providers in correct order when tracked", () => { + let providers: string[] = []; + + function CheckProviders(): React.JSX.Element { + const result = getCurrentProviders(); + providers = result; + return
Check done
; + } + + const composition = ( +
+ {createProviderTracker("ThemeProvider", 0, + createProviderTracker("FluentProvider", 1, + createProviderTracker("NotificationProvider", 2, ) + ) + )} +
+ ); + + render(composition); + expect(providers).toEqual(["ThemeProvider", "FluentProvider", "NotificationProvider"]); + }); + + // ----------------------------------------------------------------------- + // Test 5: createProviderTracker validates positioning + // ----------------------------------------------------------------------- + + it("throws error for invalid position in createProviderTracker", () => { + function Component(): React.JSX.Element { + return
Invalid
; + } + + // The error should be thrown during render, not construction + expect(() => { + // Using any to test invalid position despite type checking + // eslint-disable-next-line @typescript-eslint/no-explicit-any + render(createProviderTracker("ThemeProvider" as any, 999, )); + }).toThrow(/invalid position/i); + }); + + it("throws error when parent provider is missing in tracker", () => { + function Component(): React.JSX.Element { + return
Should not render
; + } + + expect(() => { + // NotificationProvider (position 2) requires FluentProvider (position 1) + // but we're skipping FluentProvider + render( +
+ {createProviderTracker("ThemeProvider", 0, + createProviderTracker("NotificationProvider", 2, ) + )} +
+ ); + }).toThrow(/incorrect parent structure|missing/i); + }); + + // ----------------------------------------------------------------------- + // Test 6: useProviderValidation hook validates providers + // ----------------------------------------------------------------------- + + it("logs warning in development when required providers are missing", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + function ComponentNeedsAuth(): React.JSX.Element { + useProviderValidation("ComponentNeedsAuth", ["AuthProvider"]); + return
Checking
; + } + + render(); + + // In development, should warn about missing provider + const hasWarning = warnSpy.mock.calls.some((call) => + String(call[0] || "").includes("ComponentNeedsAuth") || + String(call[0] || "").includes("missing") + ); + + // Only verify warning if NODE_ENV is development + if (process.env.NODE_ENV === "development") { + expect(hasWarning).toBe(true); + } + + warnSpy.mockRestore(); + }); + + it("does not warn when all required providers are present", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + function ComponentNeedsAuth(): React.JSX.Element { + useProviderValidation("ComponentNeedsAuth", ["ThemeProvider"]); + return
OK
; + } + + const composition = ( +
+ {createProviderTracker("ThemeProvider", 0, + createProviderTracker("FluentProvider", 1, ) + )} +
+ ); + + render(composition); + + // Should not warn about missing ThemeProvider + const warnCalls = warnSpy.mock.calls; + const hasThemeWarning = warnCalls.some((call) => + String(call[0] || "").includes("missing") && String(call[0] || "").includes("ThemeProvider") + ); + + expect(hasThemeWarning).toBe(false); + + warnSpy.mockRestore(); + }); + + // ----------------------------------------------------------------------- + // Test 7: Nested provider tracking + // ----------------------------------------------------------------------- + + it("correctly tracks nested providers through multiple levels", () => { + let capturedProviders: string[] = []; + + function InnerComponent(): React.JSX.Element { + capturedProviders = getCurrentProviders(); + return
Inner
; + } + + const composition = ( +
+ {createProviderTracker("ThemeProvider", 0, + createProviderTracker("FluentProvider", 1, + createProviderTracker("NotificationProvider", 2, + createProviderTracker("ErrorBoundary", 3, + createProviderTracker("BrowserRouter", 4, ) + ) + ) + ) + )} +
+ ); + + const { getByTestId } = render(composition); + expect(getByTestId("inner")).toBeInTheDocument(); + expect(capturedProviders).toEqual([ + "ThemeProvider", + "FluentProvider", + "NotificationProvider", + "ErrorBoundary", + "BrowserRouter", + ]); + }); + + // ----------------------------------------------------------------------- + // Test 8: Validation errors are descriptive + // ----------------------------------------------------------------------- + + it("provides helpful error messages for missing providers", () => { + let errorCaught = false; + + function Component(): React.JSX.Element { + try { + validateProvidersExist(["AuthProvider", "TimezoneProvider"]); + } catch (error) { + if (error instanceof Error) { + expect(error.message).toMatch(/missing required providers/i); + errorCaught = true; + } + } + return
Error caught
; + } + + render(); + expect(errorCaught).toBe(true); + }); + + it("provides helpful error messages for incorrect positioning", () => { + let errorCaught = false; + + function Component(): React.JSX.Element { + try { + validateProviderPosition("AuthProvider", 6); + } catch (error) { + if (error instanceof Error) { + expect(error.message).toMatch(/missing required parent/i); + errorCaught = true; + } + } + return
Error caught
; + } + + render(); + expect(errorCaught).toBe(true); + }); + + // ----------------------------------------------------------------------- + // Test 9: Invalid position numbers are rejected + // ----------------------------------------------------------------------- + + it("throws error for negative position", () => { + function Component(): React.JSX.Element { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + validateProviderPosition("InvalidProvider" as any, -1); + } catch (error) { + if (error instanceof Error) { + expect(error.message).toMatch(/invalid expected position/i); + } + } + return
Error caught
; + } + + render(); + }); + + it("throws error for position beyond sequence length", () => { + function Component(): React.JSX.Element { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + validateProviderPosition("InvalidProvider" as any, 999); + } catch (error) { + if (error instanceof Error) { + expect(error.message).toMatch(/invalid expected position/i); + } + } + return
Error caught
; + } + + render(); + }); +}); diff --git a/frontend/src/providers/providerComposition.tsx b/frontend/src/providers/providerComposition.tsx new file mode 100644 index 0000000..71c1338 --- /dev/null +++ b/frontend/src/providers/providerComposition.tsx @@ -0,0 +1,373 @@ +/** + * Provider Composition Utility — Compile-Time Provider Ordering Validation + * + * This module enforces the provider nesting order at compile-time using TypeScript's + * discriminated unions. The provider hierarchy is strictly enforced and cannot be + * violated without TypeScript errors. + * + * Provider Order (enforced at compile-time): + * 1. ThemeProvider (OUTERMOST — must be first) + * 2. FluentProvider (wraps all Fluent UI consumers) + * 3. NotificationProvider (must wrap error boundaries) + * 4. ErrorBoundary (top-level error boundary) + * 5. BrowserRouter (enables routing) + * 6. NavigationCancellationProvider (manages route cancellation) + * 7. AuthProvider (session validation) + * 8. TimezoneProvider (INNERMOST — wraps protected routes only) + * + * Usage: + * ```tsx + * const providerStack = createProviderComposition() + * .withTheme({ children }) + * .withFluent(theme) + * .withNotification() + * .build(); + * ``` + * + * TypeScript will prevent any attempt to add providers out of order: + * ```tsx + * const invalid = createProviderComposition() + * .withFluent(theme) // ❌ TypeScript Error: cannot call withFluent before withTheme + * .withTheme({ children }); + * ``` + */ + +import type { ReactElement, ReactNode } from "react"; +import { FluentProvider } from "@fluentui/react-components"; +import type { Theme } from "@fluentui/react-components"; +import { BrowserRouter } from "react-router-dom"; +import { ThemeProvider } from "./ThemeProvider"; +import { AuthProvider } from "./AuthProvider"; +import { TimezoneProvider } from "./TimezoneProvider"; +import { NavigationCancellationProvider } from "./NavigationCancellationProvider"; +import { NotificationProvider } from "../services/notificationService"; +import { ErrorBoundary } from "../components/ErrorBoundary"; +import { NotificationContainer } from "../components/NotificationContainer"; + +// ============================================================================ +// Type Definitions — Discriminated Union Pattern +// ============================================================================ + +/** + * State 0: Initial state — only ThemeProvider can be added + */ +interface ProviderState0 { + readonly __state: 0; +} + +/** + * State 1: ThemeProvider added — FluentProvider can be added next + */ +interface ProviderState1 { + readonly __state: 1; + readonly themeChildren: ReactNode; +} + +/** + * State 2: FluentProvider added — NotificationProvider can be added next + */ +interface ProviderState2 { + readonly __state: 2; + readonly themeChildren: ReactNode; + readonly theme: Theme; +} + +/** + * State 3: NotificationProvider added — ErrorBoundary can be added next + */ +interface ProviderState3 { + readonly __state: 3; + readonly themeChildren: ReactNode; + readonly theme: Theme; +} + +/** + * State 4: ErrorBoundary added — BrowserRouter can be added next + */ +interface ProviderState4 { + readonly __state: 4; + readonly themeChildren: ReactNode; + readonly theme: Theme; +} + +/** + * State 5: BrowserRouter added — NavigationCancellationProvider can be added next + */ +interface ProviderState5 { + readonly __state: 5; + readonly themeChildren: ReactNode; + readonly theme: Theme; +} + +/** + * State 6: NavigationCancellationProvider added — AuthProvider can be added next + */ +interface ProviderState6 { + readonly __state: 6; + readonly themeChildren: ReactNode; + readonly theme: Theme; +} + +/** + * State 7: AuthProvider added — TimezoneProvider can be added next (or build can be called) + */ +interface ProviderState7 { + readonly __state: 7; + readonly themeChildren: ReactNode; + readonly theme: Theme; +} + +/** + * State 8: TimezoneProvider added — ready to build final component + */ +interface ProviderState8 { + readonly __state: 8; + readonly themeChildren: ReactNode; + readonly theme: Theme; + readonly timezoneChildren: ReactNode; +} + +type ProviderCompositionState = ProviderState0 | ProviderState1 | ProviderState2 | ProviderState3 | ProviderState4 | ProviderState5 | ProviderState6 | ProviderState7 | ProviderState8; + +// ============================================================================ +// Builder — Type-Safe Provider Composition +// ============================================================================ + +/** + * Type-safe builder for composing providers in the correct order. + * + * Each method returns a builder in the next state, ensuring providers + * can only be added in the documented order. + */ +class ProviderCompositionBuilder { + /** + * Create a new builder in its initial state. + */ + static create(): ProviderCompositionBuilder { + return new ProviderCompositionBuilder({ __state: 0 }); + } + + private constructor(private state: TState) {} + + /** + * Add ThemeProvider (Step 1: OUTERMOST). + * + * This must be called first. ThemeProvider supplies theme context + * to all descendants via useThemeMode(). + */ + withTheme( + this: ProviderCompositionBuilder, + props: { children: ReactNode } + ): ProviderCompositionBuilder { + return new ProviderCompositionBuilder({ + __state: 1, + themeChildren: props.children, + } as unknown as ProviderState1); + } + + /** + * Add FluentProvider (Step 2). + * + * Must come after ThemeProvider. Supplies Fluent UI theme and design tokens + * to all Fluent UI components. + */ + withFluent( + this: ProviderCompositionBuilder, + theme: Theme + ): ProviderCompositionBuilder { + return new ProviderCompositionBuilder({ + __state: 2, + themeChildren: this.state.themeChildren, + theme, + } as unknown as ProviderState2); + } + + /** + * Add NotificationProvider (Step 3). + * + * Must come after FluentProvider. Makes useNotification() available + * to error boundaries and other components. + */ + withNotification( + this: ProviderCompositionBuilder + ): ProviderCompositionBuilder { + return new ProviderCompositionBuilder({ + __state: 3, + themeChildren: this.state.themeChildren, + theme: this.state.theme, + } as unknown as ProviderState3); + } + + /** + * Add ErrorBoundary (Step 4). + * + * Must come after NotificationProvider. Catches catastrophic errors + * that would crash the entire app. + */ + withErrorBoundary( + this: ProviderCompositionBuilder + ): ProviderCompositionBuilder { + return new ProviderCompositionBuilder({ + __state: 4, + themeChildren: this.state.themeChildren, + theme: this.state.theme, + } as unknown as ProviderState4); + } + + /** + * Add BrowserRouter (Step 5). + * + * Must come after ErrorBoundary. Enables client-side routing and + * supports useNavigate() in downstream providers. + */ + withBrowserRouter( + this: ProviderCompositionBuilder + ): ProviderCompositionBuilder { + return new ProviderCompositionBuilder({ + __state: 5, + themeChildren: this.state.themeChildren, + theme: this.state.theme, + } as unknown as ProviderState5); + } + + /** + * Add NavigationCancellationProvider (Step 6). + * + * Must come after BrowserRouter. Manages route-aware request cancellation + * using useLocation() to detect route changes. + */ + withNavigationCancellation( + this: ProviderCompositionBuilder + ): ProviderCompositionBuilder { + return new ProviderCompositionBuilder({ + __state: 6, + themeChildren: this.state.themeChildren, + theme: this.state.theme, + } as unknown as ProviderState6); + } + + /** + * Add AuthProvider (Step 7). + * + * Must come after NavigationCancellationProvider. Validates session + * on mount and provides useAuth() context to all descendants. + */ + withAuth( + this: ProviderCompositionBuilder + ): ProviderCompositionBuilder { + return new ProviderCompositionBuilder({ + __state: 7, + themeChildren: this.state.themeChildren, + theme: this.state.theme, + } as unknown as ProviderState7); + } + + /** + * Add TimezoneProvider (Step 8: INNERMOST). + * + * Optional. Must come after AuthProvider if used. Wraps protected routes + * and fetches timezone from the backend. + */ + withTimezone( + this: ProviderCompositionBuilder, + props: { children: ReactNode } + ): ProviderCompositionBuilder { + return new ProviderCompositionBuilder({ + __state: 8, + themeChildren: this.state.themeChildren, + theme: this.state.theme, + timezoneChildren: props.children, + } as unknown as ProviderState8); + } + + /** + * Build the final composed provider tree without TimezoneProvider. + * Use this for routes that don't need timezone data (e.g., setup, login). + */ + build( + this: ProviderCompositionBuilder, + innerContent: ReactNode + ): ReactElement { + const s = this.state; + return ( + + + + + + + + + {innerContent} + + + + + + + + ); + } + + /** + * Build the final composed provider tree with TimezoneProvider. + * Use this for protected routes that need timezone data. + */ + buildWithTimezone( + this: ProviderCompositionBuilder + ): ReactElement { + const s = this.state; + return ( + + + + + + + + + + {s.timezoneChildren} + + + + + + + + + ); + } +} + +export { ProviderCompositionBuilder }; + +/** + * Factory function to create a new provider composition builder. + * + * @returns A new builder in state 0 (ready for ThemeProvider) + * + * @example + * ```tsx + * const providerStack = createProviderComposition() + * .withTheme({ children }) + * .withFluent(theme) + * .withNotification() + * .withErrorBoundary() + * .withBrowserRouter() + * .withNavigationCancellation() + * .withAuth() + * .build(routes); + * ``` + */ +export function createProviderComposition(): ProviderCompositionBuilder { + return ProviderCompositionBuilder.create(); +} diff --git a/frontend/src/providers/providerOrderValidator.tsx b/frontend/src/providers/providerOrderValidator.tsx new file mode 100644 index 0000000..a7451dd --- /dev/null +++ b/frontend/src/providers/providerOrderValidator.tsx @@ -0,0 +1,322 @@ +/** + * Provider Ordering Runtime Validator + * + * This utility validates that providers are correctly nested at runtime, + * serving as a safety net for development when compile-time checks are bypassed + * (e.g., through type assertions or dynamic composition). + * + * Usage: + * ```tsx + * function AppContents() { + * validateProviderOrder(); // Throws if called outside expected provider context + * // ... rest of component + * } + * ``` + */ + +import { useContext, createContext, useEffect } from "react"; +import type { ReactNode } from "react"; + +// ============================================================================ +// Provider Tracking Context +// ============================================================================ + +interface ProviderOrderContext { + providers: Set; +} + +const ProviderOrderTrackingContext = createContext(null); + +/** + * Get the current provider order tracking context + */ +function getProviderOrderTracking(): ProviderOrderContext | null { + try { + // eslint-disable-next-line react-hooks/rules-of-hooks + return useContext(ProviderOrderTrackingContext); + } catch { + // useContext can only be called within a component + return null; + } +} + +/** + * Allowed provider order sequence. Any deviation is an error. + */ +const PROVIDER_SEQUENCE = [ + "ThemeProvider", + "FluentProvider", + "NotificationProvider", + "ErrorBoundary", + "BrowserRouter", + "NavigationCancellationProvider", + "AuthProvider", + "TimezoneProvider", +] as const; + +type ProviderName = (typeof PROVIDER_SEQUENCE)[number]; + +/** + * Validate that a provider is in the correct position relative to its siblings. + * + * Throws a descriptive error if: + * - A provider is nested in the wrong order + * - A required parent provider is missing + * + * @param providerName The name of the provider being validated + * @param expectedPosition The expected position in the provider sequence + * @throws {Error} If provider ordering is violated + * + * @example + * ```tsx + * export function AuthProvider({ children }: { children: ReactNode }): JSX.Element { + * validateProviderPosition("AuthProvider", 6); + * // ... provider implementation + * } + * ``` + */ +export function validateProviderPosition( + providerName: ProviderName, + expectedPosition: number +): void { + if (expectedPosition < 0 || expectedPosition >= PROVIDER_SEQUENCE.length) { + throw new Error( + `[ProviderValidator] Invalid expected position ${String(expectedPosition)} for ${providerName}. ` + + `Expected position between 0 and ${String(PROVIDER_SEQUENCE.length - 1)}.` + ); + } + + const tracking = getProviderOrderTracking(); + + if (tracking === null && expectedPosition > 0) { + // Only allow position 0 (ThemeProvider) to have no tracking context + throw new Error( + `[ProviderValidator] ${providerName} (position ${String(expectedPosition)}) is missing required parent providers. ` + + `Expected parent: ${String(PROVIDER_SEQUENCE[expectedPosition - 1])}. ` + + `Provider order: ${PROVIDER_SEQUENCE.slice(0, expectedPosition).join(" → ")} → [${providerName}]` + ); + } + + if (tracking !== null && expectedPosition > 0) { + const requiredParent: ProviderName = PROVIDER_SEQUENCE[expectedPosition - 1] as ProviderName; + + if (!tracking.providers.has(requiredParent)) { + const providerSlice: ProviderName[] = PROVIDER_SEQUENCE.slice(0, expectedPosition); + const missingProviders = providerSlice.filter( + (p: ProviderName) => !tracking.providers.has(p) + ); + + throw new Error( + `[ProviderValidator] ${providerName} (position ${String(expectedPosition)}) is missing required parent providers. ` + + `Missing: ${missingProviders.join(", ")}. ` + + `Expected order: ${PROVIDER_SEQUENCE.slice(0, expectedPosition).join(" → ")} → [${providerName}]` + ); + } + } +} + +/** + * Validate that all required providers are present in the component tree. + * + * Use this in components that depend on multiple providers to ensure + * they're all available. + * + * @param requiredProviders Array of provider names that must be present + * @throws {Error} If any required provider is missing + * + * @example + * ```tsx + * function Dashboard(): JSX.Element { + * validateProvidersExist(["ThemeProvider", "AuthProvider", "NotificationProvider"]); + * // ... component that uses all three providers + * } + * ``` + */ +export function validateProvidersExist(requiredProviders: ProviderName[]): void { + const tracking = getProviderOrderTracking(); + + if (tracking === null) { + throw new Error( + `[ProviderValidator] Missing required providers: ${requiredProviders.join(", ")}. ` + + `Ensure your component is wrapped with: ${requiredProviders.join(" → ")}` + ); + } + + const missing = requiredProviders.filter((p) => !tracking.providers.has(p)); + + if (missing.length > 0) { + throw new Error( + `[ProviderValidator] Missing required providers: ${missing.join(", ")}. ` + + `Ensure your component is wrapped with: ${requiredProviders.join(" → ")}` + ); + } +} + +/** + * Check if a provider is present in the current component tree. + * + * @param providerName Name of the provider to check + * @returns true if the provider is in the current tree, false otherwise + * + * @example + * ```tsx + * function MyComponent(): JSX.Element { + * if (hasProvider("AuthProvider")) { + * const auth = useAuth(); + * // ... use auth + * } + * } + * ``` + */ +export function hasProvider(providerName: ProviderName): boolean { + const tracking = getProviderOrderTracking(); + return tracking !== null && tracking.providers.has(providerName); +} + +/** + * Get all providers currently in the component tree (in order). + * + * @returns Array of provider names from outermost to innermost + * + * @example + * ```tsx + * const providers = getCurrentProviders(); + * console.log(providers); // ["ThemeProvider", "FluentProvider", "NotificationProvider", ...] + * ``` + */ +export function getCurrentProviders(): ProviderName[] { + const tracking = getProviderOrderTracking(); + if (tracking === null) { + return []; + } + + // Return providers in the order they appear in PROVIDER_SEQUENCE + return PROVIDER_SEQUENCE.filter((p) => tracking.providers.has(p)); +} + +/** + * Create a tracking wrapper for any provider. + * + * This allows runtime validation of provider nesting order. + * Call this from within any provider component. + * + * @param providerName Name of the provider + * @param expectedPosition Position in the provider sequence + * @param children Child components + * @returns JSX element with provider order tracking + * + * @internal Use this in provider implementations to enable validation + * + * @example + * ```tsx + * export function CustomProvider({ children }: { children: ReactNode }): JSX.Element { + * return createProviderTracker("CustomProvider", 5, children); + * } + * ``` + */ +export function createProviderTracker( + providerName: ProviderName, + expectedPosition: number, + children: ReactNode +): React.JSX.Element { + return ; +} + +/** + * Internal component that tracks provider nesting order. + */ +function ProviderOrderTracker({ + name, + position, + children, +}: { + name: ProviderName; + position: number; + children: ReactNode; +}): React.JSX.Element { + const parentTracking = getProviderOrderTracking(); + + // Validate position + if (position < 0 || position >= PROVIDER_SEQUENCE.length) { + throw new Error( + `[ProviderValidator] Invalid position ${String(position)} for provider ${name}. ` + + `Expected 0-${String(PROVIDER_SEQUENCE.length - 1)}.` + ); + } + + // Validate parent providers + if (position > 0) { + const expectedParent: ProviderName = PROVIDER_SEQUENCE[position - 1] as ProviderName; + if (parentTracking === null || !parentTracking.providers.has(expectedParent)) { + const providerSlice: ProviderName[] = PROVIDER_SEQUENCE.slice(0, position); + const missing = providerSlice.filter( + (p: ProviderName) => parentTracking === null || !parentTracking.providers.has(p) + ); + + throw new Error( + `[ProviderValidator] ${name} (position ${String(position)}) has incorrect parent structure. ` + + `Missing: ${missing.join(", ")}. ` + + `Expected: ${PROVIDER_SEQUENCE.slice(0, position).join(" → ")} → [${name}]` + ); + } + } + + // Create new tracking set including this provider + const newTracking: ProviderOrderContext = { + providers: new Set(parentTracking?.providers ?? []), + }; + newTracking.providers.add(name); + + return ( + + {children} + + ); +} + +/** + * Development-only hook to verify a component is wrapped by required providers. + * + * Use this in development to catch provider ordering issues early. + * + * @param componentName Name of the component (for error messages) + * @param requiredProviders Array of required provider names + * @throws {Error} in development if any required provider is missing + * + * @example + * ```tsx + * function JailsPage(): JSX.Element { + * useProviderValidation("JailsPage", ["ThemeProvider", "AuthProvider"]); + * // ... component implementation + * } + * ``` + */ +export function useProviderValidation( + componentName: string, + requiredProviders: ProviderName[] +): void { + useEffect(() => { + if (process.env.NODE_ENV === "development") { + const tracking = getProviderOrderTracking(); + + if (tracking === null) { + console.warn( + `[ProviderValidator] ${componentName} requires providers but none were found. ` + + `Required: ${requiredProviders.join(", ")}` + ); + return; + } + + const missing = requiredProviders.filter((p) => !tracking.providers.has(p)); + + if (missing.length > 0) { + console.warn( + `[ProviderValidator] ${componentName} is missing providers: ${missing.join(", ")}. ` + + `Currently available: ${Array.from(tracking.providers).join(", ")}` + ); + } + } + }, [componentName, requiredProviders]); +} + +export { ProviderOrderTrackingContext };