Skip to content

Commit 3ce0373

Browse files
rboni-dkclaude
andcommitted
feat(api): add test run results endpoint
Add GET /api/v1/test-runs/{job_id}/results — paginated individual results for a test-execution run, using the standard {items, page, limit, total} envelope. Requires view permission on the run's project; not-found and not-accessible both return 404, and only test-execution runs resolve (other job kinds 404). Results are resource-shaped: test_type is the raw type code; result_status and disposition are lowercase snake_case StrEnums shared between request filters and responses. New api/enums.py maps the title-case DB values to the API surface. disposition exposes a no_decision value for results with no triage decision (NULL in the DB); omitting the filter returns active results (confirmed plus no_decision), excluding dismissed and muted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 15d0a1b commit 3ce0373

6 files changed

Lines changed: 442 additions & 6 deletions

File tree

testgen/api/enums.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Lowercase presentation enums for API v1 request filters and responses.
2+
3+
Shared home for StrEnums that map a DB-stored value to the lowercase snake_case
4+
form exposed through the API. Several DB columns store title-case values
5+
(``test_results.result_status``, ``*.disposition``); the mapping dicts here are the
6+
single seam that normalizes them to the API surface — the DB is never changed.
7+
"""
8+
9+
from enum import StrEnum
10+
11+
from testgen.common.enums import Disposition as DbDisposition
12+
from testgen.common.models.test_result import TestResultStatus
13+
14+
15+
class ResultStatus(StrEnum):
16+
"""Outcome of a single test result."""
17+
18+
passed = "passed"
19+
failed = "failed"
20+
warning = "warning"
21+
error = "error"
22+
log = "log"
23+
24+
25+
class Disposition(StrEnum):
26+
"""Triage state of a test result. ``no_decision`` is the state of a result no one
27+
has triaged yet. Omitting the filter returns active results (``confirmed`` and
28+
``no_decision``); pass an explicit value to filter to a single state."""
29+
30+
confirmed = "confirmed"
31+
dismissed = "dismissed"
32+
muted = "muted"
33+
no_decision = "no_decision"
34+
35+
36+
RESULT_STATUS_TO_DB: dict[ResultStatus, TestResultStatus] = {
37+
ResultStatus.passed: TestResultStatus.Passed,
38+
ResultStatus.failed: TestResultStatus.Failed,
39+
ResultStatus.warning: TestResultStatus.Warning,
40+
ResultStatus.error: TestResultStatus.Error,
41+
ResultStatus.log: TestResultStatus.Log,
42+
}
43+
RESULT_STATUS_FROM_DB: dict[TestResultStatus, ResultStatus] = {v: k for k, v in RESULT_STATUS_TO_DB.items()}
44+
45+
# ``no_decision`` has no stored DB value — it corresponds to a NULL ``disposition``
46+
# column, handled explicitly at the API boundary (not present in these dicts).
47+
DISPOSITION_TO_DB: dict[Disposition, DbDisposition] = {
48+
Disposition.confirmed: DbDisposition.CONFIRMED,
49+
Disposition.dismissed: DbDisposition.DISMISSED,
50+
Disposition.muted: DbDisposition.INACTIVE,
51+
}
52+
DISPOSITION_FROM_DB: dict[DbDisposition, Disposition] = {v: k for k, v in DISPOSITION_TO_DB.items()}

testgen/api/runs.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,35 @@
11
"""API v1 — test run and profiling run retrieval."""
22

3-
from fastapi import APIRouter, Depends
4-
from sqlalchemy import select
3+
from fastapi import APIRouter, Depends, Query
4+
from sqlalchemy import or_, select
55

66
from testgen.api.deps import db_session, resolve_job
7+
from testgen.api.enums import (
8+
DISPOSITION_FROM_DB,
9+
DISPOSITION_TO_DB,
10+
RESULT_STATUS_FROM_DB,
11+
RESULT_STATUS_TO_DB,
12+
Disposition,
13+
ResultStatus,
14+
)
715
from testgen.api.schemas import (
816
ErrorResponse,
917
IssueCounts,
1018
ProfilingRunResponse,
1119
ProfilingRunResult,
1220
ResultCounts,
21+
TestResultItem,
22+
TestResultListResponse,
1323
TestRunResponse,
1424
TestRunResult,
1525
)
26+
from testgen.common.enums import Disposition as DbDisposition
1627
from testgen.common.enums import JobKey
1728
from testgen.common.models import get_current_session
1829
from testgen.common.models.hygiene_issue import HygieneIssue
1930
from testgen.common.models.job_execution import JobExecution
2031
from testgen.common.models.profiling_run import ProfilingRun
21-
from testgen.common.models.test_result import TestResult
32+
from testgen.common.models.test_result import TestResult, TestRunResultRow
2233
from testgen.common.models.test_run import TestRun
2334
from testgen.common.models.test_suite import TestSuite
2435

@@ -63,6 +74,68 @@ def get_test_run(job: JobExecution = resolve_job("view", JobExecution.job_key ==
6374
)
6475

6576

77+
def _to_item(row: TestRunResultRow) -> TestResultItem:
78+
"""Map a DB-valued result row to the API item, normalizing enum casing."""
79+
return TestResultItem(
80+
test_definition_id=row.test_definition_id,
81+
test_type=row.test_type,
82+
schema_name=row.schema_name,
83+
table_name=row.table_name,
84+
column_names=row.column_names,
85+
result_status=RESULT_STATUS_FROM_DB.get(row.status),
86+
result_measure=row.result_measure,
87+
threshold_value=row.threshold_value,
88+
result_message=row.message,
89+
test_time=row.test_time,
90+
disposition=DISPOSITION_FROM_DB[DbDisposition(row.disposition)] if row.disposition else Disposition.no_decision,
91+
)
92+
93+
94+
@router.get(
95+
"/test-runs/{job_id}/results",
96+
response_model=TestResultListResponse,
97+
)
98+
def list_test_run_results(
99+
job: JobExecution = resolve_job("view", JobExecution.job_key == JobKey.run_tests), # noqa: B008
100+
status: ResultStatus | None = Query(default=None), # noqa: B008
101+
table_name: str | None = Query(default=None),
102+
column_name: str | None = Query(default=None),
103+
test_type: str | None = Query(default=None),
104+
disposition: Disposition | None = Query(default=None), # noqa: B008
105+
page: int = Query(default=1, ge=1),
106+
limit: int = Query(default=20, ge=1, le=100),
107+
):
108+
"""List individual results for a test run.
109+
110+
Omitting ``disposition`` returns active results — confirmed and no_decision
111+
(excludes dismissed and muted); pass an explicit value to filter to one state.
112+
"""
113+
clauses = []
114+
if status:
115+
clauses.append(TestResult.status == RESULT_STATUS_TO_DB[status])
116+
if table_name:
117+
clauses.append(TestResult.table_name == table_name)
118+
if column_name:
119+
clauses.append(TestResult.column_names == column_name)
120+
if test_type:
121+
clauses.append(TestResult.test_type == test_type)
122+
if disposition is None:
123+
# Active: confirmed plus no_decision (NULL). Dismissed/muted excluded.
124+
clauses.append(or_(TestResult.disposition.is_(None), TestResult.disposition == DbDisposition.CONFIRMED.value))
125+
elif disposition == Disposition.no_decision:
126+
clauses.append(TestResult.disposition.is_(None))
127+
else:
128+
clauses.append(TestResult.disposition == DISPOSITION_TO_DB[disposition].value)
129+
130+
items, total = TestResult.list_for_run(job.id, *clauses, page=page, limit=limit)
131+
return TestResultListResponse(
132+
items=[_to_item(row) for row in items],
133+
page=page,
134+
limit=limit,
135+
total=total,
136+
)
137+
138+
66139
@router.get(
67140
"/profiling-runs/{job_id}",
68141
response_model=ProfilingRunResponse,

testgen/api/schemas.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from pydantic import BaseModel
77

8+
from testgen.api.enums import Disposition, ResultStatus
89
from testgen.common.enums import JobSource, JobStatus, PublicJobKey
910
from testgen.common.test_definition_export_import_service import ImportConfig, ImportPayload, ImportResponse
1011

@@ -78,6 +79,31 @@ class TestRunResponse(BaseModel):
7879
result: TestRunResult | None = None
7980

8081

82+
class TestResultItem(BaseModel):
83+
"""One individual test result within a test run."""
84+
85+
test_definition_id: UUID
86+
test_type: str
87+
schema_name: str
88+
table_name: str | None = None
89+
column_names: str | None = None
90+
result_status: ResultStatus | None = None
91+
result_measure: str | None = None
92+
threshold_value: str | None = None
93+
result_message: str | None = None
94+
test_time: datetime | None = None
95+
disposition: Disposition
96+
97+
98+
class TestResultListResponse(BaseModel):
99+
"""Paginated list of individual test results."""
100+
101+
items: list[TestResultItem]
102+
page: int
103+
limit: int
104+
total: int
105+
106+
81107
# --- Profiling Runs ---
82108

83109

testgen/common/models/test_result.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,23 @@ class TestResultSearchRow:
6464
result_message: str | None
6565

6666

67+
@dataclass
68+
class TestRunResultRow:
69+
"""One individual result within a single test run for the API results endpoint."""
70+
71+
test_definition_id: UUID
72+
test_type: str
73+
schema_name: str
74+
table_name: str | None
75+
column_names: str | None
76+
status: TestResultStatus | None
77+
result_measure: str | None
78+
threshold_value: str | None
79+
message: str | None
80+
test_time: datetime | None
81+
disposition: str | None
82+
83+
6784
@dataclass
6885
class TrendBucket:
6986
"""One time-bucket of failure aggregates for ``get_failure_trend``."""
@@ -175,6 +192,38 @@ def select_results(
175192
query = query.order_by(cls.status, cls.table_name, cls.column_names).offset(offset).limit(limit)
176193
return get_current_session().scalars(query).all()
177194

195+
@classmethod
196+
def list_for_run(
197+
cls,
198+
test_run_id: UUID,
199+
*clauses,
200+
page: int = 1,
201+
limit: int = 20,
202+
) -> tuple[list[TestRunResultRow], int]:
203+
"""Paginated individual results for a single run, scoped by caller-supplied WHERE clauses.
204+
205+
Monitor suites are always filtered out.
206+
"""
207+
query = (
208+
select(
209+
cls.test_definition_id.label("test_definition_id"),
210+
cls.test_type.label("test_type"),
211+
cls.schema_name.label("schema_name"),
212+
cls.table_name.label("table_name"),
213+
cls.column_names.label("column_names"),
214+
cls.status.label("status"),
215+
cls.result_measure.label("result_measure"),
216+
cls.threshold_value.label("threshold_value"),
217+
cls.message.label("message"),
218+
cls.test_time.label("test_time"),
219+
cls.disposition.label("disposition"),
220+
)
221+
.join(TestSuite, cls.test_suite_id == TestSuite.id)
222+
.where(cls.test_run_id == test_run_id, TestSuite.is_monitor.isnot(True), *clauses)
223+
.order_by(cls.status, cls.table_name, cls.column_names, cls.id)
224+
)
225+
return cls._paginate(query, page=page, limit=limit, data_class=TestRunResultRow)
226+
178227
@classmethod
179228
def select_failures(
180229
cls,

0 commit comments

Comments
 (0)