Files
caiwuzongzhang/tests/test_matching_api.py

464 lines
20 KiB
Python

"""HTTP integration tests for the canonical transfer event APIs (B-43).
Covers the admin event list/detail, manual decisions and reconcile endpoints,
personal transit mapping workflow, upload-account persistence, and the
company-side read scoping with masked counterparty evidence. Uses a real
``ThreadingHTTPServer`` like ``test_server_auth``.
"""
from __future__ import annotations
import io
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest
from openpyxl import Workbook
from bank_importer.db import connect, migrate
import server
from test_server_auth import Client, as_json
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "Cashier123"
CCB_HEADER = [
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
]
ACCOUNT_A = "6222000000000001"
ACCOUNT_B = "6222000000000002"
ACCOUNT_C = "6222000000000003"
def workbook_bytes(rows) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "正常流水"
sheet.append(CCB_HEADER)
for row in rows:
sheet.append(row)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00"):
return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""]
def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00"):
return [own, "测试公司", at, "", amount, "50000.00", "RMB", "对方", cp, "某银行", "收款", ""]
class MatchingApiTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls._old_db_path = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(cls.db_path)
migrate(connection)
assert server.ensure_bootstrap_admin(connection) is None
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.admin = Client("127.0.0.1", cls.port)
status, _, data = cls.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = cls.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
cls.company_a = cls._create_company("甲公司", "cashier-a")
cls.company_b = cls._create_company("乙公司", "cashier-b")
cls.company_c = cls._create_company("丙公司", "cashier-c")
cls.cashier_a = cls._login_company("cashier-a", cls.company_a)
cls.cashier_b = cls._login_company("cashier-b", cls.company_b)
cls.cashier_c = cls._login_company("cashier-c", cls.company_c)
cls.account_a = cls._approve_account(cls.company_a, ACCOUNT_A)
cls.account_b = cls._approve_account(cls.company_b, ACCOUNT_B)
cls.account_c = cls._approve_account(cls.company_c, ACCOUNT_C)
# One A<->B matched event and one B<->C matched event.
cls._upload_and_confirm(cls.cashier_a, cls.company_a, [outgoing(ACCOUNT_A, ACCOUNT_B, "100.00")])
cls._upload_and_confirm(cls.cashier_b, cls.company_b, [incoming(ACCOUNT_B, ACCOUNT_A, "100.00")])
cls._upload_and_confirm(cls.cashier_b, cls.company_b, [outgoing(ACCOUNT_B, ACCOUNT_C, "200.00")])
cls._upload_and_confirm(cls.cashier_c, cls.company_c, [incoming(ACCOUNT_C, ACCOUNT_B, "200.00")])
@classmethod
def _create_company(cls, name: str, username: str) -> int:
status, _, data = cls.admin.post_json(
"/api/admin/companies",
{"name": name, "username": username},
)
assert status == 200, data
cls.initial_passwords.setdefault(username, as_json(data)["initial_password"])
return as_json(data)["company_id"]
initial_passwords: dict[str, str] = {}
@classmethod
def _login_company(cls, username: str, company_id: int) -> Client:
client = Client("127.0.0.1", cls.port)
initial = cls.initial_passwords[username]
status, _, data = client.post_json(
"/api/login", {"username": username, "password": initial, "portal": "company"}
)
assert status == 200, data
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": CASHIER_PASSWORD},
)
assert status == 200, data
return client
@classmethod
def _approve_account(cls, company_id: int, number: str) -> int:
# Build the account through company submission + admin review.
company_client = {
cls.company_a: cls.cashier_a,
cls.company_b: cls.cashier_b,
cls.company_c: cls.cashier_c,
}[company_id]
status, _, data = company_client.post_json(
"/api/company/accounts",
{"bank_name": "中信银行", "account_type": "基本户",
"account_number": number, "start_date": "2026-01-01"},
)
assert status == 200, data
account_id = as_json(data)["account"]["id"]
status, _, data = cls.admin.post_json(
f"/api/admin/accounts/{account_id}/review",
{"decision": "approve", "reason": "测试启用",
"effective_from": "2026-01-01"},
)
assert status == 200, data
return account_id
@classmethod
def _upload_and_confirm(cls, client, company_id: int, rows) -> int:
content = workbook_bytes(rows)
status, _, data = cls.admin.post_multipart(
"/api/parse", {"company_id": str(company_id)}, "账单.xlsx", content
)
assert status == 200, data
batch_id = as_json(data)["batch_id"]
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
assert status == 200, data
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = client.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
assert status == 200, data
return batch_id
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db_path
server.STORAGE_DIR = cls._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
cls.temp_dir.cleanup()
def events(self, client) -> list[dict]:
status, _, data = client.get("/api/admin/transfer-events")
self.assertEqual(200, status, data)
return as_json(data)["events"]
def company_events(self, client) -> list[dict]:
status, _, data = client.get("/api/company/transfer-events")
self.assertEqual(200, status, data)
return as_json(data)["events"]
# ------------------------------------------------------------------
# Admin event views
# ------------------------------------------------------------------
def test_admin_lists_matched_events(self) -> None:
events = self.events(self.admin)
matched = [e for e in events if e["status"] == "matched"]
self.assertGreaterEqual(len(matched), 2)
by_amount = {e["amount"]: e for e in matched}
self.assertEqual(self.company_a, by_amount["100.00"]["payer_company_id"])
self.assertEqual(self.company_b, by_amount["100.00"]["payee_company_id"])
self.assertEqual("paired", by_amount["100.00"]["pairing"])
self.assertEqual(2, by_amount["100.00"]["evidence_count"])
def test_admin_event_detail_has_history_and_observations(self) -> None:
events = self.events(self.admin)
event = next(e for e in events if e["status"] == "matched")
status, _, data = self.admin.get(f"/api/admin/transfer-events/{event['event_id']}")
self.assertEqual(200, status, data)
detail = as_json(data)["event"]
self.assertEqual("matched", detail["status"])
self.assertEqual(2, len(detail["observations"]))
self.assertTrue(detail["history"])
self.assertTrue(detail["candidates"])
def test_admin_match_exceptions(self) -> None:
status, _, data = self.admin.get("/api/admin/match-exceptions")
self.assertEqual(200, status, data)
self.assertIsInstance(as_json(data)["exceptions"], list)
# ------------------------------------------------------------------
# Admin manual decisions and reconcile via API
# ------------------------------------------------------------------
def test_admin_reconcile_by_batch_is_idempotent(self) -> None:
# Re-running reconcile on an already settled batch changes nothing.
status, _, data = self.admin.post_json(
"/api/admin/transfer-events/reconcile", {"batch_id": 1}
)
self.assertEqual(200, status, data)
result = as_json(data)["matching"]
self.assertEqual(0, result["created_events"])
self.assertEqual(0, result["updated_events"])
def test_admin_confirm_single_and_reverse_via_api(self) -> None:
# A uploads its side only -> internal_single.
status, _, data = self.admin.post_multipart(
"/api/parse", {"company_id": str(self.company_a)},
"单边.xlsx", workbook_bytes([outgoing(ACCOUNT_A, ACCOUNT_B, "300.00", "2026-02-01 10:00:00")]),
)
self.assertEqual(200, status, data)
batch_id = as_json(data)["batch_id"]
status, _, data = self.cashier_a.get(f"/api/batches/{batch_id}/sheets")
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = self.cashier_a.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
self.assertEqual(200, status, data)
events = self.events(self.admin)
single = next(e for e in events if e["status"] == "internal_single" and e["amount"] == "300.00")
detail = self._admin_detail(single["event_id"])
revision = detail["revision"]
# Company users cannot write manual decisions.
status, _, data = self.cashier_a.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{"action": "reverse", "reason": "不应允许", "expected_revision": revision},
)
self.assertEqual(403, status, data)
# Admin confirms the single event based on evidence.
status, _, data = self.admin.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{"action": "assign_participant", "reason": "函证确认",
"expected_revision": revision, "request_key": "confirm-300",
"participant": {"role": "payee", "company_id": self.company_b}},
)
self.assertEqual(200, status, data)
decision = as_json(data)["decision"]
self.assertEqual("intercompany", decision["classification"])
self.assertTrue(decision["locked"])
# Replaying the same request key is idempotent.
status, _, data = self.admin.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{"action": "assign_participant", "reason": "函证确认",
"expected_revision": revision, "request_key": "confirm-300",
"participant": {"role": "payee", "company_id": self.company_b}},
)
self.assertEqual(200, status, data)
# Stale revision conflicts.
status, _, data = self.admin.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{"action": "reverse", "reason": "撤销", "expected_revision": revision},
)
self.assertEqual(409, status, data)
def _admin_detail(self, event_id: int) -> dict:
status, _, data = self.admin.get(f"/api/admin/transfer-events/{event_id}")
self.assertEqual(200, status, data)
return as_json(data)["event"]
# ------------------------------------------------------------------
# Company scope and masking
# ------------------------------------------------------------------
def test_company_sees_only_events_it_participates_in(self) -> None:
events = self.company_events(self.cashier_a)
self.assertTrue(events)
for event in events:
self.assertEqual(self.company_a, event["own_company_id"])
matched = [e for e in events if e["status"] == "matched"]
self.assertTrue(matched)
self.assertEqual(
{"乙公司"},
{e["counterparty_company_name"] for e in matched},
)
# B<->C event is invisible to A.
status, _, data = self.cashier_a.get("/api/company/transfer-events")
bc_events = [
e for e in as_json(data)["events"]
if e["counterparty_company_name"] == "丙公司"
]
self.assertEqual([], bc_events)
def test_company_detail_masks_counterparty_and_own_rows_only(self) -> None:
events = self.company_events(self.cashier_a)
event = next(e for e in events if e["status"] == "matched")
status, _, data = self.cashier_a.get(
f"/api/company/transfer-events/{event['event_id']}"
)
self.assertEqual(200, status, data)
detail = as_json(data)["event"]
self.assertEqual("乙公司", detail["counterparty"]["company_name"])
self.assertTrue(detail["counterparty"].get("account_number_masked", "").startswith("****"))
# The counterparty's full account number never leaves the server.
self.assertNotIn(ACCOUNT_B, json.dumps(detail, ensure_ascii=False))
# Only A's own observation rows are exposed.
self.assertEqual(1, len(detail["observations"]))
observation = detail["observations"][0]
self.assertTrue(observation["own_account_masked"].startswith("****"))
self.assertEqual(self.company_a, observation.get("batch_company_id"))
def test_company_cannot_read_event_it_does_not_participate_in(self) -> None:
# The B<->C event id comes from B's own list; A probing it returns 404.
status, _, data = self.cashier_b.get("/api/company/transfer-events")
bc_event_ids = [
e["event_id"] for e in as_json(data)["events"]
if e["counterparty_company_name"] == "丙公司"
]
self.assertTrue(bc_event_ids)
for event_id in bc_event_ids:
status, _, data = self.cashier_a.get(f"/api/company/transfer-events/{event_id}")
self.assertEqual(404, status, data)
status, _, data = self.cashier_a.get(f"/api/admin/transfer-events/{event_id}")
self.assertEqual(403, status, data)
def test_company_match_exceptions_scoped_to_own_company(self) -> None:
status, _, data = self.admin.post_multipart(
"/api/parse", {"company_id": str(self.company_a)},
"未决.xlsx",
workbook_bytes([outgoing(ACCOUNT_A, "9999999999999999", "77.00", "2026-02-05 10:00:00")]),
)
self.assertEqual(200, status, data)
batch_id = as_json(data)["batch_id"]
status, _, data = self.cashier_a.get(f"/api/batches/{batch_id}/sheets")
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = self.cashier_a.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
self.assertEqual(200, status, data)
status, _, data = self.cashier_a.get("/api/company/match-exceptions")
self.assertEqual(200, status, data)
mine = [e for e in as_json(data)["exceptions"] if e["amount"] == "77.00"]
self.assertEqual(1, len(mine))
self.assertEqual("unresolved", mine[0]["status"])
status, _, data = self.cashier_b.get("/api/company/match-exceptions")
self.assertEqual(200, status, data)
self.assertEqual(
[],
[e for e in as_json(data)["exceptions"] if e["amount"] == "77.00"],
)
def test_company_forbidden_on_admin_event_endpoints(self) -> None:
for call in (
lambda: self.cashier_a.get("/api/admin/transfer-events"),
lambda: self.cashier_a.get("/api/admin/match-exceptions"),
lambda: self.cashier_a.post_json(
"/api/admin/transfer-events/reconcile", {"batch_id": 1}
),
lambda: self.cashier_a.post_json(
"/api/admin/personal-transit-mappings", {}
),
lambda: self.cashier_a.get("/api/admin/personal-transit-mappings"),
):
status, _, data = call()
self.assertEqual(403, status, data)
# ------------------------------------------------------------------
# Personal transit mappings
# ------------------------------------------------------------------
def test_personal_transit_mapping_workflow(self) -> None:
status, _, data = self.admin.post_json(
"/api/admin/personal-transit-mappings",
{"account_number": "880088008800", "account_name": "张个人",
"represented_company_id": self.company_b,
"allowed_direction": "incoming", "effective_from": "2026-01-01"},
)
self.assertEqual(200, status, data)
mapping_id = as_json(data)["mapping"]["id"]
self.assertEqual("pending", as_json(data)["mapping"]["status"])
status, _, data = self.admin.post_json(
f"/api/admin/personal-transit-mappings/{mapping_id}/review",
{"decision": "approve", "reason": "资料齐全", "effective_from": "2026-01-01"},
)
self.assertEqual(200, status, data)
self.assertEqual("active", as_json(data)["mapping"]["status"])
status, _, data = self.admin.get("/api/admin/personal-transit-mappings")
self.assertEqual(200, status, data)
mappings = as_json(data)["mappings"]
self.assertTrue(any(m["id"] == mapping_id for m in mappings))
# Duplicate account number conflicts.
status, _, data = self.admin.post_json(
"/api/admin/personal-transit-mappings",
{"account_number": "880088008800", "account_name": "张个人",
"represented_company_id": self.company_b, "allowed_direction": "both"},
)
self.assertEqual(409, status, data)
# ------------------------------------------------------------------
# Upload account persistence
# ------------------------------------------------------------------
def test_upload_persists_approved_bank_account(self) -> None:
content = workbook_bytes([outgoing(ACCOUNT_A, ACCOUNT_B, "55.00", "2026-03-01 10:00:00")])
status, _, data = self.cashier_a.post_multipart(
"/api/parse", {"bank_account_id": str(self.account_a)},
"带账户.xlsx", content,
)
self.assertEqual(200, status, data)
batch_id = as_json(data)["batch_id"]
connection = connect(self.db_path)
try:
row = connection.execute(
"SELECT upload_bank_account_id FROM import_batches WHERE id = ?",
(batch_id,),
).fetchone()
finally:
connection.close()
self.assertEqual(self.account_a, row["upload_bank_account_id"])
if __name__ == "__main__":
unittest.main()