Files
shaguabijia-app-server/tests/test_withdraw.py
T
2026-07-12 19:27:43 +08:00

369 lines
17 KiB
Python

"""提现(现金 → 微信零钱)测试。
wxpay 的网络调用(code 换 openid / 发起转账 / 查单)全部 monkeypatch,不真发微信。
现金余额用 SessionLocal 直接 seed(没有"加现金"接口,正常靠金币兑换)。
"""
from __future__ import annotations
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.user import User
from app.models.wallet import CoinAccount, WithdrawOrder
from app.repositories import wallet as crud_wallet
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _seed_cash(client, token: str, phone: str, cents: int) -> None:
"""先访问 /account 触发建账户,再用 DB 直接灌现金余额。"""
client.get("/api/v1/wallet/account", headers=_auth(token))
db = SessionLocal()
try:
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
acc = db.get(CoinAccount, user.id)
acc.cash_balance_cents = cents
db.commit()
finally:
db.close()
def _patch_userinfo(monkeypatch, openid="openid_test_abc", nickname="测试昵称", avatar="https://x/y.png"):
monkeypatch.setattr(
"app.integrations.wxpay.code_to_userinfo",
lambda code: {"openid": openid, "nickname": nickname, "avatar_url": avatar, "raw": {}},
)
def _approve(bill: str) -> None:
"""模拟管理员审核通过(走 repo,等价 admin 端点 approve → execute_withdraw_transfer 发起转账)。"""
db = SessionLocal()
try:
crud_wallet.approve_withdraw(db, bill)
finally:
db.close()
def _reject(bill: str, reason: str = "不符合提现规则") -> None:
"""模拟管理员审核拒绝(走 repo,等价 admin 端点 reject → 退款 + rejected)。"""
db = SessionLocal()
try:
crud_wallet.reject_withdraw(db, bill, reason)
finally:
db.close()
def test_bind_wechat(client, monkeypatch) -> None:
_patch_userinfo(monkeypatch)
token = _login(client, "13800002001")
r = client.get("/api/v1/wallet/withdraw-info", headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["wechat_bound"] is False
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "auth_code_x"}, headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
assert body["bound"] is True
assert body["wechat_nickname"] == "测试昵称"
assert body["wechat_avatar_url"] == "https://x/y.png"
r = client.get("/api/v1/wallet/withdraw-info", headers=_auth(token))
j = r.json()
assert j["wechat_bound"] is True
assert j["wechat_nickname"] == "测试昵称"
def test_withdraw_submit_then_approve_success_flow(client, monkeypatch) -> None:
"""提现先进 reviewing(扣款不打款) → 管理员审核通过才发起转账 → 查单 SUCCESS → success。"""
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_ok", "nickname": None, "avatar_url": None, "raw": {}})
monkeypatch.setattr(
"app.integrations.wxpay.create_transfer",
lambda openid, amount_fen, out_bill_no, user_name=None: {
"status_code": 200,
"data": {"state": "WAIT_USER_CONFIRM", "package_info": "pkg123", "transfer_bill_no": "tb_1"},
},
)
token = _login(client, "13800002002")
_seed_cash(client, token, "13800002002", 100)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
# 发起提现 50 分 → 进入待审核(reviewing),已扣款但未打款,无 package_info
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
assert body["status"] == "reviewing"
assert body["package_info"] is None
assert body["cash_balance_cents"] == 50 # 已扣
bill = body["out_bill_no"]
# 现金流水有一条 -50 的 withdraw
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
txns = r.json()["items"]
assert any(t["biz_type"] == "withdraw" and t["amount_cents"] == -50 for t in txns)
# 管理员审核通过 → 发起微信转账(mock WAIT_USER_CONFIRM,单进 pending)
_approve(bill)
# 用户确认到账后查单 → SUCCESS → 归一化 success
monkeypatch.setattr(
"app.integrations.wxpay.query_transfer",
lambda out_bill_no: {"status_code": 200, "data": {"state": "SUCCESS"}},
)
r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["status"] == "success"
# 提现单列表有 1 条 success
r = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token))
items = r.json()["items"]
assert len(items) == 1 and items[0]["status"] == "success"
def test_withdraw_abandoned_confirm_cancels_and_refunds(client, monkeypatch) -> None:
"""用户进了确认页但没确认就回来:查单仍 WAIT_USER_CONFIRM → 撤销微信单 + 退款 + 单置 failed。"""
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_wait", "nickname": None, "avatar_url": None, "raw": {}})
monkeypatch.setattr(
"app.integrations.wxpay.create_transfer",
lambda openid, amount_fen, out_bill_no, user_name=None: {
"status_code": 200,
"data": {"state": "WAIT_USER_CONFIRM", "package_info": "pkg", "transfer_bill_no": "tb_w"},
},
)
token = _login(client, "13800002006")
_seed_cash(client, token, "13800002006", 100)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
bill = r.json()["out_bill_no"]
assert r.json()["status"] == "reviewing"
assert r.json()["cash_balance_cents"] == 50 # 已扣
# 管理员审核通过 → 发起转账(mock WAIT_USER_CONFIRM,单进 pending)
_approve(bill)
# 回到 app 查单:微信仍是 WAIT_USER_CONFIRM(没确认) + 撤单成功
monkeypatch.setattr(
"app.integrations.wxpay.query_transfer",
lambda out_bill_no: {"status_code": 200, "data": {"state": "WAIT_USER_CONFIRM"}},
)
cancel_called = {}
def _fake_cancel(out_bill_no):
cancel_called["bill"] = out_bill_no
return {"status_code": 200, "data": {"state": "CANCELLING"}}
monkeypatch.setattr("app.integrations.wxpay.cancel_transfer", _fake_cancel)
r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["status"] == "failed"
assert cancel_called["bill"] == bill # 确实调了撤单
# 余额已退回 100
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.json()["cash_balance_cents"] == 100
# 有退款流水
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
assert any(t["biz_type"] == "withdraw_refund" and t["amount_cents"] == 50 for t in r.json()["items"])
def test_withdraw_insufficient_cash(client, monkeypatch) -> None:
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_x", "nickname": None, "avatar_url": None, "raw": {}})
token = _login(client, "13800002003")
_seed_cash(client, token, "13800002003", 20)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
assert r.status_code == 409, r.text
def test_withdraw_not_bound(client) -> None:
token = _login(client, "13800002004")
_seed_cash(client, token, "13800002004", 100)
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
assert r.status_code == 400, r.text
def test_withdraw_approve_transfer_fail_refunds(client, monkeypatch) -> None:
"""审核通过发起转账失败(非200) + 查单 NOT_FOUND(未创建) → 自动退款 + 单 failed。"""
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_fail", "nickname": None, "avatar_url": None, "raw": {}})
monkeypatch.setattr(
"app.integrations.wxpay.create_transfer",
lambda openid, amount_fen, out_bill_no, user_name=None: {
"status_code": 400,
"data": {"code": "PARAM_ERROR", "message": "amount invalid"},
},
)
# #3:非200 也要先查单确认。这里 PARAM_ERROR=没创建,查单返回 NOT_FOUND → 退款安全
monkeypatch.setattr(
"app.integrations.wxpay.query_transfer",
lambda out_bill_no: {"status_code": 404, "data": {"code": "NOT_FOUND"}},
)
token = _login(client, "13800002005")
_seed_cash(client, token, "13800002005", 100)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
# 发起提现 → reviewing(已扣款),此刻不打款不会失败
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["status"] == "reviewing"
bill = r.json()["out_bill_no"]
# 管理员审核通过 → 转账失败 → 自动退款 + 单 failed
_approve(bill)
# 余额已退回
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.json()["cash_balance_cents"] == 100
# 有退款流水
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
txns = r.json()["items"]
assert any(t["biz_type"] == "withdraw_refund" and t["amount_cents"] == 50 for t in txns)
# 提现单 failed
r = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token))
assert r.json()["items"][0]["status"] == "failed"
def test_withdraw_idempotent_same_bill_no(client, monkeypatch) -> None:
"""#2 同 out_bill_no 重试:只转一次,第二次返回同一单,余额只扣一次。"""
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_idem", "nickname": None, "avatar_url": None, "raw": {}})
calls = {"n": 0}
def _create(openid, amount_fen, out_bill_no, user_name=None):
calls["n"] += 1
return {"status_code": 200, "data": {"state": "WAIT_USER_CONFIRM", "package_info": "pkg", "transfer_bill_no": "tb"}}
monkeypatch.setattr("app.integrations.wxpay.create_transfer", _create)
# 幂等查单时(pending)会查一次,返回仍在途
monkeypatch.setattr(
"app.integrations.wxpay.query_transfer",
lambda out_bill_no: {"status_code": 200, "data": {"state": "ACCEPTED"}},
)
token = _login(client, "13800002007")
_seed_cash(client, token, "13800002007", 100)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
bill = "idemkey1234567890"
r1 = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50, "out_bill_no": bill}, headers=_auth(token))
assert r1.status_code == 200, r1.text
r2 = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50, "out_bill_no": bill}, headers=_auth(token))
assert r2.status_code == 200, r2.text
# 提现阶段不打款 → create_transfer 一次都不会被调;两次都返回同一张待审核单
assert calls["n"] == 0
assert r1.json()["status"] == "reviewing" and r2.json()["status"] == "reviewing"
assert r1.json()["out_bill_no"] == bill
assert r2.json()["out_bill_no"] == bill
# 余额只扣一次(100-50=50)
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.json()["cash_balance_cents"] == 50
# 只有一条 withdraw 流水
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
assert sum(1 for t in r.json()["items"] if t["biz_type"] == "withdraw") == 1
def test_withdraw_allows_multiple_active_orders_with_distinct_bill_no(client, monkeypatch) -> None:
"""不同 out_bill_no 是不同提现意图,允许同时处于 reviewing;每笔分别扣款建单。"""
_patch_userinfo(monkeypatch, "openid_multi_active")
token = _login(client, "13800002017")
_seed_cash(client, token, "13800002017", 200)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r1 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 50, "out_bill_no": "multi_active_bill_1"},
headers=_auth(token),
)
r2 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 50, "out_bill_no": "multi_active_bill_2"},
headers=_auth(token),
)
assert r1.status_code == 200 and r1.json()["status"] == "reviewing", r1.text
assert r2.status_code == 200 and r2.json()["status"] == "reviewing", r2.text
assert r1.json()["out_bill_no"] != r2.json()["out_bill_no"]
account = client.get("/api/v1/wallet/account", headers=_auth(token)).json()
assert account["cash_balance_cents"] == 100
txns = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token)).json()["items"]
assert sum(1 for t in txns if t["biz_type"] == "withdraw") == 2
def test_withdraw_ambiguous_timeout_then_success_no_refund(client, monkeypatch) -> None:
"""#3 转账调用超时(异常),但查单确认已 SUCCESS → 不退款,单置 success。"""
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_amb", "nickname": None, "avatar_url": None, "raw": {}})
def _boom(openid, amount_fen, out_bill_no, user_name=None):
raise TimeoutError("read timeout")
monkeypatch.setattr("app.integrations.wxpay.create_transfer", _boom)
monkeypatch.setattr(
"app.integrations.wxpay.query_transfer",
lambda out_bill_no: {"status_code": 200, "data": {"state": "SUCCESS", "transfer_bill_no": "tb_ok"}},
)
token = _login(client, "13800002008")
_seed_cash(client, token, "13800002008", 100)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["status"] == "reviewing"
bill = r.json()["out_bill_no"]
# 审核通过 → 转账调用超时(异常) → 查单确认 SUCCESS → 不退款,单 success
_approve(bill)
r = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token))
assert r.json()["items"][0]["status"] == "success"
# 没退款:余额仍是扣后的 50,且无 withdraw_refund
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.json()["cash_balance_cents"] == 50
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
assert not any(t["biz_type"] == "withdraw_refund" for t in r.json()["items"])
def test_bind_rejects_openid_already_bound(client, monkeypatch) -> None:
"""#5 一个微信只能绑一个账号:openid 已属于别的用户 → 409。"""
shared_openid = "openid_shared_xyz"
# 先让 A 绑定
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": shared_openid, "nickname": "A", "avatar_url": None, "raw": {}})
token_a = _login(client, "13800002009")
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "ca"}, headers=_auth(token_a))
assert r.status_code == 200, r.text
# B 想绑同一个 openid → 409
token_b = _login(client, "13800002010")
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "cb"}, headers=_auth(token_b))
assert r.status_code == 409, r.text
def test_withdraw_reject_refunds(client, monkeypatch) -> None:
"""管理员审核拒绝 → 退回现金 + 单 rejected + 理由写入 fail_reason(用户可见)。"""
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_reject", "nickname": None, "avatar_url": None, "raw": {}})
token = _login(client, "13800002011")
_seed_cash(client, token, "13800002011", 100)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
# 发起提现 → reviewing(已扣款,余额 50)
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
assert r.json()["status"] == "reviewing"
assert r.json()["cash_balance_cents"] == 50
bill = r.json()["out_bill_no"]
# 管理员拒绝 → 退款 + rejected(不调微信,无需 mock 转账)
_reject(bill, "测试拒绝")
# 余额退回 100
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.json()["cash_balance_cents"] == 100
# 有退款流水
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
assert any(t["biz_type"] == "withdraw_refund" and t["amount_cents"] == 50 for t in r.json()["items"])
# 单 rejected + 理由透出
r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token))
assert r.json()["status"] == "rejected"
assert r.json()["fail_reason"] == "测试拒绝"