You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

341 lines
10 KiB

"""Tests for the dependency-graph debug endpoint (Task 1)."""
import functools
from typing import Annotated
from fastapi import Depends, FastAPI, Security
from fastapi.security import OAuth2PasswordBearer
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Helpers reused across tests
# ---------------------------------------------------------------------------
_DEBUG_PATH = "/_debug/deps"
def _app(**kwargs) -> FastAPI: # type: ignore[no-untyped-def]
return FastAPI(dependency_debug_url=_DEBUG_PATH, **kwargs)
def _find_route(payload: dict, path: str) -> dict: # type: ignore[no-untyped-def]
for r in payload["routes"]:
if r["path"] == path:
return r
raise AssertionError(f"Route {path!r} not found in debug payload") # pragma: no cover
# ---------------------------------------------------------------------------
# test_debug_disabled_by_default
# ---------------------------------------------------------------------------
def test_debug_disabled_by_default() -> None:
app = FastAPI() # dependency_debug_url not set → None
@app.get("/hello")
def hello() -> dict:
return {"hi": True}
client = TestClient(app)
# The default path and a few guesses must all 404
assert client.get("/_debug/deps").status_code == 404
assert client.get("/_debug/dependencies").status_code == 404
assert client.get("/debug").status_code == 404
# ---------------------------------------------------------------------------
# test_debug_enabled_returns_200
# ---------------------------------------------------------------------------
def test_debug_enabled_returns_200() -> None:
app = _app()
@app.get("/hello")
def hello() -> dict:
return {"hi": True}
client = TestClient(app)
resp = client.get(_DEBUG_PATH)
assert resp.status_code == 200
payload = resp.json()
assert "routes" in payload
route = _find_route(payload, "/hello")
assert "GET" in route["methods"]
assert route["dependency_graph"]["callable_name"] == "hello"
# ---------------------------------------------------------------------------
# test_debug_nested_three_levels
# ---------------------------------------------------------------------------
def test_debug_nested_three_levels() -> None:
def dep_c() -> str:
return "c"
def dep_b(c: Annotated[str, Depends(dep_c)]) -> str:
return "b"
def dep_a(b: Annotated[str, Depends(dep_b)]) -> str:
return "a"
app = _app()
@app.get("/nested")
def nested(a: Annotated[str, Depends(dep_a)]) -> dict:
return {"a": a}
client = TestClient(app)
route = _find_route(client.get(_DEBUG_PATH).json(), "/nested")
graph = route["dependency_graph"]
# Root is the endpoint itself
assert graph["callable_name"] == "nested"
# Level 1: dep_a
assert len(graph["sub_dependencies"]) == 1
a_node = graph["sub_dependencies"][0]
assert a_node["callable_name"] == "dep_a"
# Level 2: dep_b
assert len(a_node["sub_dependencies"]) == 1
b_node = a_node["sub_dependencies"][0]
assert b_node["callable_name"] == "dep_b"
# Level 3: dep_c — leaf
assert len(b_node["sub_dependencies"]) == 1
c_node = b_node["sub_dependencies"][0]
assert c_node["callable_name"] == "dep_c"
assert c_node["sub_dependencies"] == []
# ---------------------------------------------------------------------------
# test_debug_yield_dep_flagged
# ---------------------------------------------------------------------------
def test_debug_yield_dep_flagged() -> None:
def plain_dep() -> str:
return "plain"
def yield_dep(): # type: ignore[no-untyped-def]
yield "yielded"
app = _app()
@app.get("/mix")
def mix(
p: Annotated[str, Depends(plain_dep)],
y: Annotated[str, Depends(yield_dep)],
) -> dict:
return {}
route = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/mix")
subs = route["dependency_graph"]["sub_dependencies"]
# Order matches signature order: plain first, yield second
plain_node = subs[0]
yield_node = subs[1]
assert plain_node["callable_name"] == "plain_dep"
assert plain_node["is_yield"] is False
assert yield_node["callable_name"] == "yield_dep"
assert yield_node["is_yield"] is True
# ---------------------------------------------------------------------------
# test_debug_async_generator_flagged
# ---------------------------------------------------------------------------
def test_debug_async_generator_flagged() -> None:
async def async_gen_dep(): # type: ignore[no-untyped-def]
yield "async"
app = _app()
@app.get("/agen")
def agen(v: Annotated[str, Depends(async_gen_dep)]) -> dict:
return {}
node = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/agen")["dependency_graph"]
sub = node["sub_dependencies"][0]
assert sub["callable_name"] == "async_gen_dep"
assert sub["is_yield"] is True
assert sub["is_async"] is True
# ---------------------------------------------------------------------------
# test_debug_security_scopes
# ---------------------------------------------------------------------------
def test_debug_security_scopes() -> None:
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> dict:
return {"user": "me"}
app = _app()
@app.get("/protected")
def protected(
user: Annotated[dict, Security(get_current_user, scopes=["read", "admin"])],
) -> dict:
return user
route = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/protected")
# The Security node is a direct sub of the endpoint root
sec_node = route["dependency_graph"]["sub_dependencies"][0]
assert sec_node["callable_name"] == "get_current_user"
assert "read" in sec_node["security_scopes"]
assert "admin" in sec_node["security_scopes"]
# ---------------------------------------------------------------------------
# test_debug_security_and_depends_siblings
# ---------------------------------------------------------------------------
def test_debug_security_and_depends_siblings() -> None:
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def plain_dep() -> str:
return "plain"
app = _app()
@app.get("/siblings")
def siblings(
token: Annotated[str, Security(oauth2_scheme, scopes=["x"])],
plain: Annotated[str, Depends(plain_dep)],
) -> dict:
return {}
route = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/siblings")
subs = route["dependency_graph"]["sub_dependencies"]
# First sub is the Security dep (oauth2_scheme instance)
sec_sub = subs[0]
assert sec_sub["is_security_scheme"] is True
# Second sub is the plain Depends
plain_sub = subs[1]
assert plain_sub["callable_name"] == "plain_dep"
assert plain_sub["is_security_scheme"] is False
# ---------------------------------------------------------------------------
# test_debug_route_level_deps_appear
# ---------------------------------------------------------------------------
def test_debug_route_level_deps_appear() -> None:
def route_dep() -> None:
pass
app = _app()
@app.get("/with-route-dep", dependencies=[Depends(route_dep)])
def with_route_dep() -> dict:
return {}
route = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/with-route-dep")
subs = route["dependency_graph"]["sub_dependencies"]
# route_dep is prepended, so it is the first child
assert subs[0]["callable_name"] == "route_dep"
# ---------------------------------------------------------------------------
# test_debug_partial_callable_unwrapped
# ---------------------------------------------------------------------------
def test_debug_partial_callable_unwrapped() -> None:
def real_function(x: int = 1) -> int:
return x
partial_dep = functools.partial(real_function, x=42)
app = _app()
@app.get("/partial")
def partial_endpoint(v: Annotated[int, Depends(partial_dep)]) -> dict:
return {"v": v}
route = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/partial")
sub = route["dependency_graph"]["sub_dependencies"][0]
# Must resolve through the partial to the underlying function name
assert sub["callable_name"] == "real_function"
# ---------------------------------------------------------------------------
# test_debug_class_based_dep
# ---------------------------------------------------------------------------
def test_debug_class_based_dep() -> None:
class MyDep:
def __call__(self) -> str:
return "class-dep"
app = _app()
@app.get("/classdep")
def classdep(v: Annotated[str, Depends(MyDep)]) -> dict:
return {"v": v}
route = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/classdep")
sub = route["dependency_graph"]["sub_dependencies"][0]
assert sub["callable_name"] == "MyDep"
# ---------------------------------------------------------------------------
# test_debug_cached_repeat_detection
# ---------------------------------------------------------------------------
def test_debug_cached_repeat_detection() -> None:
"""A shared dep used by two sibling branches must appear fully in both
(it is NOT on the same root→leaf path). This test verifies no spurious
``cached_repeat`` flag is set in that scenario."""
def shared() -> str:
return "shared"
def branch_a(s: Annotated[str, Depends(shared)]) -> str:
return "a"
def branch_b(s: Annotated[str, Depends(shared)]) -> str:
return "b"
app = _app()
@app.get("/shared")
def endpoint(
a: Annotated[str, Depends(branch_a)],
b: Annotated[str, Depends(branch_b)],
) -> dict:
return {}
route = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/shared")
subs = route["dependency_graph"]["sub_dependencies"]
# branch_a's child "shared" should be fully expanded (no cached_repeat)
shared_under_a = subs[0]["sub_dependencies"][0]
assert shared_under_a["callable_name"] == "shared"
assert "cached_repeat" not in shared_under_a
# branch_b's child "shared" should also be fully expanded
shared_under_b = subs[1]["sub_dependencies"][0]
assert shared_under_b["callable_name"] == "shared"
assert "cached_repeat" not in shared_under_b