"""Tests for src/parsers/earnings_transfer_parser.py.""" from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from src.enums import RateType, TransferType from src.errors import InputValidationError from src.parsers.earnings_transfer_parser import ( _enrich_record, _map_rate_type, _map_transfer_type, _validate_enriched, api_record_to_transfer_record, parse_earnings_transfers, ) from src.types import ContractData def _make_raw(**overrides: object) -> dict: base: dict = { 'earnings_transfer_id': 1, 'from_contract_id': 10001, 'to_contract_id': 10002, 'transfer_type': 'transfer', 'rate_type': 'percent', 'transfer_amount': '0.5', 'input': 'closing_balance', 'negative': False, 'use_static_balance': False, 'active': 1, 'comment': None, } base.update(overrides) return base # ─── _map_transfer_type ─────────────────────────────────────────────────────── class TestMapTransferType: """Tests for _map_transfer_type.""" def test_cross_recoup(self) -> None: assert _map_transfer_type('cross_recoup') == TransferType.CROSS_RECOUPMENT def test_reclass(self) -> None: assert _map_transfer_type('reclass') == TransferType.RECLASS def test_override(self) -> None: assert _map_transfer_type('override') == TransferType.OVERRIDE def test_transfer(self) -> None: assert _map_transfer_type('transfer') == TransferType.TRANSFER def test_nr_transfer(self) -> None: assert _map_transfer_type('nr_transfer') == TransferType.TRANSFER def test_unknown_raises(self) -> None: with pytest.raises(InputValidationError, match='Unknown transfer_type'): _map_transfer_type('bogus') # ─── _map_rate_type ─────────────────────────────────────────────────────────── class TestMapRateType: """Tests for _map_rate_type.""" def test_percent(self) -> None: assert _map_rate_type('percent') == RateType.PERCENT def test_flat_rate(self) -> None: assert _map_rate_type('flat_rate') == RateType.FLAT_RATE def test_unknown_raises(self) -> None: with pytest.raises(InputValidationError, match='Unknown rate_type'): _map_rate_type('bogus') # ─── api_record_to_transfer_record ──────────────────────────────────────────── class TestApiRecordToTransferRecord: """Tests for api_record_to_transfer_record.""" def test_basic_percent_record(self) -> None: r = api_record_to_transfer_record(_make_raw()) assert r.earnings_transfer_id == 1 assert r.transfer_type == TransferType.TRANSFER assert r.rate_type == RateType.PERCENT assert r.transfer_amount == 0.5 # stored as-is (0–1) assert r.from_contract.contract_id == 10001 assert r.to_contract.contract_id == 10002 assert r.closing_balance is None assert r.currency == '' def test_flat_rate_record(self) -> None: r = api_record_to_transfer_record( _make_raw(rate_type='flat_rate', transfer_amount='250.00') ) assert r.rate_type == RateType.FLAT_RATE assert r.transfer_amount == 250.0 def test_input_net_revenue(self) -> None: r = api_record_to_transfer_record(_make_raw(input='net_revenue')) assert r.input == 'net_revenue' def test_input_gross_revenue(self) -> None: r = api_record_to_transfer_record(_make_raw(input='gross_revenue')) assert r.input == 'gross_revenue' def test_input_closing_balance(self) -> None: r = api_record_to_transfer_record(_make_raw(input='closing_balance')) assert r.input == 'closing_balance' def test_negative_true_propagated(self) -> None: r = api_record_to_transfer_record(_make_raw(negative=True)) assert r.negative is True def test_negative_false_propagated(self) -> None: r = api_record_to_transfer_record(_make_raw(negative=False)) assert r.negative is False def test_negative_int_1_propagated_as_true(self) -> None: r = api_record_to_transfer_record(_make_raw(negative=1)) assert r.negative is True def test_negative_int_0_propagated_as_false(self) -> None: r = api_record_to_transfer_record(_make_raw(negative=0)) assert r.negative is False def test_use_static_balance_true_propagated(self) -> None: r = api_record_to_transfer_record(_make_raw(use_static_balance=True)) assert r.use_static_balance is True def test_use_static_balance_false_propagated(self) -> None: r = api_record_to_transfer_record(_make_raw(use_static_balance=False)) assert r.use_static_balance is False def test_comment_propagated(self) -> None: r = api_record_to_transfer_record(_make_raw(comment='my note')) assert r.from_comment == 'my note' assert r.to_comment == 'my note' assert r.description == 'my note' def test_null_comment_becomes_empty(self) -> None: r = api_record_to_transfer_record(_make_raw(comment=None)) assert r.from_comment == '' assert r.to_comment == '' def test_cross_recoup_transfer_type(self) -> None: r = api_record_to_transfer_record(_make_raw(transfer_type='cross_recoup')) assert r.transfer_type == TransferType.CROSS_RECOUPMENT def test_percent_boundary_values_valid(self) -> None: r_min = api_record_to_transfer_record(_make_raw(transfer_amount='0.01')) assert r_min.transfer_amount == pytest.approx(0.01) r_max = api_record_to_transfer_record(_make_raw(transfer_amount='1.0')) assert r_max.transfer_amount == pytest.approx(1.0) def test_percent_above_1_raises(self) -> None: with pytest.raises(InputValidationError, match='percent transfer_amount'): api_record_to_transfer_record(_make_raw(transfer_amount='1.01')) def test_percent_zero_is_valid(self) -> None: r = api_record_to_transfer_record(_make_raw(transfer_amount='0')) assert r.transfer_amount == 0.0 def test_percent_negative_raises(self) -> None: with pytest.raises(InputValidationError, match='percent transfer_amount'): api_record_to_transfer_record(_make_raw(transfer_amount='-0.1')) def test_flat_rate_zero_raises(self) -> None: with pytest.raises(InputValidationError, match='flat_rate transfer_amount'): api_record_to_transfer_record( _make_raw(rate_type='flat_rate', transfer_amount='0') ) def test_flat_rate_negative_raises(self) -> None: with pytest.raises(InputValidationError, match='flat_rate transfer_amount'): api_record_to_transfer_record( _make_raw(rate_type='flat_rate', transfer_amount='-100') ) def test_unknown_input_raises(self) -> None: with pytest.raises(InputValidationError, match='Unknown input'): api_record_to_transfer_record(_make_raw(input='bogus_input')) def test_unknown_transfer_type_raises(self) -> None: with pytest.raises(InputValidationError, match='Unknown transfer_type'): api_record_to_transfer_record(_make_raw(transfer_type='bogus')) def test_unknown_rate_type_raises(self) -> None: with pytest.raises(InputValidationError, match='Unknown rate_type'): api_record_to_transfer_record(_make_raw(rate_type='bogus')) def test_missing_transfer_amount_raises(self) -> None: raw = _make_raw() raw['transfer_amount'] = None with pytest.raises((InputValidationError, TypeError)): api_record_to_transfer_record(raw) # ─── _enrich_record ─────────────────────────────────────────────────────────── def _make_contract_data(**overrides: object) -> ContractData: base: dict = { 'account_id': 1, 'account_name': 'Acme', 'contract_name': 'Deal A', 'currency': 'USD', 'closing_balance': 10_000.0, 'gross_revenue': 5_000.0, 'net_revenue': 4_000.0, } base.update(overrides) return ContractData(**base) class TestEnrichRecord: """Tests for _enrich_record.""" def _base_record(self, **overrides: object) -> object: return api_record_to_transfer_record(_make_raw(**overrides)) def test_all_three_balances_populated(self) -> None: record = self._base_record() data = { 10001: _make_contract_data( closing_balance=9_999.0, net_revenue=4_000.0, gross_revenue=5_000.0, ) } enriched = _enrich_record(record, data) assert enriched.closing_balance == 9_999.0 assert enriched.net_revenue == 4_000.0 assert enriched.gross_revenue == 5_000.0 def test_currency_populated(self) -> None: record = self._base_record() data = {10001: _make_contract_data(currency='EUR')} enriched = _enrich_record(record, data) assert enriched.currency == 'EUR' def test_from_contract_metadata_populated(self) -> None: record = self._base_record() data = { 10001: _make_contract_data( account_id=42, account_name='Test Account', contract_name='Test Contract', ) } enriched = _enrich_record(record, data) assert enriched.from_contract.account_id == 42 assert enriched.from_contract.account_name == 'Test Account' assert enriched.from_contract.contract_name == 'Test Contract' def test_to_contract_metadata_populated(self) -> None: record = self._base_record() data = { 10002: _make_contract_data( account_id=99, account_name='To Account', contract_name='To Contract', ) } enriched = _enrich_record(record, data) assert enriched.to_contract.account_id == 99 assert enriched.to_contract.account_name == 'To Account' def test_to_contract_balances_populated(self) -> None: record = self._base_record() data = { 10002: _make_contract_data( closing_balance=3_000.0, net_revenue=1_500.0, gross_revenue=2_000.0, ) } enriched = _enrich_record(record, data) assert enriched.to_closing_balance == 3_000.0 assert enriched.to_net_revenue == 1_500.0 assert enriched.to_gross_revenue == 2_000.0 def test_to_contract_balances_none_when_missing(self) -> None: record = self._base_record() enriched = _enrich_record(record, {}) assert enriched.to_closing_balance is None assert enriched.to_net_revenue is None assert enriched.to_gross_revenue is None def test_unknown_contract_leaves_record_unchanged(self) -> None: record = self._base_record() enriched = _enrich_record(record, {}) assert enriched is record # ─── parse_earnings_transfers ───────────────────────────────────────────────── def _default_contract_data() -> dict[int, ContractData]: """Contract data matching _make_raw() defaults (from=10001, to=10002).""" return { 10001: _make_contract_data(), 10002: _make_contract_data(), } def _mock_ows_client(status_code: int = 200, results: list | None = None) -> MagicMock: client = MagicMock() response = MagicMock() response.status_code = status_code response.json.return_value = {'items': results or []} client.get.return_value = response return client @pytest.fixture def mock_conn() -> MagicMock: """Return a mock SnowflakeConnection.""" return MagicMock() class TestParseEarningsTransfers: """Tests for parse_earnings_transfers.""" @patch('src.parsers.earnings_transfer_parser.fetch_contract_data', return_value={}) def test_empty_results(self, _mock_fetch: MagicMock, mock_conn: MagicMock) -> None: records = parse_earnings_transfers(_mock_ows_client(results=[]), mock_conn) assert records == [] @patch( 'src.parsers.earnings_transfer_parser.fetch_contract_data', return_value=_default_contract_data() | {20001: _make_contract_data()}, ) def test_returns_parsed_records( self, _mock_fetch: MagicMock, mock_conn: MagicMock ) -> None: raw = [_make_raw(), _make_raw(earnings_transfer_id=2, from_contract_id=20001)] records = parse_earnings_transfers(_mock_ows_client(results=raw), mock_conn) assert len(records) == 2 assert records[0].earnings_transfer_id == 1 assert records[1].earnings_transfer_id == 2 @patch( 'src.parsers.earnings_transfer_parser.fetch_contract_data', return_value=_default_contract_data(), ) def test_calls_correct_service_and_path( self, _mock_fetch: MagicMock, mock_conn: MagicMock ) -> None: client = _mock_ows_client() parse_earnings_transfers(client, mock_conn) client.get.assert_called_once_with( 'ows-royalties', '/earnings-transfers?limit=10000' ) @patch( 'src.parsers.earnings_transfer_parser.fetch_contract_data', return_value=_default_contract_data(), ) def test_fetches_contract_data_from_snowflake( self, mock_fetch: MagicMock, mock_conn: MagicMock ) -> None: raw = [_make_raw()] parse_earnings_transfers(_mock_ows_client(results=raw), mock_conn) mock_fetch.assert_called_once() assert mock_fetch.call_args.args[0] is mock_conn def test_non_200_raises(self, mock_conn: MagicMock) -> None: with pytest.raises(InputValidationError, match='HTTP 503'): parse_earnings_transfers(_mock_ows_client(status_code=503), mock_conn) @patch('src.parsers.earnings_transfer_parser.fetch_contract_data', return_value={}) def test_invalid_record_raises( self, _mock_fetch: MagicMock, mock_conn: MagicMock ) -> None: raw = [_make_raw(), _make_raw(earnings_transfer_id=2, transfer_type='bogus')] with pytest.raises(InputValidationError, match='invalid record'): parse_earnings_transfers(_mock_ows_client(results=raw), mock_conn) @patch('src.parsers.earnings_transfer_parser.fetch_contract_data', return_value={}) def test_multiple_invalid_records_all_reported( self, _mock_fetch: MagicMock, mock_conn: MagicMock ) -> None: raw = [ _make_raw(earnings_transfer_id=1, transfer_type='bogus'), _make_raw(earnings_transfer_id=2, rate_type='bogus'), ] with pytest.raises(InputValidationError, match='2 invalid record'): parse_earnings_transfers(_mock_ows_client(results=raw), mock_conn) @patch( 'src.parsers.earnings_transfer_parser.fetch_contract_data', return_value=_default_contract_data(), ) def test_inactive_records_are_excluded( self, _mock_fetch: MagicMock, mock_conn: MagicMock ) -> None: raw = [ _make_raw(earnings_transfer_id=1, active=1), _make_raw(earnings_transfer_id=2, active=0), _make_raw(earnings_transfer_id=3, active=None), _make_raw(earnings_transfer_id=4, active='0'), _make_raw(earnings_transfer_id=5, active=False), ] records = parse_earnings_transfers(_mock_ows_client(results=raw), mock_conn) assert len(records) == 1 assert records[0].earnings_transfer_id == 1 @patch( 'src.parsers.earnings_transfer_parser.fetch_contract_data', return_value=_default_contract_data(), ) def test_active_string_representations_are_included( self, _mock_fetch: MagicMock, mock_conn: MagicMock ) -> None: raw = [ _make_raw(earnings_transfer_id=1, active='1'), _make_raw(earnings_transfer_id=2, active=True), ] records = parse_earnings_transfers(_mock_ows_client(results=raw), mock_conn) assert len(records) == 2 @patch('src.parsers.earnings_transfer_parser.fetch_contract_data', return_value={}) def test_all_inactive_returns_empty( self, _mock_fetch: MagicMock, mock_conn: MagicMock ) -> None: raw = [_make_raw(active=0), _make_raw(earnings_transfer_id=2, active=0)] records = parse_earnings_transfers(_mock_ows_client(results=raw), mock_conn) assert records == [] @patch('src.parsers.earnings_transfer_parser.fetch_contract_data', return_value={}) def test_missing_contract_data_sets_error( self, _mock_fetch: MagicMock, mock_conn: MagicMock ) -> None: raw = [_make_raw()] records = parse_earnings_transfers(_mock_ows_client(results=raw), mock_conn) assert len(records) == 1 assert records[0].error == 'Contract 10001 not found in Snowflake' assert records[0].error_contract_id == 10001 @patch( 'src.parsers.earnings_transfer_parser.fetch_contract_data', return_value={ 10001: _make_contract_data(net_revenue=None), 10002: _make_contract_data(), }, ) def test_missing_selected_balance_sets_error( self, _mock_fetch: MagicMock, mock_conn: MagicMock ) -> None: raw = [_make_raw(input='net_revenue')] records = parse_earnings_transfers(_mock_ows_client(results=raw), mock_conn) assert len(records) == 1 assert records[0].error == 'Contract 10001 has no net_revenue in Snowflake' assert records[0].error_contract_id == 10001 @patch( 'src.parsers.earnings_transfer_parser.fetch_contract_data', return_value=_default_contract_data(), ) def test_valid_contract_data_no_error( self, _mock_fetch: MagicMock, mock_conn: MagicMock ) -> None: raw = [_make_raw()] records = parse_earnings_transfers(_mock_ows_client(results=raw), mock_conn) assert len(records) == 1 assert records[0].error is None # ─── _validate_enriched ───────────────────────────────────────────────────── class TestValidateEnriched: """Tests for _validate_enriched.""" def _enriched_record(self, **overrides: object) -> object: return api_record_to_transfer_record(_make_raw(**overrides)) def test_all_balances_none_sets_error(self) -> None: record = self._enriched_record() result = _validate_enriched(record) assert result.error == 'Contract 10001 not found in Snowflake' assert result.error_contract_id == 10001 def test_selected_balance_none_sets_error(self) -> None: record = self._enriched_record(input='gross_revenue') record = record.model_copy( update={'closing_balance': 100.0, 'net_revenue': 50.0} ) result = _validate_enriched(record) assert result.error == 'Contract 10001 has no gross_revenue in Snowflake' def test_closing_balance_present_passes(self) -> None: record = self._enriched_record() record = record.model_copy(update={'closing_balance': 100.0}) result = _validate_enriched(record) assert result.error is None def test_net_revenue_present_passes(self) -> None: record = self._enriched_record(input='net_revenue') record = record.model_copy( update={'closing_balance': 100.0, 'net_revenue': 50.0} ) result = _validate_enriched(record) assert result.error is None def test_zero_balance_is_valid(self) -> None: record = self._enriched_record() record = record.model_copy(update={'closing_balance': 0.0}) result = _validate_enriched(record) assert result.error is None