diff --git a/tests/test_api.py b/tests/test_api.py index 0e5b581..f487b2e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,87 +1,77 @@ -from dataclasses import dataclass +import asyncio +from collections.abc import Awaitable, Callable -from fastapi.testclient import TestClient - -from app.checker import CheckNetworkError, CheckTimeoutError, HttpOutcome -from app.config import Settings -from app.main import create_app +import httpx -@dataclass -class FakeChecker: - result: object - - async def check(self, url: str) -> HttpOutcome: - if isinstance(self.result, Exception): - raise self.result - assert isinstance(self.result, HttpOutcome) - return self.result +async def create(client: httpx.AsyncClient, name: str = "site") -> httpx.Response: + return await client.post( + "/monitors", json={"name": name, "url": "https://example.com/path?secret=value"} + ) -def client_with(result: object = HttpOutcome(204, 12.5, "https://example.com/")): - app = create_app(Settings()) - client = TestClient(app) - client.__enter__() - app.state.checker = FakeChecker(result) - return client - - -def create_monitor(client: TestClient, url: str = "https://example.com/") -> dict: - response = client.post("/monitors", json={"name": "site", "url": url}) +async def test_crud_and_error_semantics(client: httpx.AsyncClient) -> None: + response = await create(client) assert response.status_code == 201 - return response.json() + monitor = response.json() + assert monitor["current_status"]["state"] == "never_checked" + + response = await client.patch( + f"/monitors/{monitor['id']}", json={"name": "renamed", "expected_status": 204} + ) + assert response.status_code == 200 + assert response.json()["name"] == "renamed" + assert response.json()["expected_status"] == 204 + + assert len((await client.get("/monitors")).json()) == 1 + assert (await client.delete(f"/monitors/{monitor['id']}")).status_code == 204 + response = await client.get(f"/monitors/{monitor['id']}") + assert response.status_code == 404 + assert response.json() == {"error": {"code": "not_found", "message": "monitor not found"}} -def test_crud_and_operational_routes() -> None: - with client_with() as client: - assert client.get("/healthz").json() == {"status": "ok"} - assert client.get("/readyz").json() == {"status": "ok"} - item = create_monitor(client) - monitor_id = item["id"] - assert item["current_status"]["state"] == "unknown" - assert len(client.get("/monitors").json()) == 1 - updated = client.put(f"/monitors/{monitor_id}", json={ - "name": "new", "url": "https://example.org/" - }) - assert updated.status_code == 200 - assert updated.json()["name"] == "new" - assert client.delete(f"/monitors/{monitor_id}").status_code == 204 - missing = client.get(f"/monitors/{monitor_id}") - assert missing.status_code == 404 - assert missing.json()["error"]["code"] == "monitor_not_found" +async def test_typed_validation_is_redacted(client: httpx.AsyncClient) -> None: + response = await client.post( + "/monitors", json={"name": "", "url": "not a URL", "expected_status": 99} + ) + assert response.status_code == 422 + assert response.json()["error"]["code"] == "invalid_request" + assert "not a URL" not in response.text -def test_check_updates_status() -> None: - with client_with(HttpOutcome(503, 8.0, "https://example.com/")) as client: - item = create_monitor(client) - result = client.post(f"/monitors/{item['id']}/check") - assert result.status_code == 200 - assert result.json()["state"] == "down" - status = client.get(f"/monitors/{item['id']}/status").json() - assert status["status_code"] == 503 - assert status["latency_ms"] == 8.0 +async def test_store_survives_concurrent_api_writes(client: httpx.AsyncClient) -> None: + responses = await asyncio.gather(*(create(client, f"site-{n}") for n in range(100))) + ids = {response.json()["id"] for response in responses} + assert len(ids) == 100 + listed = (await client.get("/monitors")).json() + assert {item["id"] for item in listed} == ids -def test_timeout_and_network_errors_are_mapped_and_published() -> None: - for failure, expected_status, code in [ - (CheckTimeoutError(50.0), 504, "check_timeout"), - (CheckNetworkError(20.0), 502, "network_error"), - ]: - with client_with(failure) as client: - item = create_monitor(client) - response = client.post(f"/monitors/{item['id']}/check") - assert response.status_code == expected_status - assert response.json()["error"]["code"] == code - current = client.get(f"/monitors/{item['id']}/status").json() - assert current["state"] == "error" - assert current["error_code"] == code +async def test_check_updates_current_status(client: httpx.AsyncClient) -> None: + monitor = (await create(client)).json() + checked = await client.post(f"/monitors/{monitor['id']}/check") + assert checked.status_code == 200 + assert checked.json()["state"] == "up" + status = await client.get(f"/monitors/{monitor['id']}/status") + assert status.json() == checked.json() -def test_validation_and_capacity() -> None: - app = create_app(Settings(max_monitors=1)) - with TestClient(app) as client: - create_monitor(client) - full = client.post("/monitors", json={"name": "other", "url": "https://example.org"}) - assert full.status_code == 409 - invalid = client.post("/monitors", json={"name": "", "url": "ftp://example.com"}) - assert invalid.status_code == 422 +async def test_operational_routes(client: httpx.AsyncClient) -> None: + assert (await client.get("/health/live")).json() == {"status": "ok"} + assert (await client.get("/health/ready")).json()["storage"] == "process-local-memory" + + +async def test_redirect_is_revalidated( + client_factory: Callable[[httpx.AsyncBaseTransport], Awaitable[httpx.AsyncClient]], +) -> None: + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.host == "example.com": + return httpx.Response(302, headers={"location": "http://127.0.0.1/admin"}) + raise AssertionError("blocked redirect must not reach transport") + + client = await client_factory(httpx.MockTransport(handler)) + async with client: + monitor = (await create(client)).json() + result = await client.post(f"/monitors/{monitor['id']}/check") + assert result.json()["state"] == "blocked" + assert result.json()["observed_status"] == 302