import pytest from pytest_mock import MockerFixture from src import config from src.contract_attachments_add import add_to_destination_for_each_term from src.errors import PermanentError, TransientError _UPCS = ["00602458823264"] _ISRCS = ["USRC17607839"] _PRODUCT_TERM = { "project_transfer_term_id": 1, "contract_id": 555, "term_type": "product", "attachments": _UPCS, "conditions": [{"conditions": {}, "term_rate": "25.00", "priority": 1}], } _TRACK_TERM = { "project_transfer_term_id": 2, "contract_id": 555, "term_type": "track", "attachments": _ISRCS, "conditions": [], } _UNSUPPORTED_TERM = { "project_transfer_term_id": 3, "contract_id": 555, "term_type": "artist", "attachments": _UPCS, "conditions": [], } _LABEL_TERM = { "project_transfer_term_id": 4, "contract_id": 555, "term_type": "label", "attachments": _UPCS, "conditions": [], } def _mock_resp(mocker: MockerFixture, status_code: int, json_body: object = None, text: str = "") -> object: resp = mocker.MagicMock() resp.status_code = status_code resp.text = text if json_body is not None: resp.json.return_value = json_body else: resp.json.side_effect = ValueError("no JSON") return resp def test_product_term_sends_upcs(mocker: MockerFixture) -> None: mock_post = mocker.patch.object(config.ows_client, "post") mock_post.return_value = _mock_resp( mocker, 201, {"contract_term_id": 99001, "contract_id": 555, "term_type": "product"} ) result = add_to_destination_for_each_term( destination_vendor_id=20, staged_terms=[_PRODUCT_TERM], ) assert len(result) == 1 assert result[0]["term_type"] == "product" _, kwargs = mock_post.call_args assert kwargs["json"]["attachments"] == _UPCS assert kwargs["json"]["term_type"] == "product" assert kwargs["json"]["project_transfer_term_id"] == 1 assert "transfer-create" in kwargs["path"] def test_forwards_attachment_relations_from_staged_term(mocker: MockerFixture) -> None: """The staged term's attachment_relations (label scoping) is sent in the body. ows-royalties stores it on the created destination term so the contract detail page can show the account attached to the term. """ mock_post = mocker.patch.object(config.ows_client, "post") mock_post.return_value = _mock_resp( mocker, 201, {"contract_term_id": 1, "contract_id": 555, "term_type": "product"} ) term = {**_PRODUCT_TERM, "attachment_relations": {"label_ids": ["20"]}} add_to_destination_for_each_term( destination_vendor_id=20, staged_terms=[term], ) _, kwargs = mock_post.call_args assert kwargs["json"]["attachment_relations"] == {"label_ids": ["20"]} def test_attachment_relations_defaults_to_none_when_absent(mocker: MockerFixture) -> None: """A staged term with no attachment_relations sends None, not a missing key.""" mock_post = mocker.patch.object(config.ows_client, "post") mock_post.return_value = _mock_resp( mocker, 201, {"contract_term_id": 1, "contract_id": 555, "term_type": "product"} ) add_to_destination_for_each_term( destination_vendor_id=20, staged_terms=[_PRODUCT_TERM], ) _, kwargs = mock_post.call_args assert kwargs["json"]["attachment_relations"] is None def test_forwards_name_from_staged_term(mocker: MockerFixture) -> None: """The staged term's name is sent so ows-royalties sets contract_term_name.""" mock_post = mocker.patch.object(config.ows_client, "post") mock_post.return_value = _mock_resp( mocker, 201, {"contract_term_id": 1, "contract_id": 555, "term_type": "product"} ) term = {**_PRODUCT_TERM, "name": "Product Term"} add_to_destination_for_each_term( destination_vendor_id=20, staged_terms=[term], ) _, kwargs = mock_post.call_args assert kwargs["json"]["name"] == "Product Term" def test_name_defaults_to_none_when_absent(mocker: MockerFixture) -> None: """A staged term with no name sends None, not a missing key.""" mock_post = mocker.patch.object(config.ows_client, "post") mock_post.return_value = _mock_resp( mocker, 201, {"contract_term_id": 1, "contract_id": 555, "term_type": "product"} ) add_to_destination_for_each_term( destination_vendor_id=20, staged_terms=[_PRODUCT_TERM], ) _, kwargs = mock_post.call_args assert kwargs["json"]["name"] is None def test_track_term_sends_isrcs(mocker: MockerFixture) -> None: mock_post = mocker.patch.object(config.ows_client, "post") mock_post.return_value = _mock_resp( mocker, 201, {"contract_term_id": 99002, "contract_id": 555, "term_type": "track"} ) result = add_to_destination_for_each_term( destination_vendor_id=20, staged_terms=[_TRACK_TERM], ) assert len(result) == 1 assert result[0]["term_type"] == "track" _, kwargs = mock_post.call_args assert kwargs["json"]["attachments"] == _ISRCS assert kwargs["json"]["term_type"] == "track" assert "transfer-create" in kwargs["path"] def test_unsupported_term_type_is_skipped(mocker: MockerFixture) -> None: mock_post = mocker.patch.object(config.ows_client, "post") result = add_to_destination_for_each_term( destination_vendor_id=20, staged_terms=[_UNSUPPORTED_TERM], ) assert result == [ { "project_transfer_term_id": 3, "contract_id": 555, "term_type": "artist", "skipped": "unsupported_term_type", } ] mock_post.assert_not_called() def test_label_term_is_skipped(mocker: MockerFixture) -> None: mock_post = mocker.patch.object(config.ows_client, "post") result = add_to_destination_for_each_term( destination_vendor_id=20, staged_terms=[_LABEL_TERM], ) assert result == [ { "project_transfer_term_id": 4, "contract_id": 555, "term_type": "label", "skipped": "unsupported_term_type", } ] mock_post.assert_not_called() def test_already_created_term_is_skipped(mocker: MockerFixture) -> None: """A staged term that already has a destination_contract_term_id (set by a prior run's write-back) is not re-created, so retries don't duplicate terms.""" mock_post = mocker.patch.object(config.ows_client, "post") term = {**_PRODUCT_TERM, "destination_contract_term_id": 99001} result = add_to_destination_for_each_term( destination_vendor_id=20, staged_terms=[term], ) assert result == [ { "project_transfer_term_id": 1, "contract_id": 555, "term_type": "product", "skipped": "already_created", } ] mock_post.assert_not_called() def test_empty_attachments_skip_term(mocker: MockerFixture) -> None: """A staged term with no attachments of its own creates nothing.""" mock_post = mocker.patch.object(config.ows_client, "post") term = {**_PRODUCT_TERM, "attachments": []} result = add_to_destination_for_each_term( destination_vendor_id=20, staged_terms=[term], ) assert result[0]["skipped"] == "no_attachments_to_add" mock_post.assert_not_called() def test_4xx_on_post_raises_permanent_error(mocker: MockerFixture) -> None: mock_post = mocker.patch.object(config.ows_client, "post") mock_post.return_value = _mock_resp(mocker, 422, text="unprocessable") with pytest.raises(PermanentError, match="422"): add_to_destination_for_each_term( destination_vendor_id=20, staged_terms=[_PRODUCT_TERM], ) def test_5xx_on_post_raises_transient_error(mocker: MockerFixture) -> None: mock_post = mocker.patch.object(config.ows_client, "post") mock_post.return_value = _mock_resp(mocker, 500, text="server error") with pytest.raises(TransientError, match="500"): add_to_destination_for_each_term( destination_vendor_id=20, staged_terms=[_PRODUCT_TERM], ) def test_product_term_includes_destination_contract_term_id(mocker: MockerFixture) -> None: mock_post = mocker.patch.object(config.ows_client, "post") mock_post.return_value = _mock_resp( mocker, 201, { "contract_term_id": 99001, "contract_id": 555, "term_type": "product", }, ) result = add_to_destination_for_each_term( destination_vendor_id=20, staged_terms=[_PRODUCT_TERM], ) assert result[0]["destination_contract_term_id"] == 99001 def test_each_term_persists_its_own_attachments(mocker: MockerFixture) -> None: """Two product terms with different attachments create two terms, each carrying only its own attachments, not the union of every term's attachments.""" mock_post = mocker.patch.object(config.ows_client, "post") mock_post.return_value = _mock_resp( mocker, 201, {"contract_term_id": 1, "contract_id": 555, "term_type": "product"} ) term_a = {**_PRODUCT_TERM, "project_transfer_term_id": 1, "attachments": ["AAA"]} term_b = {**_PRODUCT_TERM, "project_transfer_term_id": 2, "attachments": ["BBB", "CCC"]} add_to_destination_for_each_term( destination_vendor_id=20, staged_terms=[term_a, term_b], ) sent = [call.kwargs["json"]["attachments"] for call in mock_post.call_args_list] assert sent == [["AAA"], ["BBB", "CCC"]]