"""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()