Files
finance/backend/tests/test_adapter_contracts.py
T

946 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Sanitized synthetic contract tests for the evidenced statement adapters.
These tests never touch real statements: every input below is synthetic and
tracked. Real-file validation lives in the separately collected private
suite (tests/test_private_fixtures.py, marker `private_fixtures`).
"""
import io
from datetime import date
import pytest
from openpyxl import Workbook
from app.errors import AppError
from app.imports.adapters import ADAPTERS, detect_adapter, magic_kind
from app.imports.adapters import alfa_xlsx as alfa_module
from app.imports.adapters.alfa_xlsx import (
AlfaCreditXlsxAdapter,
AlfaDebitXlsxAdapter,
detect_savings_transfer,
)
from app.imports.adapters.base import (
ALFA_DEBIT_XLSX_VERSION,
BANK_STATUS_HOLD,
OZON_DEBIT_PDF_VERSION,
PARSER_VERSION,
ROCKETBANK_DEBIT_PDF_VERSION,
SourceRow,
StatementDraft,
UnsupportedStatementLayout,
account_fingerprint,
money_to_kopecks,
)
from app.imports.adapters.ozon_pdf import OzonCreditPdfAdapter, OzonDebitPdfAdapter
from app.imports.adapters.rocketbank_pdf import RocketbankDebitPdfAdapter
from app.imports.service import _reconciliation_summary
NBSP = "\u00a0"
def make_source_row(**overrides: object) -> SourceRow:
values: dict[str, object] = {
"external_id": None,
"occurred_on": date(2026, 1, 1),
"posted_on": date(2026, 1, 2),
"amount_kopecks": 100,
"direction": "out",
"description": None,
"mcc": None,
"bank_category": None,
"location": None,
"row_ref": "row1",
}
values.update(overrides)
return SourceRow(**values) # type: ignore[arg-type]
def make_draft(**overrides: object) -> StatementDraft:
values: dict[str, object] = {
"adapter_id": "alfa_debit_xlsx",
"parser_version": ALFA_DEBIT_XLSX_VERSION,
"bank_code": "alfa",
"product": "debit",
"account_fingerprint": None,
"currency": "RUB",
"period_start": None,
"period_end": None,
"opening_balance_kopecks": None,
"closing_balance_kopecks": None,
"rows": (),
}
values.update(overrides)
return StatementDraft(**values) # type: ignore[arg-type]
class TestBaseContract:
def test_source_row_defaults_to_posted(self) -> None:
assert make_source_row().bank_status == "posted"
def test_negative_amount_is_rejected(self) -> None:
with pytest.raises(UnsupportedStatementLayout):
make_source_row(amount_kopecks=-1)
def test_unknown_bank_status_is_rejected(self) -> None:
with pytest.raises(UnsupportedStatementLayout):
make_source_row(bank_status="pending")
def test_draft_declared_totals_default_to_none(self) -> None:
draft = make_draft()
assert draft.declared_inflow_kopecks is None
assert draft.declared_outflow_kopecks is None
assert draft.declared_hold_kopecks is None
def test_per_contract_parser_versions(self) -> None:
assert PARSER_VERSION == "1"
assert ALFA_DEBIT_XLSX_VERSION == "3"
assert OZON_DEBIT_PDF_VERSION == "2"
assert ROCKETBANK_DEBIT_PDF_VERSION == "2"
assert AlfaDebitXlsxAdapter.parser_version == ALFA_DEBIT_XLSX_VERSION
assert OzonDebitPdfAdapter.parser_version == OZON_DEBIT_PDF_VERSION
assert RocketbankDebitPdfAdapter.parser_version == ROCKETBANK_DEBIT_PDF_VERSION
def test_registry_lists_only_verified_contracts(self) -> None:
assert [adapter.adapter_id for adapter in ADAPTERS] == [
"alfa_debit_xlsx",
"ozon_debit_pdf",
"rocketbank_debit_pdf",
]
def test_credit_adapters_stay_present_but_unregistered(self) -> None:
assert AlfaCreditXlsxAdapter.adapter_id == "alfa_credit_xlsx"
assert OzonCreditPdfAdapter.adapter_id == "ozon_credit_pdf"
assert all(
adapter.adapter_id not in {"alfa_credit_xlsx", "ozon_credit_pdf"}
for adapter in ADAPTERS
)
def test_detect_adapter_fails_closed_on_unknown_xlsx(self) -> None:
buffer = io.BytesIO()
Workbook().save(buffer)
raw = buffer.getvalue()
with pytest.raises(UnsupportedStatementLayout) as exc:
detect_adapter(raw, "xlsx")
assert exc.value.code == "IMPORT_UNSUPPORTED_LAYOUT"
def test_detect_adapter_fails_closed_on_unknown_pdf(self) -> None:
with pytest.raises(UnsupportedStatementLayout):
detect_adapter(minimal_pdf(), "pdf")
def test_magic_kind(self) -> None:
assert magic_kind(b"PK\x03\x04") == "xlsx"
assert magic_kind(b"%PDF-1.4") == "pdf"
assert magic_kind(b"not a statement") is None
class TestMoneyParsing:
def test_plain_and_separated_amounts(self) -> None:
assert money_to_kopecks("123") == 12300
assert money_to_kopecks(f"1{NBSP}234,56") == 123456
assert money_to_kopecks("1 234.56") == 123456
assert money_to_kopecks("-1 234,56 RUR") == -123456
assert money_to_kopecks("+ 500.00 \u20bd") == 50000
def test_malformed_amount_fails_closed_without_leaking_digits(self) -> None:
with pytest.raises(UnsupportedStatementLayout) as exc:
money_to_kopecks("12a3,45")
assert "#" in exc.value.message
assert "12" not in exc.value.message
def alfa_workbook_bytes(rows: list[list[object]]) -> bytes:
buffer = io.BytesIO()
workbook = Workbook()
sheet = workbook.worksheets[0]
for row in rows:
sheet.append(row)
workbook.save(buffer)
return buffer.getvalue()
def synthetic_alfa_rows() -> list[list[object]]:
return [
[None, None, None],
["Выписка по счету"],
["За период с 01.09.2026 по 04.09.2026"],
["Номер счета", None, "00000000000000000001"],
["Дата открытия счета", None, "01.01.2025"],
["Валюта счета", None, "RUR"],
["Тип счета", None, "Текущий счёт"],
["Дата формирования выписки", None, "10.09.2026"],
[
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
"Поступления",
None,
"1 000,00 RUR",
],
[None, None, None, None, None, None, None, None, None, None, "Расходы", None, "293,45 RUR"],
[
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
"Неподтвержденные операции",
None,
"100,00 RUR",
],
[
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
"Текущий баланс",
None,
"800,00 RUR",
],
["Операции по счету"],
[
"Дата операции",
"Дата проводки",
None,
"Код",
"Категория",
None,
None,
None,
None,
None,
None,
"Описание",
f"Сумма в{NBSP}валюте{NBSP}счета",
None,
"Статус",
],
[
"01.09.2026",
"01.09.2026",
None,
"000000000000001",
"Покупки",
None,
None,
None,
None,
None,
None,
"Магазин 1 MCC: 5411",
"-123,45",
"Выполнен",
],
[
"02.09.2026",
"02.09.2026",
None,
"000000000000002",
"Переводы",
None,
None,
None,
None,
None,
None,
"Перевод 2 MCC5912",
"1 000,00",
"Выполнен",
],
[
"03.09.2026",
"HOLD",
None,
"000000000000003",
"Покупки",
None,
None,
None,
None,
None,
None,
"Покупка 3",
"-100,00",
],
[
"04.09.2026",
"04.09.2026",
None,
"000000000000004",
"Покупки",
None,
None,
None,
None,
None,
None,
"Покупка 4 MCC 5941",
"-50,00",
"Выполнен",
],
[
"05.09.2026",
"05.09.2026",
None,
"000000000000005",
"Покупки",
None,
None,
None,
None,
None,
None,
"Покупка 5",
"-20,00",
"Выполнен",
],
[None],
[
"(подпись сотрудника АО «БАНК»)",
None,
None,
None,
None,
None,
None,
"Уполномоченное лицо",
],
["Страница 1 из 1"],
]
class TestAlfaDebitContract:
def setup_method(self) -> None:
self.adapter = AlfaDebitXlsxAdapter()
self.raw = alfa_workbook_bytes(synthetic_alfa_rows())
def test_detect(self) -> None:
assert self.adapter.detect(self.raw) is True
def test_detect_rejects_workbook_without_contract_header(self) -> None:
raw = alfa_workbook_bytes([["Выписка по счету"], ["Что-то другое", "123"]])
assert self.adapter.detect(raw) is False
def test_parse_contract_fields(self) -> None:
draft = self.adapter.parse(self.raw)
assert draft.adapter_id == "alfa_debit_xlsx"
assert draft.parser_version == ALFA_DEBIT_XLSX_VERSION
assert draft.bank_code == "alfa"
assert draft.product == "debit"
assert draft.currency == "RUB"
assert draft.account_fingerprint == account_fingerprint("alfa", "00000000000000000001")
assert draft.declared_inflow_kopecks == 100000
# canonical posted-only: raw 293,45 minus declared hold 100,00
assert draft.declared_outflow_kopecks == 19345
assert draft.declared_hold_kopecks == 10000
assert draft.closing_balance_kopecks == 80000
assert draft.opening_balance_kopecks is None
# exact header period fields, not min/max of transaction dates
assert draft.period_start == date(2026, 9, 1)
assert draft.period_end == date(2026, 9, 4)
def test_missing_period_field_fails_closed(self) -> None:
rows = [list(row) for row in synthetic_alfa_rows()]
rows[2] = ["Что-то другое"]
with pytest.raises(UnsupportedStatementLayout) as exc:
self.adapter.parse(alfa_workbook_bytes(rows))
assert "period" in exc.value.message
def test_inconsistent_expense_control_fails_closed(self) -> None:
rows = [list(row) for row in synthetic_alfa_rows()]
rows[9] = [
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
"Расходы",
None,
"50,00 RUR",
]
with pytest.raises(UnsupportedStatementLayout) as exc:
self.adapter.parse(alfa_workbook_bytes(rows))
assert "expense" in exc.value.message
def test_parse_rows_with_hold(self) -> None:
draft = self.adapter.parse(self.raw)
assert len(draft.rows) == 5
first, second, hold, fourth, last = draft.rows
assert (first.amount_kopecks, first.direction, first.bank_status) == (
12345,
"out",
"posted",
)
assert first.posted_on == date(2026, 9, 1)
assert (second.amount_kopecks, second.direction) == (100000, "in")
assert hold.bank_status == BANK_STATUS_HOLD
assert hold.posted_on is None
assert hold.amount_kopecks == 10000
assert hold.direction == "out"
assert last.warnings == ()
assert first.row_ref == "sheet0:row15"
assert first.bank_category == "Покупки"
def test_external_ids_from_code_column(self) -> None:
draft = self.adapter.parse(self.raw)
assert [row.external_id for row in draft.rows] == [
"000000000000001",
"000000000000002",
"000000000000003",
"000000000000004",
"000000000000005",
]
def test_mcc_from_description(self) -> None:
draft = self.adapter.parse(self.raw)
first, second, hold, fourth, last = draft.rows
assert first.mcc == 5411
assert second.mcc == 5912
assert hold.mcc is None
assert fourth.mcc == 5941
assert last.mcc is None
assert all(row.location is None for row in draft.rows)
def test_footer_is_excluded(self) -> None:
draft = self.adapter.parse(self.raw)
assert all(row.occurred_on is not None for row in draft.rows)
assert draft.rows[-1].occurred_on == date(2026, 9, 5)
def test_parse_is_deterministic(self) -> None:
assert self.adapter.parse(self.raw) == self.adapter.parse(self.raw)
def test_service_reconciliation_contract(self) -> None:
draft = self.adapter.parse(self.raw)
summary = _reconciliation_summary(draft)
assert summary["matched"] is True
assert summary["posted_totals_matched"] is True
assert summary["hold_totals_matched"] is True
def test_service_reconciliation_flags_mismatch(self) -> None:
draft = make_draft(
declared_inflow_kopecks=1000,
declared_outflow_kopecks=1,
declared_hold_kopecks=0,
rows=(make_source_row(amount_kopecks=1000, direction="in"),),
)
summary = _reconciliation_summary(draft)
assert summary["posted_totals_matched"] is False
assert summary["matched"] is False
def test_malformed_layout_fails_closed(self) -> None:
raw = alfa_workbook_bytes([["Дата", "Сумма"], ["01.09.2026", "-10,00"]])
assert self.adapter.detect(raw) is False
with pytest.raises(UnsupportedStatementLayout) as exc:
self.adapter.parse(raw)
assert exc.value.code == "IMPORT_UNSUPPORTED_LAYOUT"
def test_unexpected_mid_table_row_fails_closed(self) -> None:
rows = [list(row) for row in synthetic_alfa_rows()]
rows.insert(16, ["unexpected layout drift"])
with pytest.raises(UnsupportedStatementLayout) as exc:
self.adapter.parse(alfa_workbook_bytes(rows))
assert exc.value.code == "IMPORT_UNSUPPORTED_LAYOUT"
def test_unsupported_currency_fails_closed(self) -> None:
rows = [list(row) for row in synthetic_alfa_rows()]
rows[5][2] = "USD"
with pytest.raises(UnsupportedStatementLayout):
self.adapter.parse(alfa_workbook_bytes(rows))
def test_row_quota_is_enforced(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(alfa_module, "get_settings_row_cap", lambda: 3)
with pytest.raises(UnsupportedStatementLayout) as exc:
self.adapter.parse(self.raw)
assert "quota" in exc.value.message
class TestAlfaSavingsRule:
"""«Копилка для сдачи» contract: exact normalized phrase, statement-side
source account, outgoing posted rows only. Every input is synthetic."""
STATEMENT_ACCOUNT = "00000000000000000001"
SERVICE_ACCOUNT = "00000000000000000306"
PHRASE = (
'Перечисление средств в рамках услуги "Копилка для сдачи" '
"со счета {source} на счет {target}"
)
def rule(
self,
description: str | None,
*,
direction: str = "out",
bank_status: str = "posted",
statement_account: str = STATEMENT_ACCOUNT,
) -> bool:
return detect_savings_transfer(
description,
direction=direction,
bank_status=bank_status,
statement_account=statement_account,
)
def test_exact_phrase_matches(self) -> None:
assert self.rule(
self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT)
)
def test_spaces_case_and_quotes_are_normalized(self) -> None:
noisy = (
" Перечисление\u00a0средств в рамках услуги «КОПИЛКА ДЛЯ СДАЧИ» "
f"со счёта {self.STATEMENT_ACCOUNT} на счет {self.SERVICE_ACCOUNT} "
)
assert self.rule(noisy)
def test_mismatched_source_account_fails_closed(self) -> None:
assert not self.rule(
self.PHRASE.format(source="00000000000000000099", target=self.SERVICE_ACCOUNT)
)
def test_incoming_row_never_matches(self) -> None:
assert not self.rule(
self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT),
direction="in",
)
def test_hold_row_never_matches(self) -> None:
assert not self.rule(
self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT),
bank_status="hold",
)
def test_similar_description_without_full_pattern_fails_closed(self) -> None:
assert not self.rule("Копилка для сдачи: пополнение")
assert not self.rule(
'Перечисление в рамках услуги "Копилка для сдачи" со счета 123 на счет 306'
)
def test_masked_statement_account_fails_closed(self) -> None:
assert not self.rule(
self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT),
statement_account="0000********0001",
)
def savings_statement_rows(self) -> list[list[object]]:
rows = [list(row) for row in synthetic_alfa_rows()]
rows[9] = [
None, None, None, None, None, None, None, None, None, None,
"Расходы", None, "416,90 RUR",
]
savings_row = [
"03.09.2026",
"03.09.2026",
None,
"000000000000009",
"Переводы",
None,
None,
None,
None,
None,
None,
self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT),
"-123,45",
"Выполнен",
]
rows.insert(17, savings_row)
return rows
def test_parse_marks_savings_rows_and_bumps_version(self) -> None:
adapter = AlfaDebitXlsxAdapter()
raw = alfa_workbook_bytes(self.savings_statement_rows())
draft = adapter.parse(raw)
assert draft.parser_version == ALFA_DEBIT_XLSX_VERSION
by_code = {row.external_id: row for row in draft.rows}
assert len(draft.rows) == 6
marked = by_code["000000000000009"]
assert marked.savings_transfer is True
assert marked.direction == "out"
assert marked.bank_status == "posted"
assert marked.amount_kopecks == 12345
assert all(
row.savings_transfer is False
for code, row in by_code.items()
if code != "000000000000009"
)
def test_parse_leaves_plain_expenses_unmarked(self) -> None:
adapter = AlfaDebitXlsxAdapter()
draft = adapter.parse(alfa_workbook_bytes(synthetic_alfa_rows()))
assert all(not row.savings_transfer for row in draft.rows)
def ozon_extracted() -> tuple[list[str], list[list[list[object]]]]:
pages_text = [
"\n".join(
[
"ООО «ОЗОН Банк», ИНН 1234567890, город Москва",
"Справка о движении средств",
"Номер лицевого счёта: № 00000000000000000001, открыт 01.01.2025",
"Период выписки: 01.02.2026 – 28.02.2026",
"Входящий остаток: 1000.50 \u20bd",
]
),
"",
"\n".join(
[
"Итого зачислений за период: 3000.00 \u20bd",
"Итого списаний за период: 2500.75 \u20bd",
"Исходящий остаток: 1499.75 \u20bd",
]
),
]
pages_tables = [
[
[
[
"Дата операции",
"Документ",
"Назначение платежа",
"Сумма операции",
None,
],
[None, None, None, "Российские рубли", "Валюта"],
[
"01.02.2026 10:00:00",
"1234567890\n1",
"Оплата товаров\nпо карте 1234",
"- 500.00 \u20bd",
"- 500.00 \u20bd",
],
]
],
[
[
[
"02.02.2026 11:30:00",
"1234567891",
"Перевод\nчерез СБП",
"+ 2 000,00 \u20bd",
"+ 2 000,00 \u20bd",
]
]
],
[],
]
return pages_text, pages_tables
class TestOzonDebitContract:
def setup_method(self) -> None:
self.adapter = OzonDebitPdfAdapter()
self.pages_text, self.pages_tables = ozon_extracted()
def test_core_contract_fields(self) -> None:
draft = self.adapter._draft_from_extracted(self.pages_text, self.pages_tables)
assert draft.adapter_id == "ozon_debit_pdf"
assert draft.bank_code == "ozon"
assert draft.currency == "RUB"
assert draft.account_fingerprint == account_fingerprint("ozon", "00000000000000000001")
assert draft.period_start == date(2026, 2, 1)
assert draft.period_end == date(2026, 2, 28)
assert draft.opening_balance_kopecks == 100050
assert draft.closing_balance_kopecks == 149975
assert draft.declared_inflow_kopecks == 300000
assert draft.declared_outflow_kopecks == 250075
assert draft.declared_hold_kopecks is None
def test_multiline_cells_stay_one_transaction(self) -> None:
draft = self.adapter._draft_from_extracted(self.pages_text, self.pages_tables)
assert len(draft.rows) == 2
first, second = draft.rows
assert first.external_id == "12345678901"
assert first.description == "Оплата товаров по карте 1234"
assert (first.amount_kopecks, first.direction) == (50000, "out")
assert first.occurred_on == date(2026, 2, 1)
assert (second.amount_kopecks, second.direction) == (200000, "in")
assert second.external_id == "1234567891"
assert first.warnings == ()
def test_missing_header_marker_fails_closed(self) -> None:
pages_text, pages_tables = ozon_extracted()
pages_text[0] = "Какой-то другой документ"
with pytest.raises(UnsupportedStatementLayout):
self.adapter._draft_from_extracted(pages_text, pages_tables)
def test_missing_totals_fail_closed(self) -> None:
pages_text, pages_tables = ozon_extracted()
pages_text[2] = ""
with pytest.raises(UnsupportedStatementLayout):
self.adapter._draft_from_extracted(pages_text, pages_tables)
def test_no_rows_fails_closed(self) -> None:
pages_text, _ = ozon_extracted()
with pytest.raises(UnsupportedStatementLayout):
self.adapter._draft_from_extracted(pages_text, [[]])
def test_unexpected_row_shape_fails_closed(self) -> None:
pages_text, pages_tables = ozon_extracted()
pages_tables[0][0].append([None, None, "текст без суммы"])
with pytest.raises(UnsupportedStatementLayout):
self.adapter._draft_from_extracted(pages_text, pages_tables)
def test_detect_and_parse_fail_closed_on_blank_pdf(self) -> None:
raw = minimal_pdf()
assert self.adapter.detect(raw) is False
with pytest.raises(UnsupportedStatementLayout) as exc:
self.adapter.parse(raw)
assert "text layer" in exc.value.message
def test_oversized_pdf_is_rejected_by_quota(self, monkeypatch: pytest.MonkeyPatch) -> None:
import app.imports.readers as readers_module
monkeypatch.setattr(readers_module, "get_settings", lambda: _QuotaSettings())
with pytest.raises(AppError) as exc:
self.adapter.parse(minimal_pdf(page_count=2))
assert exc.value.code == "IMPORT_QUOTA_EXCEEDED"
def same_page_rocket_lines() -> list[tuple[int, str]]:
return [
(1, "ВЫПИСКА ПО СЧЕТУ"),
(1, "С 01.07.2026 ПО 10.09.2026"),
(1, "ДЕБЕТОВАЯ КАРТА РОКЕТ 00000000000000000001 RUR"),
(1, "ВХОДЯЩИЙ ОСТАТОК ПОСТУПЛЕНИЯ"),
(1, "0,00 RUR 0,00 RUR"),
(1, "ИСХОДЯЩИЙ ОСТАТОК РАСХОДЫ"),
(1, "0,00 RUR 0,00 RUR"),
(1, "01 ИЮЛЯ 2026 БАЛАНС НА НАЧАЛО ДНЯ: 0,00 RUR"),
(1, "ВРЕМЯ КОД ОПЕРАЦИИ ОПИСАНИЕ СУММА"),
(1, "10:00 00000000001 -10,00 RUR"),
(1, "11:00 00000000002 -20,00 RUR"),
(1, "12:00 00000000003 -30,00 RUR"),
(1, "ВСЕГО ПОСТУПЛЕНИЙ: 0,00 RUR"),
(1, "ВСЕГО РАСХОДОВ: 60,00 RUR"),
]
def synthetic_rocket_lines() -> list[tuple[int, str]]:
return [
(1, "ВЫПИСКА ПО СЧЕТУ"),
(1, "ДАТА ВЫДАЧИ ВЫПИСКИ: 11.09.2026"),
(1, "С 01.07.2026 ПО 10.09.2026"),
(1, "ИВАНОВ ИВАН ИВАНОВИЧ"),
(1, "ДЕБЕТОВАЯ КАРТА РОКЕТ 00000000000000000001 RUR"),
(1, "ВХОДЯЩИЙ ОСТАТОК ПОСТУПЛЕНИЯ ДАТА ПОСЛЕДНЕГО ДВИЖЕНИЯ"),
(1, "0,00 RUR 1 000,00 RUR 01.07.2026"),
(1, "ИСХОДЯЩИЙ ОСТАТОК РАСХОДЫ"),
(1, "500,00 RUR 500,00 RUR"),
(1, "01 ИЮЛЯ 2026 БАЛАНС НА НАЧАЛО ДНЯ: 0,00 RUR"),
(1, "ВРЕМЯ КОД ОПЕРАЦИИ ОПИСАНИЕ СУММА"),
(1, "ЗАЧИСЛЕНИЕ ПЕРЕВОДА ДЕНЕЖНЫХ СРЕДСТВ/СБП/"),
(1, "12:00 00000000001 +1 000,00 RUR"),
(1, "СЧЕТ КОРРЕСПОНДЕНТА БАНК ВТБ (ПАО)."),
(1, "ВСЕГО ПОСТУПЛЕНИЙ: 1 000,00 RUR"),
(1, "ВСЕГО РАСХОДОВ: 0,00 RUR"),
(1, "СТРАНИЦА 1/2"),
(2, "ВЫПИСКА ПО СЧЕТУ"),
(2, "ДАТА ВЫДАЧИ ВЫПИСКИ: 11.09.2026"),
(2, "С 01.07.2026 ПО 10.09.2026"),
(2, "02 ИЮЛЯ 2026 БАЛАНС НА НАЧАЛО ДНЯ: 1 000,00 RUR"),
(2, "ВРЕМЯ КОД ОПЕРАЦИИ ОПИСАНИЕ СУММА"),
(2, "ПЛАТЕЖ АВТОРИЗАЦИЯ №123,01.07.2026 10:00,0000********0000,"),
(2, "13:30 00000000002 555RUR,EKATERINBURG,MCC 5411,SHOP\\RU\\CITY\\SHOP\\ -250,50 RUR"),
(2, "0000\\ ЗДАНИЕ."),
(2, "ВСЕГО ПОСТУПЛЕНИЙ: 0,00 RUR"),
(2, "ВСЕГО РАСХОДОВ: 250,50 RUR"),
(2, "СТРАНИЦА 2/2"),
]
class TestRocketbankDebitContract:
def setup_method(self) -> None:
self.adapter = RocketbankDebitPdfAdapter()
def test_day_and_page_state_machine(self) -> None:
draft = self.adapter._draft_from_lines(synthetic_rocket_lines())
assert len(draft.rows) == 2
first, second = draft.rows
assert first.occurred_on == date(2026, 7, 1)
assert second.occurred_on == date(2026, 7, 2)
def test_row_refs_are_unique_within_a_page(self) -> None:
draft = self.adapter._draft_from_lines(synthetic_rocket_lines())
first, second = draft.rows
assert first.row_ref == "page1:record1"
assert second.row_ref == "page2:record1"
same_page = self.adapter._draft_from_lines(same_page_rocket_lines())
refs = [row.row_ref for row in same_page.rows]
assert refs == ["page1:record1", "page1:record2", "page1:record3"]
assert len(set(refs)) == len(refs)
def test_statement_controls(self) -> None:
draft = self.adapter._draft_from_lines(synthetic_rocket_lines())
assert draft.adapter_id == "rocketbank_debit_pdf"
assert draft.bank_code == "rocketbank"
assert draft.currency == "RUB"
assert draft.account_fingerprint == account_fingerprint(
"rocketbank", "00000000000000000001"
)
assert draft.period_start == date(2026, 7, 1)
assert draft.period_end == date(2026, 9, 10)
assert draft.opening_balance_kopecks == 0
assert draft.closing_balance_kopecks == 50000
assert draft.declared_inflow_kopecks == 100000
assert draft.declared_outflow_kopecks == 50000
assert draft.declared_hold_kopecks is None
def test_record_folding_and_enrichment(self) -> None:
draft = self.adapter._draft_from_lines(synthetic_rocket_lines())
first, second = draft.rows
assert first.external_id == "00000000001"
assert (first.amount_kopecks, first.direction) == (100000, "in")
assert "ЗАЧИСЛЕНИЕ ПЕРЕВОДА" in (first.description or "")
assert "СЧЕТ КОРРЕСПОНДЕНТА" in (first.description or "")
assert first.mcc is None
assert second.external_id == "00000000002"
assert (second.amount_kopecks, second.direction) == (25050, "out")
assert second.mcc == 5411
assert second.location == "EKATERINBURG"
assert (second.description or "").startswith("ПЛАТЕЖ АВТОРИЗАЦИЯ")
assert "555RUR" in (second.description or "")
assert "ЗДАНИЕ." in (second.description or "")
assert all(row.warnings == () for row in draft.rows)
def test_repeated_headers_and_daily_totals_are_ignored(self) -> None:
draft = self.adapter._draft_from_lines(synthetic_rocket_lines())
joined = " ".join(row.description or "" for row in draft.rows)
assert "ВСЕГО" not in joined
assert "ВЫПИСКА ПО СЧЕТУ" not in joined
assert "СТРАНИЦА" not in joined
def test_parse_is_deterministic(self) -> None:
assert self.adapter._draft_from_lines(
synthetic_rocket_lines()
) == self.adapter._draft_from_lines(synthetic_rocket_lines())
def test_record_before_day_section_fails_closed(self) -> None:
lines = [
(1, "ВЫПИСКА ПО СЧЕТУ"),
(1, "ДЕБЕТОВАЯ КАРТА РОКЕТ 00000000000000000001 RUR"),
(1, "12:00 00000000001 +1 000,00 RUR"),
]
with pytest.raises(UnsupportedStatementLayout):
self.adapter._draft_from_lines(lines)
def test_missing_header_block_fails_closed(self) -> None:
lines = [
(1, "ВЫПИСКА ПО СЧЕТУ"),
(1, "ДЕБЕТОВАЯ КАРТА РОКЕТ 00000000000000000001 RUR"),
(1, "01 ИЮЛЯ 2026 БАЛАНС НА НАЧАЛО ДНЯ: 0,00 RUR"),
(1, "12:00 00000000001 +1 000,00 RUR"),
]
with pytest.raises(UnsupportedStatementLayout):
self.adapter._draft_from_lines(lines)
def test_unrecognized_content_fails_closed(self) -> None:
lines = synthetic_rocket_lines()[:11] + [(1, "НЕИЗВЕСТНАЯ СТРОКА БЕЗ СТРУКТУРЫ")]
with pytest.raises(UnsupportedStatementLayout) as exc:
self.adapter._draft_from_lines(lines)
assert exc.value.code == "IMPORT_UNSUPPORTED_LAYOUT"
def test_fold_line_cap_fails_closed(self) -> None:
junk = [(1, f"НЕИЗВЕСТНАЯ СТРОКА {index}") for index in range(7)]
lines = synthetic_rocket_lines()[:11] + junk
with pytest.raises(UnsupportedStatementLayout):
self.adapter._draft_from_lines(lines)
def test_detect_and_parse_fail_closed_on_blank_pdf(self) -> None:
raw = minimal_pdf()
assert self.adapter.detect(raw) is False
with pytest.raises(UnsupportedStatementLayout) as exc:
self.adapter.parse(raw)
assert "text layer" in exc.value.message
def test_oversized_pdf_is_rejected_by_quota(self, monkeypatch: pytest.MonkeyPatch) -> None:
import app.imports.readers as readers_module
monkeypatch.setattr(readers_module, "get_settings", lambda: _QuotaSettings())
with pytest.raises(AppError) as exc:
self.adapter.parse(minimal_pdf(page_count=2))
assert exc.value.code == "IMPORT_QUOTA_EXCEEDED"
class _QuotaSettings:
max_pdf_pages = 1
def minimal_pdf(page_count: int = 1, page_text: str = "") -> bytes:
"""Build a small valid single-font PDF with empty or ASCII-only pages."""
objects: list[tuple[int, bytes]] = []
page_ids: list[int] = []
next_id = 3
for _ in range(page_count):
page_id = next_id
content_id = next_id + 1
next_id += 2
page_ids.append(page_id)
content = (
f"BT /F1 12 Tf 72 720 Td ({page_text}) Tj ET".encode("latin-1", "replace")
if page_text
else b""
)
objects.append(
(
content_id,
b"<< /Length %d >>\nstream\n%s\nendstream" % (len(content), content),
)
)
objects.append(
(
page_id,
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents %d 0 R >>"
% content_id,
)
)
font_id = next_id
objects.append((font_id, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"))
objects.insert(
0,
(
2,
b"<< /Type /Pages /Kids ["
+ b" ".join(b"%d 0 R" % page_id for page_id in page_ids)
+ b"] /Count %d >>" % page_count,
),
)
objects.insert(0, (1, b"<< /Type /Catalog /Pages 2 0 R >>"))
out = bytearray(b"%PDF-1.4\n")
offsets: dict[int, int] = {}
for number, body in objects:
offsets[number] = len(out)
out += b"%d 0 obj\n" % number + body + b"\nendobj\n"
xref_position = len(out)
size = max(offsets) + 1
out += b"xref\n0 %d\n" % size
out += b"0000000000 65535 f \n"
for number in range(1, size):
out += b"%010d 00000 n \n" % offsets[number]
out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (
size,
xref_position,
)
return bytes(out)