"""Unit testcases for ProjectTransferTerm logic.""" import datetime from decimal import Decimal from unittest.mock import MagicMock, patch from abacus_contract.tests.utils.factories import ( ContractFactory, ContractTermConditionFactory, ContractTermFactory, ) from royalties.logic import project_transfer_term as logic from royalties.tests.utils.factories import ( ProjectTransferTermConditionFactory, ProjectTransferTermFactory, ) @patch('royalties.logic.project_transfer_term.ProjectTransferTermCondition') @patch('royalties.logic.project_transfer_term.ProjectTransferTerm') def test_get_terms_for_job_returns_empty_list_when_no_terms( mock_term_model, mock_condition_model ): """No persisted terms for the given job → empty array, status 200.""" _stub_term_query(mock_term_model, []) result = logic.get_terms_for_job(job_id=99) assert result.status == 200 assert result.message == [] mock_condition_model.query.filter.assert_not_called() @patch('royalties.logic.project_transfer_term.ProjectTransferTermCondition') @patch('royalties.logic.project_transfer_term.ProjectTransferTerm') def test_get_terms_for_job_groups_conditions_under_their_term( mock_term_model, mock_condition_model ): """Conditions are nested under the right parent term.""" term_a = ProjectTransferTermFactory.build( project_transfer_term_id=1, job_id=42, term_type='product' ) term_b = ProjectTransferTermFactory.build( project_transfer_term_id=2, job_id=42, term_type='track' ) cond_a1 = ProjectTransferTermConditionFactory.build( project_transfer_term_condition_id=10, project_transfer_term_id=1, priority=1, term_rate=Decimal('80.00'), ) cond_b1 = ProjectTransferTermConditionFactory.build( project_transfer_term_condition_id=20, project_transfer_term_id=2, priority=1, term_rate=Decimal('70.00'), ) _stub_term_query(mock_term_model, [term_a, term_b]) _stub_condition_query(mock_condition_model, [cond_a1, cond_b1]) result = logic.get_terms_for_job(job_id=42) assert result.status == 200 assert len(result.message) == 2 assert result.message[0]['project_transfer_term_id'] == 1 assert len(result.message[0]['conditions']) == 1 assert ( result.message[0]['conditions'][0]['project_transfer_term_condition_id'] == 10 ) assert result.message[1]['project_transfer_term_id'] == 2 assert len(result.message[1]['conditions']) == 1 assert ( result.message[1]['conditions'][0]['project_transfer_term_condition_id'] == 20 ) def test_create_terms_for_job_returns_empty_when_input_is_empty(): """Empty input is a no-op that returns 201 with an empty array.""" result = logic.create_terms_for_job(job_id=42, terms_input=[]) assert result.status == 201 assert result.message == [] @patch('royalties.logic.project_transfer_term.db') @patch('royalties.logic.project_transfer_term.ProjectTransferTermCondition') @patch('royalties.logic.project_transfer_term.ProjectTransferTerm') def test_create_terms_for_job_persists_term_and_conditions( mock_term_model, mock_condition_model, mock_db ): """Each input term writes one term row plus one row per condition.""" built_term = ProjectTransferTermFactory.build( project_transfer_term_id=7, job_id=42, name='Greatest Hits Override', term_type='product', attachments=['196589123456'], attachment_relations={'label_ids': ['acc-1']}, ) mock_term_model.build.return_value = built_term # Real, un-persisted condition instances so the schema dump can serialize # them after build (mocked builds otherwise return MagicMocks that fail # Decimal coercion in marshmallow). cond_a = ProjectTransferTermConditionFactory.build( project_transfer_term_condition_id=11, project_transfer_term_id=7, name='Worldwide default', priority=1, term_rate=Decimal('80.00'), commission=Decimal('0.00'), conditions={}, ) cond_b = ProjectTransferTermConditionFactory.build( project_transfer_term_condition_id=12, project_transfer_term_id=7, name='US neighboring rights', priority=2, term_rate=Decimal('20.00'), commission=Decimal('5.00'), conditions={'countries': ['US']}, ) mock_condition_model.build.side_effect = [cond_a, cond_b] terms_input = [ { 'contract_id': 100, 'name': 'Greatest Hits Override', 'term_type': 'product', 'attachments': ['196589123456'], 'attachment_relations': {'label_ids': ['acc-1']}, 'conditions': [ { 'name': 'Worldwide default', 'priority': 1, 'term_rate': '80.00', 'conditions': {}, }, { 'name': 'US neighboring rights', 'priority': 2, 'term_rate': '20.00', 'commission': '5.00', 'conditions': {'countries': ['US']}, }, ], } ] result = logic.create_terms_for_job(job_id=42, terms_input=terms_input) assert result.status == 201 assert mock_term_model.build.call_count == 1 term_call = mock_term_model.build.call_args assert term_call.kwargs['job_id'] == 42 assert term_call.kwargs['contract_id'] == 100 assert term_call.kwargs['name'] == 'Greatest Hits Override' assert term_call.kwargs['term_type'] == 'product' assert term_call.kwargs['attachments'] == ['196589123456'] assert term_call.kwargs['attachment_relations'] == {'label_ids': ['acc-1']} assert 'created_by' in term_call.kwargs assert 'created_at' in term_call.kwargs assert mock_condition_model.build.call_count == 2 cond_calls = mock_condition_model.build.call_args_list assert cond_calls[0].kwargs['name'] == 'Worldwide default' # commission omitted on input → defaults to schema's load_default assert cond_calls[0].kwargs['commission'] in (0, '0.00', Decimal('0.00')) assert cond_calls[1].kwargs['name'] == 'US neighboring rights' assert cond_calls[1].kwargs['commission'] == Decimal('5.00') mock_db.session.flush.assert_called_once() mock_db.session.commit.assert_called_once() @patch('royalties.logic.project_transfer_term.db') @patch('royalties.logic.project_transfer_term.ProjectTransferTerm') def test_create_terms_for_job_rolls_back_on_error(mock_term_model, mock_db): """A DB error during build/flush triggers a rollback and re-raise.""" mock_term_model.build.side_effect = RuntimeError('db down') try: logic.create_terms_for_job( job_id=42, terms_input=[ {'contract_id': 100, 'term_type': 'product', 'conditions': []} ], ) except RuntimeError: pass else: raise AssertionError('Expected RuntimeError to propagate') mock_db.session.rollback.assert_called_once() mock_db.session.commit.assert_not_called() @patch('royalties.logic.project_transfer_term.ContractTermCondition') @patch('royalties.logic.project_transfer_term.ContractTerm') @patch('royalties.logic.project_transfer_term.db') @patch('royalties.logic.project_transfer_term.ProjectTransferTermCondition') @patch('royalties.logic.project_transfer_term.ProjectTransferTerm') def test_create_terms_for_job_expands_bare_label_input_per_active_label_term( mock_term_model, mock_condition_model, mock_db, mock_ct, mock_ctc ): """A label input with no conditions stages one row per active label term. Each staged row mirrors its contract_term (name, attachments, relations) and is pre-linked via destination_contract_term_id; each real condition is snapshotted and pre-linked via destination_contract_term_condition_id. """ label_a = MagicMock( contract_term_id=500, contract_term_name='Label Term A', attachments=['11'], attachments_relations={'label_ids': ['11']}, ) label_b = MagicMock( contract_term_id=501, contract_term_name='Label Term B', attachments=['22'], attachments_relations=None, ) mock_ct.query.filter.return_value.order_by.return_value.all.return_value = [ label_a, label_b, ] cond_a1 = MagicMock( contract_term_condition_id=900, contract_term_condition_name='Digital', priority=1, term_rate=Decimal('100.00'), commission=Decimal('0.00'), conditions={'transaction_types': [59, 103]}, ) cond_a2 = MagicMock( contract_term_condition_id=901, contract_term_condition_name='Everything else', priority=2, term_rate=Decimal('85.00'), commission=Decimal('15.00'), conditions={'transaction_types': [1, 4]}, ) cond_b1 = MagicMock( contract_term_condition_id=902, contract_term_condition_name=None, priority=1, term_rate=Decimal('90.00'), commission=Decimal('10.00'), conditions={}, ) mock_ctc.get_active_term_conditions_with_order_by.side_effect = lambda term_id: { 500: [cond_a1, cond_a2], 501: [cond_b1], }[term_id] staged_a = ProjectTransferTermFactory.build( project_transfer_term_id=7, job_id=42, contract_id=100, name='Label Term A', term_type='label', attachments=['11'], attachment_relations={'label_ids': ['11']}, destination_contract_term_id=500, ) staged_b = ProjectTransferTermFactory.build( project_transfer_term_id=8, job_id=42, contract_id=100, name='Label Term B', term_type='label', attachments=['22'], attachment_relations=None, destination_contract_term_id=501, ) mock_term_model.build.side_effect = [staged_a, staged_b] mock_condition_model.build.side_effect = [ ProjectTransferTermConditionFactory.build( project_transfer_term_condition_id=30, project_transfer_term_id=7, name='Digital', priority=1, term_rate=Decimal('100.00'), commission=Decimal('0.00'), conditions={'transaction_types': [59, 103]}, destination_contract_term_condition_id=900, ), ProjectTransferTermConditionFactory.build( project_transfer_term_condition_id=31, project_transfer_term_id=7, name='Everything else', priority=2, term_rate=Decimal('85.00'), commission=Decimal('15.00'), conditions={'transaction_types': [1, 4]}, destination_contract_term_condition_id=901, ), ProjectTransferTermConditionFactory.build( project_transfer_term_condition_id=32, project_transfer_term_id=8, name=None, priority=1, term_rate=Decimal('90.00'), commission=Decimal('10.00'), conditions={}, destination_contract_term_condition_id=902, ), ] result = logic.create_terms_for_job( job_id=42, terms_input=[{'contract_id': 100, 'term_type': 'label', 'conditions': []}], ) assert result.status == 201 assert mock_term_model.build.call_count == 2 term_calls = mock_term_model.build.call_args_list assert term_calls[0].kwargs['contract_id'] == 100 assert term_calls[0].kwargs['term_type'] == 'label' assert term_calls[0].kwargs['name'] == 'Label Term A' assert term_calls[0].kwargs['attachments'] == ['11'] assert term_calls[0].kwargs['attachment_relations'] == {'label_ids': ['11']} assert term_calls[0].kwargs['destination_contract_term_id'] == 500 assert term_calls[1].kwargs['name'] == 'Label Term B' assert term_calls[1].kwargs['destination_contract_term_id'] == 501 assert mock_condition_model.build.call_count == 3 cond_calls = mock_condition_model.build.call_args_list assert cond_calls[0].kwargs['name'] == 'Digital' assert cond_calls[0].kwargs['priority'] == 1 assert cond_calls[0].kwargs['term_rate'] == Decimal('100.00') assert cond_calls[0].kwargs['commission'] == Decimal('0.00') assert cond_calls[0].kwargs['conditions'] == {'transaction_types': [59, 103]} assert cond_calls[0].kwargs['destination_contract_term_condition_id'] == 900 assert cond_calls[1].kwargs['destination_contract_term_condition_id'] == 901 assert cond_calls[2].kwargs['project_transfer_term_id'] == 8 assert cond_calls[2].kwargs['destination_contract_term_condition_id'] == 902 assert len(result.message) == 2 assert result.message[0]['destination_contract_term_id'] == 500 assert len(result.message[0]['conditions']) == 2 assert len(result.message[1]['conditions']) == 1 @patch('royalties.logic.project_transfer_term.ContractTermCondition') @patch('royalties.logic.project_transfer_term.ContractTerm') @patch('royalties.logic.project_transfer_term.db') @patch('royalties.logic.project_transfer_term.ProjectTransferTermCondition') @patch('royalties.logic.project_transfer_term.ProjectTransferTerm') def test_create_terms_for_job_label_input_without_label_terms_stays_bare( mock_term_model, mock_condition_model, mock_db, mock_ct, mock_ctc ): """No active label terms on the contract → the bare label row as before.""" mock_ct.query.filter.return_value.order_by.return_value.all.return_value = [] staged = ProjectTransferTermFactory.build( project_transfer_term_id=7, job_id=42, contract_id=100, term_type='label', ) mock_term_model.build.return_value = staged result = logic.create_terms_for_job( job_id=42, terms_input=[{'contract_id': 100, 'term_type': 'label', 'conditions': []}], ) assert result.status == 201 assert mock_term_model.build.call_count == 1 term_call = mock_term_model.build.call_args assert term_call.kwargs.get('destination_contract_term_id') is None mock_condition_model.build.assert_not_called() mock_ctc.get_active_term_conditions_with_order_by.assert_not_called() assert len(result.message) == 1 assert result.message[0]['conditions'] == [] @patch('royalties.logic.project_transfer_term.ContractTermCondition') @patch('royalties.logic.project_transfer_term.ContractTerm') @patch('royalties.logic.project_transfer_term.db') @patch('royalties.logic.project_transfer_term.ProjectTransferTermCondition') @patch('royalties.logic.project_transfer_term.ProjectTransferTerm') def test_create_terms_for_job_label_input_with_conditions_is_not_expanded( mock_term_model, mock_condition_model, mock_db, mock_ct, mock_ctc ): """A label input with explicit conditions is persisted as sent.""" staged = ProjectTransferTermFactory.build( project_transfer_term_id=7, job_id=42, contract_id=100, term_type='label', ) mock_term_model.build.return_value = staged mock_condition_model.build.return_value = ProjectTransferTermConditionFactory.build( project_transfer_term_condition_id=30, project_transfer_term_id=7, priority=1, term_rate=Decimal('80.00'), commission=Decimal('0.00'), conditions={}, ) result = logic.create_terms_for_job( job_id=42, terms_input=[ { 'contract_id': 100, 'term_type': 'label', 'conditions': [{'priority': 1, 'term_rate': '80.00', 'conditions': {}}], } ], ) assert result.status == 201 assert mock_term_model.build.call_count == 1 assert ( mock_term_model.build.call_args.kwargs.get('destination_contract_term_id') is None ) assert mock_condition_model.build.call_count == 1 assert ( mock_condition_model.build.call_args.kwargs.get( 'destination_contract_term_condition_id' ) is None ) mock_ct.query.filter.assert_not_called() def test_create_terms_for_job_label_expansion_against_real_label_terms(): """Unmocked: the label expansion reads real contract_term rows from the DB. Exercises the actual ContractTerm/ContractTermCondition queries (soft-delete filters included), the real flush/commit, and a GET round-trip. """ contract = ContractFactory.create() label_a = ContractTermFactory.create( contract=contract, contract_term_name='Label Base', attachments=['11'], attachments_relations=None, ) label_b = ContractTermFactory.create( contract=contract, contract_term_name='Label Override' ) ContractTermFactory.create( contract=contract, contract_term_name='Deleted label term', deleted_at=datetime.datetime(2026, 1, 1), ) cond_a1 = ContractTermConditionFactory.create( contract_term=label_a, priority=1, term_rate=Decimal('100.00'), commission=Decimal('0.00'), ) cond_a2 = ContractTermConditionFactory.create( contract_term=label_a, priority=2, term_rate=Decimal('85.00'), commission=Decimal('15.00'), ) ContractTermConditionFactory.create( contract_term=label_a, priority=3, deleted_at=datetime.datetime(2026, 1, 1), ) ContractTermConditionFactory.create(contract_term=label_b, priority=1) job_id = 990001 result = logic.create_terms_for_job( job_id=job_id, terms_input=[ { 'contract_id': contract.contract_id, 'term_type': 'label', 'conditions': [], } ], ) assert result.status == 201 assert len(result.message) == 2 by_dest = {term['destination_contract_term_id']: term for term in result.message} assert set(by_dest) == {label_a.contract_term_id, label_b.contract_term_id} staged_a = by_dest[label_a.contract_term_id] assert staged_a['name'] == 'Label Base' assert staged_a['attachments'] == ['11'] assert [c['priority'] for c in staged_a['conditions']] == [1, 2] assert { c['destination_contract_term_condition_id'] for c in staged_a['conditions'] } == {cond_a1.contract_term_condition_id, cond_a2.contract_term_condition_id} assert len(by_dest[label_b.contract_term_id]['conditions']) == 1 # GET round-trip re-reads the persisted rows from the DB. fetched = logic.get_terms_for_job(job_id=job_id) assert fetched.status == 200 assert len(fetched.message) == 2 fetched_a = {t['destination_contract_term_id']: t for t in fetched.message}[ label_a.contract_term_id ] assert len(fetched_a['conditions']) == 2 assert all( c['destination_contract_term_condition_id'] is not None for c in fetched_a['conditions'] ) @patch('royalties.logic.project_transfer_term.db') @patch('royalties.logic.project_transfer_term.ProjectTransferTermCondition') @patch('royalties.logic.project_transfer_term.ProjectTransferTerm') def test_set_destination_term_id_updates_and_returns_term( mock_term_model, mock_condition_model, mock_db ): """Happy path: sets destination_contract_term_id and returns the term.""" term = MagicMock() term.project_transfer_term_id = 5 mock_term_model.query.get.return_value = term _stub_condition_query(mock_condition_model, []) result = logic.set_destination_term_id( term_id=5, destination_contract_term_id=137451 ) assert result.status == 200 term.update_attributes.assert_called_once_with(destination_contract_term_id=137451) mock_db.session.commit.assert_called_once() @patch('royalties.logic.project_transfer_term.ProjectTransferTerm') def test_set_destination_term_id_returns_404_when_term_missing(mock_term_model): """Missing term returns 404.""" mock_term_model.query.get.return_value = None result = logic.set_destination_term_id(term_id=999, destination_contract_term_id=1) assert result.status == 404 @patch('royalties.logic.project_transfer_term.db') @patch('royalties.logic.project_transfer_term.ProjectTransferTermCondition') @patch('royalties.logic.project_transfer_term.ProjectTransferTerm') def test_set_destination_term_id_rolls_back_on_error( mock_term_model, mock_condition_model, mock_db ): """DB error rolls back and re-raises.""" term = MagicMock() term.project_transfer_term_id = 5 mock_term_model.query.get.return_value = term term.update_attributes.side_effect = RuntimeError('db down') try: logic.set_destination_term_id(term_id=5, destination_contract_term_id=1) except RuntimeError: pass else: raise AssertionError('Expected RuntimeError') mock_db.session.rollback.assert_called_once() mock_db.session.commit.assert_not_called() @patch('royalties.logic.project_transfer_term.ContractTerm') def test_get_active_label_term_returns_term_when_found(mock_ct): """Returns contract_term_id and contract_id when an active label term exists.""" term = MagicMock() term.contract_term_id = 137451 term.contract_id = 556612 mock_ct.query.filter.return_value.filter.return_value.filter.return_value.order_by.return_value.first.return_value = term result = logic.get_active_label_term_for_contract(contract_id=556612) assert result.status == 200 assert result.message['contract_term_id'] == 137451 assert result.message['contract_id'] == 556612 @patch('royalties.logic.project_transfer_term.ContractTerm') def test_get_active_label_term_returns_404_when_none(mock_ct): """Returns 404 when no active label term exists for the contract.""" mock_ct.query.filter.return_value.filter.return_value.filter.return_value.order_by.return_value.first.return_value = None result = logic.get_active_label_term_for_contract(contract_id=999) assert result.status == 404 @patch('royalties.logic.project_transfer_term.ProjectTransferTermCondition') @patch('royalties.logic.project_transfer_term.create_transfer_contract_term') @patch('royalties.logic.project_transfer_term.db') @patch('royalties.logic.project_transfer_term.ProjectTransferTerm') def test_create_destination_term_creates_and_links_atomically( mock_term_model, mock_db, mock_create, mock_condition_model ): """No existing link → create the term, link the term + its conditions, one commit.""" term = MagicMock() term.destination_contract_term_id = None _stub_locked_term(mock_term_model, term) mock_create.return_value = { 'contract_term_id': 9001, 'contract_id': 100, 'term_type': 'product', 'conditions': [{'contract_term_condition_id': 5001, 'priority': 1}], } staged_cond = MagicMock() staged_cond.priority = 1 _stub_condition_query(mock_condition_model, [staged_cond]) result = logic.create_destination_term( project_transfer_term_id=7, contract_id=100, term_type='product', attachments=['196589123456'], conditions=[{'conditions': {}, 'term_rate': '80.00', 'priority': 1}], ) assert result.status == 201 assert result.message['contract_term_id'] == 9001 # Locked the staged row, created without its own commit, linked term + condition. mock_term_model.query.with_for_update.assert_called_once() assert mock_create.call_args.kwargs['commit'] is False term.update_attributes.assert_called_once_with(destination_contract_term_id=9001) staged_cond.update_attributes.assert_called_once_with( destination_contract_term_condition_id=5001 ) mock_db.session.commit.assert_called_once() @patch('royalties.logic.project_transfer_term.create_transfer_contract_term') @patch('royalties.logic.project_transfer_term.db') @patch('royalties.logic.project_transfer_term.ProjectTransferTerm') def test_create_destination_term_is_idempotent_when_already_linked( mock_term_model, mock_db, mock_create ): """Already linked → return it (200) without creating a duplicate or committing.""" term = MagicMock() term.destination_contract_term_id = 137451 _stub_locked_term(mock_term_model, term) result = logic.create_destination_term( project_transfer_term_id=7, contract_id=100, term_type='product', attachments=['196589123456'], conditions=[], ) assert result.status == 200 assert result.message['contract_term_id'] == 137451 assert result.message['idempotent'] is True mock_create.assert_not_called() mock_db.session.commit.assert_not_called() @patch('royalties.logic.project_transfer_term.ProjectTransferTerm') def test_create_destination_term_returns_404_when_term_missing(mock_term_model): """Unknown staged term → 404.""" _stub_locked_term(mock_term_model, None) result = logic.create_destination_term( project_transfer_term_id=999, contract_id=100, term_type='product', attachments=['x'], conditions=[], ) assert result.status == 404 @patch('royalties.logic.project_transfer_term.create_transfer_contract_term') @patch('royalties.logic.project_transfer_term.db') @patch('royalties.logic.project_transfer_term.ProjectTransferTerm') def test_create_destination_term_rolls_back_on_error( mock_term_model, mock_db, mock_create ): """A failure while linking rolls back and re-raises (no partial commit).""" term = MagicMock() term.destination_contract_term_id = None _stub_locked_term(mock_term_model, term) mock_create.return_value = { 'contract_term_id': 9001, 'contract_id': 100, 'term_type': 'product', 'conditions': [], } term.update_attributes.side_effect = RuntimeError('db down') try: logic.create_destination_term( project_transfer_term_id=7, contract_id=100, term_type='product', attachments=['x'], conditions=[], ) except RuntimeError: pass else: raise AssertionError('Expected RuntimeError') mock_db.session.rollback.assert_called_once() mock_db.session.commit.assert_not_called() def _stub_locked_term(mock_term_model, term): """Wire Term.query.with_for_update().filter(...).first() to return `term`.""" mock_term_model.query.with_for_update.return_value.filter.return_value.first.return_value = term def _stub_term_query(mock_term_model, terms): """Wire the chained Term.query.filter(...).all() to return `terms`.""" chain = MagicMock() chain.all.return_value = terms mock_term_model.query.filter.return_value = chain def _stub_condition_query(mock_condition_model, conditions): """Wire the chained Condition.query.filter(...).all() to return `conditions`.""" chain = MagicMock() chain.all.return_value = conditions mock_condition_model.query.filter.return_value = chain