Skip to content

Commit 528e154

Browse files
authored
Merge pull request #50 from fastapi-startkit/fix/inertia-provider
feat: fix the inertia providers
2 parents 66ba22f + 8148782 commit 528e154

7 files changed

Lines changed: 297 additions & 2 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
# ── fastapi_startkit package ──────────────────────────────────────────
3434
- name: Install dependencies (fastapi_startkit)
3535
working-directory: fastapi_startkit
36-
run: uv sync --group dev --extra database --extra sqlite
36+
run: uv sync --group dev --extra database --extra sqlite --extra fastapi --extra vite
3737

3838
- name: Run tests (fastapi_startkit)
3939
working-directory: fastapi_startkit

fastapi_startkit/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies = [
1919
[project.optional-dependencies]
2020
fastapi = [
2121
"fastapi[standard] (>=0.124.4,<0.125.0)",
22+
"itsdangerous>=2.2.0",
2223
]
2324
database = [
2425
"faker>=40.13.0",
@@ -48,6 +49,7 @@ dev = [
4849
"pytest-asyncio>=1.3.0",
4950
"ruff>=0.9.0",
5051
"twine>=6.2.0",
52+
"itsdangerous>=2.2.0",
5153
]
5254

5355

fastapi_startkit/src/fastapi_startkit/inertia/provider.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class InertiaProvider(Provider):
99

1010
def register(self) -> None:
1111
"""Bind the Inertia class to the container."""
12-
self.app.bind("inertia", Inertia(self.app))
12+
self.app.bind("inertia", Inertia)
1313

1414
def boot(self) -> None:
1515
"""Configure template globals and middleware."""
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import unittest
2+
from fastapi_startkit.inertia.inertia import Inertia, ResponseFactory, InertiaResponse
3+
4+
class TestInertia(unittest.TestCase):
5+
def setUp(self):
6+
# Reset the singleton instance before each test
7+
Inertia._instance = None
8+
9+
def test_factory_share_single_key(self):
10+
factory = ResponseFactory()
11+
factory.share("app_name", "FastAPI Startkit")
12+
self.assertEqual(factory.shared_props["app_name"], "FastAPI Startkit")
13+
14+
def test_factory_share_dict(self):
15+
factory = ResponseFactory()
16+
factory.share({"user": "John", "role": "admin"})
17+
self.assertEqual(factory.shared_props["user"], "John")
18+
self.assertEqual(factory.shared_props["role"], "admin")
19+
20+
def test_factory_set_version(self):
21+
factory = ResponseFactory()
22+
factory.set_version("1.0.0")
23+
self.assertEqual(factory.get_version(), "1.0.0")
24+
25+
def test_factory_set_version_callable(self):
26+
factory = ResponseFactory()
27+
factory.set_version(lambda: "2.0.0")
28+
self.assertEqual(factory.get_version(), "2.0.0")
29+
30+
def test_factory_render_returns_response(self):
31+
factory = ResponseFactory()
32+
factory.share("auth", {"user": None})
33+
response = factory.render("Dashboard", {"count": 10})
34+
35+
self.assertIsInstance(response, InertiaResponse)
36+
self.assertEqual(response.component, "Dashboard")
37+
self.assertEqual(response.props, {"count": 10})
38+
self.assertEqual(response.shared_props, {"auth": {"user": None}})
39+
40+
def test_facade_singleton(self):
41+
instance1 = Inertia.instance()
42+
instance2 = Inertia.instance()
43+
self.assertIs(instance1, instance2)
44+
45+
def test_facade_proxies_to_instance(self):
46+
Inertia.share("foo", "bar")
47+
self.assertEqual(Inertia.instance().shared_props["foo"], "bar")
48+
49+
Inertia.version("v1")
50+
self.assertEqual(Inertia.get_version(), "v1")
51+
52+
Inertia.set_root_view("app.html")
53+
self.assertEqual(Inertia.instance().root_view, "app.html")
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import json
2+
import unittest
3+
from unittest.mock import MagicMock
4+
from fastapi import Request
5+
from fastapi_startkit.inertia.inertia import InertiaResponse, OptionalProp
6+
from fastapi_startkit.inertia.constant import Header
7+
8+
class TestInertiaResponse(unittest.IsolatedAsyncioTestCase):
9+
def setUp(self):
10+
self.mock_request = MagicMock(spec=Request)
11+
self.mock_request.headers = {}
12+
self.mock_request.url = "http://localhost/test"
13+
14+
async def test_inertia_response_to_json_on_inertia_request(self):
15+
self.mock_request.headers = {Header.INERTIA: "true"}
16+
17+
response = InertiaResponse(
18+
component="User/Index",
19+
shared_props={"app": "Test"},
20+
props={"users": []},
21+
version="v1"
22+
)
23+
24+
actual_response = await response.to_response(self.mock_request)
25+
26+
self.assertEqual(actual_response.status_code, 200)
27+
self.assertEqual(actual_response.headers[Header.INERTIA], "true")
28+
29+
content = json.loads(actual_response.body)
30+
self.assertEqual(content["component"], "User/Index")
31+
self.assertEqual(content["props"], {"app": "Test", "users": []})
32+
self.assertEqual(content["version"], "v1")
33+
self.assertEqual(content["url"], "/test")
34+
35+
async def test_inertia_response_partial_reload(self):
36+
self.mock_request.headers = {
37+
Header.INERTIA: "true",
38+
Header.INERTIA_PARTIAL_COMPONENT: "User/Index",
39+
"X-Inertia-Partial-Data": "users"
40+
}
41+
42+
response = InertiaResponse(
43+
component="User/Index",
44+
shared_props={"app": "Test"},
45+
props={"users": ["user1"], "stats": {"likes": 10}},
46+
)
47+
48+
actual_response = await response.to_response(self.mock_request)
49+
data = json.loads(actual_response.body)
50+
51+
# Should only include "users", exclude "app" and "stats"
52+
self.assertIn("users", data["props"])
53+
self.assertNotIn("app", data["props"])
54+
self.assertNotIn("stats", data["props"])
55+
56+
async def test_inertia_response_optional_props(self):
57+
# 1. Normal request - optional prop should be excluded
58+
self.mock_request.headers = {Header.INERTIA: "true"}
59+
60+
lazy_called = False
61+
def get_lazy():
62+
nonlocal lazy_called
63+
lazy_called = True
64+
return "lazy data"
65+
66+
response = InertiaResponse(
67+
component="User/Index",
68+
shared_props={},
69+
props={"regular": "data", "lazy": OptionalProp(get_lazy)},
70+
)
71+
72+
actual_response = await response.to_response(self.mock_request)
73+
data = json.loads(actual_response.body)
74+
self.assertEqual(data["props"], {"regular": "data"})
75+
self.assertFalse(lazy_called)
76+
77+
# 2. Partial reload requesting lazy prop - should be included
78+
self.mock_request.headers = {
79+
Header.INERTIA: "true",
80+
Header.INERTIA_PARTIAL_COMPONENT: "User/Index",
81+
"X-Inertia-Partial-Data": "lazy"
82+
}
83+
84+
actual_response = await response.to_response(self.mock_request)
85+
data = json.loads(actual_response.body)
86+
self.assertEqual(data["props"], {"lazy": "lazy data"})
87+
self.assertTrue(lazy_called)
88+
89+
async def test_inertia_response_resolves_callable_props(self):
90+
self.mock_request.headers = {Header.INERTIA: "true"}
91+
92+
async def get_async_data():
93+
return "async result"
94+
95+
response = InertiaResponse(
96+
component="Test",
97+
shared_props={"sync": lambda: "sync result"},
98+
props={"async": get_async_data},
99+
)
100+
101+
actual_response = await response.to_response(self.mock_request)
102+
data = json.loads(actual_response.body)
103+
self.assertEqual(data["props"]["sync"], "sync result")
104+
self.assertEqual(data["props"]["async"], "async result")
105+
106+
async def test_inertia_response_initial_render_raises_if_no_templates(self):
107+
# Standard request (no X-Inertia header)
108+
self.mock_request.headers = {}
109+
110+
response = InertiaResponse(component="Test", shared_props={}, props={})
111+
112+
# This should fail because we haven't mocked the application container
113+
with self.assertRaisesRegex(RuntimeError, "Inertia requires 'templates' to be bound"):
114+
await response.to_response(self.mock_request)
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import unittest
2+
from fastapi import FastAPI
3+
from fastapi.testclient import TestClient
4+
from unittest.mock import MagicMock, patch
5+
from fastapi_startkit.inertia.middleware import InertiaMiddleware
6+
from fastapi_startkit.inertia.constant import Header
7+
from fastapi_startkit.inertia.inertia import Inertia
8+
9+
class TestInertiaMiddleware(unittest.IsolatedAsyncioTestCase):
10+
def setUp(self):
11+
self.app = FastAPI()
12+
self.app.add_middleware(InertiaMiddleware)
13+
14+
@self.app.get("/test")
15+
async def test_route():
16+
return {"message": "ok"}
17+
18+
@self.app.post("/redirect")
19+
async def test_redirect():
20+
from fastapi.responses import RedirectResponse
21+
return RedirectResponse(url="/test", status_code=302)
22+
23+
@self.app.put("/redirect-put")
24+
async def test_redirect_put():
25+
from fastapi.responses import RedirectResponse
26+
return RedirectResponse(url="/test", status_code=302)
27+
28+
@self.app.get("/fragment-redirect")
29+
async def test_fragment_redirect():
30+
from fastapi.responses import RedirectResponse
31+
return RedirectResponse(url="/test#section", status_code=302)
32+
33+
self.client = TestClient(self.app)
34+
# Reset Inertia singleton
35+
Inertia._instance = None
36+
37+
def test_middleware_adds_vary_header(self):
38+
response = self.client.get("/test")
39+
self.assertEqual(response.headers["Vary"], Header.INERTIA)
40+
41+
@patch("fastapi_startkit.application.app")
42+
def test_middleware_version_conflict(self, mock_app_getter):
43+
# Setup mock container
44+
mock_container = MagicMock()
45+
mock_app_getter.return_value = mock_container
46+
47+
# Mock Vite version
48+
mock_vite = MagicMock()
49+
mock_vite.manifest_hash.return_value = "v2"
50+
mock_container.has.side_effect = lambda k: k == "vite"
51+
mock_container.make.side_effect = lambda k: mock_vite if k == "vite" else None
52+
53+
# Request with old version
54+
response = self.client.get("/test", headers={
55+
Header.INERTIA: "true",
56+
Header.INERTIA_VERSION: "v1"
57+
})
58+
59+
self.assertEqual(response.status_code, 409)
60+
self.assertEqual(response.headers[Header.INERTIA_LOCATION], "http://testserver/test")
61+
62+
def test_middleware_changes_302_to_303_on_put_patch_delete(self):
63+
# POST stays 302
64+
response = self.client.post("/redirect", follow_redirects=False)
65+
self.assertEqual(response.status_code, 302)
66+
67+
# PUT changes to 303
68+
response = self.client.put("/redirect-put", follow_redirects=False, headers={Header.INERTIA: "true"})
69+
self.assertEqual(response.status_code, 303)
70+
71+
def test_middleware_redirect_with_fragment(self):
72+
response = self.client.get("/fragment-redirect", headers={Header.INERTIA: "true"})
73+
74+
self.assertEqual(response.status_code, 409)
75+
self.assertEqual(response.headers[Header.INERTIA_REDIRECT], "/test#section")
76+
77+
@patch("fastapi_startkit.application.app")
78+
def test_middleware_resolves_validation_errors_from_session(self, mock_app_getter):
79+
# We need a fresh app and session-enabled middleware
80+
from fastapi import Request
81+
from starlette.middleware.sessions import SessionMiddleware
82+
83+
app = FastAPI()
84+
app.add_middleware(InertiaMiddleware)
85+
app.add_middleware(SessionMiddleware, secret_key="secret")
86+
87+
@app.get("/set-errors")
88+
def set_errors(request: Request):
89+
request.session["errors"] = {"email": "Required"}
90+
return "ok"
91+
92+
@app.get("/check-errors")
93+
def check_errors(request: Request):
94+
# Middleware should have shared the errors from the session
95+
# We access the singleton via the facade
96+
return Inertia.instance().shared_props.get("errors", {})
97+
98+
# Mock container for version check (avoiding 409)
99+
mock_container = MagicMock()
100+
mock_app_getter.return_value = mock_container
101+
mock_container.has.return_value = False
102+
103+
client = TestClient(app)
104+
105+
# Reset Inertia singleton for this specific test
106+
Inertia._instance = None
107+
108+
# 1. First request sets the errors in session
109+
client.get("/set-errors")
110+
111+
# 2. Second request should have errors shared by middleware
112+
response = client.get("/check-errors")
113+
self.assertEqual(response.json(), {"email": "Required"})

fastapi_startkit/uv.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)