diff --git a/fastapi_startkit/tests/broadcasting/test_provider.py b/fastapi_startkit/tests/broadcasting/test_provider.py new file mode 100644 index 00000000..54d03747 --- /dev/null +++ b/fastapi_startkit/tests/broadcasting/test_provider.py @@ -0,0 +1,153 @@ +from types import SimpleNamespace + +from fastapi_startkit.application import Application +from fastapi_startkit.broadcasting.manager import BroadcastManager +from fastapi_startkit.broadcasting.provider import ReverbProvider +from fastapi_startkit.broadcasting.reverb.server import ReverbServer + + +_MISSING = object() + + +def make_app(tmp_path): + return Application(base_path=tmp_path, env="testing", providers=[ReverbProvider]) + + +def mount_auth(tmp_path): + """Register broadcasting and mount only the auth endpoint. + + The full ``boot()`` mounts a catch-all WebSocket sub-app at ``""`` which + shadows the HTTP auth route, so exercise the auth handler on its own. + """ + app = Application(base_path=tmp_path, env="testing", providers=[]) + provider = ReverbProvider(app) + provider.register() + provider._mount_auth_endpoint() + return app + + +def auth_handler(app): + for route in app.fastapi.routes: + if getattr(route, "path", None) == "/broadcasting/auth": + return route.endpoint + raise AssertionError("auth route not mounted") + + +class FakeRequest: + """Stand-in for a Starlette Request. + + ``from __future__ import annotations`` in the provider turns the handler's + ``Request`` annotation into an unresolved forward-ref, so FastAPI cannot + inject a real request through the test client. Calling the handler with + this fake exercises the same body directly. + """ + + def __init__(self, content_type, *, json_body=None, form_body=None, user=_MISSING): + self.headers = {"content-type": content_type} + self._json = json_body or {} + self._form = form_body or {} + self.state = SimpleNamespace() + if user is not _MISSING: + self.state.user = user + + async def json(self): + return self._json + + async def form(self): + return self._form + + +class TestRegister: + def test_binds_manager_and_server(self, tmp_path): + app = make_app(tmp_path) + assert isinstance(app.make("broadcasting"), BroadcastManager) + assert isinstance(app.make("reverb.server"), ReverbServer) + + def test_publishes_channels_stub(self, tmp_path): + app = make_app(tmp_path) + published = app.published_resources.get("broadcasting", {}) + assert any(dest == "routes/channels.py" for dest in published.values()) + + +class TestAuthEndpoint: + async def test_public_channel_allowed_json(self, tmp_path): + handler = auth_handler(mount_auth(tmp_path)) + response = await handler(FakeRequest("application/json", json_body={"channel_name": "orders"})) + assert response.status_code == 200 + + async def test_private_channel_denied_without_rule(self, tmp_path): + handler = auth_handler(mount_auth(tmp_path)) + response = await handler(FakeRequest("application/json", json_body={"channel_name": "private-secret"})) + assert response.status_code == 403 + + async def test_accepts_form_encoded_body(self, tmp_path): + handler = auth_handler(mount_auth(tmp_path)) + response = await handler( + FakeRequest("application/x-www-form-urlencoded", form_body={"channel_name": "public-room"}) + ) + assert response.status_code == 200 + + async def test_uses_request_state_user(self, tmp_path): + handler = auth_handler(mount_auth(tmp_path)) + response = await handler(FakeRequest("application/json", json_body={"channel_name": "orders"}, user=object())) + assert response.status_code == 200 + + async def test_resolves_user_from_callable_auth_service(self, tmp_path): + app = mount_auth(tmp_path) + app.bind("auth", SimpleNamespace(user=lambda: SimpleNamespace(id=1))) + handler = auth_handler(app) + response = await handler(FakeRequest("application/json", json_body={"channel_name": "orders"})) + assert response.status_code == 200 + + async def test_resolves_user_from_attribute_auth_service(self, tmp_path): + app = mount_auth(tmp_path) + app.bind("auth", SimpleNamespace(user=SimpleNamespace(id=2))) + handler = auth_handler(app) + response = await handler(FakeRequest("application/json", json_body={"channel_name": "orders"})) + assert response.status_code == 200 + + +class TestChannelsFileAutoloading: + async def test_registered_channel_authorizer_is_used(self, tmp_path): + routes = tmp_path / "routes" + routes.mkdir() + (routes / "channels.py").write_text( + "from fastapi_startkit.broadcasting import channel\n" + "\n" + "@channel('private-room.{room}')\n" + "async def authorize_room(user, room):\n" + " return True\n" + ) + + app = make_app(tmp_path) + registry = app.make("broadcasting").channel_registry + assert await registry.authorize("private-room.7", None) is True + + def test_missing_channels_file_is_ignored(self, tmp_path): + # No routes/channels.py — boot must still succeed. + app = make_app(tmp_path) + assert isinstance(app.make("broadcasting"), BroadcastManager) + + +class _RaisingFastapiApp: + """Minimal app whose ``.fastapi`` raises, mimicking FastAPI not installed.""" + + base_path = "/nonexistent-base-path" + + @property + def fastapi(self): + raise RuntimeError("FastAPI not available") + + +class TestMountingWithoutFastapi: + def test_mount_websocket_skips_when_fastapi_unavailable(self): + provider = ReverbProvider(_RaisingFastapiApp()) + provider._mount_websocket() # returns without raising + + def test_mount_auth_endpoint_skips_when_fastapi_unavailable(self): + provider = ReverbProvider(_RaisingFastapiApp()) + provider._mount_auth_endpoint() # returns without raising + + def test_load_channels_file_skips_when_absent(self): + provider = ReverbProvider(_RaisingFastapiApp()) + provider._load_channels_file() # base_path has no routes/channels.py diff --git a/fastapi_startkit/tests/inertia/test_provider.py b/fastapi_startkit/tests/inertia/test_provider.py new file mode 100644 index 00000000..aa1cb92a --- /dev/null +++ b/fastapi_startkit/tests/inertia/test_provider.py @@ -0,0 +1,58 @@ +import json +from types import SimpleNamespace +from unittest.mock import MagicMock + +from fastapi_startkit.inertia.inertia import Inertia +from fastapi_startkit.inertia.middleware import InertiaMiddleware +from fastapi_startkit.inertia.provider import InertiaProvider + + +def make_templates(): + """A stand-in for Jinja2Templates exposing an ``env.globals`` dict.""" + return SimpleNamespace(env=SimpleNamespace(globals={})) + + +class TestRegister: + def test_binds_inertia(self): + app = MagicMock() + InertiaProvider(app).register() + app.bind.assert_called_once_with("inertia", Inertia) + + +class TestBoot: + def test_adds_middleware_and_injects_globals_when_templates_present(self): + templates = make_templates() + app = MagicMock() + app.has.return_value = True + app.make.side_effect = lambda key: {"templates": templates, "inertia": "INERTIA"}[key] + + InertiaProvider(app).boot() + + app.add_middleware.assert_called_once_with(InertiaMiddleware) + assert callable(templates.env.globals["inertia"]) + assert templates.env.globals["Inertia"] == "INERTIA" + + def test_skips_globals_when_templates_absent(self): + app = MagicMock() + app.has.return_value = False + + InertiaProvider(app).boot() + + app.add_middleware.assert_called_once_with(InertiaMiddleware) + app.make.assert_not_called() + + def test_inertia_helper_renders_page_markup(self): + templates = make_templates() + app = MagicMock() + app.has.return_value = True + app.make.side_effect = lambda key: {"templates": templates, "inertia": "INERTIA"}[key] + + InertiaProvider(app).boot() + helper = templates.env.globals["inertia"] + + page = {"component": "Dashboard", "props": {"count": 3}} + html = str(helper(page)) + + assert json.dumps(page) in html + assert 'data-page="app"' in html + assert 'id="app"' in html diff --git a/fastapi_startkit/tests/storage/test_provider.py b/fastapi_startkit/tests/storage/test_provider.py new file mode 100644 index 00000000..4cdda8d7 --- /dev/null +++ b/fastapi_startkit/tests/storage/test_provider.py @@ -0,0 +1,61 @@ +from unittest.mock import MagicMock + +from fastapi.testclient import TestClient + +from fastapi_startkit.application import Application +from fastapi_startkit.storage.providers.provider import StorageProvider +from fastapi_startkit.storage.storage import StorageManager + + +def make_app(tmp_path): + return Application(base_path=tmp_path, env="testing", providers=[StorageProvider]) + + +class TestRegister: + def test_binds_storage_manager_with_drivers(self, tmp_path): + app = make_app(tmp_path) + storage = app.make("storage") + assert isinstance(storage, StorageManager) + assert "local" in storage.drivers + assert "s3" in storage.drivers + + +class TestBootRoute: + def test_serves_existing_public_file(self, tmp_path): + app = make_app(tmp_path) + public_dir = tmp_path / "storage" / "app" / "public" + (public_dir / "hello.txt").write_text("hi there") + + client = TestClient(app.fastapi) + response = client.get("/storage/hello.txt") + + assert response.status_code == 200 + assert response.text == "hi there" + + def test_returns_404_for_missing_file(self, tmp_path): + app = make_app(tmp_path) + client = TestClient(app.fastapi) + assert client.get("/storage/nope.txt").status_code == 404 + + def test_returns_404_for_directory_path(self, tmp_path): + app = make_app(tmp_path) + public_dir = tmp_path / "storage" / "app" / "public" + (public_dir / "subdir").mkdir(parents=True, exist_ok=True) + + client = TestClient(app.fastapi) + assert client.get("/storage/subdir").status_code == 404 + + +class TestBootWithoutFastapi: + def test_boot_returns_early_when_no_fastapi(self): + app = MagicMock() + app.published_resources = {} + app.fastapi = None + + provider = StorageProvider(app) + # Returns early on the falsy fastapi check — no route registration, + # no mkdir. Reaching here without error exercises that branch. + provider.boot() + + # The config stub is still published before the early return. + assert app.published_resources != {} diff --git a/fastapi_startkit/tests/utils/test_loader.py b/fastapi_startkit/tests/utils/test_loader.py new file mode 100644 index 00000000..99df62f8 --- /dev/null +++ b/fastapi_startkit/tests/utils/test_loader.py @@ -0,0 +1,128 @@ +import pytest + +from fastapi_startkit.exceptions.exceptions import LoaderNotFound +from fastapi_startkit.loader.Loader import Loader, parameters_filter + + +MODULE_SOURCE = """ +from collections import UserList + + +class Dog(UserList): + pass + + +class Cat(UserList): + pass + + +SPEED = 5 + + +def bark(): + return "woof" +""" + + +@pytest.fixture +def module_dir(tmp_path): + """A directory (no dots in the path) containing a single loadable module.""" + pkg = tmp_path / "zoo_pkg" + pkg.mkdir() + (pkg / "zoo.py").write_text(MODULE_SOURCE) + return str(pkg) + + +@pytest.fixture +def module_file(module_dir): + return f"{module_dir}/zoo.py" + + +class TestGetModules: + def test_returns_loaded_modules_keyed_by_name(self, module_dir): + modules = Loader().get_modules(module_dir) + assert "zoo" in modules + assert hasattr(modules["zoo"], "Dog") + + def test_accepts_single_path_or_list(self, module_dir): + loader = Loader() + assert loader.get_modules([module_dir]).keys() == loader.get_modules(module_dir).keys() + + +class TestFind: + def test_find_returns_matching_class(self, module_dir): + from collections import UserList + + dog = Loader().find(UserList, [module_dir], "Dog") + assert dog.__name__ == "Dog" + + def test_find_returns_none_when_missing(self, module_dir): + from collections import UserList + + assert Loader().find(UserList, [module_dir], "Nope") is None + + def test_find_raises_when_missing_and_requested(self, module_dir): + from collections import UserList + + with pytest.raises(LoaderNotFound): + Loader().find(UserList, [module_dir], "Nope", raise_exception=True) + + +class TestFindAll: + def test_find_all_returns_subclasses(self, module_dir): + from collections import UserList + + classes = Loader().find_all(UserList, [module_dir]) + assert {"Dog", "Cat"} <= set(classes.keys()) + + def test_find_all_raises_when_none_found(self, module_dir): + class Unrelated: + pass + + loader = Loader() + with pytest.raises(LoaderNotFound): + loader.find_all(Unrelated, [module_dir], raise_exception=True) + + def test_find_all_empty_without_raise(self, module_dir): + class Unrelated: + pass + + assert Loader().find_all(Unrelated, [module_dir]) == {} + + +class TestGetObjects: + def test_get_object_from_path(self, module_file): + assert Loader().get_object(module_file, "SPEED") == 5 + + def test_get_objects_from_path(self, module_file): + objects = Loader().get_objects(module_file) + assert "Dog" in objects + assert "bark" in objects + + def test_get_objects_with_filter(self, module_file): + objects = Loader().get_objects(module_file, filter_method=lambda o: callable(o)) + assert "bark" in objects + assert "SPEED" not in objects + + def test_get_objects_from_module_instance(self, module_file): + loader = Loader() + module = loader.get_object(module_file, None) + objects = loader.get_objects(module) + assert "Dog" in objects + + def test_get_objects_missing_module_returns_none(self, tmp_path): + assert Loader().get_objects(f"{tmp_path}/does_not_exist.py") is None + + +class TestGetParameters: + def test_returns_non_dunder_members(self, module_file): + params = Loader().get_parameters(module_file) + assert "SPEED" in params + assert "Dog" in params + assert not any(name.startswith("__") for name in params) + + +class TestParametersFilter: + def test_rejects_dunder(self): + assert parameters_filter("__init__", object()) is False + assert parameters_filter("value", 1) is True diff --git a/fastapi_startkit/tests/vite/test_vite.py b/fastapi_startkit/tests/vite/test_vite.py new file mode 100644 index 00000000..c57bbfb4 --- /dev/null +++ b/fastapi_startkit/tests/vite/test_vite.py @@ -0,0 +1,301 @@ +import hashlib +import json +import os + +import pytest + +from fastapi_startkit.vite.exceptions import ViteException, ViteManifestNotFoundException +from fastapi_startkit.vite.vite import Vite + + +MANIFEST = { + "resources/js/app.js": { + "file": "assets/app-abc123.js", + "src": "resources/js/app.js", + "isEntry": True, + "imports": ["_vendor-xyz.js"], + "css": ["assets/app-def456.css"], + }, + "_vendor-xyz.js": { + "file": "assets/vendor-xyz.js", + "css": ["assets/vendor-css.css"], + }, + "resources/images/logo.png": { + "file": "assets/logo-hash.png", + "src": "resources/images/logo.png", + }, +} + + +@pytest.fixture(autouse=True) +def _clear_manifest_cache(): + """The manifest cache is a class attribute shared across instances.""" + Vite._manifests.clear() + yield + Vite._manifests.clear() + + +@pytest.fixture +def public_path(tmp_path): + return str(tmp_path) + + +def write_manifest(public_path, build_directory="build", manifest=None): + build_dir = os.path.join(public_path, build_directory) + os.makedirs(build_dir, exist_ok=True) + path = os.path.join(build_dir, "manifest.json") + with open(path, "w") as f: + json.dump(manifest if manifest is not None else MANIFEST, f) + return path + + +def write_hot_file(public_path, contents="http://localhost:5173"): + path = os.path.join(public_path, "hot") + with open(path, "w") as f: + f.write(contents) + return path + + +class TestConfiguration: + def test_defaults(self, public_path): + vite = Vite(public_path) + assert vite.csp_nonce() is None + assert vite.preloaded_assets() == {} + assert not vite.is_running_hot() + + def test_use_csp_nonce_generates_and_sets(self, public_path): + vite = Vite(public_path) + nonce = vite.use_csp_nonce() + assert isinstance(nonce, str) and len(nonce) > 0 + assert vite.csp_nonce() == nonce + + assert vite.use_csp_nonce("fixed") == "fixed" + assert vite.csp_nonce() == "fixed" + + def test_fluent_setters_return_self(self, public_path): + vite = Vite(public_path) + assert vite.use_integrity_key("sri") is vite + assert vite.with_entry_points(["a.js"]) is vite + assert vite.create_asset_paths_using(lambda p: p) is vite + assert vite.use_script_tag_attributes({"defer": True}) is vite + assert vite.use_style_tag_attributes({"media": "all"}) is vite + assert vite.use_preload_tag_attributes({"x": "y"}) is vite + + +class TestAssetPath: + def test_default_asset_path(self, public_path): + vite = Vite(public_path) + assert vite._asset_path("build/app.js") == "/build/app.js" + + def test_asset_url_prefix(self, public_path): + vite = Vite(public_path, asset_url="https://cdn.example.com/") + assert vite._asset_path("build/app.js") == "https://cdn.example.com/build/app.js" + + def test_custom_resolver(self, public_path): + vite = Vite(public_path).create_asset_paths_using(lambda p: f"resolved::{p}") + assert vite._asset_path("build/app.js") == "resolved::build/app.js" + + +class TestHotMode: + def test_is_running_hot(self, public_path): + vite = Vite(public_path) + assert not vite.is_running_hot() + write_hot_file(public_path) + assert vite.is_running_hot() + + def test_call_in_hot_mode(self, public_path): + write_hot_file(public_path, "http://localhost:5173/") + vite = Vite(public_path) + html = vite("resources/js/app.js") + assert "http://localhost:5173/@vite/client" in html + assert "http://localhost:5173/resources/js/app.js" in html + + def test_read_hot_origin_falls_back_when_empty(self, public_path): + write_hot_file(public_path, " ") + vite = Vite(public_path) + assert vite._read_hot_origin() == "http://localhost:5173" + + def test_asset_in_hot_mode(self, public_path): + write_hot_file(public_path, "http://localhost:5173") + vite = Vite(public_path) + assert vite.asset("resources/images/logo.png") == "http://localhost:5173/resources/images/logo.png" + + def test_react_refresh_hot(self, public_path): + write_hot_file(public_path, "http://localhost:5173") + vite = Vite(public_path) + vite.use_csp_nonce("abc") + html = vite.react_refresh() + assert "@react-refresh" in html + assert 'nonce="abc"' in html + + def test_react_refresh_not_hot_returns_empty(self, public_path): + vite = Vite(public_path) + assert vite.react_refresh() == "" + + def test_manifest_hash_none_when_hot(self, public_path): + write_hot_file(public_path) + vite = Vite(public_path) + assert vite.manifest_hash() is None + + +class TestProductionMode: + def test_call_produces_tags(self, public_path): + write_manifest(public_path) + vite = Vite(public_path) + html = vite("resources/js/app.js") + + assert "/build/assets/app-abc123.js" in html + assert "/build/assets/vendor-xyz.js" in html + assert "/build/assets/app-def456.css" in html + assert 'rel="modulepreload"' in html + assert "