d582f15681
- Implemented tests for import polling functionality, ensuring correct behavior for active and terminal import statuses. - Created tests for the login page, validating user interactions and error handling. - Added unit tests for money utility functions, covering parsing, formatting, and summation of monetary values. - Developed tests for query keys, ensuring proper scoping and invalidation roots. chore: setup testing environment and configuration - Added setup file for testing with Vitest and Testing Library. - Configured TypeScript and Vite for the frontend project. - Created Docker and Nginx configurations for local development and production environments. - Established a PostgreSQL initialization script for setting up application roles. docs: add README and environment configuration for infra - Included README for the infra directory, detailing local development setup and service descriptions. - Created an example environment configuration file for local development with placeholders for sensitive information.
284 lines
9.4 KiB
Python
284 lines
9.4 KiB
Python
import uuid
|
|
from contextlib import contextmanager
|
|
from datetime import date
|
|
|
|
import pytest
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.errors import AppError
|
|
from app.identity.models import User
|
|
from app.imports import service
|
|
from app.imports.adapters.base import SourceRow
|
|
from app.imports.models import (
|
|
DedupCandidate,
|
|
ParseRun,
|
|
SourceRecord,
|
|
SourceRecordLink,
|
|
StatementDocument,
|
|
)
|
|
from app.ledger.models import Operation
|
|
from app.ledger.service import create_account
|
|
from app.spaces.service import create_space
|
|
|
|
|
|
def seed(db: Session):
|
|
user = User(email=f"{uuid.uuid4().hex}@example.com", password_hash="unused")
|
|
db.add(user)
|
|
db.flush()
|
|
space = create_space(db, user=user, name="Imports")
|
|
account = create_account(
|
|
db,
|
|
space_id=space.id,
|
|
user=user,
|
|
name="Debit",
|
|
kind="debit",
|
|
opened_on=date(2026, 1, 1),
|
|
)
|
|
return user, space, account
|
|
|
|
|
|
def ready_document(db: Session):
|
|
user, space, account = seed(db)
|
|
document = StatementDocument(
|
|
space_id=space.id,
|
|
account_id=account.id,
|
|
original_filename="synthetic.xlsx",
|
|
size_bytes=100,
|
|
content_sha256=uuid.uuid4().hex + uuid.uuid4().hex,
|
|
object_key=uuid.uuid4().hex,
|
|
status="ready",
|
|
reconciliation={"full": True, "matched": True},
|
|
uploaded_by_user_id=user.id,
|
|
)
|
|
db.add(document)
|
|
db.flush()
|
|
run = ParseRun(
|
|
space_id=space.id,
|
|
document_id=document.id,
|
|
adapter_id="alfa_debit_xlsx",
|
|
status="succeeded",
|
|
)
|
|
db.add(run)
|
|
db.flush()
|
|
document.active_parse_run_id = run.id
|
|
source = SourceRecord(
|
|
space_id=space.id,
|
|
document_id=document.id,
|
|
parse_run_id=run.id,
|
|
occurred_on=date(2026, 1, 5),
|
|
amount_kopecks=12_345,
|
|
direction="out",
|
|
row_ref="sheet0:row2",
|
|
record_fp=uuid.uuid4().hex,
|
|
)
|
|
db.add(source)
|
|
db.flush()
|
|
return user, space, document
|
|
|
|
|
|
def test_confirm_and_revert_are_idempotent(db: Session) -> None:
|
|
user, space, document = ready_document(db)
|
|
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
|
|
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
|
|
db.flush()
|
|
assert db.query(Operation).filter_by(space_id=space.id).count() == 1
|
|
assert db.query(SourceRecordLink).filter_by(space_id=space.id).count() == 1
|
|
|
|
service.revert_document(db, space_id=space.id, document_id=document.id, user=user)
|
|
service.revert_document(db, space_id=space.id, document_id=document.id, user=user)
|
|
db.flush()
|
|
assert db.query(Operation).filter_by(space_id=space.id).count() == 0
|
|
assert db.query(SourceRecordLink).filter_by(space_id=space.id).count() == 0
|
|
|
|
|
|
def test_confirm_rejects_arithmetic_mismatch(db: Session) -> None:
|
|
user, space, document = ready_document(db)
|
|
document.reconciliation = {"full": True, "matched": False}
|
|
with pytest.raises(AppError) as exc:
|
|
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
|
|
assert exc.value.code == "IMPORT_RECONCILIATION_MISMATCH"
|
|
|
|
|
|
def test_confirmed_import_must_be_reverted_before_reparse(db: Session) -> None:
|
|
user, space, document = ready_document(db)
|
|
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
|
|
with pytest.raises(AppError) as exc:
|
|
service.prepare_reparse(db, space_id=space.id, document_id=document.id, user=user)
|
|
assert exc.value.code == "IMPORT_REPARSE_CONFIRMED"
|
|
|
|
|
|
def test_invalid_direction_is_critical() -> None:
|
|
source = service._source_from_row(
|
|
space_id=uuid.uuid4(),
|
|
document_id=uuid.uuid4(),
|
|
run_id=uuid.uuid4(),
|
|
row=SourceRow(
|
|
external_id=None,
|
|
occurred_on=date(2026, 1, 1),
|
|
posted_on=None,
|
|
amount_kopecks=100,
|
|
direction="sideways",
|
|
description=None,
|
|
mcc=None,
|
|
bank_category=None,
|
|
location=None,
|
|
row_ref="row1",
|
|
),
|
|
)
|
|
assert source.critical is True
|
|
assert source.field_warnings == {"codes": ["direction_invalid"]}
|
|
|
|
|
|
def test_unexpected_parser_failure_reaches_terminal_state(db: Session, monkeypatch) -> None:
|
|
_user, space, account = seed(db)
|
|
document = StatementDocument(
|
|
space_id=space.id,
|
|
account_id=account.id,
|
|
original_filename="synthetic.pdf",
|
|
size_bytes=10,
|
|
content_sha256=uuid.uuid4().hex + uuid.uuid4().hex,
|
|
object_key=uuid.uuid4().hex,
|
|
dek_wrapped=b"key",
|
|
dek_wrap_nonce=b"nonce",
|
|
content_nonce=b"nonce",
|
|
status="queued",
|
|
)
|
|
db.add(document)
|
|
db.flush()
|
|
|
|
@contextmanager
|
|
def existing_session(_space_id):
|
|
yield db
|
|
|
|
class BrokenStore:
|
|
def get(self, _key):
|
|
raise RuntimeError("storage unavailable")
|
|
|
|
monkeypatch.setattr(service, "session_scope", existing_session)
|
|
monkeypatch.setattr(service, "get_blob_store", lambda: BrokenStore())
|
|
service.process_statement_document(document.id, space.id)
|
|
assert document.status == "failed"
|
|
assert document.error_code == "IMPORT_PARSE_FAILED"
|
|
|
|
|
|
def test_duplicate_candidate_can_reuse_unique_operation_without_client_id(db: Session) -> None:
|
|
user, space, first_document = ready_document(db)
|
|
service.confirm_document(db, space_id=space.id, document_id=first_document.id, user=user)
|
|
first_source = (
|
|
db.query(SourceRecord).filter_by(parse_run_id=first_document.active_parse_run_id).one()
|
|
)
|
|
second = StatementDocument(
|
|
space_id=space.id,
|
|
account_id=first_document.account_id,
|
|
original_filename="second.xlsx",
|
|
size_bytes=100,
|
|
content_sha256=uuid.uuid4().hex + uuid.uuid4().hex,
|
|
object_key=uuid.uuid4().hex,
|
|
status="review_required",
|
|
reconciliation={"full": True, "matched": True},
|
|
)
|
|
db.add(second)
|
|
db.flush()
|
|
run = ParseRun(
|
|
space_id=space.id,
|
|
document_id=second.id,
|
|
adapter_id="alfa_debit_xlsx",
|
|
status="succeeded",
|
|
)
|
|
db.add(run)
|
|
db.flush()
|
|
second.active_parse_run_id = run.id
|
|
duplicate_source = SourceRecord(
|
|
space_id=space.id,
|
|
document_id=second.id,
|
|
parse_run_id=run.id,
|
|
occurred_on=first_source.occurred_on,
|
|
amount_kopecks=first_source.amount_kopecks,
|
|
direction=first_source.direction,
|
|
row_ref="sheet0:row9",
|
|
record_fp=first_source.record_fp,
|
|
)
|
|
db.add(duplicate_source)
|
|
db.flush()
|
|
assert service._build_candidates(db, document=second, run=run)
|
|
candidate = db.query(DedupCandidate).filter_by(source_record_id=duplicate_source.id).one()
|
|
assert candidate.candidate_kind == "duplicate"
|
|
service.decide_source_record(
|
|
db,
|
|
space_id=space.id,
|
|
document_id=second.id,
|
|
source_record_id=duplicate_source.id,
|
|
decision="duplicate",
|
|
duplicate_operation_id=None,
|
|
user=user,
|
|
)
|
|
assert second.status == "ready"
|
|
service.confirm_document(db, space_id=space.id, document_id=second.id, user=user)
|
|
assert db.query(Operation).filter_by(space_id=space.id).count() == 1
|
|
|
|
|
|
def test_transfer_merge_revert_restores_counterpart_operation(db: Session) -> None:
|
|
user, space, first_document = ready_document(db)
|
|
service.confirm_document(db, space_id=space.id, document_id=first_document.id, user=user)
|
|
first_source = (
|
|
db.query(SourceRecord).filter_by(parse_run_id=first_document.active_parse_run_id).one()
|
|
)
|
|
target = create_account(
|
|
db,
|
|
space_id=space.id,
|
|
user=user,
|
|
name="Second debit",
|
|
kind="debit",
|
|
opened_on=date(2026, 1, 1),
|
|
)
|
|
second = StatementDocument(
|
|
space_id=space.id,
|
|
account_id=target.id,
|
|
original_filename="target.xlsx",
|
|
size_bytes=100,
|
|
content_sha256=uuid.uuid4().hex + uuid.uuid4().hex,
|
|
object_key=uuid.uuid4().hex,
|
|
status="review_required",
|
|
reconciliation={"full": True, "matched": True},
|
|
)
|
|
db.add(second)
|
|
db.flush()
|
|
run = ParseRun(
|
|
space_id=space.id,
|
|
document_id=second.id,
|
|
adapter_id="alfa_debit_xlsx",
|
|
status="succeeded",
|
|
)
|
|
db.add(run)
|
|
db.flush()
|
|
second.active_parse_run_id = run.id
|
|
incoming = SourceRecord(
|
|
space_id=space.id,
|
|
document_id=second.id,
|
|
parse_run_id=run.id,
|
|
occurred_on=first_source.occurred_on,
|
|
amount_kopecks=first_source.amount_kopecks,
|
|
direction="in",
|
|
row_ref="sheet0:row10",
|
|
record_fp=uuid.uuid4().hex,
|
|
)
|
|
db.add(incoming)
|
|
db.flush()
|
|
assert service._build_candidates(db, document=second, run=run)
|
|
candidate = db.query(DedupCandidate).filter_by(source_record_id=incoming.id).one()
|
|
assert candidate.candidate_kind == "transfer"
|
|
service.decide_source_record(
|
|
db,
|
|
space_id=space.id,
|
|
document_id=second.id,
|
|
source_record_id=incoming.id,
|
|
decision="transfer",
|
|
duplicate_operation_id=None,
|
|
user=user,
|
|
)
|
|
service.confirm_document(db, space_id=space.id, document_id=second.id, user=user)
|
|
assert db.query(Operation).filter_by(space_id=space.id, type="transfer").count() == 1
|
|
service.revert_document(db, space_id=space.id, document_id=second.id, user=user)
|
|
assert db.query(Operation).filter_by(space_id=space.id, type="transfer").count() == 0
|
|
assert db.query(Operation).filter_by(space_id=space.id, type="expense").count() == 1
|