From cd5429abddc021e0fe449292dd4f8247875c797f Mon Sep 17 00:00:00 2001 From: Arkadiusz Kwasigroch Date: Tue, 25 Aug 2026 11:35:04 +0200 Subject: [PATCH] refactor(backend): extract test result crud module --- .../src/rhesis/backend/app/crud/__init__.py | 76 -------------- .../rhesis/backend/app/crud/test_result.py | 98 +++++++++++++++++++ .../rhesis/backend/app/routers/test_result.py | 23 ++--- .../rhesis/backend/app/services/test_run.py | 3 +- .../execution/executors/output_providers.py | 6 +- .../tasks/execution/executors/results.py | 7 +- tests/backend/auth/test_object_level_auth.py | 16 +-- .../metrics/test_database_integration.py | 5 +- .../routes/test_trace_linking_timing.py | 7 +- .../security/test_organization_filtering.py | 7 +- tests/backend/tasks/test_output_providers.py | 8 +- 11 files changed, 143 insertions(+), 113 deletions(-) create mode 100644 apps/backend/src/rhesis/backend/app/crud/test_result.py diff --git a/apps/backend/src/rhesis/backend/app/crud/__init__.py b/apps/backend/src/rhesis/backend/app/crud/__init__.py index 54f40fd8c7..8289e487f3 100644 --- a/apps/backend/src/rhesis/backend/app/crud/__init__.py +++ b/apps/backend/src/rhesis/backend/app/crud/__init__.py @@ -1084,82 +1084,6 @@ def _recompute_affected_test_sets(deleted_ids: List[uuid.UUID]) -> None: ) -# Test Result CRUD -_TEST_RESULT_RELATED_FIELDS = ( - include(models.TestResult.test_run), - include(models.TestResult.test), - include(models.TestResult.test, models.Test.prompt), - include(models.TestResult.test, models.Test.requirement), -) - - -def get_test_result( - db: Session, test_result_id: uuid.UUID, organization_id: str = None, user_id: str = None -) -> Optional[models.TestResult]: - """Get test_result with relationships (tags, test, test_run) eagerly loaded.""" - return get_item_detail( - db, - models.TestResult, - test_result_id, - organization_id=organization_id, - user_id=user_id, - related_fields=_TEST_RESULT_RELATED_FIELDS, - ) - - -def get_test_results( - db: Session, - skip: int = 0, - limit: int = 10, - sort_by: str = "created_at", - sort_order: str = "desc", - filter: str | None = None, - organization_id: str = None, - user_id: str = None, -) -> List[models.TestResult]: - """Get test_results with relationships (tags, test, test_run) eagerly loaded.""" - return ( - QueryBuilder(db, models.TestResult) - .with_related(*_TEST_RESULT_RELATED_FIELDS) - .with_default_derived_field_loads() - .with_organization_filter(organization_id) - .with_visibility_filter(user_id) - .with_odata_filter(filter) - .with_sorting(sort_by, sort_order) - .with_pagination(skip, limit) - .all() - ) - - -def create_test_result( - db: Session, - test_result: schemas.TestResultCreate, - organization_id: str = None, - user_id: str = None, -) -> models.TestResult: - """Create test_result.""" - return create_item(db, models.TestResult, test_result, organization_id, user_id) - - -def update_test_result( - db: Session, - test_result_id: uuid.UUID, - test_result: schemas.TestResultUpdate, - organization_id: str = None, - user_id: str = None, -) -> Optional[models.TestResult]: - """Update test_result.""" - return update_item(db, models.TestResult, test_result_id, test_result, organization_id, user_id) - - -def delete_test_result( - db: Session, test_result_id: uuid.UUID, organization_id: str, user_id: str -) -> Optional[models.TestResult]: - return delete_item( - db, models.TestResult, test_result_id, organization_id=organization_id, user_id=user_id - ) - - # TypeLookup CRUD def get_type_lookup( db: Session, type_lookup_id: uuid.UUID, organization_id: str = None, user_id: str = None diff --git a/apps/backend/src/rhesis/backend/app/crud/test_result.py b/apps/backend/src/rhesis/backend/app/crud/test_result.py new file mode 100644 index 0000000000..f1e536d039 --- /dev/null +++ b/apps/backend/src/rhesis/backend/app/crud/test_result.py @@ -0,0 +1,98 @@ +"""CRUD operations for test results. + +Part of the incremental split of the ``crud`` monolith: ``crud/__init__.py`` still holds +the bulk of the functions, and per-entity modules like this one take over as the code +around them is touched. + +``_TEST_RESULT_RELATED_FIELDS`` is what ``TestResultDetail`` serializes -- the test run, the +test, and the test's prompt and requirement. All many-to-one, so eager-loading them in one +query costs nothing; without them a results list issues four queries per row. +""" + +import uuid +from typing import List, Optional + +from sqlalchemy.orm import Session + +from rhesis.backend.app import models, schemas +from rhesis.backend.app.utils.crud_utils import ( + create_item, + delete_item, + get_item_detail, + update_item, +) +from rhesis.backend.app.utils.query_utils import QueryBuilder, include + +_TEST_RESULT_RELATED_FIELDS = ( + include(models.TestResult.test_run), + include(models.TestResult.test), + include(models.TestResult.test, models.Test.prompt), + include(models.TestResult.test, models.Test.requirement), +) + + +def get_test_result( + db: Session, test_result_id: uuid.UUID, organization_id: str = None, user_id: str = None +) -> Optional[models.TestResult]: + """Get test_result with relationships (tags, test, test_run) eagerly loaded.""" + return get_item_detail( + db, + models.TestResult, + test_result_id, + organization_id=organization_id, + user_id=user_id, + related_fields=_TEST_RESULT_RELATED_FIELDS, + ) + + +def get_test_results( + db: Session, + skip: int = 0, + limit: int = 10, + sort_by: str = "created_at", + sort_order: str = "desc", + filter: str | None = None, + organization_id: str = None, + user_id: str = None, +) -> List[models.TestResult]: + """Get test_results with relationships (tags, test, test_run) eagerly loaded.""" + return ( + QueryBuilder(db, models.TestResult) + .with_related(*_TEST_RESULT_RELATED_FIELDS) + .with_default_derived_field_loads() + .with_organization_filter(organization_id) + .with_visibility_filter(user_id) + .with_odata_filter(filter) + .with_sorting(sort_by, sort_order) + .with_pagination(skip, limit) + .all() + ) + + +def create_test_result( + db: Session, + test_result: schemas.TestResultCreate, + organization_id: str = None, + user_id: str = None, +) -> models.TestResult: + """Create test_result.""" + return create_item(db, models.TestResult, test_result, organization_id, user_id) + + +def update_test_result( + db: Session, + test_result_id: uuid.UUID, + test_result: schemas.TestResultUpdate, + organization_id: str = None, + user_id: str = None, +) -> Optional[models.TestResult]: + """Update test_result.""" + return update_item(db, models.TestResult, test_result_id, test_result, organization_id, user_id) + + +def delete_test_result( + db: Session, test_result_id: uuid.UUID, organization_id: str, user_id: str +) -> Optional[models.TestResult]: + return delete_item( + db, models.TestResult, test_result_id, organization_id=organization_id, user_id=user_id + ) diff --git a/apps/backend/src/rhesis/backend/app/routers/test_result.py b/apps/backend/src/rhesis/backend/app/routers/test_result.py index c1962b41a5..0a0c9f0549 100644 --- a/apps/backend/src/rhesis/backend/app/routers/test_result.py +++ b/apps/backend/src/rhesis/backend/app/routers/test_result.py @@ -8,13 +8,14 @@ from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified -from rhesis.backend.app import crud, models, schemas +from rhesis.backend.app import models, schemas from rhesis.backend.app.auth.affordances import populate_review_permitted_actions from rhesis.backend.app.auth.capabilities import Permission from rhesis.backend.app.auth.principal import resolve_principal_from_request from rhesis.backend.app.auth.rbac import authorize_object, project_id_from_scope from rhesis.backend.app.auth.user_utils import require_current_user_or_token from rhesis.backend.app.crud import file as file_crud +from rhesis.backend.app.crud import test_result as test_result_crud from rhesis.backend.app.dependencies import ( get_tenant_context, get_tenant_db_session, @@ -92,7 +93,7 @@ def create_test_result( ) test_result.status_id = status.id - return crud.create_test_result( + return test_result_crud.create_test_result( db=db, test_result=test_result, organization_id=organization_id, user_id=user_id ) @@ -117,7 +118,7 @@ def read_test_results( ): """Get all test results""" organization_id, user_id = tenant_context - results = crud.get_test_results( + results = test_result_crud.get_test_results( db, skip=skip, limit=limit, @@ -143,7 +144,7 @@ def read_test_result( ): """Get a specific test result by ID""" organization_id, user_id = tenant_context - db_test_result = crud.get_test_result( + db_test_result = test_result_crud.get_test_result( db, test_result_id=test_result_id, organization_id=organization_id, user_id=user_id ) if db_test_result is None: @@ -173,7 +174,7 @@ def update_test_result( automatically updated based on whether all metrics passed. """ organization_id, user_id = tenant_context - db_test_result = crud.get_test_result( + db_test_result = test_result_crud.get_test_result( db, test_result_id=test_result_id, organization_id=organization_id, user_id=user_id ) if db_test_result is None: @@ -208,7 +209,7 @@ def update_test_result( ) test_result.status_id = status.id - return crud.update_test_result( + return test_result_crud.update_test_result( db=db, test_result_id=test_result_id, test_result=test_result, @@ -227,7 +228,7 @@ def delete_test_result( ): """Delete a test result. Only the creator may delete their own result.""" organization_id, user_id = tenant_context - db_test_result = crud.get_test_result( + db_test_result = test_result_crud.get_test_result( db, test_result_id=test_result_id, organization_id=organization_id, user_id=user_id ) if db_test_result is None: @@ -240,7 +241,7 @@ def delete_test_result( ): raise HTTPException(status_code=403, detail="Not authorized to delete this test result") - return crud.delete_test_result( + return test_result_crud.delete_test_result( db=db, test_result_id=test_result_id, organization_id=organization_id, user_id=user_id ) @@ -272,7 +273,7 @@ def add_review( organization_id, user_id = tenant_context # Get the test result - db_test_result = crud.get_test_result( + db_test_result = test_result_crud.get_test_result( db, test_result_id=test_result_id, organization_id=organization_id, user_id=user_id ) if db_test_result is None: @@ -373,7 +374,7 @@ def update_review( organization_id, user_id = tenant_context # Get the test result - db_test_result = crud.get_test_result( + db_test_result = test_result_crud.get_test_result( db, test_result_id=test_result_id, organization_id=organization_id, user_id=user_id ) if db_test_result is None: @@ -496,7 +497,7 @@ def delete_review( organization_id, user_id = tenant_context # Get the test result - db_test_result = crud.get_test_result( + db_test_result = test_result_crud.get_test_result( db, test_result_id=test_result_id, organization_id=organization_id, user_id=user_id ) if db_test_result is None: diff --git a/apps/backend/src/rhesis/backend/app/services/test_run.py b/apps/backend/src/rhesis/backend/app/services/test_run.py index 14b7ebf6fb..a75aa3df77 100644 --- a/apps/backend/src/rhesis/backend/app/services/test_run.py +++ b/apps/backend/src/rhesis/backend/app/services/test_run.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Session from rhesis.backend.app import crud, models, schemas +from rhesis.backend.app.crud import test_result as test_result_crud from rhesis.backend.app.crud.metric import get_requirement_metrics from rhesis.backend.app.crud.test_run import get_test_run, get_test_run_requirements @@ -39,7 +40,7 @@ def get_test_results_for_test_run( limit = 100 # Use maximum allowed limit while True: - test_results_batch = crud.get_test_results( + test_results_batch = test_result_crud.get_test_results( db, skip=skip, limit=limit, filter=filter_str, organization_id=organization_id ) if not test_results_batch: diff --git a/apps/backend/src/rhesis/backend/tasks/execution/executors/output_providers.py b/apps/backend/src/rhesis/backend/tasks/execution/executors/output_providers.py index 741d8bb924..9d28f2f11d 100644 --- a/apps/backend/src/rhesis/backend/tasks/execution/executors/output_providers.py +++ b/apps/backend/src/rhesis/backend/tasks/execution/executors/output_providers.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: pass -from rhesis.backend.app import crud +from rhesis.backend.app.crud import test_result as test_result_crud from rhesis.backend.app.crud.telemetry import get_trace_by_id from rhesis.backend.app.dependencies import get_endpoint_service from rhesis.backend.app.services.endpoint.result_processing import process_endpoint_result @@ -363,7 +363,7 @@ async def get_output( class TestResultOutput(OutputProvider): """Stored output from a previous TestResult -- works for any test type. - Uses crud.get_test_results() with OData filter for multi-tenant safe lookup. + Uses test_result_crud.get_test_results() with OData filter for multi-tenant safe lookup. """ def __init__(self, reference_test_run_id: str): @@ -385,7 +385,7 @@ async def get_output( # Reuse existing CRUD with OData filter (multi-tenant safe) filter_str = f"test_run_id eq {self.reference_test_run_id} and test_id eq {test_id}" - results = crud.get_test_results( + results = test_result_crud.get_test_results( db, limit=1, filter=filter_str, diff --git a/apps/backend/src/rhesis/backend/tasks/execution/executors/results.py b/apps/backend/src/rhesis/backend/tasks/execution/executors/results.py index 4814e679d3..1e645595f0 100644 --- a/apps/backend/src/rhesis/backend/tasks/execution/executors/results.py +++ b/apps/backend/src/rhesis/backend/tasks/execution/executors/results.py @@ -11,9 +11,10 @@ from sqlalchemy.orm import Session -from rhesis.backend.app import crud, schemas +from rhesis.backend.app import schemas from rhesis.backend.app.constants import TestResultStatus from rhesis.backend.app.crud import file as file_crud +from rhesis.backend.app.crud import test_result as test_result_crud from rhesis.backend.app.models.test import Test from rhesis.backend.app.utils.crud_utils import get_or_create_status from rhesis.backend.app.utils.response_extractor import has_http_error_in_result @@ -71,7 +72,7 @@ def check_existing_result( f"test_configuration_id eq {test_config_id} and " f"test_run_id eq {test_run_id} and test_id eq {test_id}" ) - existing_results = crud.get_test_results( + existing_results = test_result_crud.get_test_results( db, limit=1, filter=filter_str, organization_id=organization_id, user_id=user_id ) @@ -305,7 +306,7 @@ def create_test_result_record( } try: - result = crud.create_test_result( + result = test_result_crud.create_test_result( db, schemas.TestResultCreate(**test_result_data), organization_id=organization_id, diff --git a/tests/backend/auth/test_object_level_auth.py b/tests/backend/auth/test_object_level_auth.py index beff63b112..3ca7607c8d 100644 --- a/tests/backend/auth/test_object_level_auth.py +++ b/tests/backend/auth/test_object_level_auth.py @@ -198,10 +198,10 @@ def test_creator_can_update(self): client = _client(caller_id=_CREATOR_ID) with patch( - "rhesis.backend.app.routers.test_result.crud.get_test_result", + "rhesis.backend.app.routers.test_result.test_result_crud.get_test_result", return_value=result, ), patch( - "rhesis.backend.app.routers.test_result.crud.update_test_result", + "rhesis.backend.app.routers.test_result.test_result_crud.update_test_result", return_value=result, ): resp = client.put( @@ -216,7 +216,7 @@ def test_non_creator_cannot_update(self): client = _client(caller_id=_OTHER_ID) with patch( - "rhesis.backend.app.routers.test_result.crud.get_test_result", + "rhesis.backend.app.routers.test_result.test_result_crud.get_test_result", return_value=result, ): resp = client.put( @@ -230,7 +230,7 @@ def test_missing_result_returns_404(self): client = _client(caller_id=_CREATOR_ID) with patch( - "rhesis.backend.app.routers.test_result.crud.get_test_result", + "rhesis.backend.app.routers.test_result.test_result_crud.get_test_result", return_value=None, ): resp = client.put( @@ -252,10 +252,10 @@ def test_creator_can_delete(self): client = _client(caller_id=_CREATOR_ID) with patch( - "rhesis.backend.app.routers.test_result.crud.get_test_result", + "rhesis.backend.app.routers.test_result.test_result_crud.get_test_result", return_value=result, ), patch( - "rhesis.backend.app.routers.test_result.crud.delete_test_result", + "rhesis.backend.app.routers.test_result.test_result_crud.delete_test_result", return_value=result, ): resp = client.delete(f"/test_results/{_RESOURCE_ID}") @@ -267,7 +267,7 @@ def test_non_creator_cannot_delete(self): client = _client(caller_id=_OTHER_ID) with patch( - "rhesis.backend.app.routers.test_result.crud.get_test_result", + "rhesis.backend.app.routers.test_result.test_result_crud.get_test_result", return_value=result, ): resp = client.delete(f"/test_results/{_RESOURCE_ID}") @@ -278,7 +278,7 @@ def test_missing_result_returns_404(self): client = _client(caller_id=_CREATOR_ID) with patch( - "rhesis.backend.app.routers.test_result.crud.get_test_result", + "rhesis.backend.app.routers.test_result.test_result_crud.get_test_result", return_value=None, ): resp = client.delete(f"/test_results/{_RESOURCE_ID}") diff --git a/tests/backend/metrics/test_database_integration.py b/tests/backend/metrics/test_database_integration.py index 42640bb45c..fda6f39ae2 100644 --- a/tests/backend/metrics/test_database_integration.py +++ b/tests/backend/metrics/test_database_integration.py @@ -7,7 +7,8 @@ import pytest -from rhesis.backend.app import crud, models, schemas +from rhesis.backend.app import models, schemas +from rhesis.backend.app.crud import test_result as test_result_crud from rhesis.backend.app.crud.metric import ( create_metric, delete_metric, @@ -302,7 +303,7 @@ def test_test_result_test_metrics_retrieval( test_db.refresh(test_result) # Retrieve via CRUD - retrieved_result = crud.get_test_result(test_db, test_result.id, test_org_id) + retrieved_result = test_result_crud.get_test_result(test_db, test_result.id, test_org_id) assert retrieved_result is not None assert retrieved_result.test_metrics is not None diff --git a/tests/backend/routes/test_trace_linking_timing.py b/tests/backend/routes/test_trace_linking_timing.py index 0f10117533..f0421f4512 100644 --- a/tests/backend/routes/test_trace_linking_timing.py +++ b/tests/backend/routes/test_trace_linking_timing.py @@ -17,8 +17,9 @@ from rhesis.telemetry.schemas import SpanKind, StatusCode from sqlalchemy.orm import Session -from rhesis.backend.app import crud, models +from rhesis.backend.app import models from rhesis.backend.app.constants import TestExecutionContext +from rhesis.backend.app.crud import test_result as test_result_crud from rhesis.backend.app.crud.telemetry import create_trace_spans from rhesis.backend.app.schemas.telemetry import OTELSpanCreate from rhesis.backend.app.services.telemetry.enrichment import EnrichmentService @@ -89,7 +90,9 @@ def create_test_result( test_output={}, ) - return crud.create_test_result(db, test_result_data, organization_id=organization_id) + return test_result_crud.create_test_result( + db, test_result_data, organization_id=organization_id + ) @pytest.mark.asyncio async def test_batch_processor_delay_simulation( diff --git a/tests/backend/security/test_organization_filtering.py b/tests/backend/security/test_organization_filtering.py index d3e8cdd7a0..51d2b5c5b8 100644 --- a/tests/backend/security/test_organization_filtering.py +++ b/tests/backend/security/test_organization_filtering.py @@ -13,6 +13,7 @@ from rhesis.backend.app import crud, models from rhesis.backend.app.crud import model as model_crud from rhesis.backend.app.crud import task as task_crud +from rhesis.backend.app.crud import test_result as test_result_crud from rhesis.backend.app.crud.metric import create_metric, get_metric from rhesis.backend.app.crud.test_run import get_test_run from rhesis.backend.app.crud.token import ( @@ -198,7 +199,7 @@ def test_get_test_result_organization_filtering(self, test_db: Session): test_db.commit() # User from org1 should be able to access the test result - result_org1 = crud.get_test_result( + result_org1 = test_result_crud.get_test_result( test_db, test_result.id, organization_id=str(org1.id), user_id=str(user1.id) ) assert result_org1 is not None @@ -206,7 +207,7 @@ def test_get_test_result_organization_filtering(self, test_db: Session): assert str(result_org1.organization_id) == str(org1.id) # User from org2 should NOT be able to access the test result - result_org2 = crud.get_test_result( + result_org2 = test_result_crud.get_test_result( test_db, test_result.id, organization_id=str(org2.id), user_id=str(user2.id) ) assert result_org2 is None @@ -609,7 +610,7 @@ def test_organization_filtering_regression_suite(self, test_db: Session): crud_functions = [ ("get_task", task_crud.get_task), ("get_test", crud.get_test), - ("get_test_result", crud.get_test_result), + ("get_test_result", test_result_crud.get_test_result), ("get_test_run", get_test_run), ("get_endpoint", crud.get_endpoint), ("get_prompt", crud.get_prompt), diff --git a/tests/backend/tasks/test_output_providers.py b/tests/backend/tasks/test_output_providers.py index ab1d15277d..539a199b14 100644 --- a/tests/backend/tasks/test_output_providers.py +++ b/tests/backend/tasks/test_output_providers.py @@ -653,7 +653,7 @@ async def test_loads_stored_output(self): mock_result.test_output = {"output": "stored response"} with patch( - "rhesis.backend.tasks.execution.executors.output_providers.crud.get_test_results", + "rhesis.backend.tasks.execution.executors.output_providers.test_result_crud.get_test_results", return_value=[mock_result], ): provider = TestResultOutput(reference_test_run_id=run_id) @@ -675,7 +675,7 @@ async def test_raises_when_no_stored_output(self): run_id = str(uuid4()) test_id = str(uuid4()) with patch( - "rhesis.backend.tasks.execution.executors.output_providers.crud.get_test_results", + "rhesis.backend.tasks.execution.executors.output_providers.test_result_crud.get_test_results", return_value=[], ): provider = TestResultOutput(reference_test_run_id=run_id) @@ -695,7 +695,7 @@ async def test_raises_when_output_is_none(self): mock_result.test_output = None with patch( - "rhesis.backend.tasks.execution.executors.output_providers.crud.get_test_results", + "rhesis.backend.tasks.execution.executors.output_providers.test_result_crud.get_test_results", return_value=[mock_result], ): provider = TestResultOutput(reference_test_run_id=run_id) @@ -714,7 +714,7 @@ async def test_odata_filter_string(self): mock_result.test_output = {"output": "ok"} with patch( - "rhesis.backend.tasks.execution.executors.output_providers.crud.get_test_results", + "rhesis.backend.tasks.execution.executors.output_providers.test_result_crud.get_test_results", return_value=[mock_result], ) as mock_get: provider = TestResultOutput(reference_test_run_id=run_id)