Skip to content

Commit 999414f

Browse files
(test) add resolver mutation and route-gap regression coverage
Expand resolver openapi middleware coverage, add mutation route tests, and include route-gap regression workflows with supporting test config updates.
1 parent 195c85d commit 999414f

9 files changed

Lines changed: 467 additions & 5 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ coverage.xml
1212
.vscode/
1313
.hypothesis/
1414
.venv/
15+
mutants/

middleware/openapi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from fastapi import FastAPI
2020
from fastapi.openapi.utils import get_openapi
2121

22-
JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"
22+
JSON_SCHEMA_DIALECT = "https://spec.openapis.org/oas/3.1/dialect/base"
2323
_METHOD_ACTIONS: dict[str, str] = {
2424
"GET": "Retrieve",
2525
"POST": "Create",

openapi.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

openapi.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2877,4 +2877,4 @@ components:
28772877
scheme: bearer
28782878
bearerFormat: JWT
28792879
description: Context JWT carrying tenant and user scope for internal calls.
2880-
jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema
2880+
jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,8 @@ exclude = [
159159
[tool.pylint.main]
160160
jobs = 1
161161
extension-pkg-allow-list = ["sqlalchemy"]
162-
ignore = [".git", "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".venv", "venv", "build", "dist", "tmp", "vendor", "tests"]
162+
ignore = [".git", "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".venv", "venv", "build", "dist", "tmp", "vendor", "tests", "mutants"]
163+
ignore-paths = ["^tests/", "^mutants/"]
163164
init-hook = "import os, sys; cwd = os.getcwd(); sys.path.insert(0, cwd) if cwd not in sys.path else None"
164165

165166
[tool.pylint.messages_control]

tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
import pytest
1515

16+
os.environ.setdefault("MUTANT_UNDER_TEST", "")
17+
1618
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
1719
if ROOT not in sys.path:
1820
sys.path.insert(0, ROOT)

tests/test_mutation_routes.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
from __future__ import annotations
2+
3+
from datetime import UTC, datetime
4+
5+
import pytest
6+
from fastapi import HTTPException
7+
8+
from api.requests import AnalyzeJobCreateRequest, AnalyzeRequest, TopologyRequest
9+
from api.responses import JobStatus
10+
from api.responses.jobs import AnalyzeJobSummary as JobView
11+
from api.routes import analyze as analyze_route
12+
from api.routes import jobs as jobs_route
13+
from api.routes import topology as topology_route
14+
from services.security_service import InternalContext
15+
16+
17+
def _ctx() -> InternalContext:
18+
return InternalContext(
19+
tenant_id="tenant-a",
20+
org_id="tenant-a",
21+
user_id="u1",
22+
username="alice",
23+
permissions=["create:rca", "read:rca", "delete:rca"],
24+
group_ids=[],
25+
role="user",
26+
is_superuser=False,
27+
)
28+
29+
30+
def _job(status: JobStatus = JobStatus.COMPLETED) -> JobView:
31+
now = datetime.now(UTC)
32+
return JobView(
33+
job_id="job-1",
34+
report_id="rep-1",
35+
status=status,
36+
created_at=now,
37+
started_at=now,
38+
finished_at=now,
39+
duration_ms=5,
40+
error="transient",
41+
summary_preview="ok",
42+
tenant_id="tenant-a",
43+
requested_by="u1",
44+
)
45+
46+
47+
@pytest.mark.asyncio
48+
async def test_analyze_and_template_routes(monkeypatch: pytest.MonkeyPatch) -> None:
49+
async def fake_run(req: AnalyzeRequest):
50+
return {
51+
"tenant_id": req.tenant_id,
52+
"start": req.start,
53+
"end": req.end,
54+
"duration_seconds": req.end - req.start,
55+
"metric_anomalies": [],
56+
"log_bursts": [],
57+
"log_patterns": [],
58+
"service_latency": [],
59+
"error_propagation": [],
60+
"slo_alerts": [],
61+
"root_causes": [],
62+
"ranked_causes": [],
63+
"change_points": [],
64+
"log_metric_links": [],
65+
"forecasts": [],
66+
"degradation_signals": [],
67+
"anomaly_clusters": [],
68+
"granger_results": [],
69+
"bayesian_scores": [],
70+
"analysis_warnings": [],
71+
"overall_severity": "low",
72+
"summary": "ok",
73+
"quality": {
74+
"anomaly_density": {},
75+
"suppression_counts": {},
76+
"gating_profile": None,
77+
"confidence_calibration_version": None,
78+
},
79+
}
80+
81+
monkeypatch.setattr(analyze_route, "run_analysis", fake_run)
82+
monkeypatch.setattr(
83+
analyze_route.analysis_config_service,
84+
"template_response",
85+
lambda: {
86+
"version": 1,
87+
"defaults": {"request": {"step": "15s"}},
88+
"template_yaml": "version: 1",
89+
"file_name": "resolver-rca-defaults.yaml",
90+
},
91+
)
92+
93+
out = await analyze_route.analyze(AnalyzeRequest(tenant_id="tenant-a", start=1, end=2))
94+
template = await analyze_route.analyze_config_template()
95+
96+
assert out["tenant_id"] == "tenant-a"
97+
assert template.file_name == "resolver-rca-defaults.yaml"
98+
99+
100+
@pytest.mark.asyncio
101+
async def test_jobs_routes(monkeypatch: pytest.MonkeyPatch) -> None:
102+
monkeypatch.setattr(jobs_route, "ensure_permission", lambda _name: None)
103+
monkeypatch.setattr(jobs_route, "get_internal_context", _ctx)
104+
105+
async def fake_create(payload, ctx):
106+
assert payload.tenant_id == "tenant-a"
107+
assert ctx.user_id == "u1"
108+
return _job(JobStatus.QUEUED)
109+
110+
async def fake_list(ctx, status_filter, limit, cursor):
111+
assert status_filter == JobStatus.COMPLETED
112+
assert limit == 5
113+
assert cursor == "c1"
114+
return [_job(JobStatus.COMPLETED)], "c2"
115+
116+
async def fake_get(job_id, ctx):
117+
assert job_id == "job-1"
118+
assert ctx.tenant_id == "tenant-a"
119+
return _job()
120+
121+
async def fake_result(job_id, ctx):
122+
assert job_id == "job-1"
123+
assert ctx.tenant_id == "tenant-a"
124+
return _job(), {"report": True}
125+
126+
async def fake_report(report_id, ctx):
127+
assert report_id == "rep-1"
128+
assert ctx.user_id == "u1"
129+
return _job(), {"report_id": report_id}
130+
131+
async def fake_delete(report_id, ctx):
132+
assert report_id == "rep-1"
133+
assert ctx.user_id == "u1"
134+
return None
135+
136+
monkeypatch.setattr(jobs_route.rca_job_service, "create_job", fake_create)
137+
monkeypatch.setattr(jobs_route.rca_job_service, "list_jobs", fake_list)
138+
monkeypatch.setattr(jobs_route.rca_job_service, "get_job", fake_get)
139+
monkeypatch.setattr(jobs_route.rca_job_service, "get_job_result", fake_result)
140+
monkeypatch.setattr(jobs_route.rca_job_service, "get_report", fake_report)
141+
monkeypatch.setattr(jobs_route.rca_job_service, "delete_report", fake_delete)
142+
143+
created = await jobs_route.create_job(AnalyzeJobCreateRequest(tenant_id="tenant-a", start=1, end=2))
144+
listed = await jobs_route.list_jobs(status_filter=JobStatus.COMPLETED, limit=5, cursor="c1")
145+
summary = await jobs_route.get_job("job-1")
146+
result = await jobs_route.get_job_result("job-1")
147+
report = await jobs_route.get_report("rep-1")
148+
deleted = await jobs_route.delete_report("rep-1")
149+
150+
assert created.status == JobStatus.QUEUED
151+
assert listed.next_cursor == "c2"
152+
assert listed.items[0].summary_preview == "ok"
153+
assert summary.job_id == "job-1"
154+
assert summary.summary_preview == "ok"
155+
assert result.result == {"report": True}
156+
assert report.result == {"report_id": "rep-1"}
157+
assert deleted.deleted is True
158+
assert "started_at" in listed.items[0].model_fields_set
159+
assert "finished_at" in listed.items[0].model_fields_set
160+
assert "duration_ms" in listed.items[0].model_fields_set
161+
assert listed.items[0].error == "transient"
162+
assert "summary_preview" in listed.items[0].model_fields_set
163+
assert summary.started_at is not None
164+
assert summary.finished_at is not None
165+
assert summary.duration_ms == 5
166+
assert summary.error == "transient"
167+
assert "started_at" in summary.model_fields_set
168+
assert "finished_at" in summary.model_fields_set
169+
assert "duration_ms" in summary.model_fields_set
170+
assert "error" in summary.model_fields_set
171+
assert "summary_preview" in summary.model_fields_set
172+
173+
174+
def test_jobs_required_context_raises_when_missing(monkeypatch: pytest.MonkeyPatch) -> None:
175+
monkeypatch.setattr(jobs_route, "get_internal_context", lambda: None)
176+
with pytest.raises(HTTPException) as exc:
177+
jobs_route._required_context()
178+
assert exc.value.status_code == 401
179+
assert exc.value.detail == "Missing internal context"
180+
181+
def test_jobs_require_permission_forwards_name(monkeypatch: pytest.MonkeyPatch) -> None:
182+
seen = {}
183+
184+
def fake_ensure_permission(name: str) -> None:
185+
seen["name"] = name
186+
187+
monkeypatch.setattr(jobs_route, "ensure_permission", fake_ensure_permission)
188+
jobs_route._require_permission("read:rca")
189+
assert seen["name"] == "read:rca"
190+
191+
@pytest.mark.asyncio
192+
async def test_topology_route(monkeypatch: pytest.MonkeyPatch) -> None:
193+
class DummyProvider:
194+
async def query_traces(self, filters, start, end):
195+
assert filters == {}
196+
assert start == 1
197+
assert end == 100
198+
return {
199+
"traces": [
200+
{
201+
"rootServiceName": "checkout",
202+
"spanSet": {
203+
"spans": [
204+
{
205+
"attributes": [
206+
{"key": "service.name", "value": {"stringValue": "checkout"}},
207+
{"key": "peer.service", "value": {"stringValue": "payments"}},
208+
]
209+
}
210+
]
211+
},
212+
}
213+
]
214+
}
215+
216+
monkeypatch.setattr(topology_route, "enforce_request_tenant", lambda req: req)
217+
monkeypatch.setattr(topology_route, "safe_call", lambda coro: coro)
218+
monkeypatch.setattr(topology_route, "get_provider", lambda _tid: DummyProvider())
219+
220+
out = await topology_route.blast_radius(
221+
TopologyRequest(tenant_id="tenant-a", start=1, end=100, root_service="checkout", max_depth=2)
222+
)
223+
assert out["root_service"] == "checkout"
224+
assert "payments" in out["affected_downstream"]

tests/test_openapi_middleware.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
from __future__ import annotations
1212

13+
import tomllib
14+
1315
from fastapi import FastAPI
1416

1517
from middleware import openapi as openapi_middleware
@@ -40,6 +42,60 @@ def test_apply_inferred_responses_for_secured_and_ready_paths() -> None:
4042
assert ready_responses["503"]["description"] == "Service Unavailable"
4143

4244

45+
def test_project_version_helpers_cover_success_and_fallback(monkeypatch) -> None:
46+
original_read_text = openapi_middleware.Path.read_text
47+
48+
monkeypatch.setattr(
49+
openapi_middleware.Path,
50+
"read_text",
51+
lambda self, encoding="utf-8": "[project]\nversion = '9.9.9'\n",
52+
)
53+
assert openapi_middleware._project_version() == "9.9.9"
54+
55+
monkeypatch.setattr(
56+
openapi_middleware.Path,
57+
"read_text",
58+
lambda self, encoding="utf-8": "[project]\nversion = ''\n",
59+
)
60+
assert openapi_middleware._project_version() == openapi_middleware._DEFAULT_APP_VERSION
61+
62+
monkeypatch.setattr(
63+
openapi_middleware.Path,
64+
"read_text",
65+
lambda self, encoding="utf-8": (_ for _ in ()).throw(OSError("boom")),
66+
)
67+
assert openapi_middleware._project_version() == openapi_middleware._DEFAULT_APP_VERSION
68+
69+
monkeypatch.setattr(
70+
openapi_middleware.Path,
71+
"read_text",
72+
lambda self, encoding="utf-8": "bad-toml",
73+
)
74+
monkeypatch.setattr(
75+
openapi_middleware.tomllib,
76+
"loads",
77+
lambda _text: (_ for _ in ()).throw(tomllib.TOMLDecodeError("bad", "", 0)),
78+
)
79+
assert openapi_middleware._project_version() == openapi_middleware._DEFAULT_APP_VERSION
80+
81+
monkeypatch.setattr(openapi_middleware.Path, "read_text", original_read_text)
82+
83+
84+
def test_install_custom_openapi_sets_info_version(monkeypatch) -> None:
85+
app = FastAPI(title="Resolver", version="ignored", description="desc")
86+
openapi_middleware.install_custom_openapi(app)
87+
88+
monkeypatch.setattr(
89+
openapi_middleware,
90+
"get_openapi",
91+
lambda **_kwargs: {"info": {"title": "Resolver"}, "paths": {}},
92+
)
93+
monkeypatch.setattr(openapi_middleware, "_project_version", lambda: "1.2.3")
94+
95+
generated = app.openapi()
96+
assert generated["info"]["version"] == "1.2.3"
97+
98+
4399
def test_install_custom_openapi_cache_and_schema_walk(monkeypatch) -> None:
44100
app = FastAPI()
45101
app.openapi_schema = {"cached": True}
@@ -105,7 +161,7 @@ def test_install_custom_openapi_cache_and_schema_walk(monkeypatch) -> None:
105161
]
106162
ready_responses = generated["paths"]["/api/v1/ready"]["get"]["responses"]
107163
assert ready_responses["503"]["description"] == "Service Unavailable"
108-
assert generated["jsonSchemaDialect"] == "https://json-schema.org/draft/2020-12/schema"
164+
assert generated["jsonSchemaDialect"] == "https://spec.openapis.org/oas/3.1/dialect/base"
109165
schemes = generated["components"]["securitySchemes"]
110166
assert schemes["ServiceToken"]["name"] == "x-service-token"
111167
assert schemes["ContextBearer"]["scheme"] == "bearer"

0 commit comments

Comments
 (0)