import json from datetime import UTC, datetime from typing import Any from unittest.mock import Mock, patch import pytest from src.atmos.models import ( AtmosValidationMetadata, AtmosValidationResult, ) from src.clients import ows_assets from src.clients.ows_assets import ( VALIDATION_COMPLETE, VALIDATION_ERROR, VALIDATION_WARNING, OwsAssetsError, OwsAssetsNotFound, ) from src.config import APPLICATION_NAME def _ok_response(payload: Any) -> Mock: response = Mock() response.status_code = 200 response.json.return_value = payload return response def _error_response(status_code: int, text: str) -> Mock: response = Mock() response.status_code = status_code response.text = text return response def _metadata() -> AtmosValidationMetadata: return AtmosValidationMetadata( container="Wave", codec="PCM", codec_id="1", channels=12, sample_rate_hz=48000, bits_per_sample=24, is_truncated=False, adm_profile="Dolby Atmos Master", duration_ms=7805, stereo_reference_is_truncated=False, stereo_reference_duration_ms=7805, ) def _result( errors: dict[str, str] | None = None, warnings: dict[str, str] | None = None, metadata: AtmosValidationMetadata | None = None, ) -> AtmosValidationResult: errors = errors or {} warnings = warnings or {} return AtmosValidationResult( is_valid=not errors, metadata=metadata, errors=errors, warnings=warnings, ) class TestGetStereoForAsset: def test_returns_bucket_and_key(self) -> None: with patch.object(ows_assets, "ows") as ows_factory: ows_factory.return_value.get.return_value = _ok_response( {"bucket": "stereo-bucket", "key": "k/stereo.flac"} ) bucket, key = ows_assets.get_stereo_for_asset("k/atmos.wav") assert bucket == "stereo-bucket" assert key == "k/stereo.flac" def test_calls_endpoint_with_basename(self) -> None: with patch.object(ows_assets, "ows") as ows_factory: ows_factory.return_value.get.return_value = _ok_response( {"bucket": "stereo-bucket", "key": "k/stereo.flac"} ) ows_assets.get_stereo_for_asset("some/nested/path/atmos.wav") ows_factory.return_value.get.assert_called_once_with( service_name="ows-assets", path="/internal/assets/atmos.wav/stereo", ) def test_ignores_unknown_response_fields(self) -> None: with patch.object(ows_assets, "ows") as ows_factory: ows_factory.return_value.get.return_value = _ok_response( {"bucket": "stereo-bucket", "key": "k/stereo.flac", "added_later": "ok"} ) bucket, key = ows_assets.get_stereo_for_asset("k/atmos.wav") assert (bucket, key) == ("stereo-bucket", "k/stereo.flac") def test_raises_not_found_on_404(self) -> None: with patch.object(ows_assets, "ows") as ows_factory: ows_factory.return_value.get.return_value = _error_response(404, "not found") with pytest.raises(OwsAssetsNotFound, match="404"): ows_assets.get_stereo_for_asset("k/atmos.wav") def test_raises_ows_error_on_non_404(self) -> None: with patch.object(ows_assets, "ows") as ows_factory: ows_factory.return_value.get.return_value = _error_response(503, "service unavailable") with pytest.raises(OwsAssetsError, match="503") as excinfo: ows_assets.get_stereo_for_asset("k/atmos.wav") assert excinfo.type is OwsAssetsError def test_raises_on_malformed_response_body(self) -> None: with patch.object(ows_assets, "ows") as ows_factory: ows_factory.return_value.get.return_value = _ok_response({"bucket": "only-bucket"}) with pytest.raises(OwsAssetsError, match="malformed"): ows_assets.get_stereo_for_asset("k/atmos.wav") class TestPostValidationResult: @pytest.mark.parametrize( ("errors", "warnings", "expected_status"), [ pytest.param({}, {}, VALIDATION_COMPLETE, id="no_findings"), pytest.param({"bits_per_sample": "bad"}, {}, VALIDATION_ERROR, id="error"), pytest.param({}, {"sample_rate_hz": "prefer"}, VALIDATION_WARNING, id="warning_only"), pytest.param( {"bits_per_sample": "bad"}, {"codec": "prefer"}, VALIDATION_ERROR, id="error_takes_precedence_over_warning", ), ], ) def test_status_reflects_error_warning_partition( self, errors: dict[str, str], warnings: dict[str, str], expected_status: str ) -> None: with patch.object(ows_assets, "ows") as ows_factory: ows_factory.return_value.post.return_value = _ok_response({}) ows_assets.post_validation_result("k/atmos.wav", _result(errors=errors, warnings=warnings)) body = ows_factory.return_value.post.call_args.kwargs["json"] assert body["status"] == expected_status assert body["message"]["status"] == expected_status def test_posts_to_status_endpoint(self) -> None: with patch.object(ows_assets, "ows") as ows_factory: ows_factory.return_value.post.return_value = _ok_response({}) ows_assets.post_validation_result("k/atmos.wav", _result()) call_kwargs = ows_factory.return_value.post.call_args.kwargs assert call_kwargs["service_name"] == "ows-assets" assert call_kwargs["path"] == "/v2/asset/status" def test_message_envelope_shape(self) -> None: with patch.object(ows_assets, "ows") as ows_factory: ows_factory.return_value.post.return_value = _ok_response({}) ows_assets.post_validation_result("k/atmos.wav", _result()) message = ows_factory.return_value.post.call_args.kwargs["json"]["message"] assert message["function"] == APPLICATION_NAME assert set(message) == {"function", "status", "description"} def test_description_carries_metadata_errors_and_warnings(self) -> None: with patch.object(ows_assets, "ows") as ows_factory: ows_factory.return_value.post.return_value = _ok_response({}) ows_assets.post_validation_result( "k/atmos.wav", _result( errors={"bits_per_sample": "need 24-bit"}, warnings={"sample_rate_hz": "prefer 48kHz"}, metadata=_metadata(), ), ) description = json.loads(ows_factory.return_value.post.call_args.kwargs["json"]["message"]["description"]) assert description["errors"] == {"bits_per_sample": "need 24-bit"} assert description["warnings"] == {"sample_rate_hz": "prefer 48kHz"} assert description["metadata"]["bits_per_sample"] == 24 # Lock in the computed_field + stereo_reference_* fields on the wire payload. assert description["metadata"]["stereo_reference_is_truncated"] is False assert description["metadata"]["stereo_reference_duration_ms"] == 7805 assert description["metadata"]["stereo_reference_duration_diff_ms"] == 0 def test_description_metadata_is_null_when_absent(self) -> None: with patch.object(ows_assets, "ows") as ows_factory: ows_factory.return_value.post.return_value = _ok_response({}) ows_assets.post_validation_result("k/atmos.wav", _result(metadata=None)) description = json.loads(ows_factory.return_value.post.call_args.kwargs["json"]["message"]["description"]) assert description == {"metadata": None, "errors": {}, "warnings": {}} def test_timestamp_uses_iso8601_with_microseconds_and_z_suffix(self) -> None: frozen_now = datetime(2026, 5, 21, 12, 34, 56, 789012, tzinfo=UTC) with ( patch.object(ows_assets, "ows") as ows_factory, patch("src.clients.ows_assets.datetime") as datetime_mock, ): datetime_mock.now.return_value = frozen_now ows_factory.return_value.post.return_value = _ok_response({}) ows_assets.post_validation_result("k/atmos.wav", _result()) body = ows_factory.return_value.post.call_args.kwargs["json"] assert body["timestamp"] == "2026-05-21T12:34:56.789012Z" def test_uses_atmos_key_basename_as_filename(self) -> None: with patch.object(ows_assets, "ows") as ows_factory: ows_factory.return_value.post.return_value = _ok_response({}) ows_assets.post_validation_result("some/nested/path/atmos.wav", _result()) body = ows_factory.return_value.post.call_args.kwargs["json"] assert body["filename"] == "atmos.wav" def test_raises_not_found_on_404(self) -> None: with patch.object(ows_assets, "ows") as ows_factory: ows_factory.return_value.post.return_value = _error_response(404, "not found") with pytest.raises(OwsAssetsNotFound, match="404"): ows_assets.post_validation_result("k/atmos.wav", _result()) @pytest.mark.parametrize("status_code", [500, 503]) def test_raises_ows_error_on_non_404(self, status_code: int) -> None: with patch.object(ows_assets, "ows") as ows_factory: ows_factory.return_value.post.return_value = _error_response(status_code, "boom") with pytest.raises(OwsAssetsError, match=str(status_code)) as excinfo: ows_assets.post_validation_result("k/atmos.wav", _result()) assert excinfo.type is OwsAssetsError