"""Tests for processor — ContractProcessor, _make_identifier.""" import pytest from fakes import FakeAbacusClient, make_gql_input from domain.contract_input_builder import ContractInputBuilder from domain.processor import ContractProcessor, _make_identifier from infra import CsvFileReader, CsvOutputSink, RowReader from infra.row_reader import RowValidationError from schemas import ContractRow, MissingFieldPolicy, ProcessingStatus MINIMAL_CSV_ROW = { 'Account ID': '123', 'Contract Name': 'Test', 'Contract Type': 'Distribution', 'Current Period Start Date': '01/01/2026', } class _FakeRowIterable: """Test double for RowIterable — yields pre-built rows.""" def __init__(self, fieldnames, rows): self._fieldnames = fieldnames self._rows = rows @property def fieldnames(self): return self._fieldnames def __iter__(self): return iter(self._rows) def _make_processor(dry_run=False, fail_create=False, output_sink=None): abacus = FakeAbacusClient(fail_create=fail_create) proc = ContractProcessor( abacus=abacus, contract_builder=ContractInputBuilder( signing_entities=[], run_controllers=[], ), row_reader=RowReader(readers={'.csv': CsvFileReader()}), dry_run=dry_run, output_sink=output_sink, ) return proc, abacus class TestMakeIdentifier: def test_valid(self): row = ContractRow(**MINIMAL_CSV_ROW) assert _make_identifier(row) == ('123', 'Test') def test_missing_account_id(self): row = ContractRow(**{**MINIMAL_CSV_ROW, 'Account ID': ''}) assert _make_identifier(row) is None def test_missing_contract_name(self): row = ContractRow(**{**MINIMAL_CSV_ROW, 'Contract Name': ''}) assert _make_identifier(row) is None class TestCreateAndAttach: def test_dry_run(self): proc, _ = _make_processor(dry_run=True) result = proc._create_and_attach(make_gql_input()) assert result.status == ProcessingStatus.DRY_RUN def test_success_without_run_controller(self): proc, abacus = _make_processor() result = proc._create_and_attach(make_gql_input()) assert result.status == ProcessingStatus.SUCCESS assert len(abacus.created) == 1 assert abacus.created[0].contract.run_controller_id is None def test_success_with_run_controller(self): proc, abacus = _make_processor() gql_input = make_gql_input() contract_with_rc = gql_input.contract.model_copy( update={'run_controller_id': 5} ) input_with_rc = gql_input.model_copy(update={'contract': contract_with_rc}) result = proc._create_and_attach(input_with_rc) assert result.status == ProcessingStatus.SUCCESS assert len(abacus.created) == 1 assert abacus.created[0].contract.run_controller_id == 5 def test_create_failure(self): proc, _ = _make_processor(fail_create=True) result = proc._create_and_attach(make_gql_input()) assert result.status == ProcessingStatus.ERROR class TestProcessCsv: def _write_csv(self, tmp_path, rows, filename='input.csv'): path = tmp_path / filename if not rows: path.write_text('') return str(path) import csv with open(path, 'w', newline='') as f: writer = csv.DictWriter(f, fieldnames=rows[0].keys()) writer.writeheader() writer.writerows(rows) return str(path) def test_dry_run_processes_rows(self, tmp_path): csv_file = self._write_csv( tmp_path, [ MINIMAL_CSV_ROW, {**MINIMAL_CSV_ROW, 'Account ID': '2', 'Contract Name': 'C2'}, ], ) proc, _ = _make_processor(dry_run=True) results = proc.process(input_file=csv_file) assert len(results.success) == 2 def test_limit_stops_early(self, tmp_path): csv_file = self._write_csv( tmp_path, [ {**MINIMAL_CSV_ROW, 'Account ID': str(i), 'Contract Name': f'C{i}'} for i in range(1, 4) ], ) proc, _ = _make_processor(dry_run=True) results = proc.process(input_file=csv_file, limit=2) assert len(results.success) == 2 def test_invalid_rows_are_skipped(self, tmp_path): csv_file = self._write_csv( tmp_path, [{**MINIMAL_CSV_ROW, 'Account ID': ''}], ) proc, _ = _make_processor(dry_run=True) results = proc.process(input_file=csv_file) assert len(results.success) == 0 assert len(results.skipped) == 1 def test_duplicate_rows_are_skipped(self, tmp_path): csv_file = self._write_csv(tmp_path, [MINIMAL_CSV_ROW, MINIMAL_CSV_ROW]) proc, _ = _make_processor(dry_run=True) results = proc.process(input_file=csv_file) assert len(results.success) == 1 assert len(results.skipped) == 1 assert results.skipped[0].reason == 'Duplicate in input file' def test_missing_csv_raises(self): proc, _ = _make_processor() with pytest.raises(FileNotFoundError): proc.process(input_file='/nonexistent.csv') def test_resume_skips_already_processed(self, tmp_path): csv_file = self._write_csv( tmp_path, [ MINIMAL_CSV_ROW, {**MINIMAL_CSV_ROW, 'Account ID': '456', 'Contract Name': 'New'}, ], ) success_csv = str(tmp_path / 'success.csv') import csv as csv_mod with open(success_csv, 'w', newline='') as f: writer = csv_mod.DictWriter( f, fieldnames=[ 'Contract ID', 'Account ID', 'Contract Name', 'Contract Type', 'Current Period Start Date', ], ) writer.writeheader() writer.writerow( { 'Contract ID': '99', 'Account ID': '123', 'Contract Name': 'Test', 'Contract Type': 'Distribution', 'Current Period Start Date': '01/01/2026', } ) proc, _ = _make_processor(dry_run=True) results = proc.process(input_file=csv_file, resume_csv=success_csv) assert len(results.success) == 1 def test_success_csv_written(self, tmp_path): csv_file = self._write_csv(tmp_path, [MINIMAL_CSV_ROW]) success_csv = str(tmp_path / 'success.csv') sink = CsvOutputSink(success_csv=success_csv) proc, _ = _make_processor(dry_run=True, output_sink=sink) proc.process(input_file=csv_file) content = (tmp_path / 'success.csv').read_text() assert 'Contract ID' in content assert 'DRY_RUN' in content def test_skip_policy_skips_rows_missing_period_start(self, tmp_path): csv_file = self._write_csv( tmp_path, [{**MINIMAL_CSV_ROW, 'Current Period Start Date': ''}], ) proc, _ = _make_processor(dry_run=True) results = proc.process(input_file=csv_file, policy=MissingFieldPolicy.SKIP) assert len(results.success) == 0 assert len(results.skipped) == 1 def test_default_policy_applies_defaults(self, tmp_path): csv_file = self._write_csv( tmp_path, [{**MINIMAL_CSV_ROW, 'Current Period Start Date': ''}], ) proc, _ = _make_processor(dry_run=True) results = proc.process(input_file=csv_file, policy=MissingFieldPolicy.DEFAULT) assert len(results.success) == 1 def test_failures_csv_written_on_errors(self, tmp_path): csv_file = self._write_csv( tmp_path, [{**MINIMAL_CSV_ROW, 'Account ID': ''}], ) success_csv = str(tmp_path / 'results.csv') sink = CsvOutputSink(success_csv=success_csv) proc, _ = _make_processor(dry_run=True, output_sink=sink) proc.process(input_file=csv_file) failures_csv = tmp_path / 'results_failures.csv' assert failures_csv.exists() content = failures_csv.read_text() assert 'SKIPPED' in content def test_validation_errors_surfaced_in_results(self, tmp_path): """Rows that fail Pydantic validation appear in results.skipped.""" class FakeRowReader: def read(self, file_path): return _FakeRowIterable( fieldnames=['Account ID', 'Contract Name', 'Contract Type'], rows=[ ( 1, RowValidationError( raw_data={'Account ID': 'bad', 'Contract Name': 'X'}, error='validation failed', ), ), (2, ContractRow(**MINIMAL_CSV_ROW)), ], ) proc = ContractProcessor( abacus=FakeAbacusClient(), contract_builder=ContractInputBuilder( signing_entities=[], run_controllers=[] ), row_reader=FakeRowReader(), dry_run=True, ) results = proc.process(input_file='dummy.csv') assert len(results.skipped) == 1 assert results.skipped[0].row == 1 assert 'validation failed' in results.skipped[0].reason assert len(results.success) == 1 def test_sink_auto_flushes_at_interval(self, tmp_path): """Verify the sink flushes internally after flush_interval rows.""" rows = [ {**MINIMAL_CSV_ROW, 'Account ID': str(i), 'Contract Name': f'C{i}'} for i in range(1, 6) ] csv_file = self._write_csv(tmp_path, rows) success_csv = str(tmp_path / 'success.csv') sink = CsvOutputSink(success_csv=success_csv, flush_interval=3) proc, _ = _make_processor(dry_run=True, output_sink=sink) proc.process(input_file=csv_file) content = (tmp_path / 'success.csv').read_text() lines = content.strip().split('\n') # header + 5 data rows assert len(lines) == 6 def test_resume_appends_to_existing_csv(self, tmp_path): """When resuming, new results are appended (not overwritten).""" success_csv = str(tmp_path / 'success.csv') import csv as csv_mod # Pre-populate with one existing result with open(success_csv, 'w', newline='') as f: writer = csv_mod.DictWriter( f, fieldnames=[ 'Contract ID', 'Account ID', 'Contract Name', 'Contract Type', 'Current Period Start Date', ], ) writer.writeheader() writer.writerow( { 'Contract ID': '99', 'Account ID': '123', 'Contract Name': 'Test', 'Contract Type': 'Distribution', 'Current Period Start Date': '01/01/2026', } ) csv_file = self._write_csv( tmp_path, [ MINIMAL_CSV_ROW, {**MINIMAL_CSV_ROW, 'Account ID': '456', 'Contract Name': 'New'}, ], ) sink = CsvOutputSink(success_csv=success_csv) proc, _ = _make_processor(dry_run=True, output_sink=sink) proc.process(input_file=csv_file, resume_csv=success_csv) with open(success_csv, 'r') as f: rows = list(csv_mod.DictReader(f)) # Original row + 1 new row (other was skipped via resume) assert len(rows) == 2 assert rows[0]['Account ID'] == '123' assert rows[1]['Account ID'] == '456' def test_reuse_processor_no_state_leakage(self, tmp_path): """Calling process() twice on the same instance starts fresh.""" csv_file = self._write_csv(tmp_path, [MINIMAL_CSV_ROW]) proc, _ = _make_processor(dry_run=True) results1 = proc.process(input_file=csv_file) assert len(results1.success) == 1 results2 = proc.process(input_file=csv_file) assert len(results2.success) == 1