from typing import Any import pytest from aws_testing_utils.lambda_handler import LambdaHandler from test_fixtures.mysql import MySQLConnection from config import HANDLE_ERROR_FUNCTION_NAME pytestmark = pytest.mark.lambda_name("handle-error") def test_marks_job_failed_with_cause( art_relations_db: MySQLConnection, transfer_job: dict[str, Any], lambda_handler: LambdaHandler, ) -> None: lambda_handler.invoke( HANDLE_ERROR_FUNCTION_NAME, {"job_id": transfer_job["job_id"], "error": {"Cause": "something went wrong"}}, ) job = art_relations_db.fetchone( "SELECT status, failure_reason FROM project_transfer_job WHERE job_id = %s", (transfer_job["job_id"],), ) assert job is not None assert job["status"] == "FAILED" assert job["failure_reason"] == "something went wrong" def test_marks_job_failed_with_error_name( art_relations_db: MySQLConnection, transfer_job: dict[str, Any], lambda_handler: LambdaHandler, ) -> None: lambda_handler.invoke( HANDLE_ERROR_FUNCTION_NAME, {"job_id": transfer_job["job_id"], "error": {"Error": "States.TaskFailed"}}, ) job = art_relations_db.fetchone( "SELECT status, failure_reason FROM project_transfer_job WHERE job_id = %s", (transfer_job["job_id"],), ) assert job is not None assert job["status"] == "FAILED" assert job["failure_reason"] == "States.TaskFailed" def test_long_preflight_conflict_failure_reason_stored_without_truncation( art_relations_db: MySQLConnection, transfer_job: dict[str, Any], lambda_handler: LambdaHandler, ) -> None: """Scenarios: Handles PreflightConflictError + Very long error message. A realistic PreflightConflictError aggregates many per-release conflicts and can run to several KB. This test verifies that the full Cause string survives the round-trip through ows-project-manager's PATCH endpoint and persists in `project_transfer_job.failure_reason` without truncation. """ conflicts = [ f"Release {1000 + i} blocked by collaborator agreement; " f"originating vendor 6971 differs from destination vendor 7123" for i in range(50) ] long_cause = "PreflightConflictError: Transfer blocked by 50 conflicts: " + " | ".join(conflicts) lambda_handler.invoke( HANDLE_ERROR_FUNCTION_NAME, {"job_id": transfer_job["job_id"], "error": {"Cause": long_cause}}, ) job = art_relations_db.fetchone( "SELECT status, failure_reason FROM project_transfer_job WHERE job_id = %s", (transfer_job["job_id"],), ) assert job is not None assert job["status"] == "FAILED" assert job["failure_reason"] == long_cause, ( f"failure_reason was truncated for job {transfer_job['job_id']}: " f"expected {len(long_cause)} chars, got {len(job['failure_reason'] or '')}" ) def test_patch_on_already_failed_job_surfaces_function_error( transfer_job: dict[str, Any], lambda_handler: LambdaHandler, ) -> None: """Regression test for ows-project-manager's "already FAILED, cannot update" guard. PM's PATCH /transfer/job/{id} refuses to update jobs that are already in a terminal FAILED state (returns 400). The lambda surfaces that 400 as a FunctionError on the AWS response — the signal Step Functions uses to route to its own error handling, so a runaway retry can't silently overwrite a real failure_reason with a stale one. If PM ever changes to allow re-PATCH on FAILED, this test catches the change. """ job_id = transfer_job["job_id"] # First invocation: QUEUED → FAILED. lambda_handler.invoke( HANDLE_ERROR_FUNCTION_NAME, {"job_id": job_id, "error": {"Cause": "first failure"}}, ) # Second invocation: PM rejects with 400 → lambda raises → FunctionError. response = lambda_handler.invoke( HANDLE_ERROR_FUNCTION_NAME, {"job_id": job_id, "error": {"Cause": "second failure"}}, assertion=False, ) assert response["StatusCode"] == 200 assert "FunctionError" in response, ( f"Expected lambda to surface PM's already-FAILED guard as a FunctionError " f"for job {job_id}, but response had none: " f"{ {k: v for k, v in response.items() if k != 'Payload'} }" ) def test_function_error_when_job_id_does_not_exist( lambda_handler: LambdaHandler, ) -> None: """Generic error-propagation check: PM's PATCH on a non-existent job_id returns 4xx, which the lambda must surface as a FunctionError so SFN can route to its error handler rather than silently swallow the failure. Uses a job_id chosen high enough that no real QA job is expected to have it. No fixture is needed — the lambda fails before any DB mutation. """ nonexistent_job_id = 2_000_000_000 response = lambda_handler.invoke( HANDLE_ERROR_FUNCTION_NAME, {"job_id": nonexistent_job_id, "error": {"Cause": "no such job"}}, assertion=False, ) assert response["StatusCode"] == 200 assert "FunctionError" in response, ( f"Expected lambda to surface PM's 4xx for nonexistent job {nonexistent_job_id} " f"as a FunctionError, but response had none: " f"{ {k: v for k, v in response.items() if k != 'Payload'} }" )