"""Tests for get product logic layer.""" import json from contextlib import nullcontext from typing import Any, ContextManager from unittest.mock import MagicMock, _Call, call import pytest from flexmock import flexmock from pytest_mock import MockerFixture from requests import HTTPError from sqlalchemy.exc import SQLAlchemyError from syrupy.assertion import SnapshotAssertion from assets.constants import ( api, asset_types, error, product as product_constants, ) from assets.constants.error import ERROR_ASSET_FINAL_NOT_FOUND from assets.exceptions import ( AssetDeleteFailure, AssetFinalNotFound, AssetUploadNotFound, InvalidProductStatus, ) from assets.logic import delete as delete_logic, product from assets.models import ( asset_final, asset_status, asset_upload, asset_upload_type, ows_product, ows_track, ) PRODUCT_ID = 1001 @pytest.fixture def fixture_product() -> dict[str, Any]: """Fixture for mock product.""" return {"product_id": PRODUCT_ID, "upc": 12345} @pytest.fixture def fixture_product_assets() -> list[dict[str, Any]]: """Fixture for mock product assets.""" return [ { "id": 1000, "upc": "12345", "filename": "unique_filename_1", "original_filename": "original_filename_1", "product_id": PRODUCT_ID, "track_unique_id": 123, "asset_type": "WAV", "asset_upload_type_id": 1, }, { "id": 1001, "upc": "12345", "filename": "unique_filename_2", "original_filename": "original_filename_2", "product_id": PRODUCT_ID, "track_unique_id": 321, "asset_type": "WAV", "asset_upload_type_id": 3, }, { "id": 1002, "upc": "12345", "filename": "unique_filename_3", "original_filename": "original_filename_3", "product_id": PRODUCT_ID, "track_unique_id": 234, "asset_type": "JPEG", "asset_upload_type_id": 2, }, ] @pytest.fixture def fixture_asset_statuses() -> dict[int, dict[str, Any] | None]: """Fixture for mock asset_statuses.""" return {1000: {"status": "completed"}, 1001: {"status": "completed"}, 1002: None} @pytest.fixture def fixture_asset_final() -> list[dict[str, Any]]: """Fixture for asset_final record.""" return [ { "asset_upload_id": 1000, "filename": "some_file.mp3", "asset_type": "MP3_192", "bucket": "some_bucket", "duration": 85500, }, { "asset_upload_id": 1001, "filename": "some_file.mp3", "asset_type": "MP3_192", "bucket": "some_bucket", "duration": 85900, }, ] @pytest.fixture def fixture_get_product_by_pid_and_type() -> list[dict[str, Any]]: """Fixture for mock product assets.""" return [ { "id": 1002, "filename": "unique_filename_3", "original_filename": "original_filename_3", "product_id": PRODUCT_ID, "track_unique_id": 234, "asset_type": "JPEG", }, { "id": 1003, "filename": "unique_filename_4", "original_filename": "original_filename_4", "product_id": PRODUCT_ID, "track_unique_id": None, "asset_type": "TIF", }, ] @pytest.fixture def fixture_get_product_mocks( fixture_product: dict[str, Any], fixture_product_assets: list[dict[str, Any]], fixture_asset_statuses: dict[int, dict[str, Any] | None], ) -> dict[str, Any]: """Fixture with correct mocks for get_product call.""" return { "get_product_by_id": fixture_product, "get_asset_uploads": fixture_product_assets, "get_asset_statuses": fixture_asset_statuses, } @pytest.fixture def fixture_remove_image_assets( fixture_get_product_by_pid_and_type: list[dict[str, Any]], ) -> dict[str, Any]: """Fixture with correct mocks for remove_image_asset_by_product_id call.""" return { "get_product": {"status": "not_in_content"}, "get_asset_uploads_by_pid_and_types": fixture_get_product_by_pid_and_type, "delete_asset": {"succeeded": [{"asset": {}, "delete_details": {}}]}, } def test_get_assets_by_product_id_success( fixture_asset_final: list[dict[str, Any]], fixture_get_product_mocks: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: """Test for success of get_assets_by_product_id.""" ( flexmock(asset_upload_type) .should_receive("resolve_asset_upload_type") .with_args(1) .and_return("stereo") ) ( flexmock(asset_upload_type) .should_receive("resolve_asset_upload_type") .with_args(3) .and_return("atmos") ) ( flexmock(asset_upload_type) .should_receive("resolve_asset_upload_type") .with_args(2) .and_return("static_artwork") ) ( flexmock(asset_upload) .should_receive("get_asset_uploads_by_product_id") .with_args(product_id=PRODUCT_ID, api_version=api.API_VERSION_V2) .and_return(fixture_get_product_mocks["get_asset_uploads"]) .once() ) ( flexmock(asset_status) .should_receive("get_asset_statuses_by_asset_upload_ids") .with_args(list(fixture_get_product_mocks["get_asset_statuses"].keys())) .and_return(fixture_get_product_mocks["get_asset_statuses"]) .once() ) ( flexmock(asset_final) .should_receive("get_asset_final_by_asset_upload_id_and_type") .with_args(fixture_get_product_mocks["get_asset_uploads"][0]["id"], "MP3_192") .and_return(fixture_asset_final[0]) ) ( flexmock(asset_final) .should_receive("get_asset_final_by_asset_upload_id_and_type") .with_args(fixture_get_product_mocks["get_asset_uploads"][1]["id"], "MP3_192") .and_return(fixture_asset_final[1]) ) ( flexmock(asset_final) .should_receive("get_asset_final_by_asset_upload_id_and_type") .with_args(fixture_get_product_mocks["get_asset_uploads"][2]["id"], "MP3_192") .and_raise(AssetFinalNotFound(error.ERROR_ASSET_FINAL_NOT_FOUND)) ) assert product.get_assets_by_product_id(PRODUCT_ID) == snapshot def test_get_assets_by_product_id_with_validation_errors_and_warnings( fixture_asset_final: list[dict[str, Any]], fixture_get_product_mocks: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: ( flexmock(asset_upload_type) .should_receive("resolve_asset_upload_type") .with_args(1) .and_return("stereo") ) ( flexmock(asset_upload_type) .should_receive("resolve_asset_upload_type") .with_args(3) .and_return("atmos") ) ( flexmock(asset_upload_type) .should_receive("resolve_asset_upload_type") .with_args(2) .and_return("static_artwork") ) ( flexmock(asset_upload) .should_receive("get_asset_uploads_by_product_id") .with_args(product_id=PRODUCT_ID, api_version=api.API_VERSION_V2) .and_return(fixture_get_product_mocks["get_asset_uploads"]) .once() ) ( flexmock(asset_status) .should_receive("get_asset_statuses_by_asset_upload_ids") .with_args(list(fixture_get_product_mocks["get_asset_statuses"].keys())) .and_return( { 1000: { "status": "validation_error", "message": { "description": """ An error occurred during the audio validation. Error message: bits_per_sample { 0: "value is not exactly '16'", 1: "value is not exactly '24'" }. sample_rate Invalid input value for bits_per_sample. Expected value(s) -> None: [16, 24]. """ }, }, 1001: { "status": "validation_error", "message": { "description": json.dumps( { "errors": { "bits_per_sample": { "0": "value is not exactly '16'", "1": "value is not exactly '24'", } }, "metadata": { "duration_ms": 211540, "channels": 2, "codec": "pcm", "sample_rate": 44100, "bits_per_sample": 32, "bit_rate": 2822400, "container": "wave", "mime_type": "audio/vnd.wave", "file_size_bytes": 74631330, "lossless": True, }, } ), }, }, 1002: { "status": "validation_warning", "message": { "description": json.dumps( { "metadata": { "duration_ms": 211540, "integrated_loudness_lkfs": -15, }, "errors": {}, "warnings": { "integrated_loudness_lkfs": "should not exceed -18 LKFS", }, } ), }, }, } ) .once() ) ( flexmock(asset_final) .should_receive("get_asset_final_by_asset_upload_id_and_type") .and_raise(SQLAlchemyError("query error")) .times(3) ) assert product.get_assets_by_product_id(PRODUCT_ID) == snapshot class TestGetValidationStatusInfo: @pytest.mark.parametrize( ("description", "expected"), [ pytest.param( json.dumps( {"metadata": {"codec": "aac"}, "errors": {"codec": "must be PCM"}} ), { "metadata": {"codec": "aac"}, "invalid_metadata": ["codec"], "warning_metadata": [], }, id="errors_only", ), pytest.param( json.dumps( { "metadata": {"integrated_loudness_lkfs": -15}, "warnings": { "integrated_loudness_lkfs": "should not exceed -18 LKFS" }, } ), { "metadata": {"integrated_loudness_lkfs": -15}, "invalid_metadata": [], "warning_metadata": ["integrated_loudness_lkfs"], }, id="warnings_only", ), pytest.param( json.dumps( { "metadata": {"codec": "aac", "integrated_loudness_lkfs": -15}, "errors": {"codec": "must be PCM"}, "warnings": { "integrated_loudness_lkfs": "should not exceed -18 LKFS" }, } ), { "metadata": {"codec": "aac", "integrated_loudness_lkfs": -15}, "invalid_metadata": ["codec"], "warning_metadata": ["integrated_loudness_lkfs"], }, id="errors_and_warnings", ), pytest.param( json.dumps({"errors": {"codec": "must be PCM"}}), {}, id="returns_empty_without_metadata", ), pytest.param( json.dumps({"metadata": {"codec": "pcm"}}), {}, id="returns_empty_without_errors_or_warnings", ), pytest.param( json.dumps( {"metadata": {"codec": "pcm"}, "errors": None, "warnings": None} ), {}, id="treats_null_errors_and_warnings_as_empty", ), pytest.param( "not json at all", {}, id="returns_empty_on_malformed_json", ), ], ) def test_parses_description( self, description: str, expected: dict[str, Any] ) -> None: assert product._get_validation_status_info(description) == expected @pytest.mark.parametrize( "expected_raise, expected_exception, error_message", [ ( pytest.raises(SQLAlchemyError), SQLAlchemyError("query error"), "query error", ), ( pytest.raises(AssetUploadNotFound), AssetUploadNotFound("asset upload not found"), "404 Not Found: asset upload not found", ), ], ) def test_get_assets_by_product_id_get_asset_uploads_failure( expected_raise: ContextManager[Any], expected_exception: Exception, error_message: str, ) -> None: """Test for success of get_assets_by_product_id.""" ( flexmock(asset_upload) .should_receive("get_asset_uploads_by_product_id") .and_raise(expected_exception) ) with expected_raise as exc: product.get_assets_by_product_id(PRODUCT_ID) assert str(exc.value) == error_message def test_get_assets_by_product_id_get_asset_status_failure( fixture_get_product_mocks: dict[str, Any], ) -> None: """Test for success of get_assets_by_product_id.""" error_message = "get_asset_status error" ( flexmock(asset_upload) .should_receive("get_asset_uploads_by_product_id") .and_return(fixture_get_product_mocks["get_asset_uploads"]) ) ( flexmock(asset_status) .should_receive("get_asset_statuses_by_asset_upload_ids") .and_raise(SQLAlchemyError(error_message)) ) with pytest.raises(SQLAlchemyError) as exc: product.get_assets_by_product_id(PRODUCT_ID) assert str(exc.value) == error_message _test_get_asset_final_items_with_asset_upload_by_product_id_v2_track_response = { "items": [{"tuid": 4}, {"tuid": 5}, {"tuid": 6}] } _test_get_asset_final_items_with_asset_upload_by_product_id_v2_asset_upload_response = [ { "id": 11, "track_unique_id": 1, }, { "id": 12, "track_unique_id": 2, }, { "id": 13, "track_unique_id": 3, }, { "id": 14, "track_unique_id": 4, }, { "id": 15, "track_unique_id": 5, }, { "id": 16, "track_unique_id": 6, }, ] _test_get_asset_final_items_with_asset_upload_by_product_id_v2_asset_final_response = { 14: [ { "id": 24, "asset_upload_id": 14, "asset_type": "MP3_192", }, { "id": 25, "asset_upload_id": 14, "asset_type": "FLAC", }, ], 15: [ { "id": 26, "asset_upload_id": 15, "asset_type": "FLAC", }, { "id": 27, "asset_upload_id": 15, "asset_type": "MP3_192", }, ], 16: [ { "id": 28, "asset_upload_id": 16, "asset_type": "MP3_192", }, { "id": 29, "asset_upload_id": 16, "asset_type": "FLAC", }, ], } @pytest.mark.parametrize( ( "test_description", "asset_final_asset_types_filter", "track_response", "expected_asset_upload_calls", "asset_upload_response", "expected_asset_final_calls", "expected_raise", "asset_final_response", ), [ ( "Test success no asset type filter.", None, _test_get_asset_final_items_with_asset_upload_by_product_id_v2_track_response, [call(12321, api_version=2)], _test_get_asset_final_items_with_asset_upload_by_product_id_v2_asset_upload_response, [call([14, 15, 16])], nullcontext(), _test_get_asset_final_items_with_asset_upload_by_product_id_v2_asset_final_response, ), ( "Test success empty tracks list.", None, {"items": []}, [], None, [], nullcontext(), None, ), ( "Test success with asset type filter.", {asset_types.TYPE_FILE_FLAC}, _test_get_asset_final_items_with_asset_upload_by_product_id_v2_track_response, [call(12321, api_version=2)], _test_get_asset_final_items_with_asset_upload_by_product_id_v2_asset_upload_response, [call([14, 15, 16])], nullcontext(), _test_get_asset_final_items_with_asset_upload_by_product_id_v2_asset_final_response, ), ( "Test error get_tracks_by_product_id.", None, HTTPError(response=MagicMock(status_code=500)), [], None, [], pytest.raises(HTTPError), None, ), ( "Test error get_asset_uploads_by_product_id.", None, _test_get_asset_final_items_with_asset_upload_by_product_id_v2_track_response, [call(12321, api_version=2)], [], [call([])], pytest.raises(AssetFinalNotFound), AssetFinalNotFound(ERROR_ASSET_FINAL_NOT_FOUND), ), ( "Test error get_asset_final_by_asset_upload_ids.", None, _test_get_asset_final_items_with_asset_upload_by_product_id_v2_track_response, [call(12321, api_version=2)], _test_get_asset_final_items_with_asset_upload_by_product_id_v2_asset_upload_response, [call([14, 15, 16])], pytest.raises(SQLAlchemyError), SQLAlchemyError("Query error"), ), ], ) def test_get_asset_final_items_with_asset_upload_by_product_id_v2( mocker: MockerFixture, test_description: str, asset_final_asset_types_filter: set[str], track_response: list[dict[str, Any]], expected_asset_upload_calls: list[_Call], asset_upload_response: list[dict[str, Any]], expected_asset_final_calls: list[_Call], expected_raise: ContextManager[Exception], asset_final_response: list[dict[str, Any]], snapshot: SnapshotAssertion, ) -> None: """Test get_asset_final_items_with_asset_upload_by_product_id_v2.""" get_tracks_by_product_id_mock = mocker.patch.object( ows_track, "get_tracks_by_product_id", side_effect=[track_response], ) get_asset_uploads_by_product_id_mock = mocker.patch.object( asset_upload, "get_asset_uploads_by_product_id", return_value=asset_upload_response, ) get_asset_final_by_asset_upload_ids_mock = mocker.patch.object( asset_final, "get_asset_final_by_asset_upload_ids", side_effect=[asset_final_response], ) with expected_raise: result = product.get_asset_final_items_with_asset_upload_by_product_id_v2( 12321, asset_final_asset_types_filter=asset_final_asset_types_filter ) assert result == snapshot assert get_tracks_by_product_id_mock.mock_calls == [call(12321)] assert ( get_asset_uploads_by_product_id_mock.mock_calls == expected_asset_upload_calls ) assert ( get_asset_final_by_asset_upload_ids_mock.mock_calls == expected_asset_final_calls ) def test_remove_image_assets_by_product_id_success( fixture_remove_image_assets: dict[str, Any], ) -> None: """Test for success of remove_image_assets_by_product_id.""" ( flexmock(ows_product) .should_receive("get_product_by_id") .with_args(product_id=PRODUCT_ID) .and_return(fixture_remove_image_assets["get_product"]) .once() ) flexmock(asset_upload).should_receive("get_asset_uploads").with_args( product_id=PRODUCT_ID, track_id=0, api_version=api.API_VERSION_V2, ).and_return( fixture_remove_image_assets["get_asset_uploads_by_pid_and_types"] ).once() ( flexmock(delete_logic) .should_receive("delete_assets") .and_return(fixture_remove_image_assets["delete_asset"]) .once() ) result = product.remove_image_assets_by_product_id(PRODUCT_ID) assert result def test_remove_image_assets_by_product_id_get_product_failure() -> None: """Test for success of remove_image_assets_by_product_id.""" ( flexmock(ows_product) .should_receive("get_product_by_id") .with_args(product_id=PRODUCT_ID) .and_raise(HTTPError(response=MagicMock(status_code=500))) ) with pytest.raises(HTTPError) as exc: product.remove_image_assets_by_product_id(PRODUCT_ID) assert str(exc.value) == "" def test_remove_image_assets_by_product_id_get_asset_uploads_failure( fixture_remove_image_assets: dict[str, Any], ) -> None: """Test for success of remove_image_assets_by_product_id.""" error_message = "get_asset_uploads error" ( flexmock(ows_product) .should_receive("get_product_by_id") .with_args(product_id=PRODUCT_ID) .and_return(fixture_remove_image_assets["get_product"]) ) ( flexmock(asset_upload) .should_receive("get_asset_uploads") .with_args( product_id=PRODUCT_ID, track_id=0, api_version=api.API_VERSION_V2, ) .and_raise(SQLAlchemyError(error_message)) ) with pytest.raises(SQLAlchemyError) as exc: product.remove_image_assets_by_product_id(PRODUCT_ID) assert str(exc.value) == error_message def test_remove_image_assets_by_product_id_delete_asset_failure( fixture_remove_image_assets: dict[str, Any], ) -> None: """Test for success of remove_image_assets_by_product_id.""" ( flexmock(ows_product) .should_receive("get_product_by_id") .with_args(product_id=PRODUCT_ID) .and_return(fixture_remove_image_assets["get_product"]) ) ( flexmock(asset_upload) .should_receive("get_asset_uploads") .with_args( product_id=PRODUCT_ID, track_id=0, api_version=api.API_VERSION_V2, ) .and_return(fixture_remove_image_assets["get_asset_uploads_by_pid_and_types"]) ) error_message = "delete_assets error" ( flexmock(delete_logic) .should_receive("delete_assets") .and_raise(AssetDeleteFailure(error_message)) ) with pytest.raises(AssetDeleteFailure) as exc: product.remove_image_assets_by_product_id(PRODUCT_ID) assert str(exc.value) == error_message def test_remove_image_assets_by_product_id_in_content_failure( fixture_remove_image_assets: dict[str, Any], ) -> None: """Test for delete track remove_image_assets_by_product_id failure.""" ( flexmock(ows_product) .should_receive("get_product_by_id") .with_args(product_id=PRODUCT_ID) .and_return({"status": product_constants.PRODUCT_STATUS_IN_CONTENT}) ) ( flexmock(asset_upload) .should_receive("get_asset_uploads") .with_args( product_id=PRODUCT_ID, track_id=0, api_version=api.API_VERSION_V2, ) .and_return(fixture_remove_image_assets["get_asset_uploads_by_pid_and_types"]) ) ( flexmock(delete_logic) .should_receive("delete_assets") .and_return(fixture_remove_image_assets["delete_asset"]) ) with pytest.raises(InvalidProductStatus) as exc: product.remove_image_assets_by_product_id(PRODUCT_ID) assert exc.value.description == error.ERROR_MESSAGE_PRODUCT_IN_CONTENT @pytest.mark.parametrize( ( "test_description", "mock_product_status", "mock_product_message", "expected_raise", "raised_exception", ), [ ( "Successful product assets retrieval", 200, [ { "track_unique_id": "123", "product_id": 333, "asset_type": "TIF", "s3_bucket": "my-bucket", "s3_key": "path/to/tif", "updated_timestamp": "2024-01-01 12:00:00", "updated_timestamp_us_east": "2023-12-31 19:00:00", "duration": 0, "duration_ms": 0, } ], nullcontext(), None, ), ( "Successful product assets retrieval", 200, [ { "track_unique_id": "456", "product_id": 333, "asset_type": "WAV", "s3_bucket": "my-bucket", "s3_key": "path/to/wav", "updated_timestamp": "2024-01-01 12:00:00", "updated_timestamp_us_east": "2023-12-31 19:00:00", "duration": 206.158, "duration_ms": 206158, } ], nullcontext(), None, ), ( "404 for product assets", 404, [], nullcontext(), None, ), ( "500 for product assets", 500, "internal_error", pytest.raises(SQLAlchemyError), SQLAlchemyError("query error"), ), ], ) def test_get_assets_info_by_product_id( test_description: str, mock_product_status: int, mock_product_message: str, expected_raise: ContextManager[None], raised_exception: Exception, snapshot: SnapshotAssertion, ) -> None: """Test case for getting assets by product ID with different scenarios.""" product_id = 333 if mock_product_status == 500: ( flexmock(asset_final) .should_receive("get_product_asset") .with_args([product_id]) .and_raise(raised_exception) ) else: ( flexmock(asset_final) .should_receive("get_product_asset") .with_args([product_id]) .and_return(mock_product_message) ) with expected_raise: result = product.get_assets_info_by_product_id(product_id) assert result == snapshot