Fix Stage 0 bootstrap and startup regression

Task 0.1: Create database parent directory before connecting
- main.py _lifespan now calls Path(database_path).parent.mkdir(parents=True,
  exist_ok=True) before aiosqlite.connect() so the app starts cleanly on
  a fresh Docker volume with a nested database path.

Task 0.2: SetupRedirectMiddleware redirects when db is None
- Guard now reads: if db is None or not is_setup_complete(db)
  A missing database (startup still in progress) is treated as setup not
  complete instead of silently allowing all API routes through.

Task 0.3: SetupGuard redirects to /setup on API failure
- .catch() handler now sets status to 'pending' instead of 'done'.
  A crashed backend cannot serve protected routes; conservative fallback
  is to redirect to /setup.

Task 0.4: SetupPage shows spinner while checking setup status
- Added 'checking' boolean state; full-screen Spinner is rendered until
  getSetupStatus() resolves, preventing form flash before redirect.
- Added console.warn in catch block; cleanup return added to useEffect.

Also: remove unused type: ignore[call-arg] from config.py.

Tests: 18 backend tests pass; 117 frontend tests pass.
This commit is contained in:
2026-03-15 18:05:53 +01:00
parent eb859af371
commit 21753c4f06
8 changed files with 388 additions and 144 deletions

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { SetupGuard } from "../SetupGuard";
// Mock the setup API module so tests never hit a real network.
vi.mock("../../api/setup", () => ({
getSetupStatus: vi.fn(),
}));
import { getSetupStatus } from "../../api/setup";
const mockedGetSetupStatus = vi.mocked(getSetupStatus);
function renderGuard() {
return render(
<FluentProvider theme={webLightTheme}>
<MemoryRouter initialEntries={["/dashboard"]}>
<Routes>
<Route
path="/dashboard"
element={
<SetupGuard>
<div data-testid="protected-content">Protected</div>
</SetupGuard>
}
/>
<Route
path="/setup"
element={<div data-testid="setup-page">Setup Page</div>}
/>
</Routes>
</MemoryRouter>
</FluentProvider>,
);
}
describe("SetupGuard", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows a spinner while the setup status is loading", () => {
// getSetupStatus resolves eventually — spinner should show immediately.
mockedGetSetupStatus.mockReturnValue(new Promise(() => {}));
renderGuard();
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
it("renders children when setup is complete", async () => {
mockedGetSetupStatus.mockResolvedValue({ completed: true });
renderGuard();
await waitFor(() => {
expect(screen.getByTestId("protected-content")).toBeInTheDocument();
});
});
it("redirects to /setup when setup is not complete", async () => {
mockedGetSetupStatus.mockResolvedValue({ completed: false });
renderGuard();
await waitFor(() => {
expect(screen.getByTestId("setup-page")).toBeInTheDocument();
});
expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument();
});
it("redirects to /setup when the API call fails", async () => {
// Task 0.3: a failed check must redirect to /setup, not allow through.
mockedGetSetupStatus.mockRejectedValue(new Error("Network error"));
renderGuard();
await waitFor(() => {
expect(screen.getByTestId("setup-page")).toBeInTheDocument();
});
expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument();
});
});