19f5987436
Reviewed-on: #82 Co-authored-by: xiebing <xiebing@wonderable.ai> Co-committed-by: xiebing <xiebing@wonderable.ai>
430 lines
16 KiB
Python
430 lines
16 KiB
Python
"""福利模块测试:钱包 / 签到 / 一次性任务。
|
||
|
||
用 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 笔,各省 5000−4200=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
|