Files
shaguabijia-app-server/tests/test_welfare.py
T
2026-06-26 21:10:21 +08:00

430 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""福利模块测试:钱包 / 签到 / 一次性任务。
用 sms mock 登录拿 token,再跑各接口闭环。
"""
from __future__ import annotations
import json
from app.core.config import settings
from app.core.rewards import (
COIN_PER_CENT,
COIN_PER_YUAN,
MIN_EXCHANGE_COIN,
SIGNIN_BOOST_COIN,
SIGNIN_REWARDS,
TASK_ENABLE_NOTIFICATION,
TASK_REWARDS,
)
from app.db.session import SessionLocal
from app.integrations import pangle
from app.repositories import wallet as crud_wallet
from app.repositories.user import get_user_by_phone
def _login(client, phone: str) -> str:
"""sms mock 登录,返回 access_token。"""
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 _signed_ad_callback(user_id: int, trans_id: str, **params: str) -> dict[str, str]:
body = {"user_id": str(user_id), "trans_id": trans_id, **params}
body["sign"] = pangle.build_sign(trans_id, settings.PANGLE_REWARD_SECRET)
return body
def test_account_auto_created_empty(client) -> None:
"""首次访问账户:自动建空账户,余额全 0。"""
token = _login(client, "13800001001")
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
assert body == {
"coin_balance": 0,
"cash_balance_cents": 0,
"invite_cash_balance_cents": 0, # v2 账户隔离新增(邀请奖励金,与现金隔离)
"total_coin_earned": 0,
}
def test_signin_flow(client) -> None:
"""签到状态 → 签到 → 余额到账 → 重复签到 409 → 流水有记录。"""
token = _login(client, "13800001002")
# 1. 签到前状态:未签、可签、今天是第 1 档
r = client.get("/api/v1/signin/status", headers=_auth(token))
assert r.status_code == 200, r.text
st = r.json()
assert st["today_signed"] is False
assert st["can_claim"] is True
assert st["today_cycle_day"] == 1
assert st["today_coin"] == SIGNIN_REWARDS[0]
assert len(st["steps"]) == len(SIGNIN_REWARDS)
assert st["steps"][0]["status"] == "today"
# 2. 执行签到,发第 1 档金币
r = client.post("/api/v1/signin", headers=_auth(token))
assert r.status_code == 200, r.text
res = r.json()
assert res["cycle_day"] == 1
assert res["streak"] == 1
assert res["coin_awarded"] == SIGNIN_REWARDS[0]
assert res["coin_balance"] == SIGNIN_REWARDS[0]
# 3. 账户余额到账
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.json()["coin_balance"] == SIGNIN_REWARDS[0]
# 4. 状态变为已签
r = client.get("/api/v1/signin/status", headers=_auth(token))
st = r.json()
assert st["today_signed"] is True
assert st["can_claim"] is False
assert st["consecutive_days"] == 1
assert st["steps"][0]["status"] == "claimed"
# 5. 重复签到 → 409
r = client.post("/api/v1/signin", headers=_auth(token))
assert r.status_code == 409
# 6. 金币流水有一笔 signin 入账
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
assert r.status_code == 200, r.text
page = r.json()
assert page["next_cursor"] is None
assert len(page["items"]) == 1
txn = page["items"][0]
assert txn["amount"] == SIGNIN_REWARDS[0]
assert txn["biz_type"] == "signin"
assert txn["balance_after"] == SIGNIN_REWARDS[0]
def test_signin_boost_flow(client) -> None:
"""签到后看广告膨胀 → S2S 固定补发 2000 金币,每天只能膨胀一次。"""
phone = "13800001011"
token = _login(client, phone)
r = client.post("/api/v1/signin/boost", json={}, headers=_auth(token))
assert r.status_code == 409
r = client.post("/api/v1/signin", headers=_auth(token))
assert r.status_code == 200, r.text
first_coin = r.json()["coin_awarded"]
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
uid = user.id
extra = json.dumps({"reward_scene": "signin_boost", "ad_session_id": "signin-session-1"})
r = client.get(
"/api/v1/ad/pangle-callback",
params=_signed_ad_callback(uid, "signin-boost-trans-1", extra=extra, ecpm="200"),
)
assert r.status_code == 200, r.text
assert r.json() == {"is_verify": True, "reason": 0}
r = client.post("/api/v1/signin/boost", json={"ad_ref_id": "signin-boost-trans-1"}, headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
assert body["coin_awarded"] == SIGNIN_BOOST_COIN
assert body["coin_balance"] == first_coin + SIGNIN_BOOST_COIN
r = client.get(
"/api/v1/ad/pangle-callback",
params=_signed_ad_callback(uid, "signin-boost-trans-2", extra=extra, ecpm="200"),
)
assert r.status_code == 200, r.text
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
types = [t["biz_type"] for t in r.json()["items"]]
assert "signin" in types
assert "signin_boost" in types
def test_task_claim_flow(client) -> None:
"""打开消息提醒=可重复任务:每次领取金额减半(750/375/188),claimed 恒 False,余额累加。"""
token = _login(client, "13800001003")
base = TASK_REWARDS[TASK_ENABLE_NOTIFICATION] # 750
# 1. 任务列表:可重复任务 claimed 恒 False,coin=首次金额(750)
r = client.get("/api/v1/tasks", headers=_auth(token))
assert r.status_code == 200, r.text
items = {it["task_key"]: it for it in r.json()["items"]}
assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is False
assert items[TASK_ENABLE_NOTIFICATION]["coin"] == base # 750
# 2. 第一次领 → 750,余额 750
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["coin_awarded"] == 750
assert r.json()["coin_balance"] == 750
# 3. 列表:下一次金额减半=375,仍 claimed False(可重复)
r = client.get("/api/v1/tasks", headers=_auth(token))
items = {it["task_key"]: it for it in r.json()["items"]}
assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is False
assert items[TASK_ENABLE_NOTIFICATION]["coin"] == 375
# 4. 第二次领 → 375(余额 1125),第三次 → 188(四舍五入)
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
assert r.status_code == 200 and r.json()["coin_awarded"] == 375
assert r.json()["coin_balance"] == 1125
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
assert r.status_code == 200 and r.json()["coin_awarded"] == 188
# 5. 未知任务 → 404
r = client.post("/api/v1/tasks/no_such_task/claim", headers=_auth(token))
assert r.status_code == 404
def test_task_notification_claim_capped(client) -> None:
"""可重复任务领满(减半到底)后封顶:不再可领 → 409,列表 claimed 置 True。"""
from app.core.rewards import notification_max_claims
token = _login(client, "13800001009")
base = TASK_REWARDS[TASK_ENABLE_NOTIFICATION]
cap = notification_max_claims(base) # 750 → 11
# 领满 cap 次,每次都应 200(最后几次发 1 金币)
last_coin = None
for _ in range(cap):
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
assert r.status_code == 200, r.text
last_coin = r.json()["coin_awarded"]
assert last_coin == 1 # 末次是减半到底的 1
# 再领 → 409(封顶,不再发放)
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
assert r.status_code == 409, r.text
# 列表:领满后 claimed 置 True(客户端据此隐藏)
r = client.get("/api/v1/tasks", headers=_auth(token))
items = {it["task_key"]: it for it in r.json()["items"]}
assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is True
def test_exchange_info(client) -> None:
token = _login(client, "13800001004")
r = client.get("/api/v1/wallet/exchange-info", headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
assert body["coin_per_yuan"] == COIN_PER_YUAN
assert body["min_coin"] == MIN_EXCHANGE_COIN
assert body["step_coin"] == COIN_PER_CENT
def test_exchange_flow(client) -> None:
"""先供款够兑 1 元的金币 → 兑换 1 元 → 金币扣、现金加 → 现金流水有记录。
兑换下限已降到 1 分(MIN_EXCHANGE_COIN = COIN_PER_CENT = 100), 但本用例验证兑换 1 元,
直接 grant_coins 注入 COIN_PER_YUAN(= 10000)当种子(够兑 1 元)。
"""
phone = "13800001005"
token = _login(client, phone)
# 供款: 直接注入 1 元额度金币(替代原先靠 notification=10000 领任务供款)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
crud_wallet.grant_coins(db, user.id, COIN_PER_YUAN, biz_type="test_seed", remark="测试供款")
db.commit()
# 兑换 10000 金币 → 100 分
r = client.post(
"/api/v1/wallet/exchange",
json={"coin_amount": COIN_PER_YUAN},
headers=_auth(token),
)
assert r.status_code == 200, r.text
res = r.json()
assert res["coin_amount"] == COIN_PER_YUAN
assert res["cash_added_cents"] == 100
assert res["coin_balance"] == 0
assert res["cash_balance_cents"] == 100
# 账户余额同步
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.json()["coin_balance"] == 0
assert r.json()["cash_balance_cents"] == 100
# 金币流水多了一笔 exchange_out(负)
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
types = [t["biz_type"] for t in r.json()["items"]]
assert "exchange_out" in types
out_txn = next(t for t in r.json()["items"] if t["biz_type"] == "exchange_out")
assert out_txn["amount"] == -COIN_PER_YUAN
# 现金流水有一笔 exchange_in(正)
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
page = r.json()
assert len(page["items"]) == 1
assert page["items"][0]["amount_cents"] == 100
assert page["items"][0]["biz_type"] == "exchange_in"
def test_exchange_min_floor_one_cent(client) -> None:
"""锁定本次下调的兑换下限:正好兑下限额(MIN_EXCHANGE_COIN=100 金币=1 分)应成功。
这是本 PR 的核心新能力(下限 10000→100,可兑 1 分起)。test_exchange_flow 兑的是
1 元(10000),在旧下限下也通过,证明不了新下限;本用例供款并兑换正好等于下限的
100 金币,断言到账 1 分。若有人把 MIN_EXCHANGE_COIN 改回 10000,100 会因低于下限被
判 400,本用例随之失败,从而把新下限值钉死。
"""
phone = "13800001010"
token = _login(client, phone)
# 供款: 正好注入一个下限额度的金币(=COIN_PER_CENT=100)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
crud_wallet.grant_coins(
db, user.id, MIN_EXCHANGE_COIN, biz_type="test_seed", remark="测试供款"
)
db.commit()
# 兑换下限额 100 金币 → 1 分
r = client.post(
"/api/v1/wallet/exchange",
json={"coin_amount": MIN_EXCHANGE_COIN},
headers=_auth(token),
)
assert r.status_code == 200, r.text
res = r.json()
assert res["coin_amount"] == MIN_EXCHANGE_COIN
assert res["cash_added_cents"] == 1 # coins_to_cents(100) == 1
assert res["coin_balance"] == 0
assert res["cash_balance_cents"] == 1
# 金币流水有一笔 exchange_out(=-100)
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
out_txn = next(t for t in r.json()["items"] if t["biz_type"] == "exchange_out")
assert out_txn["amount"] == -MIN_EXCHANGE_COIN
# 现金流水有且仅有一笔 exchange_in(=+1 分)
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
cash = r.json()["items"]
assert len(cash) == 1
assert cash[0]["amount_cents"] == 1
assert cash[0]["biz_type"] == "exchange_in"
def test_exchange_insufficient_and_invalid(client) -> None:
token = _login(client, "13800001006")
# 余额 0 → 兑换 1 元 → 409 余额不足
r = client.post(
"/api/v1/wallet/exchange",
json={"coin_amount": COIN_PER_YUAN},
headers=_auth(token),
)
assert r.status_code == 409
# 低于最小额(下限现为 1 分=100;取 MIN_EXCHANGE_COIN-1=99,既低于下限又非整分)→ 400
r = client.post(
"/api/v1/wallet/exchange",
json={"coin_amount": MIN_EXCHANGE_COIN - 1},
headers=_auth(token),
)
assert r.status_code == 400
# 非整分倍数(带零头)→ 400
r = client.post(
"/api/v1/wallet/exchange",
json={"coin_amount": COIN_PER_YUAN + 1},
headers=_auth(token),
)
assert r.status_code == 400
def _report_savings(client, token: str, n: int, *, original=5000, paid=4200) -> None:
"""上报 n 笔真实比价记录(source='compare'),省额 = original paid。"""
for i in range(n):
client.post(
"/api/v1/order/report",
json={
"client_event_id": f"evt-{token[-6:]}-{i}",
"platform": "美团外卖",
"pay_channel": "wechat",
"compared_price_cents": paid,
"paid_amount_cents": paid,
"original_price_cents": original,
"shop_name": f"测试门店{i}",
"dishes": ["招牌菜"],
},
headers=_auth(token),
)
def test_savings_summary_and_battle(client) -> None:
"""聚合接口对真实上报记录返回真实算出的数字(demo 兜底已停用)。"""
token = _login(client, "13800001007")
_report_savings(client, token, 2) # 2 笔,各省 50004200=800
r = client.get("/api/v1/savings/summary", headers=_auth(token))
assert r.status_code == 200, r.text
s = r.json()
assert s["order_count"] == 2
assert s["total_saved_cents"] == 1600
# 平均每单 = 总额 // 单数
assert s["avg_saved_cents"] == s["total_saved_cents"] // s["order_count"]
r = client.get("/api/v1/savings/battle", headers=_auth(token))
assert r.status_code == 200, r.text
b = r.json()
assert b["streak_days"] >= 1 # 今日上报 → 至少连续 1 天
assert b["week_saved_cents"] >= 0
assert 0 <= b["beat_percent"] <= 100
# 完成比价次数与 summary 的 order_count 同源(均 = 真实记录数)
assert b["compare_count"] == s["order_count"]
def test_savings_empty_for_new_user(client) -> None:
"""新用户无真实上报时全 0(demo 兜底已停用),两次读一致。"""
token = _login(client, "13800001008")
first = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
second = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
assert first == second
assert first["order_count"] == 0
assert first["total_saved_cents"] == 0
def test_savings_records_pagination(client) -> None:
token = _login(client, "13800001009")
_report_savings(client, token, 12) # 12 笔真实记录撑起分页
# 第一页
r = client.get("/api/v1/savings/records?limit=5", headers=_auth(token))
assert r.status_code == 200, r.text
page1 = r.json()
assert len(page1["items"]) == 5
assert page1["next_cursor"] is not None
# id 倒序
ids = [it["id"] for it in page1["items"]]
assert ids == sorted(ids, reverse=True)
# 第二页接着游标走,不与第一页重叠
r = client.get(
f"/api/v1/savings/records?limit=5&cursor={page1['next_cursor']}",
headers=_auth(token),
)
page2 = r.json()
assert max(it["id"] for it in page2["items"]) < min(ids)
def test_endpoints_require_auth(client) -> None:
"""不带 token 的福利接口统一 401。"""
assert client.get("/api/v1/wallet/account").status_code == 401
assert client.get("/api/v1/signin/status").status_code == 401
assert client.post("/api/v1/signin").status_code == 401
assert client.get("/api/v1/tasks").status_code == 401
assert client.get("/api/v1/wallet/cash-transactions").status_code == 401
assert client.post("/api/v1/wallet/exchange", json={"coin_amount": 10000}).status_code == 401
assert client.get("/api/v1/savings/summary").status_code == 401
assert client.get("/api/v1/savings/battle").status_code == 401
assert client.get("/api/v1/savings/records").status_code == 401