You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

139 lines
6.7 KiB

"""All examples in this module are invented synthetic financial records."""
from decimal import localcontext, Inexact
from pathlib import Path
import pytest
from ingestion.parsers import ParseError, parse_document
from ingestion.normalization import normalize
HEADER = 'Data,Dependencia,Historico,Documento,Tipo,Valor\n'
def bank(rows, encoding='utf-8'):
return parse_document((HEADER + rows).encode(encoding), 'BANK_CSV_V1')
def card_line(day='15.02.2026', body='LOJA INVENTADA', debit='12,34', credit='0,00', partial=False):
return day + body.ljust(43 if partial else 37) + 'BR' + f' {debit} {credit}'
def alternate_full_card_line(day='15.02.2026', body='LOJA EXTERIOR', country='ESTADOS', debit='12,34', credit='0,00'):
return day + body.ljust(33) + country.ljust(7) + f' {debit} {credit}'
def card(text, partial=False, context=None):
return normalize(parse_document(text.encode(), 'OUROCARD_PARTIAL_V1' if partial else 'OUROCARD_FULL_V1', context))
def test_csv_quotes_signs_balances_encoding_and_physical_locators():
result = bank('01/02/2026,,"Café, inventado",,,-12.34\n02/02/2026,,S A L D O,,,22.00\n\n03/02/2026,,RECEITA,,,23.00\n', 'latin-1')
rows = normalize(result)
assert result.encoding == 'latin-1'
assert sum(result.skipped.values()) == 2
assert [r.to_dict()['locator'] for r in rows] == ['line:2', 'line:5']
assert rows[0].to_dict() == dict(locator='line:2', section='1', posting_date='2026-02-01', occurrence_date='2026-02-01', description='Café, inventado', amount='-12.34', kind='EXPENSE', installment_number=None, installment_total=None, errors=[])
assert rows[1].kind == 'INCOME'
@pytest.mark.parametrize('data,profile,code', [(b'\x00','BANK_CSV_V1','NUL_BYTE'), (b'x','OFX','UNSUPPORTED_PROFILE'), (b'a,b,c\n','BANK_CSV_V1','INVALID_HEADER'), (b'x' * (10*1024*1024+1),'BANK_CSV_V1','INPUT_TOO_LARGE')], ids=['nul','unsupported','header','size'])
def test_file_errors(data, profile, code):
with pytest.raises(ParseError) as exc:
parse_document(data, profile)
assert exc.value.code == code
def test_bad_csv_rows_are_reviewable_without_raw_diagnostics():
rows = normalize(bank('31/02/2026,,SECRET,,,NaN\nnot,a,row\n'))
assert set(rows[0].errors) == {'INVALID_DATE', 'INVALID_AMOUNT'}
assert rows[0].description == 'SECRET'
assert rows[1].errors == ('INVALID_ROW',)
assert rows[1].description == 'row'
assert 'SECRET' not in str(rows[0].errors)
def test_full_card_sections_installments_credit_and_payment():
rows = card('1 - SYNTHETIC OWNER\n' + card_line(body='LOJA PARC 2/3') + '\n2 - SECOND INVENTED OWNER\n' + card_line(debit='-8,11') + '\n15.02.2026PGTO DEBITO CONTA 200211 33,00 0,00\nSaldo Anterior 20,00\nunknown text')
assert rows[0].section == '1'
assert rows[0].installment_number == 2 and rows[0].installment_total == 3
assert rows[1].section == '2' and rows[1].amount == '8.11' and rows[1].kind == 'REFUND'
assert rows[2].amount == '33.00' and rows[2].kind == 'TRANSFER'
assert rows[3].errors == ('UNRECOGNIZED_LINE',)
assert rows[3].description == 'unknown text'
@pytest.mark.parametrize('country', ['ESTADOS', 'Brasil'])
def test_full_card_accepts_extended_country_layout(country):
row = card(alternate_full_card_line(country=country))[0]
assert row.errors == ()
assert row.description == 'LOJA EXTERIOR'
assert row.amount == '-12.34'
def test_card_ambiguity_bad_shapes_and_installments_block():
assert 'AMBIGUOUS_AMOUNT' in card(card_line(debit='2,00', credit='1,00'))[0].errors
assert 'INVALID_ROW' in card('15.02.2026 MALFORMED')[0].errors
assert 'INVALID_INSTALLMENT' in card(card_line(body='LOJA PARC 3/2'))[0].errors
assert 'INVALID_AMOUNT' in card(card_line(debit='1.23,00'))[0].errors
def test_partial_dates_require_explicit_unique_context_and_retain_snapshot():
text = card_line(day='31/12', partial=True) + '\n' + card_line(day='01/01', partial=True)
context = {'start_date':'2025-12-01','end_date':'2026-01-31'}
parsed = parse_document(text.encode(), 'OUROCARD_PARTIAL_V1', context)
context['start_date'] = '2000-01-01'
assert [r.posting_date for r in normalize(parsed)] == ['2025-12-31','2026-01-01']
assert 'MISSING_DATE_CONTEXT' in card(text, True)[0].errors
assert 'AMBIGUOUS_DATE' in card(card_line(day='01/01',partial=True), True, {'start_date':'2025-01-01','end_date':'2026-01-01'})[0].errors
assert 'INVALID_DATE_CONTEXT' in card(text, True, {'start_date':'2024-01-01','end_date':'2026-01-01'})[0].errors
def test_decimal_context_does_not_change_amounts():
with localcontext() as ctx:
ctx.prec = 2
ctx.traps[Inexact] = True
assert card(card_line(debit='12.345,67'))[0].amount == '-12345.67'
assert card('15.02.2026PGTO DEBITO CONTA 200211 -12.345,67 0,00')[0].amount == '12345.67'
def test_csv_multiline_locator_and_bom():
parsed = parse_document(('\ufeff' + HEADER + '01/02/2026,,"two\nlines",,,1.00\n02/02/2026,,next,,,2.00\n').encode(), 'BANK_CSV_V1')
assert parsed.encoding == 'utf-8-sig'
assert [r.locator for r in normalize(parsed)] == ['line:2','line:4']
def test_candidate_row_limit():
with pytest.raises(ParseError, match='TOO_MANY_ITEMS'):
bank('01/02/2026,,INVENTED,,,1.00\n' * 20001)
def test_invalid_quoting_is_file_error_and_header_positions_are_fixed():
with pytest.raises(ParseError, match='INVALID_CSV'):
bank('01/02/2026,,"unterminated,,,1.00')
with pytest.raises(ParseError, match='INVALID_HEADER'):
parse_document(b'Valor,Unused,Historico,Unused,Unused,Data\n', 'BANK_CSV_V1')
def test_partial_leap_date_and_invalid_ranges():
text = card_line(day='29/02', partial=True)
assert card(text, True, {'start_date':'2024-01-01','end_date':'2024-12-31'})[0].posting_date == '2024-02-29'
assert card(text, True, {'start_date':'2025-01-01','end_date':'2025-12-31'})[0].errors == ('INVALID_DATE',)
assert card(text, True, {'start_date':'2026-01-01','end_date':'2025-12-31'})[0].errors == ('INVALID_DATE_CONTEXT',)
def test_nonzero_payment_secondary_column_is_ambiguous():
assert card('15.02.2026PGTO DEBITO CONTA 200211 33,00 2,00')[0].errors == ('AMBIGUOUS_AMOUNT',)
@pytest.mark.parametrize('filename,profile,amounts', [
('bank.csv', 'BANK_CSV_V1', ['-12.34','100.00']),
('full.txt', 'OUROCARD_FULL_V1', ['-12.34','8.11','33.00']),
('partial.txt', 'OUROCARD_PARTIAL_V1', ['-12.34','-12.34']),
])
def test_synthetic_fixture_profiles(filename, profile, amounts):
data = (Path(__file__).parent / 'fixtures' / 'synthetic' / 'ingestion' / filename).read_bytes()
rows = normalize(parse_document(data, profile, {'start_date':'2025-12-01','end_date':'2026-01-31'}))
assert [row.amount for row in rows] == amounts
assert all(not row.errors for row in rows)