diff --git a/CLAUDE.md b/CLAUDE.md index be6f546f..2afa3185 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,54 @@ Self-contained apps demonstrating specific features. Each subdirectory is an ind The template users clone when starting a new project. Contains the minimal scaffolding: `artisan` entrypoint, `bootstrap/`, `config/`, `providers/`, `routes/`, and `storage/`. It mirrors a typical project layout and is a uv workspace member of this monorepo. +## Task Tracking (Keera Agent MCP) + +Planned work for this project is tracked in **Keera Agent** via an MCP server running locally at `http://127.0.0.1:4545`. + +### Load tasks at the start of a session + +```bash +# List all tasks for this project +curl -s -X POST http://127.0.0.1:4545/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"list_tasks","arguments":{"project_path":"/Users/ellite/code/packages/fastapi-startkit-framework/fastapi_startkit"}},"id":1}' +``` + +Or open the Keera Agent UI at: `http://127.0.0.1:4545/framework` + +### Available MCP tools + +| Tool | Purpose | +|---|---| +| `list_tasks` | List tasks (filter by `status`: pending / in_progress / completed / cancelled) | +| `get_task` | Get full details of a task by numeric ID | +| `create_task` | Create a new task with title, description, acceptance criteria, testing methods, and validation steps | +| `update_task` | Update any field of a task | +| `update_task_status` | Change a task's status | +| `send_message_to_agent` | Send a message to another project's agent | +| `get_agent_messages` | Read messages in this project's agent inbox | + +### MCP JSON-RPC usage + +All calls follow the JSON-RPC 2.0 protocol — `POST http://127.0.0.1:4545/mcp` with `Content-Type: application/json`: + +```bash +# Initialize (once per session) +curl -s -X POST http://127.0.0.1:4545/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"claude-code","version":"1.0"}},"id":0}' + +# Call a tool +curl -s -X POST http://127.0.0.1:4545/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"","arguments":{...}},"id":1}' +``` + +The `project_path` for this repo is always: +``` +/Users/ellite/code/packages/fastapi-startkit-framework/fastapi_startkit +``` + ## Commands ```bash diff --git a/fastapi_startkit/tests/core/test_configuration.py b/fastapi_startkit/tests/core/test_configuration.py new file mode 100644 index 00000000..0840f4fa --- /dev/null +++ b/fastapi_startkit/tests/core/test_configuration.py @@ -0,0 +1,198 @@ +"""Tests for the Configuration system (task #8).""" + +import pytest + +from fastapi_startkit.configuration.config import Config +from fastapi_startkit.configuration.helpers import config +from fastapi_startkit.container.container import Container +from fastapi_startkit.environment.environment import env + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def restore_container_instance(): + original = Container._instance + yield + Container._instance = original + + +@pytest.fixture +def app(tmp_path): + from fastapi_startkit.application import Application + + return Application(base_path=tmp_path, env="testing") + + +@pytest.fixture +def configuration(app): + return app.make("config") + + +# --------------------------------------------------------------------------- +# env() helper +# --------------------------------------------------------------------------- + + +class TestEnvHelper: + def test_returns_string_value(self, monkeypatch): + monkeypatch.setenv("TEST_STR", "hello") + assert env("TEST_STR") == "hello" + + def test_returns_default_when_missing(self): + assert env("DOES_NOT_EXIST_XYZ", "fallback") == "fallback" + + def test_casts_integer(self, monkeypatch): + monkeypatch.setenv("TEST_INT", "42") + assert env("TEST_INT") == 42 + assert isinstance(env("TEST_INT"), int) + + def test_casts_true_string(self, monkeypatch): + monkeypatch.setenv("TEST_BOOL_T", "true") + assert env("TEST_BOOL_T") is True + + def test_casts_false_string(self, monkeypatch): + monkeypatch.setenv("TEST_BOOL_F", "false") + assert env("TEST_BOOL_F") is False + + def test_casts_True_capitalised(self, monkeypatch): + monkeypatch.setenv("TEST_BOOL_TC", "True") + assert env("TEST_BOOL_TC") is True + + def test_casts_False_capitalised(self, monkeypatch): + monkeypatch.setenv("TEST_BOOL_FC", "False") + assert env("TEST_BOOL_FC") is False + + def test_no_cast_returns_raw_string(self, monkeypatch): + monkeypatch.setenv("TEST_RAW", "42") + assert env("TEST_RAW", cast=False) == "42" + + def test_returns_none_when_value_is_none_default(self, monkeypatch): + monkeypatch.delenv("NONEXISTENT_VAR", raising=False) + assert env("NONEXISTENT_VAR", None) is None + + def test_empty_string_falls_back_to_default(self, monkeypatch): + monkeypatch.setenv("EMPTY_VAR", "") + result = env("EMPTY_VAR", "default_val") + assert result == "default_val" + + +# --------------------------------------------------------------------------- +# Configuration.set / get / has / all +# --------------------------------------------------------------------------- + + +class TestConfiguration: + def test_set_and_get_simple_value(self, configuration): + configuration.set("mykey", "myvalue") + assert configuration.get("mykey") == "myvalue" + + def test_get_returns_default_for_missing_key(self, configuration): + assert configuration.get("does_not_exist") is None + assert configuration.get("does_not_exist", "default") == "default" + + def test_has_returns_true_for_existing_key(self, configuration): + configuration.set("exists", 1) + assert configuration.has("exists") is True + + def test_has_returns_false_for_missing_key(self, configuration): + assert configuration.has("not_here") is False + + def test_all_returns_full_dict(self, configuration): + configuration.set("a", 1) + configuration.set("b", 2) + result = configuration.all() + assert result["a"] == 1 + assert result["b"] == 2 + + def test_set_dict_value(self, configuration): + configuration.set("db", {"host": "localhost", "port": 5432}) + db = configuration.get("db") + assert db["host"] == "localhost" + assert db["port"] == 5432 + + def test_overwrite_key(self, configuration): + configuration.set("key", "first") + configuration.set("key", "second") + assert configuration.get("key") == "second" + + +# --------------------------------------------------------------------------- +# Configuration.merge_with +# --------------------------------------------------------------------------- + + +class TestMergeWith: + def test_merge_adds_new_keys_from_external(self, configuration): + # No existing "redis" key — external dict becomes the base + configuration.merge_with("redis", {"HOST": "redis.example.com", "PORT": "6379"}) + result = configuration.get("redis") + assert result["host"] == "redis.example.com" + + def test_existing_values_win_over_external(self, configuration): + # Existing config takes priority over external (provider default pattern) + configuration.set("cache", {"driver": "memory", "ttl": 300}) + configuration.merge_with("cache", {"DRIVER": "redis", "TTL": "60"}) + result = configuration.get("cache") + assert result["driver"] == "memory" # existing wins + assert result["ttl"] == 300 # existing wins + + def test_merge_with_adds_missing_keys(self, configuration): + # Keys not in existing config are added from external + configuration.set("mail", {"from": "app@example.com"}) + configuration.merge_with("mail", {"DRIVER": "smtp"}) + result = configuration.get("mail") + assert result["driver"] == "smtp" # new key added + assert result["from"] == "app@example.com" # existing preserved + + def test_merge_with_non_reserved_key_works(self, configuration): + configuration.merge_with("custom_key", {"FOO": "bar"}) + assert configuration.get("custom_key")["foo"] == "bar" + + +# --------------------------------------------------------------------------- +# Config static facade (requires booted app) +# --------------------------------------------------------------------------- + + +class TestConfigStaticClass: + def test_config_get_returns_value(self, app): + app.make("config").set("app.name", "MyApp") + assert Config.get("app.name") == "MyApp" + + def test_config_set_stores_value(self, app): + Config.set("app.debug", True) + assert Config.get("app.debug") is True + + def test_config_has_existing_key(self, app): + Config.set("app.env", "testing") + assert Config.has("app.env") is True + + def test_config_has_missing_key(self, app): + assert Config.has("nonexistent.key") is False + + def test_config_get_default(self, app): + assert Config.get("missing.key", "fallback") == "fallback" + + def test_config_all_returns_mapping(self, app): + Config.set("x", 99) + result = Config.all() + # all() returns a dotty-dict mapping, supports key access + assert result["x"] == 99 + + +# --------------------------------------------------------------------------- +# config() helper function (configuration/helpers.py) +# --------------------------------------------------------------------------- + + +class TestConfigHelperFunction: + def test_config_helper_delegates_to_Config(self, app): + Config.set("site.title", "FastAPI Startkit") + assert config("site.title") == "FastAPI Startkit" + + def test_config_helper_returns_default(self, app): + assert config("missing.path", "def") == "def" diff --git a/fastapi_startkit/tests/core/test_providers.py b/fastapi_startkit/tests/core/test_providers.py new file mode 100644 index 00000000..98047fd5 --- /dev/null +++ b/fastapi_startkit/tests/core/test_providers.py @@ -0,0 +1,242 @@ +"""Tests for the Provider pattern (task #10).""" + +import pytest + +from fastapi_startkit.application import Application +from fastapi_startkit.container.container import Container +from fastapi_startkit.providers import Provider + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def restore_container_instance(): + original = Container._instance + yield + Container._instance = original + + +@pytest.fixture +def app(tmp_path): + return Application(base_path=tmp_path, env="testing") + + +# --------------------------------------------------------------------------- +# Stub providers +# --------------------------------------------------------------------------- + + +class BindingProvider(Provider): + """Registers a single binding.""" + + def register(self): + self.app.bind("binding_provider_service", "bound_value") + + +class BootAccessProvider(Provider): + """Resolves another provider's binding in boot().""" + + resolved_in_boot = None + + def register(self): + self.app.bind("boot_access_service", "from_boot_access") + + def boot(self): + BootAccessProvider.resolved_in_boot = self.app.make("binding_provider_service") + + +class OrderTrackingProvider(Provider): + """Appends 'register' / 'boot' events to a shared call_log.""" + + call_log: list = [] + + def register(self): + OrderTrackingProvider.call_log.append(f"{self.__class__.__name__}.register") + + def boot(self): + OrderTrackingProvider.call_log.append(f"{self.__class__.__name__}.boot") + + +class OrderTrackingProviderB(Provider): + call_log: list = [] + + def register(self): + OrderTrackingProviderB.call_log.append(f"{self.__class__.__name__}.register") + + def boot(self): + OrderTrackingProviderB.call_log.append(f"{self.__class__.__name__}.boot") + + +class DuplicateKeyProvider(Provider): + """Registers the same key twice to verify last-write-wins behaviour.""" + + def register(self): + self.app.bind("dup_key", "first") + self.app.bind("dup_key", "second") + + +class ConfigResolveProvider(Provider): + """Uses resolve_config / merge_config_from helpers.""" + + def register(self): + self.app.bind("cfg_service", {"default": True}) + + def boot(self): + pass + + +# --------------------------------------------------------------------------- +# Provider key inference +# --------------------------------------------------------------------------- + + +class TestProviderKey: + def test_provider_key_inferred_from_class_name(self, app): + class MyServiceProvider(Provider): + pass + + p = MyServiceProvider(app) + assert p.provider_key == "my" + + def test_provider_key_explicit(self, app): + class ExplicitProvider(Provider): + provider_key = "custom-key" + + p = ExplicitProvider(app) + assert p.provider_key == "custom-key" + + def test_provider_key_strips_provider_suffix(self, app): + class DatabaseProvider(Provider): + pass + + p = DatabaseProvider(app) + assert p.provider_key == "database" + + +# --------------------------------------------------------------------------- +# register() phase +# --------------------------------------------------------------------------- + + +class TestRegisterPhase: + def test_register_adds_binding_to_container(self, tmp_path): + app = Application(base_path=tmp_path, env="testing", providers=[BindingProvider]) + assert app.make("binding_provider_service") == "bound_value" + + def test_register_multiple_providers(self, tmp_path): + class ProviderA(Provider): + def register(self): + self.app.bind("svc_a", "value_a") + + class ProviderB(Provider): + def register(self): + self.app.bind("svc_b", "value_b") + + app = Application(base_path=tmp_path, env="testing", providers=[ProviderA, ProviderB]) + assert app.make("svc_a") == "value_a" + assert app.make("svc_b") == "value_b" + + def test_register_is_noop_by_default(self, tmp_path): + class NoopProvider(Provider): + pass + + # Should not raise + Application(base_path=tmp_path, env="testing", providers=[NoopProvider]) + + +# --------------------------------------------------------------------------- +# boot() phase +# --------------------------------------------------------------------------- + + +class TestBootPhase: + def test_boot_runs_after_register(self, tmp_path): + call_order = [] + + class TrackProvider(Provider): + def register(self): + call_order.append("register") + self.app.bind("tracked", "val") + + def boot(self): + call_order.append("boot") + + Application(base_path=tmp_path, env="testing", providers=[TrackProvider]) + assert call_order == ["register", "boot"] + + def test_boot_can_resolve_bindings_from_register(self, tmp_path): + BootAccessProvider.resolved_in_boot = None + Application(base_path=tmp_path, env="testing", providers=[BindingProvider, BootAccessProvider]) + assert BootAccessProvider.resolved_in_boot == "bound_value" + + def test_all_registers_run_before_any_boot(self, tmp_path): + combined_log = [] + + class PA(Provider): + def register(self): + combined_log.append("PA.register") + + def boot(self): + combined_log.append("PA.boot") + + class PB(Provider): + def register(self): + combined_log.append("PB.register") + + def boot(self): + combined_log.append("PB.boot") + + Application(base_path=tmp_path, env="testing", providers=[PA, PB]) + + # All registers must precede all boots + registers = [i for i, e in enumerate(combined_log) if "register" in e] + boots = [i for i, e in enumerate(combined_log) if "boot" in e] + assert max(registers) < min(boots) + + def test_boot_is_noop_by_default(self, tmp_path): + class NoBootProvider(Provider): + def register(self): + self.app.bind("nb_svc", "val") + + Application(base_path=tmp_path, env="testing", providers=[NoBootProvider]) # Should not raise + + +# --------------------------------------------------------------------------- +# Duplicate key behaviour +# --------------------------------------------------------------------------- + + +class TestDuplicateKey: + def test_last_write_wins(self, tmp_path): + app = Application(base_path=tmp_path, env="testing", providers=[DuplicateKeyProvider]) + assert app.make("dup_key") == "second" + + +# --------------------------------------------------------------------------- +# resolve_config helper +# --------------------------------------------------------------------------- + + +class TestResolveConfig: + def test_resolve_config_merges_user_over_default(self, app): + from dataclasses import dataclass + + @dataclass + class DefaultConfig: + driver: str = "memory" + ttl: int = 60 + + class MergingProvider(Provider): + result = None + + def register(self): + merged = self.resolve_config(DefaultConfig) + MergingProvider.result = merged + + p = MergingProvider(app, {"driver": "redis"}) + p.register() + assert MergingProvider.result["driver"] == "redis" + assert MergingProvider.result["ttl"] == 60 diff --git a/fastapi_startkit/tests/facades/__init__.py b/fastapi_startkit/tests/facades/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi_startkit/tests/facades/test_facades.py b/fastapi_startkit/tests/facades/test_facades.py new file mode 100644 index 00000000..e732a6df --- /dev/null +++ b/fastapi_startkit/tests/facades/test_facades.py @@ -0,0 +1,167 @@ +"""Tests for the Facade layer (task #11).""" + +import pytest + +from fastapi_startkit.container.container import Container + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def restore_container_instance(): + original = Container._instance + yield + Container._instance = original + + +@pytest.fixture +def app(tmp_path): + from fastapi_startkit.application import Application + + return Application(base_path=tmp_path, env="testing") + + +# --------------------------------------------------------------------------- +# Facade base — resolution via container +# --------------------------------------------------------------------------- + + +class TestFacadeBase: + def test_facade_delegates_attribute_to_container_binding(self, app): + """A facade's __getattr__ should delegate to the container-resolved object.""" + + class FakeService: + def greet(self): + return "hello" + + app.bind("fake_svc", FakeService()) + + from fastapi_startkit.facades.Facade import Facade + + class FakeFacade(metaclass=Facade): + key = "fake_svc" + + assert FakeFacade.greet() == "hello" + + def test_facade_raises_when_app_not_booted(self, tmp_path): + """Accessing a facade without a booted Application should raise.""" + # Reset the singleton so no app is active + Container._instance = None + + from fastapi_startkit.facades.Facade import Facade + + class OrphanFacade(metaclass=Facade): + key = "orphan_svc" + + with pytest.raises(Exception): + _ = OrphanFacade.some_method + + def test_facade_reflects_updated_container_binding(self, app): + """Re-binding a key should be reflected in the next facade call.""" + + class V1: + def name(self): + return "v1" + + class V2: + def name(self): + return "v2" + + app.bind("versioned_svc", V1()) + + from fastapi_startkit.facades.Facade import Facade + + class VersionedFacade(metaclass=Facade): + key = "versioned_svc" + + assert VersionedFacade.name() == "v1" + + app.bind("versioned_svc", V2()) + assert VersionedFacade.name() == "v2" + + +# --------------------------------------------------------------------------- +# Config facade +# --------------------------------------------------------------------------- + + +class TestConfigFacade: + def test_config_set_and_get(self, app): + from fastapi_startkit.configuration.config import Config + + Config.set("app.name", "TestApp") + assert Config.get("app.name") == "TestApp" + + def test_config_get_returns_default(self, app): + from fastapi_startkit.configuration.config import Config + + assert Config.get("does.not.exist", "default_val") == "default_val" + + def test_config_has_existing_key(self, app): + from fastapi_startkit.configuration.config import Config + + Config.set("feature.flag", True) + assert Config.has("feature.flag") is True + + def test_config_has_missing_key(self, app): + from fastapi_startkit.configuration.config import Config + + assert Config.has("absent.key") is False + + def test_config_all_returns_mapping(self, app): + from fastapi_startkit.configuration.config import Config + + Config.set("z_key", 123) + result = Config.all() + # Returns a dotty-dict mapping (supports key access like a dict) + assert result["z_key"] == 123 + + def test_config_overwrite(self, app): + from fastapi_startkit.configuration.config import Config + + Config.set("overwrite_me", "old") + Config.set("overwrite_me", "new") + assert Config.get("overwrite_me") == "new" + + def test_config_does_not_bleed_between_apps(self, tmp_path): + from fastapi_startkit.application import Application + from fastapi_startkit.configuration.config import Config + + app1 = Application(base_path=tmp_path / "app1", env="testing") + Config.set("isolated", "from_app1") + assert Config.get("isolated") == "from_app1" + + # Resetting singleton resets the config + Container._instance = None + app2 = Application(base_path=tmp_path / "app2", env="testing") + assert Config.get("isolated") is None + + +# --------------------------------------------------------------------------- +# Partial spot-check: other facades exist and have correct key attribute +# --------------------------------------------------------------------------- + + +class TestFacadeKeyAttributes: + def test_auth_facade_has_key(self): + from fastapi_startkit.facades import Auth + + assert hasattr(Auth, "key") or Auth.__class__.__name__ in ("type", "Facade") + + def test_hash_facade_key(self): + try: + from fastapi_startkit.facades import Hash + + # If importable, the key attribute should be a string + if hasattr(Hash, "key"): + assert isinstance(Hash.key, str) + except ImportError: + pytest.skip("Hash facade not available") + + def test_facade_module_exports_config(self): + from fastapi_startkit import facades + + assert hasattr(facades, "Config") diff --git a/fastapi_startkit/tests/fastapi/__init__.py b/fastapi_startkit/tests/fastapi/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi_startkit/tests/fastapi/test_router.py b/fastapi_startkit/tests/fastapi/test_router.py new file mode 100644 index 00000000..7db66ae4 --- /dev/null +++ b/fastapi_startkit/tests/fastapi/test_router.py @@ -0,0 +1,322 @@ +"""Tests for the FastAPI routing layer (task #9).""" + +from fastapi import FastAPI, Depends +from fastapi.testclient import TestClient + +from fastapi_startkit.fastapi.routers.router import Router + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def make_app(router: Router) -> TestClient: + app = FastAPI() + app.include_router(router.router) + return TestClient(app) + + +def endpoint(): + return {"ok": True} + + +# --------------------------------------------------------------------------- +# HTTP methods +# --------------------------------------------------------------------------- + + +class TestHttpMethods: + def test_get_route(self): + r = Router() + r.get("/items", endpoint) + client = make_app(r) + assert client.get("/items").status_code == 200 + + def test_post_route(self): + r = Router() + r.post("/items", endpoint) + client = make_app(r) + assert client.post("/items").status_code == 200 + + def test_put_route(self): + r = Router() + r.put("/items/1", endpoint) + client = make_app(r) + assert client.put("/items/1").status_code == 200 + + def test_patch_route(self): + r = Router() + r.patch("/items/1", endpoint) + client = make_app(r) + assert client.patch("/items/1").status_code == 200 + + def test_delete_route(self): + r = Router() + r.delete("/items/1", endpoint) + client = make_app(r) + assert client.delete("/items/1").status_code == 200 + + def test_head_route(self): + r = Router() + r.head("/items", endpoint) + client = make_app(r) + assert client.head("/items").status_code == 200 + + def test_options_route(self): + r = Router() + r.options("/items", endpoint) + client = make_app(r) + assert client.options("/items").status_code == 200 + + def test_get_returns_json(self): + r = Router() + r.get("/ping", endpoint) + client = make_app(r) + assert client.get("/ping").json() == {"ok": True} + + +# --------------------------------------------------------------------------- +# resource() — full CRUD +# --------------------------------------------------------------------------- + + +class PostController: + def index(self): + return [] + + def create(self): + return {} + + def store(self): + return {} + + def show(self, post): + return {"id": post} + + def edit(self, post): + return {"id": post} + + def update(self, post): + return {"id": post} + + def destroy(self, post): + return {"id": post} + + +class TestResourceFullCrud: + def _routes(self) -> Router: + r = Router() + r.resource("posts", PostController) + return r + + def _route_names(self) -> set: + r = self._routes() + return {route.name for route in r.router.routes} + + def test_generates_index_route(self): + client = make_app(self._routes()) + assert client.get("/posts").status_code == 200 + + def test_generates_create_route(self): + client = make_app(self._routes()) + assert client.get("/posts/create").status_code == 200 + + def test_generates_store_route(self): + client = make_app(self._routes()) + assert client.post("/posts").status_code == 200 + + def test_generates_show_route(self): + client = make_app(self._routes()) + assert client.get("/posts/1").status_code == 200 + + def test_generates_edit_route(self): + client = make_app(self._routes()) + assert client.get("/posts/1/edit").status_code == 200 + + def test_generates_update_route(self): + client = make_app(self._routes()) + assert client.put("/posts/1").status_code == 200 + + def test_generates_destroy_route(self): + client = make_app(self._routes()) + assert client.delete("/posts/1").status_code == 200 + + def test_seven_routes_total(self): + r = self._routes() + assert len(r.router.routes) == 7 + + def test_default_route_names(self): + names = self._route_names() + assert "posts" in names + assert "posts.store" in names + assert "posts.show" in names + assert "posts.edit" in names + assert "posts.update" in names + assert "posts.destroy" in names + + +# --------------------------------------------------------------------------- +# resource() — only= +# --------------------------------------------------------------------------- + + +class TestResourceOnly: + def test_only_index(self): + r = Router() + r.resource("posts", PostController, only={"index"}) + assert len(r.router.routes) == 1 + client = make_app(r) + assert client.get("/posts").status_code == 200 + assert client.get("/posts/create").status_code == 404 + + def test_only_show_and_index(self): + r = Router() + r.resource("posts", PostController, only={"index", "show"}) + names = {route.name for route in r.router.routes} + assert "posts" in names + assert "posts.show" in names + assert "posts.store" not in names + assert len(r.router.routes) == 2 + + +# --------------------------------------------------------------------------- +# resource() — excepts= +# --------------------------------------------------------------------------- + + +class TestResourceExcepts: + def test_excepts_destroy(self): + r = Router() + r.resource("posts", PostController, excepts={"destroy"}) + names = {route.name for route in r.router.routes} + assert "posts.destroy" not in names + assert len(r.router.routes) == 6 + + def test_excepts_create_and_edit(self): + r = Router() + r.resource("posts", PostController, excepts={"create", "edit"}) + names = {route.name for route in r.router.routes} + assert "posts.create" not in names + assert "posts.edit" not in names + assert len(r.router.routes) == 5 + + +# --------------------------------------------------------------------------- +# resource() — names= +# --------------------------------------------------------------------------- + + +class TestResourceNames: + def test_custom_index_name(self): + r = Router() + r.resource("posts", PostController, names={"index": "post-list"}) + names = {route.name for route in r.router.routes} + assert "post-list" in names + assert "posts" not in names + + def test_custom_show_name(self): + r = Router() + r.resource("posts", PostController, names={"show": "post-detail"}) + names = {route.name for route in r.router.routes} + assert "post-detail" in names + + +# --------------------------------------------------------------------------- +# resource() — parameters= +# --------------------------------------------------------------------------- + + +class TestResourceParameters: + def test_custom_parameter_name(self): + r = Router() + r.resource("posts", PostController, parameters={"posts": "slug"}) + paths = [route.path for route in r.router.routes] + assert "/posts/{slug}" in paths + assert "/posts/{slug}/edit" in paths + + def test_default_parameter_strips_trailing_s(self): + r = Router() + r.resource("posts", PostController) + paths = [route.path for route in r.router.routes] + assert "/posts/{post}" in paths + + +# --------------------------------------------------------------------------- +# Router with class instance (not class) +# --------------------------------------------------------------------------- + + +class TestResourceWithInstance: + def test_accepts_controller_instance(self): + r = Router() + r.resource("posts", PostController()) + assert len(r.router.routes) == 7 + + +# --------------------------------------------------------------------------- +# Router prefix +# --------------------------------------------------------------------------- + + +class TestRouterPrefix: + def test_prefix_applied_to_routes(self): + r = Router(prefix="/api/v1") + r.get("/items", endpoint) + client = make_app(r) + assert client.get("/api/v1/items").status_code == 200 + + def test_without_prefix(self): + r = Router() + r.get("/items", endpoint) + client = make_app(r) + assert client.get("/items").status_code == 200 + + +# --------------------------------------------------------------------------- +# Dependency injection +# --------------------------------------------------------------------------- + + +class TestDependencies: + def test_dependency_injected_via_depends(self): + def get_user(): + return "alice" + + def authed_endpoint(user: str = Depends(get_user)): + return {"user": user} + + r = Router() + r.get("/profile", authed_endpoint) + client = make_app(r) + response = client.get("/profile") + assert response.status_code == 200 + assert response.json() == {"user": "alice"} + + def test_router_level_dependency(self): + calls = [] + + def auth_check(): + calls.append("checked") + + r = Router(dependencies=[Depends(auth_check)]) + r.get("/secure", endpoint) + client = make_app(r) + client.get("/secure") + assert calls == ["checked"] + + +# --------------------------------------------------------------------------- +# __getattr__ delegation to underlying APIRouter +# --------------------------------------------------------------------------- + + +class TestRouterGetattr: + def test_routes_attribute_delegated(self): + r = Router() + r.get("/x", endpoint) + assert r.routes is r.router.routes + + def test_prefix_attribute_delegated(self): + r = Router(prefix="/foo") + assert r.prefix == "/foo"