Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions fastapi_startkit/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -48,6 +49,7 @@ dev = [
"pytest-asyncio>=1.3.0",
"ruff>=0.9.0",
"twine>=6.2.0",
"itsdangerous>=2.2.0",
]


Expand Down
2 changes: 1 addition & 1 deletion fastapi_startkit/src/fastapi_startkit/inertia/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
53 changes: 53 additions & 0 deletions fastapi_startkit/tests/inertia/test_inertia.py
Original file line number Diff line number Diff line change
@@ -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")
114 changes: 114 additions & 0 deletions fastapi_startkit/tests/inertia/test_inertia_response.py
Original file line number Diff line number Diff line change
@@ -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)
113 changes: 113 additions & 0 deletions fastapi_startkit/tests/inertia/test_middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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 fresh app and session-enabled middleware
from fastapi import Request
from starlette.middleware.sessions import SessionMiddleware

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

client = TestClient(app)

# 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"})
13 changes: 13 additions & 0 deletions fastapi_startkit/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading