From 0254a198d1372ddc2bd1a9926b6989282a064f85 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 7 May 2026 11:14:51 -0700 Subject: [PATCH 1/3] feat: fix the inertia providers --- fastapi_startkit/src/fastapi_startkit/inertia/provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi_startkit/src/fastapi_startkit/inertia/provider.py b/fastapi_startkit/src/fastapi_startkit/inertia/provider.py index 4884b80a..0dc2c870 100644 --- a/fastapi_startkit/src/fastapi_startkit/inertia/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/inertia/provider.py @@ -9,7 +9,7 @@ class InertiaProvider(Provider): def register(self) -> None: """Bind the Inertia class to the container.""" - self.app.bind("inertia", Inertia(self.app)) + self.app.bind("inertia", Inertia) def boot(self) -> None: """Configure template globals and middleware.""" From a5f65a2fd65ec1074bc454679a3605f157363f56 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 7 May 2026 12:00:43 -0700 Subject: [PATCH 2/3] feat: fix the inertia providers --- .github/workflows/test.yml | 2 +- fastapi_startkit/pyproject.toml | 2 + .../tests/inertia/test_inertia.py | 53 ++++++++ .../tests/inertia/test_inertia_response.py | 114 ++++++++++++++++++ .../tests/inertia/test_middleware.py | 103 ++++++++++++++++ 5 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 fastapi_startkit/tests/inertia/test_inertia.py create mode 100644 fastapi_startkit/tests/inertia/test_inertia_response.py create mode 100644 fastapi_startkit/tests/inertia/test_middleware.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4dbd792e..265c65be 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: # ── fastapi_startkit package ────────────────────────────────────────── - name: Install dependencies (fastapi_startkit) working-directory: fastapi_startkit - run: uv sync --group dev --extra database --extra sqlite + run: uv sync --group dev --extra database --extra sqlite --extra fastapi --extra vite - name: Run tests (fastapi_startkit) working-directory: fastapi_startkit diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml index 09826584..7bf89067 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ [project.optional-dependencies] fastapi = [ "fastapi[standard] (>=0.124.4,<0.125.0)", + "itsdangerous>=2.2.0", ] database = [ "faker>=40.13.0", @@ -48,6 +49,7 @@ dev = [ "pytest-asyncio>=1.3.0", "ruff>=0.9.0", "twine>=6.2.0", + "itsdangerous>=2.2.0", ] diff --git a/fastapi_startkit/tests/inertia/test_inertia.py b/fastapi_startkit/tests/inertia/test_inertia.py new file mode 100644 index 00000000..ecb7375f --- /dev/null +++ b/fastapi_startkit/tests/inertia/test_inertia.py @@ -0,0 +1,53 @@ +import unittest +from fastapi_startkit.inertia.inertia import Inertia, ResponseFactory, InertiaResponse + +class TestInertia(unittest.TestCase): + def setUp(self): + # Reset the singleton instance before each test + Inertia._instance = None + + def test_factory_share_single_key(self): + factory = ResponseFactory() + factory.share("app_name", "FastAPI Startkit") + self.assertEqual(factory.shared_props["app_name"], "FastAPI Startkit") + + def test_factory_share_dict(self): + factory = ResponseFactory() + factory.share({"user": "John", "role": "admin"}) + self.assertEqual(factory.shared_props["user"], "John") + self.assertEqual(factory.shared_props["role"], "admin") + + def test_factory_set_version(self): + factory = ResponseFactory() + factory.set_version("1.0.0") + self.assertEqual(factory.get_version(), "1.0.0") + + def test_factory_set_version_callable(self): + factory = ResponseFactory() + factory.set_version(lambda: "2.0.0") + self.assertEqual(factory.get_version(), "2.0.0") + + def test_factory_render_returns_response(self): + factory = ResponseFactory() + factory.share("auth", {"user": None}) + response = factory.render("Dashboard", {"count": 10}) + + self.assertIsInstance(response, InertiaResponse) + self.assertEqual(response.component, "Dashboard") + self.assertEqual(response.props, {"count": 10}) + self.assertEqual(response.shared_props, {"auth": {"user": None}}) + + def test_facade_singleton(self): + instance1 = Inertia.instance() + instance2 = Inertia.instance() + self.assertIs(instance1, instance2) + + def test_facade_proxies_to_instance(self): + Inertia.share("foo", "bar") + self.assertEqual(Inertia.instance().shared_props["foo"], "bar") + + Inertia.version("v1") + self.assertEqual(Inertia.get_version(), "v1") + + Inertia.set_root_view("app.html") + self.assertEqual(Inertia.instance().root_view, "app.html") diff --git a/fastapi_startkit/tests/inertia/test_inertia_response.py b/fastapi_startkit/tests/inertia/test_inertia_response.py new file mode 100644 index 00000000..aad54ece --- /dev/null +++ b/fastapi_startkit/tests/inertia/test_inertia_response.py @@ -0,0 +1,114 @@ +import json +import unittest +from unittest.mock import MagicMock +from fastapi import Request +from fastapi_startkit.inertia.inertia import InertiaResponse, OptionalProp +from fastapi_startkit.inertia.constant import Header + +class TestInertiaResponse(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.mock_request = MagicMock(spec=Request) + self.mock_request.headers = {} + self.mock_request.url = "http://localhost/test" + + async def test_inertia_response_to_json_on_inertia_request(self): + self.mock_request.headers = {Header.INERTIA: "true"} + + response = InertiaResponse( + component="User/Index", + shared_props={"app": "Test"}, + props={"users": []}, + version="v1" + ) + + actual_response = await response.to_response(self.mock_request) + + self.assertEqual(actual_response.status_code, 200) + self.assertEqual(actual_response.headers[Header.INERTIA], "true") + + content = json.loads(actual_response.body) + self.assertEqual(content["component"], "User/Index") + self.assertEqual(content["props"], {"app": "Test", "users": []}) + self.assertEqual(content["version"], "v1") + self.assertEqual(content["url"], "/test") + + async def test_inertia_response_partial_reload(self): + self.mock_request.headers = { + Header.INERTIA: "true", + Header.INERTIA_PARTIAL_COMPONENT: "User/Index", + "X-Inertia-Partial-Data": "users" + } + + response = InertiaResponse( + component="User/Index", + shared_props={"app": "Test"}, + props={"users": ["user1"], "stats": {"likes": 10}}, + ) + + actual_response = await response.to_response(self.mock_request) + data = json.loads(actual_response.body) + + # Should only include "users", exclude "app" and "stats" + self.assertIn("users", data["props"]) + self.assertNotIn("app", data["props"]) + self.assertNotIn("stats", data["props"]) + + async def test_inertia_response_optional_props(self): + # 1. Normal request - optional prop should be excluded + self.mock_request.headers = {Header.INERTIA: "true"} + + lazy_called = False + def get_lazy(): + nonlocal lazy_called + lazy_called = True + return "lazy data" + + response = InertiaResponse( + component="User/Index", + shared_props={}, + props={"regular": "data", "lazy": OptionalProp(get_lazy)}, + ) + + actual_response = await response.to_response(self.mock_request) + data = json.loads(actual_response.body) + self.assertEqual(data["props"], {"regular": "data"}) + self.assertFalse(lazy_called) + + # 2. Partial reload requesting lazy prop - should be included + self.mock_request.headers = { + Header.INERTIA: "true", + Header.INERTIA_PARTIAL_COMPONENT: "User/Index", + "X-Inertia-Partial-Data": "lazy" + } + + actual_response = await response.to_response(self.mock_request) + data = json.loads(actual_response.body) + self.assertEqual(data["props"], {"lazy": "lazy data"}) + self.assertTrue(lazy_called) + + async def test_inertia_response_resolves_callable_props(self): + self.mock_request.headers = {Header.INERTIA: "true"} + + async def get_async_data(): + return "async result" + + response = InertiaResponse( + component="Test", + shared_props={"sync": lambda: "sync result"}, + props={"async": get_async_data}, + ) + + actual_response = await response.to_response(self.mock_request) + data = json.loads(actual_response.body) + self.assertEqual(data["props"]["sync"], "sync result") + self.assertEqual(data["props"]["async"], "async result") + + async def test_inertia_response_initial_render_raises_if_no_templates(self): + # Standard request (no X-Inertia header) + self.mock_request.headers = {} + + response = InertiaResponse(component="Test", shared_props={}, props={}) + + # This should fail because we haven't mocked the application container + with self.assertRaisesRegex(RuntimeError, "Inertia requires 'templates' to be bound"): + await response.to_response(self.mock_request) diff --git a/fastapi_startkit/tests/inertia/test_middleware.py b/fastapi_startkit/tests/inertia/test_middleware.py new file mode 100644 index 00000000..1cbb4d63 --- /dev/null +++ b/fastapi_startkit/tests/inertia/test_middleware.py @@ -0,0 +1,103 @@ +import unittest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from unittest.mock import MagicMock, patch +from fastapi_startkit.inertia.middleware import InertiaMiddleware +from fastapi_startkit.inertia.constant import Header +from fastapi_startkit.inertia.inertia import Inertia + +class TestInertiaMiddleware(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.app = FastAPI() + self.app.add_middleware(InertiaMiddleware) + + @self.app.get("/test") + async def test_route(): + return {"message": "ok"} + + @self.app.post("/redirect") + async def test_redirect(): + from fastapi.responses import RedirectResponse + return RedirectResponse(url="/test", status_code=302) + + @self.app.put("/redirect-put") + async def test_redirect_put(): + from fastapi.responses import RedirectResponse + return RedirectResponse(url="/test", status_code=302) + + @self.app.get("/fragment-redirect") + async def test_fragment_redirect(): + from fastapi.responses import RedirectResponse + return RedirectResponse(url="/test#section", status_code=302) + + self.client = TestClient(self.app) + # Reset Inertia singleton + Inertia._instance = None + + def test_middleware_adds_vary_header(self): + response = self.client.get("/test") + self.assertEqual(response.headers["Vary"], Header.INERTIA) + + @patch("fastapi_startkit.application.app") + def test_middleware_version_conflict(self, mock_app_getter): + # Setup mock container + mock_container = MagicMock() + mock_app_getter.return_value = mock_container + + # Mock Vite version + mock_vite = MagicMock() + mock_vite.manifest_hash.return_value = "v2" + mock_container.has.side_effect = lambda k: k == "vite" + mock_container.make.side_effect = lambda k: mock_vite if k == "vite" else None + + # Request with old version + response = self.client.get("/test", headers={ + Header.INERTIA: "true", + Header.INERTIA_VERSION: "v1" + }) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.headers[Header.INERTIA_LOCATION], "http://testserver/test") + + def test_middleware_changes_302_to_303_on_put_patch_delete(self): + # POST stays 302 + response = self.client.post("/redirect", follow_redirects=False) + self.assertEqual(response.status_code, 302) + + # PUT changes to 303 + response = self.client.put("/redirect-put", follow_redirects=False, headers={Header.INERTIA: "true"}) + self.assertEqual(response.status_code, 303) + + def test_middleware_redirect_with_fragment(self): + response = self.client.get("/fragment-redirect", headers={Header.INERTIA: "true"}) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.headers[Header.INERTIA_REDIRECT], "/test#section") + + @patch("fastapi_startkit.application.app") + def test_middleware_resolves_validation_errors_from_session(self, mock_app_getter): + # We need a session-enabled app for this + from starlette.middleware.sessions import SessionMiddleware + self.app.add_middleware(SessionMiddleware, secret_key="secret") + + client = TestClient(self.app) + + # Mock container for version check (avoiding 409) + mock_container = MagicMock() + mock_app_getter.return_value = mock_container + mock_container.has.return_value = False + + # Set errors in session via a helper route + @self.app.get("/set-errors") + def set_errors(request): + request.session["errors"] = {"email": "Required"} + return "ok" + + @self.app.get("/check-errors") + def check_errors(): + # Middleware should have shared the errors + return Inertia.instance().shared_props["errors"] + + client.get("/set-errors") + response = client.get("/check-errors") + self.assertEqual(response.json(), {"email": "Required"}) From 81487829427e1d1cb16f56e746a2df1a80a9324d Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 7 May 2026 12:09:28 -0700 Subject: [PATCH 3/3] fix: the test --- .../tests/inertia/test_middleware.py | 34 ++++++++++++------- fastapi_startkit/uv.lock | 13 +++++++ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/fastapi_startkit/tests/inertia/test_middleware.py b/fastapi_startkit/tests/inertia/test_middleware.py index 1cbb4d63..9ff2eed8 100644 --- a/fastapi_startkit/tests/inertia/test_middleware.py +++ b/fastapi_startkit/tests/inertia/test_middleware.py @@ -76,28 +76,38 @@ def test_middleware_redirect_with_fragment(self): @patch("fastapi_startkit.application.app") def test_middleware_resolves_validation_errors_from_session(self, mock_app_getter): - # We need a session-enabled app for this + # We need a fresh app and session-enabled middleware + from fastapi import Request from starlette.middleware.sessions import SessionMiddleware - self.app.add_middleware(SessionMiddleware, secret_key="secret") - client = TestClient(self.app) + app = FastAPI() + app.add_middleware(InertiaMiddleware) + app.add_middleware(SessionMiddleware, secret_key="secret") + @app.get("/set-errors") + def set_errors(request: Request): + request.session["errors"] = {"email": "Required"} + return "ok" + + @app.get("/check-errors") + def check_errors(request: Request): + # Middleware should have shared the errors from the session + # We access the singleton via the facade + return Inertia.instance().shared_props.get("errors", {}) + # Mock container for version check (avoiding 409) mock_container = MagicMock() mock_app_getter.return_value = mock_container mock_container.has.return_value = False - # Set errors in session via a helper route - @self.app.get("/set-errors") - def set_errors(request): - request.session["errors"] = {"email": "Required"} - return "ok" + client = TestClient(app) - @self.app.get("/check-errors") - def check_errors(): - # Middleware should have shared the errors - return Inertia.instance().shared_props["errors"] + # Reset Inertia singleton for this specific test + Inertia._instance = None + # 1. First request sets the errors in session client.get("/set-errors") + + # 2. Second request should have errors shared by middleware response = client.get("/check-errors") self.assertEqual(response.json(), {"email": "Required"}) diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index 1935dd9e..97e7d28b 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -462,6 +462,7 @@ database = [ ] fastapi = [ { name = "fastapi", extra = ["standard"] }, + { name = "itsdangerous" }, ] mysql = [ { name = "aiomysql" }, @@ -479,6 +480,7 @@ vite = [ [package.dev-dependencies] dev = [ { name = "dumpdie" }, + { name = "itsdangerous" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -496,6 +498,7 @@ requires-dist = [ { name = "faker", marker = "extra == 'database'", specifier = ">=40.13.0" }, { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.124.4,<0.125.0" }, { name = "inflection", specifier = ">=0.5.1" }, + { name = "itsdangerous", marker = "extra == 'fastapi'", specifier = ">=2.2.0" }, { name = "jinja2", marker = "extra == 'vite'", specifier = ">=3.1" }, { name = "pendulum", specifier = ">=3.1.0,<4.0.0" }, { name = "pydantic", specifier = ">=2.12.5" }, @@ -507,6 +510,7 @@ provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite"] [package.metadata.requires-dev] dev = [ { name = "dumpdie", specifier = ">=1.5.0" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "ruff", specifier = ">=0.9.0" }, @@ -737,6 +741,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jaraco-classes" version = "3.4.0"