"""账本两阶段计费测试(T-P1):预扣-结算-回补一致 / 幂等 / 跨日重置 / 并发不超扣。""" import threading import pytest from gateway.proxy.ledger import Ledger @pytest.fixture() def led(tmp_path): return Ledger.init_db(tmp_path / "proxy.sqlite3") def _mk_student_key(led, balance_yuan=1.0, cap_yuan=5.0): sid = led.upsert_student("测试生", balance_yuan=balance_yuan, daily_cap_yuan=cap_yuan) kid = led.create_key(sid, f"hash-{sid}", f"prefix-{sid}") return sid, kid def test_hold_settle_refund_consistency(led): """预扣-结算-回补全链条:余额/日消费/流水三处一致。""" sid, kid = _mk_student_key(led, balance_yuan=1.0) # 1000 毫元 ts = 1788300000.0 assert led.try_hold("r1", kid, sid, "m", "b", 400, ts) is True assert led.get_student(sid)["balance_milli"] == 600 assert led.get_student(sid)["spent_today_milli"] == 400 # 结算实际 300 -> 回补 100 assert led.settle("r1", 300, in_miss_tok=100, out_tok=50, upstream_cost_milli=200) is True stu = led.get_student(sid) assert stu["balance_milli"] == 700 assert stu["spent_today_milli"] == 300 u = led.get_usage("r1") assert u["charged_milli"] == 300 assert u["margin_milli"] == 100 # charged - upstream_cost assert u["status"] == "ok" def test_settle_underpay_and_overpay(led): """结算高于预扣(多扣余额)/低于预扣(回补)两个方向。""" sid, kid = _mk_student_key(led, balance_yuan=1.0) ts = 1788300000.0 led.try_hold("r1", kid, sid, "m", "b", 100, ts) led.settle("r1", 250) # 实际更贵 -> 余额再多扣 150 assert led.get_student(sid)["balance_milli"] == 1000 - 250 led.try_hold("r2", kid, sid, "m", "b", 100, ts) led.settle("r2", 40) # 实际更便宜 -> 回补 60 assert led.get_student(sid)["balance_milli"] == 1000 - 250 - 40 def test_hold_insufficient_balance_402(led): sid, kid = _mk_student_key(led, balance_yuan=0.01) # 10 毫元 assert led.try_hold("r1", kid, sid, "m", "b", 11, ts=1788300000.0) is False u = led.get_usage("r1") assert u["status"] == "insufficient" and u["charged_milli"] == 0 assert led.get_student(sid)["balance_milli"] == 10 # 未扣 def test_daily_cap_blocks(led): """日消费上限:累计超限拒(402 语义)。""" sid, kid = _mk_student_key(led, balance_yuan=10.0, cap_yuan=0.5) # 日上限 500 ts = 1788300000.0 assert led.try_hold("r1", kid, sid, "m", "b", 300, ts) is True assert led.try_hold("r2", kid, sid, "m", "b", 300, ts) is False # 600 > 500 assert led.try_hold("r3", kid, sid, "m", "b", 200, ts) is True # 500 == 500 可 # 结算回补后额度释放:spent = 500 - 200(回补) = 300 led.settle("r1", 100) assert led.try_hold("r4", kid, sid, "m", "b", 300, ts) is False # 300+300=600 超限 assert led.try_hold("r5", kid, sid, "m", "b", 200, ts) is True # 300+200=500 恰好 def test_hold_idempotent_on_request_id(led): """request_id 幂等:重复 hold 不重复扣。""" sid, kid = _mk_student_key(led, balance_yuan=1.0) ts = 1788300000.0 assert led.try_hold("dup", kid, sid, "m", "b", 100, ts) is True assert led.try_hold("dup", kid, sid, "m", "b", 100, ts) is True assert led.get_student(sid)["balance_milli"] == 900 def test_daily_reset_across_days(led): """跨日重置:注入更晚时间戳,req_today/spent_today 归零。""" sid, kid = _mk_student_key(led, balance_yuan=1.0, cap_yuan=0.1) # 日上限 100 t1 = 1788300000.0 # 某日 led.check_and_count(kid, sid, t1) led.try_hold("r1", kid, sid, "m", "b", 100, t1) assert led.get_student(sid)["spent_today_milli"] == 100 t2 = t1 + 86400 + 60 # 次日 assert led.check_and_count(kid, sid, t2) is True # 新一天计数重置 assert led.try_hold("r2", kid, sid, "m", "b", 100, t2) is True assert led.get_student(sid)["spent_today_milli"] == 100 # 新一天重新累计 def test_void_full_refund(led): """上游失败全额退款;重复 void 不二次退。""" sid, kid = _mk_student_key(led, balance_yuan=1.0) ts = 1788300000.0 led.try_hold("r1", kid, sid, "m", "b", 400, ts) assert led.void("r1", status="aborted") is True assert led.get_student(sid)["balance_milli"] == 1000 assert led.get_usage("r1")["charged_milli"] == 0 assert led.get_usage("r1")["status"] == "aborted" assert led.void("r1") is False # 已不是 holding def test_concurrent_hold_never_overdraft(led): """并发 N 路同时预扣:余额不足者精确拒绝、不超扣(D-P11 核心验收)。""" sid, kid = _mk_student_key(led, balance_yuan=0.01) # 10 毫元 results = [] lock = threading.Lock() def worker(i): ok = led.try_hold(f"c{i}", kid, sid, "m", "b", 5, ts=1788300000.0) with lock: results.append(ok) threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)] for t in threads: t.start() for t in threads: t.join() assert sorted(results) == [False] * 8 + [True] * 2 # 恰好 2 路成功 assert led.get_student(sid)["balance_milli"] == 0 # 精确扣完,不为负 def test_record_idempotent_via_primary_key(led): """流水主键幂等:同 request_id 只保留一条。""" sid, kid = _mk_student_key(led) led.try_hold("px", kid, sid, "m", "b", 10, ts=1788300000.0) led.try_hold("px", kid, sid, "m", "b", 10, ts=1788300000.0) led.settle("px", 5) assert led.get_usage("px")["charged_milli"] == 5 assert len(led.list_usage()) == 1