0722d7b0d5
- 福利钱包: 金币/现金余额、流水、兑换 (api/v1/wallet.py, crud/wallet.py, models/wallet.py, schemas/welfare.py) - 每日签到: 连续天数 + 档位奖励 (api/v1/signin.py, crud/signin.py, models/signin.py) - 任务系统: "打开消息提醒"等任务领奖 (api/v1/tasks.py, crud/task.py, models/task.py) - 省钱战绩: 省钱汇总/战绩/店铺菜品 (api/v1/savings.py, crud/savings.py, models/savings.py) - 激励广告发奖: 穿山甲服务端回调 + 发奖规则 + 限流 (api/v1/ad.py, core/pangle.py, core/rewards.py, core/ratelimit.py, crud/ad_reward.py, schemas/ad.py, docs/ad_reward_golive_checklist.md) - 微信提现: 商家转账到零钱 V3 (core/wxpay.py); user 表加微信 openid/nickname/avatar - DB 迁移: 8 个 alembic (welfare 表/cash_transaction/openid 唯一约束/savings 店铺菜品/savings_record/ad_reward/withdraw_order+openid/user 微信字段) - 运维脚本: reconcile_withdraws(对账) + reset_signin/reset_welfare + sim_pangle_callback(模拟穿山甲回调) - 测试: test_welfare / test_withdraw / test_ad_reward - 配置: .env.example + config.py 新增福利/广告/微信支付项; main.py 挂 5 个新路由 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
258 lines
9.3 KiB
Python
258 lines
9.3 KiB
Python
"""福利模块测试:钱包 / 签到 / 一次性任务。
|
|
|
|
用 sms mock 登录拿 token,再跑各接口闭环。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from app.core.rewards import (
|
|
COIN_PER_CENT,
|
|
COIN_PER_YUAN,
|
|
MIN_EXCHANGE_COIN,
|
|
SIGNIN_REWARDS,
|
|
TASK_ENABLE_NOTIFICATION,
|
|
TASK_REWARDS,
|
|
)
|
|
|
|
|
|
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 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, "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_task_claim_flow(client) -> None:
|
|
"""任务列表 → 领"打开消息提醒" → 余额到账 → 重复领 409。"""
|
|
token = _login(client, "13800001003")
|
|
coin = TASK_REWARDS[TASK_ENABLE_NOTIFICATION]
|
|
|
|
# 1. 任务列表:未领取
|
|
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"] == coin
|
|
|
|
# 2. 领奖
|
|
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"] == coin
|
|
assert r.json()["coin_balance"] == coin
|
|
|
|
# 3. 重复领 → 409
|
|
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
|
|
assert r.status_code == 409
|
|
|
|
# 4. 列表变为已领取
|
|
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
|
|
|
|
# 5. 未知任务 → 404
|
|
r = client.post("/api/v1/tasks/no_such_task/claim", headers=_auth(token))
|
|
assert r.status_code == 404
|
|
|
|
|
|
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 元 → 金币扣、现金加 → 现金流水有记录。"""
|
|
token = _login(client, "13800001005")
|
|
# 领"打开消息提醒"得 10000 金币(= 1 元额度)
|
|
client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
|
|
|
|
# 兑换 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_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
|
|
|
|
# 低于最小额 → 400
|
|
r = client.post(
|
|
"/api/v1/wallet/exchange",
|
|
json={"coin_amount": MIN_EXCHANGE_COIN - COIN_PER_CENT},
|
|
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 test_savings_summary_and_battle(client) -> None:
|
|
"""首次访问触发 demo seeder,聚合接口返回真实算出的数字。"""
|
|
token = _login(client, "13800001007")
|
|
|
|
r = client.get("/api/v1/savings/summary", headers=_auth(token))
|
|
assert r.status_code == 200, r.text
|
|
s = r.json()
|
|
assert s["order_count"] > 0
|
|
assert s["total_saved_cents"] > 0
|
|
# 平均每单 = 总额 // 单数
|
|
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
|
|
assert b["week_saved_cents"] >= 0
|
|
assert 0 <= b["beat_percent"] <= 100
|
|
|
|
|
|
def test_savings_seeder_idempotent(client) -> None:
|
|
"""重复访问不应重复灌数据:两次 summary 完全一致。"""
|
|
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
|
|
|
|
|
|
def test_savings_records_pagination(client) -> None:
|
|
token = _login(client, "13800001009")
|
|
# 第一页
|
|
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
|