"""Tests for infra/output.py — CSV writers, OutputSink implementations.""" import csv import json import pytest from infra.output import ( CsvOutputSink, NullOutputSink, write_failures_csv, write_success_csv, ) from schemas import AbacusContract, GraphQLResult, ProcessingStatus from schemas.results import ErrorRow, SkippedRow class TestWriteSuccessCsv: def test_writes_header_and_rows(self, tmp_path): out = tmp_path / 'success.csv' write_success_csv( [{'Contract ID': '1', 'Name': 'A'}], str(out), fieldnames=['Name'], ) content = out.read_text() assert 'Contract ID' in content assert ',A' in content def test_append_does_not_repeat_header(self, tmp_path): out = tmp_path / 'success.csv' write_success_csv( [{'Contract ID': '1', 'Name': 'A'}], str(out), fieldnames=['Name'], ) write_success_csv( [{'Contract ID': '2', 'Name': 'B'}], str(out), fieldnames=['Name'], append=True, ) lines = out.read_text().strip().split('\n') # Header once + two data rows assert len(lines) == 3 assert lines[0].startswith('Contract ID') def test_overwrite_replaces_file(self, tmp_path): out = tmp_path / 'success.csv' write_success_csv( [{'Contract ID': '1', 'Name': 'old'}], str(out), fieldnames=['Name'], ) write_success_csv( [{'Contract ID': '2', 'Name': 'new'}], str(out), fieldnames=['Name'], append=False, ) content = out.read_text() assert 'old' not in content assert 'new' in content class TestWriteFailuresCsv: def test_writes_errors_and_skipped(self, tmp_path): out = tmp_path / 'failures.csv' errors = [ ErrorRow( row=1, data={'Account ID': '1', 'Contract Name': 'Fail'}, result=GraphQLResult( status=ProcessingStatus.ERROR, error='Server error' ), ) ] skipped = [ SkippedRow( row=2, data={'Account ID': '2', 'Contract Name': 'Skip'}, reason='Missing field', ) ] write_failures_csv( errors, skipped, str(out), fieldnames=['Account ID', 'Contract Name'] ) with open(out, 'r') as f: reader = csv.DictReader(f) rows = list(reader) assert len(rows) == 2 assert rows[0]['Status'] == 'ERROR' assert rows[0]['Reason'] == 'Server error' assert rows[0]['Account ID'] == '1' assert rows[0]['Contract Name'] == 'Fail' assert rows[1]['Status'] == 'SKIPPED' assert rows[1]['Reason'] == 'Missing field' assert rows[1]['Account ID'] == '2' def test_empty_results(self, tmp_path): out = tmp_path / 'failures.csv' write_failures_csv([], [], str(out), fieldnames=['Account ID']) with open(out, 'r') as f: reader = csv.DictReader(f) rows = list(reader) assert len(rows) == 0 class TestCsvOutputSink: def test_buffers_and_flushes_on_close(self, tmp_path): out = str(tmp_path / 'success.csv') sink = CsvOutputSink(success_csv=out, flush_interval=10) sink.open(['Name']) sink.add_success({'Contract ID': '1', 'Name': 'A'}) sink.add_success({'Contract ID': '2', 'Name': 'B'}) # Not flushed yet — below interval assert not (tmp_path / 'success.csv').exists() sink.close() lines = (tmp_path / 'success.csv').read_text().strip().split('\n') assert len(lines) == 3 # header + 2 rows def test_auto_flushes_at_interval(self, tmp_path): out = str(tmp_path / 'success.csv') sink = CsvOutputSink(success_csv=out, flush_interval=2) sink.open(['Name']) sink.add_success({'Contract ID': '1', 'Name': 'A'}) assert not (tmp_path / 'success.csv').exists() sink.add_success({'Contract ID': '2', 'Name': 'B'}) # Should have auto-flushed at 2 assert (tmp_path / 'success.csv').exists() lines = (tmp_path / 'success.csv').read_text().strip().split('\n') assert len(lines) == 3 # header + 2 rows # Add more and close sink.add_success({'Contract ID': '3', 'Name': 'C'}) sink.close() lines = (tmp_path / 'success.csv').read_text().strip().split('\n') assert len(lines) == 4 # header + 3 rows def test_appends_when_file_exists(self, tmp_path): out = str(tmp_path / 'success.csv') # Pre-populate with existing data with open(out, 'w', newline='') as f: writer = csv.DictWriter( f, fieldnames=['Contract ID', 'Name'], extrasaction='ignore' ) writer.writeheader() writer.writerow({'Contract ID': '1', 'Name': 'Existing'}) sink = CsvOutputSink(success_csv=out) sink.open(['Name']) sink.add_success({'Contract ID': '2', 'Name': 'New'}) sink.close() with open(out, 'r') as f: rows = list(csv.DictReader(f)) assert len(rows) == 2 assert rows[0]['Name'] == 'Existing' assert rows[1]['Name'] == 'New' def test_overwrites_when_no_existing_file(self, tmp_path): out = str(tmp_path / 'success.csv') sink = CsvOutputSink(success_csv=out) sink.open(['Name']) sink.add_success({'Contract ID': '1', 'Name': 'A'}) sink.close() with open(out, 'r') as f: rows = list(csv.DictReader(f)) assert len(rows) == 1 def test_close_is_idempotent(self, tmp_path): out = str(tmp_path / 'success.csv') sink = CsvOutputSink(success_csv=out) sink.open(['Name']) sink.add_success({'Contract ID': '1', 'Name': 'A'}) sink.close() sink.close() # Second close should not fail or duplicate with open(out, 'r') as f: rows = list(csv.DictReader(f)) assert len(rows) == 1 def test_write_failures(self, tmp_path): out = str(tmp_path / 'results.csv') sink = CsvOutputSink(success_csv=out) sink.open(['Account ID']) sink.write_failures( errors=[], skipped=[ SkippedRow(row=1, data={'Account ID': '1'}, reason='bad'), ], ) failures = tmp_path / 'results_failures.csv' assert failures.exists() with open(str(failures), 'r') as f: rows = list(csv.DictReader(f)) assert len(rows) == 1 def test_flush_failure_propagates(self, tmp_path): """Flush errors are not silently swallowed — they propagate.""" out = str(tmp_path / 'nonexistent_dir' / 'success.csv') sink = CsvOutputSink(success_csv=out) sink.open(['Name']) sink.add_success({'Contract ID': '1', 'Name': 'A'}) with pytest.raises(FileNotFoundError): sink.close() def test_failures_path_uses_pathlib(self, tmp_path): """Failures path is derived via pathlib, not string replacement.""" out = str(tmp_path / 'my.csv.results.csv') sink = CsvOutputSink(success_csv=out) sink.open(['Account ID']) sink.write_failures( errors=[], skipped=[ SkippedRow(row=1, data={'Account ID': '1'}, reason='bad'), ], ) # pathlib.with_stem produces 'my.csv.results_failures.csv', # not the old behavior of 'my_failures.csv.results.csv' failures = tmp_path / 'my.csv.results_failures.csv' assert failures.exists() class TestCsvOutputSinkLifecycle: def test_add_success_before_open_raises(self, tmp_path): out = str(tmp_path / 'success.csv') sink = CsvOutputSink(success_csv=out) with pytest.raises(RuntimeError, match='open\\(\\) must be called'): sink.add_success({'Name': 'A'}) def test_write_failures_before_open_raises(self, tmp_path): out = str(tmp_path / 'success.csv') sink = CsvOutputSink(success_csv=out) with pytest.raises(RuntimeError, match='open\\(\\) must be called'): sink.write_failures(errors=[], skipped=[]) class TestNullOutputSink: def test_all_methods_are_noop(self): sink = NullOutputSink() sink.open(['Name']) sink.add_success({'Name': 'A'}) sink.write_failures([], []) sink.close()