Files
shaguabijia-app-server/tests/test_withdraw.py
T
OuYingJun1024 6f2731ee2d refactor(integrations): 穿山甲验签改 GroMore SHA256 + pangle/wxpay 从 core 移入 integrations
- 验签: pangle.build_sign/verify_callback_sign 改为 GroMore 官方
  sign=SHA256("{m-key}:{trans_id}")(普通 SHA256,只签 trans_id);
  回调响应改成 GroMore 要求的 {is_verify, reason}
- 发奖: 按回调 reward_amount 发金币(rewards 带回退/夹紧),ad_reward 幂等 + 每日上限
- 分层: app/core/{pangle,wxpay}.py → app/integrations/(对齐 jiguang/meituan/sms);
  更新 ad.py / crud/wallet.py / scripts/sim_pangle_callback.py / 测试的 import
  (含 test_withdraw 的 monkeypatch 路径 app.core.wxpay→app.integrations.wxpay)
- 测试: test_ad_reward + test_withdraw 共 17 项通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:44:49 +08:00

272 lines
13 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
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 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_success_flow(client, monkeypatch) -> None:
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 分
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"] == "pending"
assert body["package_info"] == "pkg123"
assert body["mch_id"] and body["app_id"]
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)
# 查单 → 微信返回 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()["cash_balance_cents"] == 50 # 已扣
# 回到 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_transfer_fail_refunds(client, monkeypatch) -> None:
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))
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
assert r.status_code == 502, r.text
# 余额已退回
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
assert calls["n"] == 1 # 只真发起一次转账
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_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"] == "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