Skip to content
Open
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
Empty file.
85 changes: 85 additions & 0 deletions fastapi_startkit/tests/inertia/test_inertia_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Tests for Inertia context and middleware header behaviour (task #13).

Covers: current_request ContextVar, X-Inertia header presence,
non-Inertia passthrough, InertiaResponse.__call__ without middleware.
"""

import unittest
from unittest.mock import MagicMock

from fastapi import FastAPI, Request
from fastapi.testclient import TestClient

from fastapi_startkit.inertia.constant import Header
from fastapi_startkit.inertia.context import current_request
from fastapi_startkit.inertia.inertia import Inertia, InertiaResponse
from fastapi_startkit.inertia.middleware import InertiaMiddleware


class TestCurrentRequestContextVar(unittest.TestCase):
def test_default_value_is_none(self):
token = current_request.set(None)
assert current_request.get() is None
current_request.reset(token)

def test_set_and_get_request(self):
mock_req = MagicMock(spec=Request)
token = current_request.set(mock_req)
assert current_request.get() is mock_req
current_request.reset(token)

def test_reset_restores_previous(self):
token = current_request.set(None)
mock_req = MagicMock(spec=Request)
token2 = current_request.set(mock_req)
current_request.reset(token2)
assert current_request.get() is None
current_request.reset(token)


class TestInertiaResponseCallWithoutMiddleware(unittest.IsolatedAsyncioTestCase):
async def test_call_without_middleware_raises_runtime_error(self):
# Ensure context has no request set
token = current_request.set(None)
try:
response = InertiaResponse(component="Test", shared_props={}, props={})
with self.assertRaisesRegex(RuntimeError, "InertiaResponse requires InertiaMiddleware"):
await response(scope={}, receive=None, send=None)
finally:
current_request.reset(token)


class TestMiddlewareHeaderBehaviour(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.add_middleware(InertiaMiddleware)

@self.app.get("/ping")
def ping():
return {"ok": True}

self.client = TestClient(self.app)
Inertia._instance = None

def test_non_inertia_request_passes_through(self):
response = self.client.get("/ping")
assert response.status_code == 200
assert response.json() == {"ok": True}

def test_inertia_request_gets_vary_header(self):
response = self.client.get("/ping", headers={Header.INERTIA: "true"})
assert response.headers.get("Vary") == Header.INERTIA

def test_non_inertia_request_gets_vary_header(self):
# Vary is set on all responses regardless of X-Inertia presence
response = self.client.get("/ping")
assert response.headers.get("Vary") == Header.INERTIA

def test_x_inertia_header_name_constant(self):
assert Header.INERTIA == "X-Inertia"

def test_x_inertia_version_header_name_constant(self):
assert Header.INERTIA_VERSION == "X-Inertia-Version"

def test_x_inertia_location_header_name_constant(self):
assert Header.INERTIA_LOCATION == "X-Inertia-Location"
194 changes: 194 additions & 0 deletions fastapi_startkit/tests/inertia/test_inertia_props.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"""Tests for InertiaProps evaluation (task #13).

Covers: OptionalProp (lazy) vs eager props, shared props merging,
full response structure, and InertiaResponse.with_() builder.
"""

import json
import unittest
from unittest.mock import MagicMock

from fastapi import Request

from fastapi_startkit.inertia.constant import Header
from fastapi_startkit.inertia.inertia import Inertia, InertiaResponse, OptionalProp, ResponseFactory


class TestOptionalProp(unittest.TestCase):
def test_optional_prop_calls_callback(self):
called = []

def cb():
called.append(True)
return "value"

prop = OptionalProp(cb)
result = prop()
assert result == "value"
assert called

def test_optional_prop_is_callable(self):
prop = OptionalProp(lambda: 42)
assert callable(prop)

def test_optional_prop_wraps_any_callable(self):
prop = OptionalProp(lambda: {"key": "val"})
assert prop() == {"key": "val"}


class TestEagerProps(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.request = MagicMock(spec=Request)
self.request.headers = {Header.INERTIA: "true"}
self.request.url = "http://localhost/page"

async def test_eager_props_always_included(self):
response = InertiaResponse(
component="Page",
shared_props={},
props={"eager": "value"},
)
actual = await response.to_response(self.request)
data = json.loads(actual.body)
assert data["props"]["eager"] == "value"

async def test_lazy_prop_excluded_from_full_load(self):
evaluated = []

def expensive():
evaluated.append(True)
return "expensive"

response = InertiaResponse(
component="Page",
shared_props={},
props={"lazy": OptionalProp(expensive)},
)
actual = await response.to_response(self.request)
data = json.loads(actual.body)
assert "lazy" not in data["props"]
assert not evaluated

async def test_lazy_prop_included_in_partial_reload(self):
evaluated = []

def expensive():
evaluated.append(True)
return "computed"

self.request.headers = {
Header.INERTIA: "true",
Header.INERTIA_PARTIAL_COMPONENT: "Page",
"X-Inertia-Partial-Data": "lazy",
}
response = InertiaResponse(
component="Page",
shared_props={},
props={"lazy": OptionalProp(expensive)},
)
actual = await response.to_response(self.request)
data = json.loads(actual.body)
assert data["props"]["lazy"] == "computed"
assert evaluated


class TestSharedPropsmerging(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.request = MagicMock(spec=Request)
self.request.headers = {Header.INERTIA: "true"}
self.request.url = "http://localhost/"

async def test_shared_props_merged_into_response(self):
response = InertiaResponse(
component="Dashboard",
shared_props={"auth": {"user": "alice"}},
props={"count": 5},
)
actual = await response.to_response(self.request)
data = json.loads(actual.body)
assert data["props"]["auth"] == {"user": "alice"}
assert data["props"]["count"] == 5

async def test_page_props_override_shared_on_conflict(self):
response = InertiaResponse(
component="Dashboard",
shared_props={"key": "shared"},
props={"key": "page"},
)
actual = await response.to_response(self.request)
data = json.loads(actual.body)
# page props win because all_props = {**shared, **props}
assert data["props"]["key"] == "page"

async def test_full_response_structure(self):
response = InertiaResponse(
component="Users/Index",
shared_props={"app": "Startkit"},
props={"users": []},
version="v1.0",
)
actual = await response.to_response(self.request)
data = json.loads(actual.body)
assert "component" in data
assert "props" in data
assert "url" in data
assert "version" in data
assert data["component"] == "Users/Index"
assert data["version"] == "v1.0"


class TestInertiaResponseBuilder(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.request = MagicMock(spec=Request)
self.request.headers = {Header.INERTIA: "true"}
self.request.url = "http://localhost/users"

async def test_with_adds_props(self):
response = InertiaResponse(
component="Users",
shared_props={},
props={"initial": True},
)
response.with_("extra", "data")

actual = await response.to_response(self.request)
data = json.loads(actual.body)
assert data["props"]["extra"] == "data"
assert data["props"]["initial"] is True

async def test_with_dict_merges_props(self):
response = InertiaResponse(component="Users", shared_props={}, props={})
response.with_({"a": 1, "b": 2})

actual = await response.to_response(self.request)
data = json.loads(actual.body)
assert data["props"]["a"] == 1
assert data["props"]["b"] == 2

async def test_with_root_view_changes_view(self):
response = InertiaResponse(component="Page", shared_props={}, props={})
result = response.with_root_view("custom.html")
assert result.root_view == "custom.html"


class TestResponseFactory(unittest.TestCase):
def setUp(self):
Inertia._instance = None

def test_factory_renders_inertia_response(self):
factory = ResponseFactory()
factory.share("global", True)
resp = factory.render("Home", {"title": "Hello"})
assert isinstance(resp, InertiaResponse)
assert resp.component == "Home"
assert resp.props == {"title": "Hello"}
assert resp.shared_props == {"global": True}

def test_callable_version_resolved(self):
factory = ResponseFactory()
factory.set_version(lambda: "3.0")
assert factory.get_version() == "3.0"

def test_none_version_returns_none(self):
factory = ResponseFactory()
assert factory.get_version() is None
Loading