"""Unit tests for signing_entity_sap_profit_center logic.""" from unittest.mock import MagicMock, patch import pytest from marshmallow import ValidationError from sqlalchemy.exc import SQLAlchemyError from werkzeug.exceptions import HTTPException from abacus_contract import models from abacus_contract.logic import signing_entity_sap_profit_center as logic from abacus_contract.tests.utils.factories import ( AccountContractFactory, ContractFactory, ReferenceSapProfitCenterFactory, ReferenceSigningEntityFactory, SigningEntitySapProfitCenterFactory, ) def test_create_signing_entity_sap_profit_center_succeeds(): """Valid SE + PC -> 201 with the new junction row.""" se = ReferenceSigningEntityFactory.create() pc = ReferenceSapProfitCenterFactory.create() result = logic.create_signing_entity_sap_profit_center( reference_signing_entity_id=se.reference_signing_entity_id, reference_sap_profit_center_id=pc.reference_sap_profit_center_id, ) assert result.status == 201 assert ( result.message['reference_signing_entity_id'] == se.reference_signing_entity_id ) assert ( result.message['reference_sap_profit_center_id'] == pc.reference_sap_profit_center_id ) assert result.message['signing_entity_sap_profit_center_id'] is not None assert result.message['deleted_at'] is None def test_create_signing_entity_sap_profit_center_duplicate_live_returns_409(): """A live (SE, PC) row already exists -> 409.""" existing = SigningEntitySapProfitCenterFactory.create() result = logic.create_signing_entity_sap_profit_center( reference_signing_entity_id=existing.reference_signing_entity_id, reference_sap_profit_center_id=existing.reference_sap_profit_center_id, ) assert result.status == 409 def test_create_signing_entity_sap_profit_center_soft_deleted_match_is_restored(): """A soft-deleted matching row is restored in place (no new row created).""" se = ReferenceSigningEntityFactory.create() pc = ReferenceSapProfitCenterFactory.create() old_row = SigningEntitySapProfitCenterFactory.create( reference_signing_entity=se, reference_sap_profit_center=pc ) old_id = old_row.signing_entity_sap_profit_center_id models.SigningEntitySapProfitCenter.delete_by_id_or_error(old_id, soft_delete=True) result = logic.create_signing_entity_sap_profit_center( reference_signing_entity_id=se.reference_signing_entity_id, reference_sap_profit_center_id=pc.reference_sap_profit_center_id, ) assert result.status == 201 # Same physical row, restored (deleted_at cleared). assert result.message['signing_entity_sap_profit_center_id'] == old_id refreshed = models.SigningEntitySapProfitCenter.query.get(old_id) assert refreshed.deleted_at is None assert refreshed.deleted_by is None def test_create_signing_entity_sap_profit_center_concurrent_insert_returns_409( monkeypatch, ): """Race: another transaction inserts the same (SE, PC) between our SELECT and INSERT. Simulates by making the existence SELECT return ``None`` while a conflicting row is in the DB. The INSERT then hits the UNIQUE index, which we translate to 409 instead of letting the IntegrityError bubble up as a 500. """ se = ReferenceSigningEntityFactory.create() pc = ReferenceSapProfitCenterFactory.create() # Pre-existing row that will trigger the UNIQUE violation on INSERT. SigningEntitySapProfitCenterFactory.create( reference_signing_entity=se, reference_sap_profit_center=pc ) fake_query = MagicMock() fake_query.filter_by.return_value.first.return_value = None monkeypatch.setattr(models.SigningEntitySapProfitCenter, 'query', fake_query) result = logic.create_signing_entity_sap_profit_center( reference_signing_entity_id=se.reference_signing_entity_id, reference_sap_profit_center_id=pc.reference_sap_profit_center_id, ) assert result.status == 409 def test_create_signing_entity_sap_profit_center_unknown_se_raises_400(): """Non-existent SE -> framework abort with status 400.""" pc = ReferenceSapProfitCenterFactory.create() with pytest.raises(HTTPException) as exc_info: logic.create_signing_entity_sap_profit_center( reference_signing_entity_id=99999999, reference_sap_profit_center_id=pc.reference_sap_profit_center_id, ) assert exc_info.value.code == 400 def test_create_signing_entity_sap_profit_center_unknown_pc_raises_400(): """Non-existent PC -> framework abort with status 400.""" se = ReferenceSigningEntityFactory.create() with pytest.raises(HTTPException) as exc_info: logic.create_signing_entity_sap_profit_center( reference_signing_entity_id=se.reference_signing_entity_id, reference_sap_profit_center_id=99999999, ) assert exc_info.value.code == 400 def test_delete_signing_entity_sap_profit_center_no_contracts_succeeds(): """No contracts reference the pair -> 204 and row is soft-deleted.""" junction = SigningEntitySapProfitCenterFactory.create() result = logic.delete_signing_entity_sap_profit_center( junction.signing_entity_sap_profit_center_id ) assert result.status == 204 refreshed = models.SigningEntitySapProfitCenter.query.get( junction.signing_entity_sap_profit_center_id ) assert refreshed.deleted_at is not None def test_delete_signing_entity_sap_profit_center_with_active_contract_returns_409( create_mock_account, ): """A contract still references the (SE, PC) -> 409 and row is not modified.""" se = ReferenceSigningEntityFactory.create() pc = ReferenceSapProfitCenterFactory.create() junction = SigningEntitySapProfitCenterFactory.create( reference_signing_entity=se, reference_sap_profit_center=pc ) contract = ContractFactory.create( reference_signing_entity=se, reference_sap_profit_center_id=pc.reference_sap_profit_center_id, ) AccountContractFactory.create(contract=contract) result = logic.delete_signing_entity_sap_profit_center( junction.signing_entity_sap_profit_center_id ) assert result.status == 409 refreshed = models.SigningEntitySapProfitCenter.query.get( junction.signing_entity_sap_profit_center_id ) assert refreshed.deleted_at is None def test_delete_signing_entity_sap_profit_center_not_found_returns_404(): """Unknown junction id -> 404 response.""" result = logic.delete_signing_entity_sap_profit_center(99999999) assert result.status == 404 def test_delete_signing_entity_sap_profit_center_already_soft_deleted_returns_404(): """Re-deleting an already-soft-deleted row -> 404 response.""" junction = SigningEntitySapProfitCenterFactory.create() models.SigningEntitySapProfitCenter.delete_by_id_or_error( junction.signing_entity_sap_profit_center_id, soft_delete=True ) result = logic.delete_signing_entity_sap_profit_center( junction.signing_entity_sap_profit_center_id ) assert result.status == 404 @patch( 'abacus_contract.logic.signing_entity_sap_profit_center.models.ReferenceSigningEntity.get_by_ids' ) def test_validate_signing_entities_success(mock_get_by_ids): """Test that validation passes successfully when all IDs exist in the database.""" mock_signing_entity_ids = [1, 2, 3] mock_get_by_ids.return_value = [ {'reference_signing_entity_id': 1}, {'reference_signing_entity_id': 2}, {'reference_signing_entity_id': 3}, ] result = logic._validate_signing_entities_are_valid(mock_signing_entity_ids) assert result is True mock_get_by_ids.assert_called_once_with(mock_signing_entity_ids) @patch( 'abacus_contract.logic.signing_entity_sap_profit_center.models.ReferenceSigningEntity.get_by_ids' ) def test_validate_signing_entities_raises_validation_error(mock_get_by_ids): """Test that a ValidationError is raised if any ID is missing from the database.""" mock_signing_entity_ids = [1, 2, 3] mock_get_by_ids.return_value = [ {'reference_signing_entity_id': 1}, {'reference_signing_entity_id': 2}, ] with pytest.raises(ValidationError) as exc_info: logic._validate_signing_entities_are_valid(mock_signing_entity_ids) assert ( str(exc_info.value) == 'One or more reference_signing_entity_ids are invalid.' ) mock_get_by_ids.assert_called_once_with(mock_signing_entity_ids) @patch( 'abacus_contract.logic.signing_entity_sap_profit_center.models.ReferenceSigningEntity.get_by_ids' ) def test_validate_signing_entities_empty_list(mock_get_by_ids): """Test that validation passes cleanly if an empty list is provided.""" mock_signing_entity_ids = [] mock_get_by_ids.return_value = [] result = logic._validate_signing_entities_are_valid(mock_signing_entity_ids) assert result is True mock_get_by_ids.assert_not_called() def test_bulk_associate_empty_signing_entity_ids(): """Should return an empty response with status 201 immediately if input list is empty.""" res = logic.bulk_associate_signing_entities( sap_profit_center_id=10, signing_entity_ids=[] ) assert res.status == 201 assert res.message == [] @patch( 'abacus_contract.logic.signing_entity_sap_profit_center.models.ReferenceSapProfitCenter.get_by_id_or_error' ) @patch( 'abacus_contract.logic.signing_entity_sap_profit_center.models.SigningEntitySapProfitCenter.get_by_profit_center_and_signing_entities' ) @patch( 'abacus_contract.logic.signing_entity_sap_profit_center._validate_signing_entities_are_valid' ) @patch( 'abacus_contract.logic.signing_entity_sap_profit_center.models.SigningEntitySapProfitCenter.build' ) @patch('abacus_contract.logic.signing_entity_sap_profit_center.db.session') def test_bulk_associate_success_mixed_insert_and_restore( mock_session, mock_build, mock_validate, mock_get_existing, mock_get_pc ): """Should successfully restore soft-deleted rows and insert brand new rows.""" mock_signing_entities = [ ReferenceSigningEntityFactory.create(with_junction_row=False), ReferenceSigningEntityFactory.create(with_junction_row=False), ReferenceSigningEntityFactory.create(with_junction_row=False), ] mock_profit_center = ReferenceSapProfitCenterFactory.create( display_name='Test Profit Center 1' ) junction_rows = [ SigningEntitySapProfitCenterFactory.create( reference_signing_entity=mock_signing_entities[0], reference_sap_profit_center=mock_profit_center, deleted_by='Test User', deleted_at='2026-07-07', ), SigningEntitySapProfitCenterFactory.create( reference_signing_entity=mock_signing_entities[1], reference_sap_profit_center=mock_profit_center, deleted_by='Test User', deleted_at='2026-07-07', ), ] signing_entity_ids = [ signing_entity.reference_signing_entity_id for signing_entity in mock_signing_entities ] mock_sap_profit_center_id = mock_profit_center.reference_sap_profit_center_id mock_get_existing.return_value = junction_rows new_se_sp = SigningEntitySapProfitCenterFactory.create( reference_signing_entity=mock_signing_entities[2], reference_sap_profit_center=mock_profit_center, ) mock_build.return_value = new_se_sp res = logic.bulk_associate_signing_entities( sap_profit_center_id=mock_sap_profit_center_id, signing_entity_ids=signing_entity_ids, ) assert res.status == 201 mock_get_pc.assert_called_once_with(mock_sap_profit_center_id, error_status=404) mock_get_existing.assert_called_once_with( mock_sap_profit_center_id, signing_entity_ids ) mock_validate.assert_called_once_with(signing_entity_ids[2:3]) mock_build.assert_called_once_with( reference_signing_entity_id=signing_entity_ids[2], reference_sap_profit_center_id=mock_sap_profit_center_id, ) assert junction_rows[1].deleted_at is None assert junction_rows[1].deleted_by is None mock_session.flush.assert_called_once() mock_session.commit.assert_called_once() @patch( 'abacus_contract.logic.signing_entity_sap_profit_center.models.ReferenceSapProfitCenter.get_by_id_or_error' ) @patch( 'abacus_contract.logic.signing_entity_sap_profit_center.models.SigningEntitySapProfitCenter.get_by_profit_center_and_signing_entities' ) @patch( 'abacus_contract.logic.signing_entity_sap_profit_center._validate_signing_entities_are_valid' ) @patch( 'abacus_contract.logic.signing_entity_sap_profit_center.models.SigningEntitySapProfitCenter.build' ) @patch('abacus_contract.logic.signing_entity_sap_profit_center.db.session') def test_bulk_associate_conflict_active_row_exists( mock_session, mock_build, mock_validate, mock_get_existing, mock_get_pc ): """Should return a 409 error response when an active relationship already exists.""" mock_signing_entities = [ ReferenceSigningEntityFactory.create(with_junction_row=False), ReferenceSigningEntityFactory.create(with_junction_row=False), ] mock_profit_center = ReferenceSapProfitCenterFactory.create( display_name='Test Profit Center 1' ) junction_rows = [ SigningEntitySapProfitCenterFactory.create( reference_signing_entity=mock_signing_entities[0], reference_sap_profit_center=mock_profit_center, ) ] signing_entity_ids = [ signing_entity.reference_signing_entity_id for signing_entity in mock_signing_entities ] mock_sap_profit_center_id = mock_profit_center.reference_sap_profit_center_id mock_get_existing.return_value = junction_rows res = logic.bulk_associate_signing_entities( sap_profit_center_id=mock_sap_profit_center_id, signing_entity_ids=signing_entity_ids, ) assert res.status == 409 mock_get_pc.assert_called_once_with(mock_sap_profit_center_id, error_status=404) mock_get_existing.assert_called_once_with( mock_sap_profit_center_id, signing_entity_ids ) mock_validate.assert_not_called() mock_build.assert_not_called() mock_session.flush.assert_not_called() mock_session.commit.assert_not_called() @patch( 'abacus_contract.logic.signing_entity_sap_profit_center.models.ReferenceSapProfitCenter.get_by_id_or_error' ) @patch( 'abacus_contract.logic.signing_entity_sap_profit_center.models.SigningEntitySapProfitCenter.get_by_profit_center_and_signing_entities' ) @patch( 'abacus_contract.logic.signing_entity_sap_profit_center._validate_signing_entities_are_valid' ) @patch( 'abacus_contract.logic.signing_entity_sap_profit_center.models.SigningEntitySapProfitCenter.build' ) @patch('abacus_contract.logic.signing_entity_sap_profit_center.db.session') def test_bulk_associate_validation_error_propagates( mock_session, mock_build, mock_validate, mock_get_existing, mock_get_pc ): """Should return validation_error payload if _validate_signing_entities_are_valid throws a ValidationError.""" signing_entity_ids = [1, 2] mock_sap_profit_center_id = 1 mock_get_existing.return_value = [] mock_validate.side_effect = ValidationError("Signing entity doesn't exist.") res = logic.bulk_associate_signing_entities( sap_profit_center_id=mock_sap_profit_center_id, signing_entity_ids=signing_entity_ids, ) assert res.status == 400 mock_get_pc.assert_called_once_with(mock_sap_profit_center_id, error_status=404) mock_get_existing.assert_called_once_with( mock_sap_profit_center_id, signing_entity_ids ) mock_validate.assert_called_once_with(signing_entity_ids) mock_build.assert_not_called() mock_session.flush.assert_not_called() mock_session.commit.assert_not_called() @patch( 'abacus_contract.logic.signing_entity_sap_profit_center.models.ReferenceSapProfitCenter.get_by_id_or_error' ) @patch('abacus_contract.logic.signing_entity_sap_profit_center.db.session') def test_bulk_associate_database_error_triggers_rollback(mock_session, mock_get_pc): """Should trigger db.session.rollback and re-raise the error when an unexpected DB issue hits.""" mock_get_pc.side_effect = SQLAlchemyError('Database lost connection') with pytest.raises(SQLAlchemyError) as exc_info: logic.bulk_associate_signing_entities( sap_profit_center_id=10, signing_entity_ids=[1] ) assert 'Database lost connection' in str(exc_info.value) mock_session.rollback.assert_called_once()