"""Tests for contract term transfer logic — bulk add, bulk remove, and create.""" from unittest.mock import MagicMock, patch import pytest from abacus_contract.constants.constants import CONTRACT_TERM_TYPES as TYPES from abacus_contract.logic import contract_term_transfer as logic def _make_term(term_id, contract_id, term_type, attachments): """Build a mock ContractTerm with the given attributes.""" term = MagicMock() term.contract_term_id = term_id term.contract_id = contract_id term.term_type = term_type term.attachments = attachments term.deleted_at = None return term @pytest.fixture def mock_db(): """Mock the db session used in contract_term_transfer logic.""" with patch('abacus_contract.logic.contract_term_transfer.db') as db: yield db @pytest.fixture def mock_term_query(): """Mock the ContractTerm model used in contract_term_transfer logic.""" with patch('abacus_contract.logic.contract_term_transfer.ContractTerm') as ct: yield ct @pytest.fixture def mock_condition_model(): """Mock the ContractTermCondition model used in contract_term_transfer logic.""" with patch( 'abacus_contract.logic.contract_term_transfer.ContractTermCondition' ) as ctc: yield ctc def _stub_account_terms(mock_term_query, terms): """Wire the chained query for bulk_remove (account-scoped join).""" ( mock_term_query.query.join.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.all.return_value ) = terms def _stub_contract_term(mock_term_query, term): """Wire the chained query for _find_active_term (contract-scoped, returns first).""" ( mock_term_query.query.filter.return_value.filter.return_value.filter.return_value.first.return_value ) = term class TestBulkRemove: """Tests for bulk_remove_from_contract_terms.""" def test_removes_upcs_from_product_terms(self, mock_db, mock_term_query): """Removes specified UPCs from product term attachments.""" term = _make_term(10, 1, TYPES.PRODUCT, ['111', '222', '333']) _stub_account_terms(mock_term_query, [term]) result = logic.bulk_remove_from_contract_terms( account_id=456, upcs=['222', '333'], isrcs=[] ) term.update_attributes.assert_called_once_with(attachments=['111']) mock_db.session.commit.assert_called_once() assert result['total_removed'] == 1 assert result['updated_terms'][0]['removed_attachments'] == ['222', '333'] def test_removes_isrcs_from_track_terms(self, mock_db, mock_term_query): """Removes specified ISRCs from track term attachments.""" term = _make_term(20, 1, TYPES.TRACK, ['USRC17607839', 'GBUM71507104']) _stub_account_terms(mock_term_query, [term]) result = logic.bulk_remove_from_contract_terms( account_id=456, upcs=[], isrcs=['USRC17607839'] ) term.update_attributes.assert_called_once_with(attachments=['GBUM71507104']) assert result['updated_terms'][0]['removed_attachments'] == ['USRC17607839'] def test_soft_deletes_term_when_all_attachments_removed( self, mock_db, mock_term_query ): """Soft-deletes the term when removing empties its attachments list.""" term = _make_term(10, 1, TYPES.PRODUCT, ['222', '333']) _stub_account_terms(mock_term_query, [term]) result = logic.bulk_remove_from_contract_terms( account_id=456, upcs=['222', '333'], isrcs=[] ) term._soft_delete.assert_called_once() term.update_attributes.assert_not_called() assert result['updated_terms'][0]['soft_deleted'] is True assert result['updated_terms'][0]['remaining_attachments'] == [] def test_skips_terms_with_no_matching_values(self, mock_db, mock_term_query): """Terms whose attachments don't overlap with the removal set are untouched.""" term = _make_term(10, 1, TYPES.PRODUCT, ['111', '555']) _stub_account_terms(mock_term_query, [term]) result = logic.bulk_remove_from_contract_terms( account_id=456, upcs=['999'], isrcs=[] ) term.update_attributes.assert_not_called() term._soft_delete.assert_not_called() mock_db.session.commit.assert_not_called() assert result['total_removed'] == 0 def test_no_terms_found_returns_empty_result(self, mock_db, mock_term_query): """Returns zero-removed result when no active terms exist for the account.""" _stub_account_terms(mock_term_query, []) result = logic.bulk_remove_from_contract_terms( account_id=456, upcs=['222'], isrcs=[] ) mock_db.session.commit.assert_not_called() assert result['total_removed'] == 0 assert result['updated_terms'] == [] def test_handles_multiple_terms_in_single_transaction( self, mock_db, mock_term_query ): """Commits once even when multiple terms are updated.""" term1 = _make_term(10, 1, TYPES.PRODUCT, ['111', '222']) term2 = _make_term(20, 2, TYPES.TRACK, ['USRC17607839', 'GBUM71507104']) _stub_account_terms(mock_term_query, [term1, term2]) result = logic.bulk_remove_from_contract_terms( account_id=456, upcs=['222'], isrcs=['USRC17607839'] ) term1.update_attributes.assert_called_once_with(attachments=['111']) term2.update_attributes.assert_called_once_with(attachments=['GBUM71507104']) mock_db.session.commit.assert_called_once() assert result['total_removed'] == 2 def test_rolls_back_on_db_error(self, mock_db, mock_term_query): """Rolls back the transaction and re-raises on any DB error.""" term = _make_term(10, 1, TYPES.PRODUCT, ['222', '333']) term.update_attributes.side_effect = Exception('DB error') _stub_account_terms(mock_term_query, [term]) with pytest.raises(Exception, match='DB error'): logic.bulk_remove_from_contract_terms( account_id=456, upcs=['222'], isrcs=[] ) mock_db.session.rollback.assert_called_once() mock_db.session.commit.assert_not_called() def test_raises_value_error_when_both_lists_empty(self, mock_db, mock_term_query): """Raises ValueError immediately when both upcs and isrcs are empty.""" with pytest.raises(ValueError, match='at least one'): logic.bulk_remove_from_contract_terms(account_id=456, upcs=[], isrcs=[]) def test_isrcs_not_removed_from_product_terms(self, mock_db, mock_term_query): """ISRCs only match track terms; product terms are not modified.""" term = _make_term(10, 1, TYPES.PRODUCT, ['USRC17607839']) _stub_account_terms(mock_term_query, [term]) result = logic.bulk_remove_from_contract_terms( account_id=456, upcs=[], isrcs=['USRC17607839'] ) term.update_attributes.assert_not_called() assert result['total_removed'] == 0 def test_upcs_not_removed_from_track_terms(self, mock_db, mock_term_query): """UPCs only match product terms; track terms are not modified.""" term = _make_term(20, 2, TYPES.TRACK, ['111']) _stub_account_terms(mock_term_query, [term]) result = logic.bulk_remove_from_contract_terms( account_id=456, upcs=['111'], isrcs=[] ) term.update_attributes.assert_not_called() assert result['total_removed'] == 0 class TestBulkAdd: """Tests for bulk_add_to_contract_terms.""" def test_merges_upcs_into_existing_product_term(self, mock_db, mock_term_query): """Merges incoming UPCs into an existing product term's attachments.""" existing = _make_term(10, 99, TYPES.PRODUCT, ['111', '222']) _stub_contract_term(mock_term_query, existing) result = logic.bulk_add_to_contract_terms( account_id=456, contract_id=99, upcs=['222', '333'], isrcs=[], conditions=[] ) existing.update_attributes.assert_called_once_with( attachments=['111', '222', '333'] ) mock_db.session.commit.assert_called_once() assert result['total_modified'] == 1 assert result['updated_terms'][0]['added_values'] == ['222', '333'] assert result['created_terms'] == [] def test_merges_isrcs_into_existing_track_term(self, mock_db, mock_term_query): """Merges incoming ISRCs into an existing track term's attachments.""" existing = _make_term(20, 99, TYPES.TRACK, ['ISRC1']) _stub_contract_term(mock_term_query, existing) result = logic.bulk_add_to_contract_terms( account_id=456, contract_id=99, upcs=[], isrcs=['ISRC1', 'ISRC2'], conditions=[], ) existing.update_attributes.assert_called_once_with( attachments=['ISRC1', 'ISRC2'] ) assert result['total_modified'] == 1 assert result['updated_terms'][0]['term_type'] == TYPES.TRACK def test_creates_new_product_term_when_none_exists( self, mock_db, mock_term_query, mock_condition_model ): """Creates a new product term with conditions when no active term exists.""" _stub_contract_term(mock_term_query, None) new_term = _make_term(11, 99, TYPES.PRODUCT, ['111']) mock_term_query.build.return_value = new_term conditions = [{'conditions': {}, 'term_rate': 80.0, 'priority': 1}] result = logic.bulk_add_to_contract_terms( account_id=456, contract_id=99, upcs=['111'], isrcs=[], conditions=conditions, ) mock_term_query.build.assert_called_once_with( contract_id=99, term_type=TYPES.PRODUCT, attachments=['111'], attachments_relations=None, contract_term_name=None, ) mock_condition_model.build.assert_called_once_with( contract_term_id=11, conditions={}, term_rate=80.0, commission=20.0, priority=1, ) mock_db.session.flush.assert_called_once() mock_db.session.commit.assert_called_once() assert result['total_modified'] == 1 assert result['created_terms'][0]['term_id'] == 11 assert result['updated_terms'] == [] def test_creates_new_track_term_when_none_exists( self, mock_db, mock_term_query, mock_condition_model ): """Creates a new track term when no active term exists for the contract.""" _stub_contract_term(mock_term_query, None) new_term = _make_term(21, 99, TYPES.TRACK, ['ISRC1']) mock_term_query.build.return_value = new_term result = logic.bulk_add_to_contract_terms( account_id=456, contract_id=99, upcs=[], isrcs=['ISRC1'], conditions=[] ) mock_term_query.build.assert_called_once_with( contract_id=99, term_type=TYPES.TRACK, attachments=['ISRC1'], attachments_relations=None, contract_term_name=None, ) assert result['total_modified'] == 1 assert result['created_terms'][0]['term_type'] == TYPES.TRACK def test_no_duplicate_values_when_merging(self, mock_db, mock_term_query): """Duplicate UPCs/ISRCs are discarded when merging into existing attachments.""" existing = _make_term(10, 99, TYPES.PRODUCT, ['111', '222']) _stub_contract_term(mock_term_query, existing) logic.bulk_add_to_contract_terms( account_id=456, contract_id=99, upcs=['111', '333'], isrcs=[], conditions=[] ) existing.update_attributes.assert_called_once_with( attachments=['111', '222', '333'] ) def test_rolls_back_on_db_error(self, mock_db, mock_term_query): """Rolls back the transaction and re-raises on any DB error.""" existing = _make_term(10, 99, TYPES.PRODUCT, ['111']) existing.update_attributes.side_effect = Exception('DB error') _stub_contract_term(mock_term_query, existing) with pytest.raises(Exception, match='DB error'): logic.bulk_add_to_contract_terms( account_id=456, contract_id=99, upcs=['222'], isrcs=[], conditions=[] ) mock_db.session.rollback.assert_called_once() mock_db.session.commit.assert_not_called() def test_raises_value_error_when_both_lists_empty(self, mock_db, mock_term_query): """Raises ValueError immediately when both upcs and isrcs are empty.""" with pytest.raises(ValueError, match='at least one'): logic.bulk_add_to_contract_terms( account_id=456, contract_id=99, upcs=[], isrcs=[], conditions=[] ) def test_creates_multiple_conditions( self, mock_db, mock_term_query, mock_condition_model ): """Creates one ContractTermCondition row per entry in the conditions list.""" _stub_contract_term(mock_term_query, None) new_term = _make_term(11, 99, TYPES.PRODUCT, ['111']) mock_term_query.build.return_value = new_term conditions = [ {'conditions': {'territory': 'US'}, 'term_rate': 75.0, 'priority': 1}, {'conditions': {'territory': 'EU'}, 'term_rate': 70.0, 'priority': 2}, ] logic.bulk_add_to_contract_terms( account_id=456, contract_id=99, upcs=['111'], isrcs=[], conditions=conditions, ) assert mock_condition_model.build.call_count == 2 def test_handles_both_upcs_and_isrcs( self, mock_db, mock_term_query, mock_condition_model ): """Processes both UPCs and ISRCs in a single transaction.""" product_term = _make_term(10, 99, TYPES.PRODUCT, ['111']) track_term = _make_term(20, 99, TYPES.TRACK, ['ISRC1']) ( mock_term_query.query.filter.return_value.filter.return_value.filter.return_value.first.side_effect ) = [product_term, track_term] result = logic.bulk_add_to_contract_terms( account_id=456, contract_id=99, upcs=['222'], isrcs=['ISRC2'], conditions=[] ) assert result['total_modified'] == 2 assert len(result['updated_terms']) == 2 assert result['created_terms'] == [] def test_sets_attachment_relations_on_created_product_term( self, mock_db, mock_term_query, mock_condition_model ): """Persists attachment_relations (label scoping) on a newly created term. The transfer UI attaches the destination account as a label id; the contract detail page reads attachments_relations.label_ids to display the account on the term, so it must be stored on the created term. """ _stub_contract_term(mock_term_query, None) new_term = _make_term(11, 99, TYPES.PRODUCT, ['111']) mock_term_query.build.return_value = new_term logic.bulk_add_to_contract_terms( account_id=456, contract_id=99, upcs=['111'], isrcs=[], conditions=[], attachment_relations={'label_ids': ['789']}, ) mock_term_query.build.assert_called_once_with( contract_id=99, term_type=TYPES.PRODUCT, attachments=['111'], attachments_relations={'label_ids': ['789']}, contract_term_name=None, ) def test_merge_does_not_overwrite_existing_attachment_relations( self, mock_db, mock_term_query ): """Merging into an existing term leaves its attachments_relations intact. An existing destination term already carries its own label scoping; adding more attachments must not clobber it. """ existing = _make_term(10, 99, TYPES.PRODUCT, ['111']) _stub_contract_term(mock_term_query, existing) logic.bulk_add_to_contract_terms( account_id=456, contract_id=99, upcs=['222'], isrcs=[], conditions=[], attachment_relations={'label_ids': ['789']}, ) existing.update_attributes.assert_called_once_with(attachments=['111', '222']) def test_sets_contract_term_name_on_created_term( self, mock_db, mock_term_query, mock_condition_model ): """Persists the staged term name as contract_term_name on a new term. The staged transfer term carries a name; without forwarding it the created term has no name, unlike a manually added term. """ _stub_contract_term(mock_term_query, None) new_term = _make_term(11, 99, TYPES.PRODUCT, ['111']) mock_term_query.build.return_value = new_term logic.bulk_add_to_contract_terms( account_id=456, contract_id=99, upcs=['111'], isrcs=[], conditions=[], name='Product Term', ) mock_term_query.build.assert_called_once_with( contract_id=99, term_type=TYPES.PRODUCT, attachments=['111'], attachments_relations=None, contract_term_name='Product Term', ) class TestCreateTransferTerm: """Tests for create_transfer_contract_term.""" def test_always_creates_new_term( self, mock_db, mock_term_query, mock_condition_model ): """Always builds a new ContractTerm regardless of existing terms.""" new_term = _make_term(55, 99, TYPES.PRODUCT, ['111', '222']) mock_term_query.build.return_value = new_term result = logic.create_transfer_contract_term( contract_id=99, term_type=TYPES.PRODUCT, attachments=['111', '222'], conditions=[], ) mock_term_query.build.assert_called_once_with( contract_id=99, term_type=TYPES.PRODUCT, attachments=['111', '222'], attachments_relations=None, contract_term_name=None, ) assert mock_db.session.flush.called mock_db.session.commit.assert_called_once() assert result == { 'contract_term_id': 55, 'contract_id': 99, 'term_type': TYPES.PRODUCT, 'conditions': [], } def test_commit_false_flushes_but_does_not_commit( self, mock_db, mock_term_query, mock_condition_model ): """commit=False flushes (so the id is available) but does not commit.""" new_term = _make_term(55, 99, TYPES.PRODUCT, ['111']) mock_term_query.build.return_value = new_term result = logic.create_transfer_contract_term( contract_id=99, term_type=TYPES.PRODUCT, attachments=['111'], conditions=[], commit=False, ) assert mock_db.session.flush.called mock_db.session.commit.assert_not_called() assert result['contract_term_id'] == 55 def test_calls_create_conditions_with_new_term_id( self, mock_db, mock_term_query, mock_condition_model ): """Passes the new term's ID and conditions list to _create_conditions.""" new_term = _make_term(77, 99, TYPES.TRACK, ['ISRC1']) mock_term_query.build.return_value = new_term conditions = [{'conditions': {}, 'term_rate': 80.0, 'priority': 1}] logic.create_transfer_contract_term( contract_id=99, term_type=TYPES.TRACK, attachments=['ISRC1'], conditions=conditions, ) mock_condition_model.build.assert_called_once_with( contract_term_id=77, conditions={}, term_rate=80.0, commission=20.0, priority=1, ) def test_rolls_back_on_db_error(self, mock_db, mock_term_query): """Rolls back the transaction and re-raises on any DB error.""" mock_db.session.flush.side_effect = Exception('flush failed') new_term = _make_term(55, 99, TYPES.PRODUCT, ['111']) mock_term_query.build.return_value = new_term with pytest.raises(Exception, match='flush failed'): logic.create_transfer_contract_term( contract_id=99, term_type=TYPES.PRODUCT, attachments=['111'], conditions=[], ) mock_db.session.rollback.assert_called_once() mock_db.session.commit.assert_not_called() def test_returns_contract_term_id_contract_id_term_type( self, mock_db, mock_term_query, mock_condition_model ): """Result dict contains exactly contract_term_id, contract_id, term_type.""" new_term = _make_term(42, 7, TYPES.TRACK, ['ISRCX']) mock_term_query.build.return_value = new_term result = logic.create_transfer_contract_term( contract_id=7, term_type=TYPES.TRACK, attachments=['ISRCX'], conditions=[], ) assert result['contract_term_id'] == 42 assert result['contract_id'] == 7 assert result['term_type'] == TYPES.TRACK assert set(result.keys()) == { 'contract_term_id', 'contract_id', 'term_type', 'conditions', } def test_returns_created_condition_id_priority_mapping( self, mock_db, mock_term_query, mock_condition_model ): """Return includes each created condition's id + priority for write-back linking.""" new_term = _make_term(55, 99, TYPES.PRODUCT, ['111']) mock_term_query.build.return_value = new_term cond = MagicMock() cond.contract_term_condition_id = 7001 cond.priority = 1 mock_condition_model.build.return_value = cond result = logic.create_transfer_contract_term( contract_id=99, term_type=TYPES.PRODUCT, attachments=['111'], conditions=[{'conditions': {}, 'term_rate': 80.0, 'priority': 1}], ) assert result['conditions'] == [ {'contract_term_condition_id': 7001, 'priority': 1} ]