"""PullBankingDetailsProcessor tests.""" from datetime import date import logging from typing import Any, Generator from unittest.mock import MagicMock, patch from faker import Faker import pytest from src.connectors.payoneer import PayoneerApiException from src.models import PayoneerPayeeDetails, PullBankingDetailsInputRow from src.processors.banking_details.payoneer_mapping import ( _normalize_bank_field_name as normalize_bank_field_name, map_payoneer_response, ) from src.processors.banking_details.pull_banking_details import ( PullBankingDetailsProcessor, REPORT_CHUNK_SIZE, ) from src.processors.exceptions import ProcessingError from tests.utils import create_sample_csv_buffer def _rows(raw: list[dict[str, str]]) -> list[PullBankingDetailsInputRow]: """Wrap raw CSV-like dicts into typed input rows. Matches what `_load_input` does in production; tests that exercise `_fetch_banking_details` directly build typed rows via this helper instead of poking raw dicts into `_input_rows`. """ return [PullBankingDetailsInputRow.model_validate(r) for r in raw] def _model(raw: dict[str, Any]) -> PayoneerPayeeDetails: """Validate a raw Payoneer response dict into the typed model. The connector returns validated models now; tests that build raw response dicts run them through this to exercise the same boundary. """ return PayoneerPayeeDetails.model_validate(raw) @pytest.fixture(autouse=True) def _no_rate_limit(request: pytest.FixtureRequest) -> Generator[None, None, None]: """Disable rate limiting in tests so they run instantly. Tests marked `real_rate_limit` opt out and exercise the real pyrate-limiter wiring so a refactor that drops `try_acquire` cannot pass CI silently. """ if 'real_rate_limit' in request.keywords: yield return with patch( 'src.processors.banking_details.pull_banking_details.Limiter' ) as mock_cls: mock_cls.return_value.try_acquire = MagicMock(return_value=True) yield # Mirrors the shape returned by the v4 # get-payee-details-with-payout-method-details endpoint: a single # `payout_method` object, human-readable bank field names with spaces, # type == "BankTransfer". SAMPLE_PAYONEER_RESPONSE = { 'type': 'INDIVIDUAL', 'contact': { 'first_name': 'John', 'last_name': 'Doe', 'date_of_birth': '1990-01-15', 'email': 'john.doe@example.com', }, 'address': { 'address_line_1': '123 Main St', 'address_line_2': 'Apt 4', 'city': 'New York', 'state': 'NY', 'country': 'US', 'zip_code': '10001', }, 'company': None, 'payout_method': { 'type': 'BankTransfer', 'bank_account_type': '1', 'country': 'US', 'currency': 'USD', 'bank_field_details': [ {'name': 'Bank Name', 'value': 'Chase'}, {'name': 'Account Number', 'value': '123456789'}, {'name': 'Routing Number', 'value': '021000021'}, ], }, } class TestPullBankingDetailsProcessor: """PullBankingDetailsProcessor test suite.""" def test_process_calls_all_steps(self, faker: Faker) -> None: """Test process method calls all steps.""" processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._load_input = MagicMock() # type: ignore processor._fetch_banking_details = MagicMock() # type: ignore processor._upload_reports = MagicMock() # type: ignore processor.process() assert processor._load_input.called assert processor._fetch_banking_details.called assert processor._upload_reports.called def test_load_input_success(self, faker: Faker) -> None: """Test loading valid CSV input.""" buffer = create_sample_csv_buffer( ( ('program_id', 'payee_id', 'vendor_id'), ('100250970', '12345', '99001'), ('100260310', '67890', '99002'), ) ) processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_file_buffer = buffer processor._load_input() assert len(processor._input_rows) == 2 assert processor._input_rows[0].program_id == 100250970 assert processor._input_rows[0].payee_id == 12345 assert processor._input_rows[0].vendor_id == '99001' def test_load_input_empty_file(self, faker: Faker) -> None: """Test loading empty CSV raises error.""" buffer = create_sample_csv_buffer((('program_id', 'payee_id', 'vendor_id'),)) processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_file_buffer = buffer with pytest.raises(ProcessingError, match='Input file is empty'): processor._load_input() def test_load_input_missing_columns(self, faker: Faker) -> None: """Test loading CSV with missing columns raises error.""" buffer = create_sample_csv_buffer( ( ('program_id', 'wrong_column'), ('100250970', '12345'), ) ) processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_file_buffer = buffer with pytest.raises(ProcessingError, match='Missing required columns.*payee_id'): processor._load_input() @patch('src.processors.banking_details.pull_banking_details.get_payee_details') def test_fetch_banking_details_success( self, mock_get_details: MagicMock, faker: Faker, ) -> None: """Test fetching banking details from Payoneer.""" mock_get_details.return_value = _model(SAMPLE_PAYONEER_RESPONSE) processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_rows = _rows( [{'program_id': '100250970', 'payee_id': '12345', 'vendor_id': '99001'}] ) processor._fetch_banking_details() mock_get_details.assert_called_once_with(100250970, 12345) assert len(processor._results) == 1 assert processor._results[0]['vendorId'] == '99001' assert processor._results[0]['payee_id'] == '12345' assert processor._results[0]['firstName'] == 'John' @patch('src.processors.banking_details.pull_banking_details.get_payee_details') def test_fetch_banking_details_api_error_logged( self, mock_get_details: MagicMock, faker: Faker, ) -> None: """Test API errors are logged and processing continues.""" mock_get_details.side_effect = PayoneerApiException('API failure') processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_rows = _rows( [ {'program_id': '100250970', 'payee_id': '12345', 'vendor_id': '99001'}, {'program_id': '100250970', 'payee_id': '67890', 'vendor_id': '99002'}, ] ) with pytest.raises(ProcessingError, match='No banking details retrieved'): processor._fetch_banking_details() assert processor._logs is not None assert len(processor._logs.get(logging.ERROR, [])) == 2 @patch('src.processors.banking_details.pull_banking_details.get_payee_details') def test_fetch_banking_details_404_logged_as_warning( self, mock_get_details: MagicMock, faker: Faker, ) -> None: """Test 404 (payee not found) is logged at WARNING level.""" def by_payee(_program_id: int, payee_id: int) -> PayoneerPayeeDetails | None: return None if payee_id == 11111 else _model(SAMPLE_PAYONEER_RESPONSE) mock_get_details.side_effect = by_payee processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_rows = _rows( [ {'program_id': '100250970', 'payee_id': '11111', 'vendor_id': '99001'}, {'program_id': '100250970', 'payee_id': '22222', 'vendor_id': '99002'}, ] ) processor._fetch_banking_details() assert len(processor._results) == 1 assert processor._results[0]['payee_id'] == '22222' assert processor._logs is not None warnings = processor._logs.get(logging.WARNING, []) assert len(warnings) == 1 assert 'payee not found' in warnings[0].message def test_load_input_skips_empty_fields(self, faker: Faker) -> None: """Test rows with empty program_id, payee_id, or vendor_id are rejected at load time. Pydantic rejects empty ints (program_id / payee_id) under the `invalid_` categories; empty `vendor_id` (min_length=1) falls through to the generic `invalid_input` category. """ buffer = create_sample_csv_buffer( ( ('program_id', 'payee_id', 'vendor_id'), ('', '12345', '99001'), ('100250970', '', '99002'), ('100250970', '12345', ''), ) ) processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_file_buffer = buffer processor._load_input() assert processor._input_rows == [] assert processor._failure_counts['invalid_program_id'] == 1 assert processor._failure_counts['invalid_payee_id'] == 1 assert processor._failure_counts['invalid_input'] == 1 def test_load_input_rejects_non_numeric_payee_id(self, faker: Faker) -> None: """payee_id is path-interpolated into the Payoneer URL; reject non-numeric values at the processor boundary rather than trusting the CSV. """ buffer = create_sample_csv_buffer( ( ('program_id', 'payee_id', 'vendor_id'), ('100250970', '1/../admin', '99'), ('100250970', 'abc', '99'), ) ) processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_file_buffer = buffer processor._load_input() assert processor._input_rows == [] assert processor._logs is not None errors = processor._logs.get(logging.ERROR, []) assert len(errors) == 2 assert all('invalid payee_id' in e.message for e in errors) def test_load_input_skips_malformed_program_id(self, faker: Faker) -> None: """Test rows with non-numeric program_id are skipped, not crash.""" buffer = create_sample_csv_buffer( ( ('program_id', 'payee_id', 'vendor_id'), ('not-a-number', '12345', '99001'), ('100250970.0', '67890', '99002'), ('100250970', '99999', '99003'), ) ) processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_file_buffer = buffer processor._load_input() # Only the valid row is retained assert len(processor._input_rows) == 1 assert processor._input_rows[0].program_id == 100250970 assert processor._input_rows[0].payee_id == 99999 assert processor._logs is not None errors = processor._logs.get(logging.ERROR, []) assert len(errors) == 2 assert all('invalid program_id' in e.message for e in errors) def test_load_input_parses_optional_date_of_birth(self, faker: Faker) -> None: """date_of_birth is optional; yyyy-mm-dd values parse to a date, blanks stay None so the mapper can fall back to Payoneer's value. """ buffer = create_sample_csv_buffer( ( ('program_id', 'payee_id', 'vendor_id', 'date_of_birth'), ('100250970', '12345', '99001', '1985-04-12'), ('100250970', '67890', '99002', ''), ) ) processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_file_buffer = buffer processor._load_input() assert len(processor._input_rows) == 2 assert processor._input_rows[0].date_of_birth == date(1985, 4, 12) assert processor._input_rows[1].date_of_birth is None def test_load_input_missing_date_of_birth_column(self, faker: Faker) -> None: """date_of_birth column is optional: absent entirely is fine.""" buffer = create_sample_csv_buffer( ( ('program_id', 'payee_id', 'vendor_id'), ('100250970', '12345', '99001'), ) ) processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_file_buffer = buffer processor._load_input() assert len(processor._input_rows) == 1 assert processor._input_rows[0].date_of_birth is None def test_load_input_rejects_invalid_date_of_birth(self, faker: Faker) -> None: """Non-yyyy-mm-dd values are rejected so the downstream CSV cell only ever carries the format PostBankingDetailsProcessor accepts. """ buffer = create_sample_csv_buffer( ( ('program_id', 'payee_id', 'vendor_id', 'date_of_birth'), ('100250970', '12345', '99001', '04/12/1985'), ('100250970', '67890', '99002', 'not-a-date'), ) ) processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_file_buffer = buffer processor._load_input() assert processor._input_rows == [] assert processor._failure_counts['invalid_date_of_birth'] == 2 assert processor._logs is not None errors = processor._logs.get(logging.ERROR, []) assert len(errors) == 2 assert all('invalid date_of_birth' in e.message for e in errors) @patch('src.processors.banking_details.pull_banking_details.get_payee_details') def test_fetch_banking_details_emits_date_of_birth_override( self, mock_get_details: MagicMock, faker: Faker, ) -> None: """The CSV-supplied date_of_birth must win over Payoneer's value in the emitted report row. """ mock_get_details.return_value = _model(SAMPLE_PAYONEER_RESPONSE) processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_rows = _rows( [ { 'program_id': '100250970', 'payee_id': '12345', 'vendor_id': '99001', 'date_of_birth': '1980-06-01', } ] ) processor._fetch_banking_details() assert processor._results[0]['dateOfBirth'] == '1980-06-01' @patch('src.processors.banking_details.pull_banking_details.get_payee_details') def test_processing_error_includes_failure_breakdown( self, mock_get_details: MagicMock, faker: Faker, ) -> None: """When every row fails, the ProcessingError message must include a per-category count so operators can tell auth failures from 404s from validation errors at a glance instead of digging through logs. """ def by_payee(_program_id: int, payee_id: int) -> Any: if payee_id == 11111: raise PayoneerApiException('auth failed') return None # 404 mock_get_details.side_effect = by_payee buffer = create_sample_csv_buffer( ( ('program_id', 'payee_id', 'vendor_id'), ('100250970', '11111', '99001'), ('100250970', '22222', '99002'), ('', '33333', '99003'), ('abc', '44444', '99004'), ) ) processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_file_buffer = buffer processor._load_input() with pytest.raises(ProcessingError) as exc_info: processor._fetch_banking_details() msg = str(exc_info.value) assert 'No banking details retrieved' in msg # Both empty-string and non-numeric program_id now classify as # Pydantic int-parse failures on `program_id` → invalid_program_id. assert '2 invalid_program_id' in msg assert '1 not_found' in msg assert '1 payoneer_api_error' in msg @patch('src.processors.banking_details.pull_banking_details.get_payee_details') def test_mapper_warnings_forwarded_to_logs( self, mock_get_details: MagicMock, faker: Faker, ) -> None: """Mapper-emitted warnings (unknown labels, sparse fields, unknown bank_account_type) must surface as row-level WARNING log entries so Sentry sees them via the base-class capture_message flow. """ response = dict(SAMPLE_PAYONEER_RESPONSE) response['payout_method'] = { 'type': 'BankTransfer', 'bank_account_type': '9', 'country': 'US', 'currency': 'USD', 'bank_field_details': [ {'name': 'Some Brand New Field', 'value': 'lost'}, ], } mock_get_details.return_value = _model(response) processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_rows = _rows( [{'program_id': '100250970', 'payee_id': '12345', 'vendor_id': '99001'}] ) processor._fetch_banking_details() assert len(processor._results) == 1 assert processor._logs is not None warnings = processor._logs.get(logging.WARNING, []) assert any('Unrecognised Payoneer bank field' in w.message for w in warnings) assert any('Unknown Payoneer bank_account_type' in w.message for w in warnings) @patch('src.processors.banking_details.pull_banking_details.get_payee_details') def test_unexpected_worker_exception_does_not_abort_batch( self, mock_get_details: MagicMock, faker: Faker, ) -> None: """A non-PayoneerApiException raised inside the worker (e.g. a future schema-drift KeyError, or any unhandled exception path) must be caught by the as_completed guard and logged so the remaining successful rows still produce a report. Without the guard, the first unexpected exception aborts the as_completed loop and discards all already-completed work. """ def by_payee(_program_id: int, payee_id: int) -> Any: if payee_id == 11111: raise RuntimeError('thread-level boom') return _model(SAMPLE_PAYONEER_RESPONSE) mock_get_details.side_effect = by_payee processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_rows = _rows( [ {'program_id': '100250970', 'payee_id': '11111', 'vendor_id': '99001'}, {'program_id': '100250970', 'payee_id': '22222', 'vendor_id': '99002'}, {'program_id': '100250970', 'payee_id': '33333', 'vendor_id': '99003'}, ] ) processor._fetch_banking_details() assert len(processor._results) == 2 assert {r['payee_id'] for r in processor._results} == {'22222', '33333'} assert processor._logs is not None errors = processor._logs.get(logging.ERROR, []) assert any('unexpected worker failure' in e.message for e in errors) @pytest.mark.real_rate_limit @patch('src.processors.banking_details.pull_banking_details.get_payee_details') def test_real_limiter_acquires_one_token_per_row( self, mock_get_details: MagicMock, faker: Faker, ) -> None: """The real pyrate-limiter must be invoked once per submitted row. Regression guard against a refactor that removes `limiter.try_acquire(...)`: without this assertion the autouse stub would silently let the change land and we'd burst past Payoneer's 2400 rpm cap in prod. """ from pyrate_limiter import Limiter as _RealLimiter mock_get_details.return_value = _model(SAMPLE_PAYONEER_RESPONSE) processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_rows = _rows( [ {'program_id': '100250970', 'payee_id': str(i), 'vendor_id': str(i)} for i in range(5) ] ) with patch.object( _RealLimiter, 'try_acquire', autospec=True, return_value=True ) as spy: processor._fetch_banking_details() assert spy.call_count == 5 for call_args in spy.call_args_list: assert call_args.args[1] == 'payoneer' @patch('src.processors.banking_details.pull_banking_details.get_payee_details') def test_concurrent_workers_collect_all_results_and_logs( self, mock_get_details: MagicMock, faker: Faker, ) -> None: """Real ThreadPoolExecutor across many rows: every row must land in either `_results` or `_logs`, with no duplicates from racing `_add_log` calls. """ import time def by_payee(_program_id: int, payee_id: int) -> Any: time.sleep(0.005) if payee_id % 7 == 0: raise PayoneerApiException(f'transient {payee_id}') return _model(SAMPLE_PAYONEER_RESPONSE) mock_get_details.side_effect = by_payee rows = [ { 'program_id': '100250970', 'payee_id': str(i), 'vendor_id': str(i), } for i in range(1, 51) ] processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_rows = _rows(rows) processor._fetch_banking_details() expected_failures = sum(1 for i in range(1, 51) if i % 7 == 0) expected_successes = 50 - expected_failures assert len(processor._results) == expected_successes assert processor._logs is not None errors = processor._logs.get(logging.ERROR, []) assert len(errors) == expected_failures # No row appears in both buckets. success_payees = {r['payee_id'] for r in processor._results} error_payees = { msg.split('payee=')[1].split(':')[0].strip() for msg in (e.message for e in errors) if 'payee=' in msg } assert success_payees.isdisjoint(error_payees) @patch('src.processors.banking_details.pull_banking_details.get_payee_details') @patch('src.processors.banking_details.pull_banking_details.s3') @patch('src.processors.base.s3') @patch('src.processors.base.capture_message') def test_execute_flushes_row_logs_on_processing_error( self, mock_capture: MagicMock, mock_base_s3: MagicMock, _mock_processor_s3: MagicMock, mock_get_details: MagicMock, faker: Faker, ) -> None: """End-to-end: when every row fails, ProcessingError still triggers the base-class `_report_logs` so the per-row failures hit Sentry via capture_message instead of being silently dropped. """ mock_get_details.side_effect = PayoneerApiException('auth failed') csv = create_sample_csv_buffer( ( ('program_id', 'payee_id', 'vendor_id'), ('100250970', '11111', '99001'), ('100250970', '22222', '99002'), ) ) mock_base_s3.download_file.return_value = csv with pytest.raises(ProcessingError, match='No banking details retrieved'): PullBankingDetailsProcessor.execute('bucket', 'path/file.csv') # Sentry sees the aggregated row-level errors. mock_capture.assert_called_once() sent_message = mock_capture.call_args.args[0] assert 'Row 1' in sent_message and 'Row 2' in sent_message assert 'auth failed' in sent_message @patch('src.processors.banking_details.pull_banking_details.map_payoneer_response') @patch('src.processors.banking_details.pull_banking_details.get_payee_details') def test_mapper_failure_is_logged_per_row( self, mock_get_details: MagicMock, mock_map: MagicMock, faker: Faker, ) -> None: """A mapper exception (e.g. KeyError on a future field) must be caught at the row level so the failure is logged and the rest of the batch continues. """ mock_get_details.return_value = _model(SAMPLE_PAYONEER_RESPONSE) def map_side_effect( _vendor: str, payee: str, _details: PayoneerPayeeDetails, **_kwargs: Any, ) -> tuple[dict[str, Any], list[str]]: if payee == '11111': raise KeyError('SomeNewField') return {'payee_id': payee, 'vendorId': _vendor}, [] mock_map.side_effect = map_side_effect processor = PullBankingDetailsProcessor(faker.pystr(), faker.pystr()) processor._input_rows = _rows( [ {'program_id': '100250970', 'payee_id': '11111', 'vendor_id': '99001'}, {'program_id': '100250970', 'payee_id': '22222', 'vendor_id': '99002'}, ] ) processor._fetch_banking_details() assert len(processor._results) == 1 assert processor._results[0]['payee_id'] == '22222' assert processor._logs is not None errors = processor._logs.get(logging.ERROR, []) assert any('failed to map Payoneer response' in e.message for e in errors) @patch('src.processors.banking_details.pull_banking_details.s3') def test_upload_reports_escapes_formula_prefixes( self, mock_s3: MagicMock, faker: Faker ) -> None: """Cells starting with =/+/-/@/\\t get a leading apostrophe. Prevents Excel / LibreOffice from evaluating values from Payoneer as formulas when a reviewer opens the downloaded report. """ processor = PullBankingDetailsProcessor('test-bucket', faker.pystr()) processor._results = [ { 'vendorId': '99001', 'payee_id': '1', 'payeeType': 'INDIVIDUAL', 'firstName': '=HYPERLINK("evil")', 'lastName': '+cmd', 'email': '-sum', 'BankName': '@attack', 'AccountNumber': '\tleading-tab', 'IBAN': 'GB00BANK', # benign — untouched } ] processor._upload_reports() buffer = mock_s3.upload_file.call_args.args[2] buffer.seek(0) body = buffer.read() assert "'=HYPERLINK" in body assert "'+cmd" in body assert "'-sum" in body assert "'@attack" in body assert "'\tleading-tab" in body assert 'GB00BANK' in body # benign values untouched assert '=HYPERLINK("evil")' not in body.replace("'=HYPERLINK", '') # Round-trip the buffer through csv.reader to confirm the # neutralised cells survive quoting/escaping intact and arrive # exactly as written. import csv as _csv from io import StringIO as _StringIO reader = _csv.DictReader(_StringIO(body)) rows = list(reader) assert len(rows) == 1 assert rows[0]['firstName'] == '\'=HYPERLINK("evil")' assert rows[0]['lastName'] == "'+cmd" assert rows[0]['email'] == "'-sum" assert rows[0]['BankName'] == "'@attack" assert rows[0]['IBAN'] == 'GB00BANK' @patch('src.processors.banking_details.pull_banking_details.s3') def test_upload_reports_single_chunk( self, mock_s3: MagicMock, faker: Faker ) -> None: """Test report upload with data fitting in a single chunk.""" processor = PullBankingDetailsProcessor('test-bucket', faker.pystr()) processor._results = [ map_payoneer_response( '100250970', str(i), _model(SAMPLE_PAYONEER_RESPONSE) )[0] for i in range(5) ] processor._upload_reports() mock_s3.upload_file.assert_called_once() call_args = mock_s3.upload_file.call_args assert call_args[0][0] == 'test-bucket' assert call_args[0][1].startswith('reports/pull_banking_details_report_') assert call_args[0][1].endswith('.csv') assert '_part' not in call_args[0][1] @patch('src.processors.banking_details.pull_banking_details.s3') def test_upload_reports_multiple_chunks( self, mock_s3: MagicMock, faker: Faker ) -> None: """Test report upload with data split into multiple chunks.""" processor = PullBankingDetailsProcessor('test-bucket', faker.pystr()) processor._results = [ map_payoneer_response( '100250970', str(i), _model(SAMPLE_PAYONEER_RESPONSE) )[0] for i in range(REPORT_CHUNK_SIZE + 5) ] processor._upload_reports() assert mock_s3.upload_file.call_count == 2 first_call = mock_s3.upload_file.call_args_list[0] second_call = mock_s3.upload_file.call_args_list[1] assert '_part1' in first_call[0][1] assert '_part2' in second_call[0][1] @patch('src.processors.banking_details.pull_banking_details.s3') def test_upload_reports_raises_on_s3_error( self, mock_s3: MagicMock, faker: Faker ) -> None: """Test S3 upload errors are logged and re-raised.""" mock_s3.upload_file.side_effect = Exception('S3 unavailable') processor = PullBankingDetailsProcessor('test-bucket', faker.pystr()) processor._results = [ map_payoneer_response( '100250970', '12345', _model(SAMPLE_PAYONEER_RESPONSE) )[0] ] with pytest.raises(Exception, match='S3 unavailable'): processor._upload_reports() class TestMapPayoneerResponse: """Tests for map_payoneer_response.""" def test_individual_payee(self) -> None: """Test mapping an individual payee response.""" result, _warnings = map_payoneer_response( '99001', '12345', _model(SAMPLE_PAYONEER_RESPONSE) ) assert result['vendorId'] == '99001' assert result['payee_id'] == '12345' assert result['payeeType'] == 'INDIVIDUAL' assert result['firstName'] == 'John' assert result['lastName'] == 'Doe' assert result['dateOfBirth'] == '1990-01-15' assert result['email'] == 'john.doe@example.com' assert result['address1'] == '123 Main St' assert result['address2'] == 'Apt 4' assert result['city'] == 'New York' assert result['state'] == 'NY' assert result['country'] == 'US' assert result['postal_code'] == '10001' assert result['bankAccountType'] == 'PERSONAL' assert result['bankCountry'] == 'US' assert result['currency'] == 'USD' assert result['BankName'] == 'Chase' assert result['AccountNumber'] == '123456789' assert result['RoutingNumber'] == '021000021' assert result['IBAN'] == '' def test_real_sample_company_sek(self) -> None: """Pinned against a real v4 response for a Swedish company payee. Notes about the real response: - Payoneer keeps the legal entity name in `contact.first_name`; the mapper promotes it to `companyName` for COMPANY-typed payees. - `payout_method` is a single object (not a list). - SWIFT / BIC arrives as one field; we store it under `Swift`. """ response = { 'account_id': 'XXXXXX', 'type': 'COMPANY', 'company': { 'url': 'http://www.example.com', 'incorporated_address_1': 'Incorp Street 1', 'incorporated_address_2': '', 'incorporated_city': 'Goteborg', 'incorporated_state': '', 'incorporated_zipcode': '41103', 'incorporated_country': 'SE', }, 'contact': { 'first_name': 'Contact', 'last_name': 'Person', 'email': 'contact@example.com', 'mobile': '700000000', 'mobile_country': 'SE', 'mobile_country_code': '46', }, 'address': { 'address_line_1': 'Mailing Street 2', 'address_line_2': '', 'city': 'Goteborg', 'state': '', 'country': 'SE', 'zip_code': '41104', }, 'payout_method': { 'type': 'BankTransfer', 'bank_account_type': '2', 'country': 'SE', 'currency': 'SEK', 'bank_field_details': [ {'name': 'Bank Name', 'value': 'Handelsbanken'}, {'name': 'Account Name', 'value': 'Example AB'}, {'name': 'Account Number', 'value': '1234567890'}, {'name': 'SWIFT / BIC', 'value': 'HANDSESS'}, {'name': 'Bank Code', 'value': '6000'}, {'name': 'IBAN', 'value': 'SE4560000000000123456789'}, ], }, } result, _warnings = map_payoneer_response( '100260310', '99999', _model(response) ) assert result['payeeType'] == 'COMPANY' # Payoneer's v4 response has no company.name; the legal entity name # lives in contact.first_name and is promoted to companyName. assert result['companyName'] == 'Contact' assert result['firstName'] == '' assert result['lastName'] == 'Person' assert result['address1'] == 'Mailing Street 2' assert result['city'] == 'Goteborg' assert result['country'] == 'SE' assert result['postal_code'] == '41104' assert result['bankAccountType'] == 'COMPANY' assert result['bankCountry'] == 'SE' assert result['currency'] == 'SEK' assert result['BankName'] == 'Handelsbanken' assert result['AccountName'] == 'Example AB' assert result['AccountNumber'] == '1234567890' assert result['Swift'] == 'HANDSESS' assert result['BankCode'] == '6000' assert result['IBAN'] == 'SE4560000000000123456789' def test_real_sample_company_us_routing(self) -> None: """Pinned against a real v4 COMPANY response with mailing address. Locks in the decision to use `address.*` (mailing) rather than `company.incorporated_*` when both are populated on a COMPANY payee. Also confirms the `Routing` + `AccountType` shape works for COMPANY, not only INDIVIDUAL. """ response = { 'account_id': 'XXXXXXXX', 'type': 'COMPANY', 'company': { 'incorporated_address_1': 'Incorporated Blvd 1', 'incorporated_address_2': '', 'incorporated_city': 'Dover', 'incorporated_state': 'DE', 'incorporated_zipcode': '19901', 'incorporated_country': 'US', }, 'contact': { 'first_name': 'ACME LLC', 'last_name': 'Ops', 'email': 'ops@acme.example', 'mobile': '5551234567', 'mobile_country': 'US', 'mobile_country_code': '1', }, 'address': { 'address_line_1': '1 Mailing Street', 'address_line_2': '', 'city': 'New York', 'state': 'NY', 'country': 'US', 'zip_code': '10001', }, 'payout_method': { 'type': 'BankTransfer', 'bank_account_type': '2', 'country': 'US', 'currency': 'USD', 'bank_field_details': [ {'name': 'Bank Name', 'value': 'Chase'}, {'name': 'Account Name', 'value': 'ACME LLC'}, {'name': 'Account Number', 'value': '999888777'}, {'name': 'Routing', 'value': '021000021'}, {'name': 'AccountType', 'value': 'C'}, ], }, } result, _warnings = map_payoneer_response( '100250970', '55555', _model(response) ) assert result['payeeType'] == 'COMPANY' # company.name absent in the v4 response; first_name carries the # legal entity name and is promoted to companyName. assert result['companyName'] == 'ACME LLC' assert result['firstName'] == '' assert result['lastName'] == 'Ops' # Mailing address wins over incorporated address. assert result['address1'] == '1 Mailing Street' assert result['city'] == 'New York' assert result['state'] == 'NY' assert result['country'] == 'US' assert result['postal_code'] == '10001' assert result['bankAccountType'] == 'COMPANY' assert result['BankName'] == 'Chase' assert result['AccountName'] == 'ACME LLC' assert result['AccountNumber'] == '999888777' assert result['RoutingNumber'] == '021000021' assert result['AccountType'] == 'C' def test_real_sample_individual_us_routing(self) -> None: """Pinned against a real v4 response with `Routing` + `AccountType`. This sample shows two Payoneer quirks in a single response: - `"Routing"` abbreviated instead of `"Routing Number"` — handled via an explicit alias. - `"AccountType"` already in our target PascalCase (no space) — handled by the default space-strip being a no-op. """ response = { 'account_id': 'XXXXXXXX', 'type': 'INDIVIDUAL', 'contact': { 'first_name': 'First', 'last_name': 'Last', 'email': 'f@l.com', 'mobile': '5551234567', 'mobile_country': 'US', 'mobile_country_code': '1', }, 'address': { 'address_line_1': '1 Main St', 'address_line_2': '', 'city': 'Springfield', 'state': 'IL', 'country': 'US', 'zip_code': '62701', }, 'payout_method': { 'type': 'BankTransfer', 'bank_account_type': '1', 'country': 'US', 'currency': 'USD', 'bank_field_details': [ {'name': 'Bank Name', 'value': 'Chase'}, {'name': 'Account Name', 'value': 'First Last'}, {'name': 'Account Number', 'value': '123456789'}, {'name': 'Routing', 'value': '021000021'}, {'name': 'AccountType', 'value': 'C'}, ], }, } result, _warnings = map_payoneer_response( '100250970', '77777', _model(response) ) assert result['payeeType'] == 'INDIVIDUAL' assert result['state'] == 'IL' assert result['BankName'] == 'Chase' assert result['AccountName'] == 'First Last' assert result['AccountNumber'] == '123456789' assert result['RoutingNumber'] == '021000021' assert result['AccountType'] == 'C' def test_real_sample_individual_gbp(self) -> None: """Pinned against a real v4 response for a UK individual payee. Notes about the real response: - `type` is INDIVIDUAL even though the caller may consider the payee a company internally (payees sometimes register a company name in the first_name field). - `company` is absent entirely. - `address` has no `state` field. - UK bank details arrive with `Sort Code` (space) — normalized to `SortCode` via space-stripping. """ response = { 'account_id': 'XXXXXXX', 'type': 'INDIVIDUAL', 'contact': { 'first_name': 'Fn', 'last_name': 'Ln', 'email': 'x@y.com', 'mobile': '7000000000', 'mobile_country': 'GB', 'mobile_country_code': '44', }, 'address': { 'address_line_1': 'Flat 1, High Street', 'address_line_2': '', 'city': 'London', 'country': 'GB', 'zip_code': 'E1 1AA', }, 'payout_method': { 'type': 'BankTransfer', 'bank_account_type': '1', 'country': 'GB', 'currency': 'GBP', 'bank_field_details': [ {'name': 'Bank Name', 'value': 'Barclays'}, {'name': 'Account Name', 'value': 'Fn Ln'}, {'name': 'Account Number', 'value': '12345678'}, {'name': 'Sort Code', 'value': '20-00-00'}, ], }, } result, _warnings = map_payoneer_response( '100250970', '88888', _model(response) ) assert result['payeeType'] == 'INDIVIDUAL' assert result['firstName'] == 'Fn' assert result['lastName'] == 'Ln' assert result['companyName'] == '' assert result['address1'] == 'Flat 1, High Street' assert result['address2'] == '' assert result['city'] == 'London' assert result['state'] == '' assert result['country'] == 'GB' assert result['postal_code'] == 'E1 1AA' assert result['bankAccountType'] == 'PERSONAL' assert result['bankCountry'] == 'GB' assert result['currency'] == 'GBP' assert result['BankName'] == 'Barclays' assert result['AccountName'] == 'Fn Ln' assert result['AccountNumber'] == '12345678' assert result['SortCode'] == '20-00-00' assert result['Swift'] == '' assert result['IBAN'] == '' def test_no_payout_method(self) -> None: """Test mapping with no payout method information.""" response = { 'type': 'INDIVIDUAL', 'contact': {'first_name': 'Jane'}, 'address': {}, 'payout_method': None, } result, _warnings = map_payoneer_response( '100250970', '55555', _model(response) ) assert result['bankAccountType'] == '' assert result['BankName'] == '' def test_none_nested_objects(self) -> None: """Test mapping with None contact/address/company.""" response = { 'type': 'INDIVIDUAL', 'contact': None, 'address': None, 'company': None, 'payout_method': None, } result, _warnings = map_payoneer_response( '100250970', '44444', _model(response) ) assert result['firstName'] == '' assert result['address1'] == '' assert result['companyName'] == '' assert result['bankAccountType'] == '' def test_country_uk_normalized_to_gb(self) -> None: """Payoneer returns non-ISO "UK"; we emit the ISO "GB" so ows-payee accepts the code. """ response = { 'type': 'INDIVIDUAL', 'contact': {'first_name': 'Fn', 'last_name': 'Ln'}, 'address': {'country': 'UK', 'zip_code': 'E1 1AA'}, 'payout_method': { 'type': 'BankTransfer', 'bank_account_type': '1', 'country': 'UK', 'currency': 'GBP', 'bank_field_details': [], }, } result, _warnings = map_payoneer_response('99001', '12345', _model(response)) assert result['country'] == 'GB' assert result['bankCountry'] == 'GB' def test_unrecognised_bank_field_returns_warning(self) -> None: """Unknown bank field names are surfaced via the returned warnings. The normalizer is best-effort; if Payoneer starts returning a label we haven't mapped, the dropped value must show up in the per-row warnings (which the processor forwards to its log report) instead of being silently lost. """ response = { 'type': 'INDIVIDUAL', 'contact': {}, 'address': {}, 'payout_method': { 'type': 'BankTransfer', 'bank_field_details': [ {'name': 'Bank Name', 'value': 'Chase'}, {'name': 'Some Brand New Field', 'value': 'value-lost'}, ], }, } result, warnings = map_payoneer_response('100250970', '33333', _model(response)) assert result['BankName'] == 'Chase' assert any( 'Unrecognised Payoneer bank field' in w and 'SomeBrandNewField' in w for w in warnings ) def test_missing_name_key_returns_warning(self) -> None: """Bank field entry without a `name` key is surfaced as a warning. Payoneer occasionally returns sparse/partial entries in bank_field_details; the mapper emits a warning the caller can forward to its row log instead of crashing the worker thread. """ response = { 'type': 'INDIVIDUAL', 'contact': {}, 'address': {}, 'payout_method': { 'type': 'BankTransfer', 'bank_field_details': [ {'name': 'Bank Name', 'value': 'Chase'}, {'value': 'orphan-value-no-name'}, ], }, } result, warnings = map_payoneer_response('100250970', '33333', _model(response)) assert result['BankName'] == 'Chase' assert any('missing name' in w.lower() for w in warnings) def test_unknown_bank_account_type_returns_warning(self) -> None: """Non-empty `bank_account_type` outside the known set must surface a warning so the unmapped code is fixable, not silently blanked. """ response = { 'type': 'INDIVIDUAL', 'contact': {}, 'address': {}, 'payout_method': { 'type': 'BankTransfer', 'bank_account_type': '9', 'bank_field_details': [], }, } result, warnings = map_payoneer_response('100250970', '44444', _model(response)) assert result['bankAccountType'] == '' assert any('Unknown Payoneer bank_account_type' in w for w in warnings) def test_card_or_account_preserved(self) -> None: """Card-based payee with 'Card or Account' field (16 in sample). Payees 100176770/572, 100176830/3925, 100176930/456, etc. have a card-based payout method. All fields including 'Card or Account' are preserved in the report and PayeeDetails. """ response = { 'type': 'INDIVIDUAL', 'contact': {'first_name': 'Card', 'last_name': 'User'}, 'address': {'country': 'US'}, 'payout_method': { 'type': 'BankTransfer', 'bank_account_type': '1', 'country': 'US', 'currency': 'USD', 'bank_field_details': [ {'name': 'Bank Name', 'value': 'Chase'}, {'name': 'Account Name', 'value': 'Card User'}, {'name': 'SWIFT / BIC', 'value': 'CHASUS33'}, {'name': 'IBAN', 'value': 'US12345678901234'}, {'name': 'Card or Account', 'value': '411111111111'}, ], }, } result, _warnings = map_payoneer_response('99001', '456', _model(response)) assert result['BankName'] == 'Chase' assert result['Swift'] == 'CHASUS33' assert result['IBAN'] == 'US12345678901234' assert result['CardorAccount'] == '411111111111' def test_card_number_and_card_or_account_both_preserved(self) -> None: """Card payee with both 'Card Number' and 'Card or Account' (2 in sample). Payees 100176770/67870 and 100176930/69617 have both card fields. Both are now in schema and preserved through to PayeeDetails. """ response = { 'type': 'INDIVIDUAL', 'contact': {'first_name': 'Card', 'last_name': 'Both'}, 'address': {'country': 'US'}, 'payout_method': { 'type': 'BankTransfer', 'bank_account_type': '1', 'country': 'US', 'currency': 'USD', 'bank_field_details': [ {'name': 'Bank Name', 'value': 'Wells Fargo'}, {'name': 'Account Name', 'value': 'Card Both'}, {'name': 'Card Number', 'value': '4111111111111111'}, {'name': 'Card or Account', 'value': '411111111111'}, ], }, } result, _warnings = map_payoneer_response('99002', '67870', _model(response)) assert result['BankName'] == 'Wells Fargo' assert result['AccountName'] == 'Card Both' assert result['CardNumber'] == '4111111111111111' assert result['CardorAccount'] == '411111111111' def test_individual_australian_bsb(self) -> None: """Australian INDIVIDUAL with BSB + SWIFT/BIC combo (243 in sample).""" response = { 'type': 'INDIVIDUAL', 'contact': {'first_name': 'Jane', 'last_name': 'Smith', 'email': 'j@s.au'}, 'address': { 'address_line_1': '10 George St', 'city': 'Sydney', 'country': 'AU', 'zip_code': '2000', }, 'payout_method': { 'type': 'BankTransfer', 'bank_account_type': '1', 'country': 'AU', 'currency': 'AUD', 'bank_field_details': [ {'name': 'Bank Name', 'value': 'ANZ'}, {'name': 'Account Name', 'value': 'Jane Smith'}, {'name': 'Account Number', 'value': '123456789'}, {'name': 'SWIFT / BIC', 'value': 'ANZBAU3M'}, {'name': 'BSB', 'value': '012003'}, ], }, } result, _warnings = map_payoneer_response('99001', '11111', _model(response)) assert result['bankCountry'] == 'AU' assert result['currency'] == 'AUD' assert result['BSB'] == '012003' assert result['Swift'] == 'ANZBAU3M' assert result['BankName'] == 'ANZ' def test_individual_european_bic_iban(self) -> None: """European INDIVIDUAL with BIC + Bank Code + IBAN, no SWIFT (449 in sample).""" response = { 'type': 'INDIVIDUAL', 'contact': {'first_name': 'Hans', 'last_name': 'Muller', 'email': 'h@m.de'}, 'address': { 'address_line_1': 'Berliner Str 1', 'city': 'Berlin', 'country': 'DE', 'zip_code': '10115', }, 'payout_method': { 'type': 'BankTransfer', 'bank_account_type': '1', 'country': 'DE', 'currency': 'EUR', 'bank_field_details': [ {'name': 'Bank Name', 'value': 'Deutsche Bank'}, {'name': 'Account Name', 'value': 'Hans Muller'}, {'name': 'Account Number', 'value': '1234567890'}, {'name': 'BIC', 'value': 'DEUTDEDB'}, {'name': 'Bank Code', 'value': '10070024'}, {'name': 'IBAN', 'value': 'DE89370400440532013000'}, ], }, } result, _warnings = map_payoneer_response('99002', '22222', _model(response)) assert result['BIC'] == 'DEUTDEDB' assert result['BankCode'] == '10070024' assert result['IBAN'] == 'DE89370400440532013000' assert result['Swift'] == '' def test_individual_id_number_fields(self) -> None: """INDIVIDUAL with ID Number + IBAN + SWIFT (93 in sample).""" response = { 'type': 'INDIVIDUAL', 'contact': {'first_name': 'Ali', 'last_name': 'Khan', 'email': 'a@k.pk'}, 'address': { 'address_line_1': '1 Jinnah Ave', 'city': 'Karachi', 'country': 'PK', 'zip_code': '75500', }, 'payout_method': { 'type': 'BankTransfer', 'bank_account_type': '1', 'country': 'PK', 'currency': 'USD', 'bank_field_details': [ {'name': 'Bank Name', 'value': 'HBL'}, {'name': 'Account Name', 'value': 'Ali Khan'}, {'name': 'IBAN', 'value': 'PK36SCBL0000001123456702'}, {'name': 'ID Number', 'value': '42201-1234567-1'}, {'name': 'SWIFT / BIC', 'value': 'HABORPKX'}, ], }, } result, _warnings = map_payoneer_response('99003', '33333', _model(response)) assert result['IdNumber'] == '42201-1234567-1' assert result['Swift'] == 'HABORPKX' assert result['IBAN'] == 'PK36SCBL0000001123456702' def test_individual_chinese_bank_all_aliases(self) -> None: """Chinese INDIVIDUAL exercising Account Name (English) + Prov/State aliases. This is the most alias-heavy combo observed (7 in sample), exercising 3 aliases in one response. """ response = { 'type': 'INDIVIDUAL', 'contact': {'first_name': 'Wei', 'last_name': 'Li', 'email': 'w@l.cn'}, 'address': { 'address_line_1': '1 Nanjing Rd', 'city': 'Shanghai', 'country': 'CN', 'zip_code': '200001', }, 'payout_method': { 'type': 'BankTransfer', 'bank_account_type': '1', 'country': 'CN', 'currency': 'CNY', 'bank_field_details': [ {'name': 'Bank Name', 'value': 'ICBC'}, {'name': 'Account Name', 'value': '\u674e\u4f1f'}, {'name': 'Account Name (English)', 'value': 'Wei Li'}, {'name': 'Account Number', 'value': '6222021234567890'}, {'name': 'Bank Code', 'value': '102'}, {'name': 'Branch Code', 'value': '10200'}, {'name': 'Province Code', 'value': '31'}, {'name': 'Prov / State', 'value': 'Shanghai'}, {'name': 'City Code', 'value': '3100'}, {'name': 'ID Number', 'value': '310101199001011234'}, {'name': 'Storefront URL', 'value': 'https://example.com'}, {'name': 'Address', 'value': '1 Nanjing Rd'}, ], }, } result, _warnings = map_payoneer_response('99004', '44444', _model(response)) assert result['AccountNameEnglish'] == 'Wei Li' assert result['State'] == 'Shanghai' assert result['ProvinceCode'] == '31' assert result['CityCode'] == '3100' assert result['IdNumber'] == '310101199001011234' assert result['StorefrontURL'] == 'https://example.com' assert result['Address'] == '1 Nanjing Rd' assert result['BranchCode'] == '10200' def test_company_australian_bsb(self) -> None: """Australian COMPANY with BSB + SWIFT/BIC (169 in sample).""" response = { 'type': 'COMPANY', 'company': {'incorporated_country': 'AU'}, 'contact': { 'first_name': 'Oz Corp', 'last_name': 'Admin', 'email': 'a@oz.au', }, 'address': { 'address_line_1': '1 Collins St', 'city': 'Melbourne', 'country': 'AU', 'zip_code': '3000', }, 'payout_method': { 'type': 'BankTransfer', 'bank_account_type': '2', 'country': 'AU', 'currency': 'AUD', 'bank_field_details': [ {'name': 'Bank Name', 'value': 'CBA'}, {'name': 'Account Name', 'value': 'Oz Corp Pty Ltd'}, {'name': 'Account Number', 'value': '12345678'}, {'name': 'SWIFT / BIC', 'value': 'CTBAAU2S'}, {'name': 'BSB', 'value': '062000'}, ], }, } result, _warnings = map_payoneer_response('99005', '55555', _model(response)) assert result['payeeType'] == 'COMPANY' assert result['BSB'] == '062000' assert result['Swift'] == 'CTBAAU2S' def test_company_brazilian_tax_number(self) -> None: """Brazilian COMPANY with Account Tax Number + Bank Number (128 in sample).""" response = { 'type': 'COMPANY', 'company': {'incorporated_country': 'BR'}, 'contact': { 'first_name': 'BR Corp', 'last_name': 'Fin', 'email': 'f@br.com', }, 'address': { 'address_line_1': 'Av Paulista 1000', 'city': 'Sao Paulo', 'state': 'SP', 'country': 'BR', 'zip_code': '01310-100', }, 'payout_method': { 'type': 'BankTransfer', 'bank_account_type': '2', 'country': 'BR', 'currency': 'BRL', 'bank_field_details': [ {'name': 'Bank Name', 'value': 'Banco do Brasil'}, {'name': 'Account Name', 'value': 'BR Corp Ltda'}, {'name': 'Account Number', 'value': '12345-6'}, {'name': 'Bank Number', 'value': '001'}, {'name': 'Account Tax Number', 'value': '12.345.678/0001-90'}, {'name': 'AccountType', 'value': 'C'}, ], }, } result, _warnings = map_payoneer_response('99006', '66666', _model(response)) assert result['BankNumber'] == '001' assert result['AccountTaxNumber'] == '12.345.678/0001-90' assert result['AccountType'] == 'C' def test_date_of_birth_override_wins_over_payoneer(self) -> None: """Input CSV `date_of_birth` takes precedence over Payoneer's `contact.date_of_birth` so operators can correct stale or missing values without round-tripping through Payoneer. """ result, _warnings = map_payoneer_response( '99001', '12345', _model(SAMPLE_PAYONEER_RESPONSE), date_of_birth_override=date(1980, 6, 1), ) assert result['dateOfBirth'] == '1980-06-01' def test_date_of_birth_override_absent_keeps_payoneer_value(self) -> None: """No override → emit Payoneer's value unchanged.""" result, _warnings = map_payoneer_response( '99001', '12345', _model(SAMPLE_PAYONEER_RESPONSE) ) assert result['dateOfBirth'] == '1990-01-15' class TestNormalizeBankFieldName: """Tests for normalize_bank_field_name. Parametrization is driven by the full set of labels observed in a sample of 14,705 real Payoneer responses, so any regression in the normalizer or alias map fails the suite loudly. """ @pytest.mark.parametrize( ('payoneer_name', 'expected'), [ # Simple space-strip, already in correct PascalCase. ('Bank Name', 'BankName'), ('Account Name', 'AccountName'), ('Account Number', 'AccountNumber'), ('Bank Code', 'BankCode'), ('Bank Number', 'BankNumber'), ('Branch Code', 'BranchCode'), ('Branch Name', 'BranchName'), ('Sort Code', 'SortCode'), ('Account Tax Number', 'AccountTaxNumber'), ('Province Code', 'ProvinceCode'), ('City Code', 'CityCode'), ('Storefront URL', 'StorefrontURL'), ('Foreign ID Number', 'ForeignIDNumber'), # Labels Payoneer already returns in PascalCase — no-op. ('IBAN', 'IBAN'), ('BIC', 'BIC'), ('BSB', 'BSB'), ('CNIC', 'CNIC'), ('SNIC', 'SNIC'), ('Suffix', 'Suffix'), ('Address', 'Address'), ('AccountType', 'AccountType'), # Explicit aliases for labels that don't space-strip cleanly. ('Routing Number', 'RoutingNumber'), ('Routing', 'RoutingNumber'), ('SWIFT / BIC', 'Swift'), ('SWIFT/BIC', 'Swift'), ('ID Number', 'IdNumber'), ('ID Type', 'IdType'), ('Account Name (English)', 'AccountNameEnglish'), ('Account holder citizenship', 'AccountHolderCitizenship'), ('Passport number', 'PassportNumber'), ('Prov / State', 'State'), ], ) def test_known_mappings(self, payoneer_name: str, expected: str) -> None: assert normalize_bank_field_name(payoneer_name) == expected def test_every_known_label_lands_in_schema(self) -> None: """Every label seen in real data must resolve to a schema column. Serves as a tripwire: if the `BANK_FIELDS_DETAILS_NAMES` constant ever changes (rename, removal), this test surfaces which real-world Payoneer labels would start being silently dropped. """ from src.constants import BANK_FIELDS_DETAILS_NAMES # All labels observed in production sample data (14,715 responses). observed = [ 'Bank Name', 'Account Name', 'Account Number', 'AccountType', 'Routing', 'Sort Code', 'SWIFT / BIC', 'IBAN', 'Bank Code', 'BIC', 'Branch Code', 'Account Tax Number', 'Bank Number', 'Branch Name', 'BSB', 'ID Number', 'ID Type', 'Account Name (English)', 'Account holder citizenship', 'Suffix', 'CNIC', 'Storefront URL', 'Province Code', 'Prov / State', 'City Code', 'Address', 'Passport number', 'SNIC', 'Foreign ID Number', 'Card or Account', 'Card Number', ] unmapped = [ label for label in observed if normalize_bank_field_name(label) not in BANK_FIELDS_DETAILS_NAMES ] assert not unmapped, f'Payoneer labels not mapped to schema: {unmapped}'