Merge branch 'main' into feat/admin-api-db-docs
This commit is contained in:
+44
-6
@@ -120,27 +120,65 @@ def test_callback_bad_sign(client) -> None:
|
||||
assert _coin_balance(client, token) == 0
|
||||
|
||||
|
||||
def test_daily_cap(client) -> None:
|
||||
"""达到每日上限后继续回调 → 受理但不发(capped),余额封顶。"""
|
||||
def test_daily_count_cap(client, monkeypatch) -> None:
|
||||
"""达到每日发奖**次数兜底上限**后继续回调 → 受理但不发(capped),余额封顶。
|
||||
|
||||
次数上限现为时长闸的兜底(默认 200),这里 monkeypatch 调小到 3 加速(不真跑 200 次);
|
||||
本用例不上报观看时长 → 时长主闸不触发,纯验次数兜底。
|
||||
"""
|
||||
# 次数兜底现走 app_config getter(rebase 合 main 的运营可配后);patch getter 调小到 3
|
||||
monkeypatch.setattr("app.core.rewards.get_ad_daily_limit", lambda db: 3)
|
||||
phone = "13800003004"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
|
||||
for i in range(DAILY_AD_REWARD_LIMIT):
|
||||
for i in range(3):
|
||||
r = _callback(client, _signed(uid, f"trans_cap_{i}"))
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# 第 limit+1 次:capped,仍 is_valid(不让穿山甲重试),但不加币
|
||||
# 第 4 次:capped,仍 is_verify(不让穿山甲重试),但不加币
|
||||
r = _callback(client, _signed(uid, "trans_cap_over"))
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"is_verify": True, "reason": 0}
|
||||
|
||||
assert _coin_balance(client, token) == DAILY_AD_REWARD_LIMIT * AD_REWARD_COIN
|
||||
assert _coin_balance(client, token) == 3 * AD_REWARD_COIN
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["used_today"] == DAILY_AD_REWARD_LIMIT
|
||||
assert st["used_today"] == 3
|
||||
assert st["remaining"] == 0
|
||||
|
||||
|
||||
def test_daily_watch_time_cap(client, monkeypatch) -> None:
|
||||
"""看广告累计观看时长达每日上限(主闸,这里调小到 100s)后,再回调发奖 → capped 不发金币。
|
||||
|
||||
时长由前端 watch-report 上报累计;到顶后 ① watch-report/reward-status remaining=0(客户端不再展示),
|
||||
② 后端发奖也因时长闸记 capped(双闸,前端绕不过)。次数兜底不动它(此时只看了 1 次,远没到次数上限)。
|
||||
"""
|
||||
# 时长上限两处引用都要 patch:api 用 rewards.X、grant 闸/today_status 用 ad_reward 内绑定的 X
|
||||
monkeypatch.setattr("app.core.rewards.DAILY_AD_WATCH_SECONDS_LIMIT", 100)
|
||||
monkeypatch.setattr("app.repositories.ad_reward.DAILY_AD_WATCH_SECONDS_LIMIT", 100)
|
||||
phone = "13800003201"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
|
||||
# 上报一次 100s 观看 → 当日累计达上限,remaining=0
|
||||
r = client.post("/api/v1/ad/watch-report", json={"seconds": 100}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["watched_seconds_today"] == 100
|
||||
assert r.json()["watch_seconds_remaining"] == 0
|
||||
|
||||
# 此时回调发奖 → 时长闸命中 → capped(is_verify 仍 true,不让重试),不加金币
|
||||
before = _coin_balance(client, token)
|
||||
r = _callback(client, _signed(uid, "trans_time_cap"))
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"is_verify": True, "reason": 0}
|
||||
assert _coin_balance(client, token) == before # 未加币
|
||||
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["watched_seconds_today"] == 100
|
||||
assert st["watch_seconds_limit"] == 100
|
||||
assert st["watch_seconds_remaining"] == 0
|
||||
|
||||
|
||||
def test_callback_unknown_user(client) -> None:
|
||||
"""验签过但 user_id 不存在 → 不发奖(is_verify false + reason),不崩。"""
|
||||
params = _signed(999999, "trans_ghost")
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Admin M5 配置后台化测试:列出/改配置 + 改配**真生效** + 校验 + 审计。
|
||||
|
||||
autouse 清理每个用例后清空 app_config,避免改配污染其他文件的福利测试。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.admin.main import admin_app
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.admin import AdminAuditLog
|
||||
from app.models.app_config import AppConfig
|
||||
from app.repositories import ad_reward, signin
|
||||
from app.repositories import user as user_repo
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_client() -> TestClient:
|
||||
return TestClient(admin_app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def token() -> str:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if admin_repo.get_by_username(db, "cfg_admin") is None:
|
||||
admin_repo.create_admin(
|
||||
db, username="cfg_admin", password="cfgpass12", role="super_admin"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
c = TestClient(admin_app)
|
||||
return c.post(
|
||||
"/admin/api/auth/login", json={"username": "cfg_admin", "password": "cfgpass12"}
|
||||
).json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_config() -> Iterator[None]:
|
||||
"""每个用例后清空 app_config,避免改配污染其他文件的福利测试(它们假设默认值)。"""
|
||||
yield
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(delete(AppConfig))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _auth(t: str) -> dict:
|
||||
return {"Authorization": f"Bearer {t}"}
|
||||
|
||||
|
||||
def _seed_user(phone: str) -> int:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return user_repo.upsert_user_for_login(db, phone=phone, register_channel="sms").id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_list_config(admin_client: TestClient, token: str) -> None:
|
||||
r = admin_client.get("/admin/api/config", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
items = {i["key"]: i for i in r.json()}
|
||||
assert "signin_rewards" in items and "ad_daily_limit" in items
|
||||
assert items["signin_rewards"]["value"] == [10, 20, 30, 50, 80, 120, 200]
|
||||
assert items["signin_rewards"]["overridden"] is False
|
||||
|
||||
|
||||
def test_update_signin_takes_effect(admin_client: TestClient, token: str) -> None:
|
||||
r = admin_client.patch(
|
||||
"/admin/api/config/signin_rewards",
|
||||
json={"value": [100, 200, 300, 400, 500, 600, 700]},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["value"][0] == 100 and r.json()["overridden"] is True
|
||||
|
||||
# 业务真的用上新值(配置后台化的核心验证)
|
||||
uid = _seed_user("13912340001")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
st = signin.get_status(db, uid)
|
||||
assert st.steps[0].coin == 100
|
||||
assert st.today_coin == 100 # 首签=第 1 天=100(新值)
|
||||
logs = db.execute(
|
||||
select(AdminAuditLog).where(
|
||||
AdminAuditLog.action == "config.set",
|
||||
AdminAuditLog.target_id == "signin_rewards",
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(logs) == 1 and logs[0].detail["after"][0] == 100
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> None:
|
||||
r = admin_client.patch(
|
||||
"/admin/api/config/ad_daily_limit", json={"value": 5}, headers=_auth(token)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
uid = _seed_user("13912340002")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# today_status 现返 7 元组(末两位为观看时长闸:已看秒数 / 上限),取次数上限位
|
||||
_used, limit, _coin, _rc, _cd, _ws, _wl = ad_reward.today_status(db, uid)
|
||||
assert limit == 5 # 新配置生效
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_config_validation(admin_client: TestClient, token: str) -> None:
|
||||
# 签到档位长度≠7
|
||||
assert admin_client.patch(
|
||||
"/admin/api/config/signin_rewards", json={"value": [1, 2, 3]}, headers=_auth(token)
|
||||
).status_code == 400
|
||||
# int 负数
|
||||
assert admin_client.patch(
|
||||
"/admin/api/config/ad_daily_limit", json={"value": -5}, headers=_auth(token)
|
||||
).status_code == 400
|
||||
# 未知 key
|
||||
assert admin_client.patch(
|
||||
"/admin/api/config/nope", json={"value": 1}, headers=_auth(token)
|
||||
).status_code == 404
|
||||
+79
-10
@@ -10,6 +10,7 @@ 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:
|
||||
@@ -43,6 +44,24 @@ def _patch_userinfo(monkeypatch, openid="openid_test_abc", nickname="测试昵
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
@@ -64,7 +83,8 @@ def test_bind_wechat(client, monkeypatch) -> None:
|
||||
assert j["wechat_nickname"] == "测试昵称"
|
||||
|
||||
|
||||
def test_withdraw_success_flow(client, monkeypatch) -> None:
|
||||
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",
|
||||
@@ -77,13 +97,12 @@ def test_withdraw_success_flow(client, monkeypatch) -> None:
|
||||
_seed_cash(client, token, "13800002002", 100)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
# 发起提现 50 分
|
||||
# 发起提现 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"] == "pending"
|
||||
assert body["package_info"] == "pkg123"
|
||||
assert body["mch_id"] and body["app_id"]
|
||||
assert body["status"] == "reviewing"
|
||||
assert body["package_info"] is None
|
||||
assert body["cash_balance_cents"] == 50 # 已扣
|
||||
bill = body["out_bill_no"]
|
||||
|
||||
@@ -92,7 +111,10 @@ def test_withdraw_success_flow(client, monkeypatch) -> None:
|
||||
txns = r.json()["items"]
|
||||
assert any(t["biz_type"] == "withdraw" and t["amount_cents"] == -50 for t in txns)
|
||||
|
||||
# 查单 → 微信返回 SUCCESS → 归一化 success
|
||||
# 管理员审核通过 → 发起微信转账(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"}},
|
||||
@@ -123,8 +145,12 @@ def test_withdraw_abandoned_confirm_cancels_and_refunds(client, monkeypatch) ->
|
||||
|
||||
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",
|
||||
@@ -166,7 +192,8 @@ def test_withdraw_not_bound(client) -> None:
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
def test_withdraw_transfer_fail_refunds(client, monkeypatch) -> None:
|
||||
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",
|
||||
@@ -184,8 +211,14 @@ def test_withdraw_transfer_fail_refunds(client, monkeypatch) -> None:
|
||||
_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 == 502, r.text
|
||||
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))
|
||||
@@ -222,7 +255,9 @@ def test_withdraw_idempotent_same_bill_no(client, monkeypatch) -> None:
|
||||
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 # 只真发起一次转账
|
||||
# 提现阶段不打款 → 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)
|
||||
@@ -249,7 +284,13 @@ def test_withdraw_ambiguous_timeout_then_success_no_refund(client, monkeypatch)
|
||||
|
||||
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"
|
||||
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
|
||||
@@ -269,3 +310,31 @@ def test_bind_rejects_openid_already_bound(client, monkeypatch) -> None:
|
||||
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"] == "测试拒绝"
|
||||
|
||||
Reference in New Issue
Block a user