Skip to content

Commit 5fdbd2d

Browse files
author
testgen-ci-bot
committed
Merge remote-tracking branch 'origin/enterprise' into feat/TG-1098-mcp-test-definition-export-and-import
2 parents 733a72d + abf2959 commit 5fdbd2d

6 files changed

Lines changed: 458 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: 90 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,82 @@ def get_test_run(job: JobExecution = resolve_job("view", JobExecution.job_key ==
6374
)
6475

6576

77+
def _disposition_from_db(value: str | None) -> Disposition:
78+
"""Map a stored ``disposition`` to the API enum, degrading unknown values to ``no_decision``.
79+
80+
A NULL or unmapped value resolves to ``no_decision`` rather than raising, so a single odd
81+
row never fails serialization of the whole results page.
82+
"""
83+
if not value:
84+
return Disposition.no_decision
85+
try:
86+
return DISPOSITION_FROM_DB[DbDisposition(value)]
87+
except (ValueError, KeyError):
88+
return Disposition.no_decision
89+
90+
91+
def _to_item(row: TestRunResultRow) -> TestResultItem:
92+
"""Map a DB-valued result row to the API item, normalizing enum casing."""
93+
return TestResultItem(
94+
test_definition_id=row.test_definition_id,
95+
test_type=row.test_type,
96+
schema_name=row.schema_name,
97+
table_name=row.table_name,
98+
column_names=row.column_names,
99+
result_status=RESULT_STATUS_FROM_DB.get(row.status),
100+
result_measure=row.result_measure,
101+
threshold_value=row.threshold_value,
102+
result_message=row.message,
103+
test_time=row.test_time,
104+
disposition=_disposition_from_db(row.disposition),
105+
)
106+
107+
108+
@router.get(
109+
"/test-runs/{job_id}/results",
110+
response_model=TestResultListResponse,
111+
)
112+
def list_test_run_results(
113+
job: JobExecution = resolve_job("view", JobExecution.job_key == JobKey.run_tests), # noqa: B008
114+
status: ResultStatus | None = Query(default=None), # noqa: B008
115+
table_name: str | None = Query(default=None),
116+
column_name: str | None = Query(default=None),
117+
test_type: str | None = Query(default=None),
118+
disposition: Disposition | None = Query(default=None), # noqa: B008
119+
page: int = Query(default=1, ge=1),
120+
limit: int = Query(default=20, ge=1, le=100),
121+
):
122+
"""List individual results for a test run.
123+
124+
Omitting ``disposition`` returns active results — confirmed and no_decision
125+
(excludes dismissed and muted); pass an explicit value to filter to one state.
126+
"""
127+
clauses = []
128+
if status:
129+
clauses.append(TestResult.status == RESULT_STATUS_TO_DB[status])
130+
if table_name:
131+
clauses.append(TestResult.table_name == table_name)
132+
if column_name:
133+
clauses.append(TestResult.column_names == column_name)
134+
if test_type:
135+
clauses.append(TestResult.test_type == test_type)
136+
if disposition is None:
137+
# Active: confirmed plus no_decision (NULL). Dismissed/muted excluded.
138+
clauses.append(or_(TestResult.disposition.is_(None), TestResult.disposition == DbDisposition.CONFIRMED.value))
139+
elif disposition == Disposition.no_decision:
140+
clauses.append(TestResult.disposition.is_(None))
141+
else:
142+
clauses.append(TestResult.disposition == DISPOSITION_TO_DB[disposition].value)
143+
144+
items, total = TestResult.list_for_run(job.id, *clauses, page=page, limit=limit)
145+
return TestResultListResponse(
146+
items=[_to_item(row) for row in items],
147+
page=page,
148+
limit=limit,
149+
total=total,
150+
)
151+
152+
66153
@router.get(
67154
"/profiling-runs/{job_id}",
68155
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)