"""看激励视频发奖测试(穿山甲 S2S 回调)。 回调无 JWT,靠验签——测试用配置里的 mock 密钥自签自验。覆盖:发奖到账、幂等、 验签失败、每日上限、未知用户、客户端进度查询、回调未配置。 """ from __future__ import annotations from sqlalchemy import select from app.core.config import settings from app.integrations import pangle from app.core.rewards import AD_REWARD_COIN, DAILY_AD_REWARD_LIMIT, MAX_AD_REWARD_COIN from app.db.session import SessionLocal from app.models.user import User 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 _user_id(phone: str) -> int: db = SessionLocal() try: return db.execute(select(User.id).where(User.phone == phone)).scalar_one() finally: db.close() def _signed(user_id: int, trans_id: str, **extra: str) -> dict[str, str]: """构造带合法签名的回调参数(模拟穿山甲服务器)。sign 只对 trans_id 签(穿山甲规范)。""" params = {"user_id": str(user_id), "trans_id": trans_id, **extra} params["sign"] = pangle.build_sign(trans_id, settings.PANGLE_REWARD_SECRET) return params def _callback(client, params: dict[str, str]): return client.get("/api/v1/ad/pangle-callback", params=params) def _coin_balance(client, token: str) -> int: return client.get("/api/v1/wallet/account", headers=_auth(token)).json()["coin_balance"] def test_callback_grants_coins(client) -> None: """验签通过的回调(无 reward_amount → 回退 AD_REWARD_COIN)→ 发金币到账 + 计数 +1。""" phone = "13800003001" token = _login(client, phone) uid = _user_id(phone) r = _callback(client, _signed(uid, "trans_a1", reward_name="金币")) assert r.status_code == 200, r.text assert r.json() == {"is_verify": True, "reason": 0} assert _coin_balance(client, token) == AD_REWARD_COIN st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json() assert st["used_today"] == 1 assert st["daily_limit"] == DAILY_AD_REWARD_LIMIT assert st["remaining"] == DAILY_AD_REWARD_LIMIT - 1 assert st["coin_per_ad"] == AD_REWARD_COIN def test_callback_uses_reward_amount(client) -> None: """带合法 reward_amount → 按它发金币(而非常量);异常值回退/夹紧。""" phone = "13800003011" token = _login(client, phone) uid = _user_id(phone) # 合法值:发 250 assert _callback(client, _signed(uid, "ra_ok", reward_amount="250")).status_code == 200 assert _coin_balance(client, token) == 250 # ≤0 / 非数字 → 回退 AD_REWARD_COIN(累加 100) assert _callback(client, _signed(uid, "ra_zero", reward_amount="0")).status_code == 200 assert _coin_balance(client, token) == 250 + AD_REWARD_COIN # 超上限 → 夹紧到 MAX_AD_REWARD_COIN assert _callback(client, _signed(uid, "ra_big", reward_amount="999999")).status_code == 200 assert _coin_balance(client, token) == 250 + AD_REWARD_COIN + MAX_AD_REWARD_COIN def test_callback_idempotent(client) -> None: """同一 trans_id 二次回调 → 只发一次金币(穿山甲重试不重复发)。""" phone = "13800003002" token = _login(client, phone) uid = _user_id(phone) p = _signed(uid, "trans_dup") assert _callback(client, p).status_code == 200 assert _callback(client, p).status_code == 200 # 重试 assert _coin_balance(client, token) == AD_REWARD_COIN # 没翻倍 st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json() assert st["used_today"] == 1 def test_callback_bad_sign(client) -> None: """签名错误 → 403,不发金币。""" phone = "13800003003" token = _login(client, phone) uid = _user_id(phone) params = {"user_id": str(uid), "trans_id": "trans_bad", "sign": "deadbeef"} r = _callback(client, params) assert r.status_code == 403, r.text assert _coin_balance(client, token) == 0 def test_daily_cap(client) -> None: """达到每日上限后继续回调 → 受理但不发(capped),余额封顶。""" phone = "13800003004" token = _login(client, phone) uid = _user_id(phone) for i in range(DAILY_AD_REWARD_LIMIT): r = _callback(client, _signed(uid, f"trans_cap_{i}")) assert r.status_code == 200, r.text # 第 limit+1 次:capped,仍 is_valid(不让穿山甲重试),但不加币 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 st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json() assert st["used_today"] == DAILY_AD_REWARD_LIMIT assert st["remaining"] == 0 def test_callback_unknown_user(client) -> None: """验签过但 user_id 不存在 → 不发奖(is_verify false + reason),不崩。""" params = _signed(999999, "trans_ghost") r = _callback(client, params) assert r.status_code == 200, r.text assert r.json() == {"is_verify": False, "reason": 2} def test_reward_status_requires_auth(client) -> None: """客户端进度接口需登录。""" r = client.get("/api/v1/ad/reward-status") assert r.status_code == 401 def test_callback_disabled_returns_503(client, monkeypatch) -> None: """未配置回调(开关关)时 → 503。""" monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False) # 503 发生在验签/发奖之前,不需要真实用户 r = _callback(client, _signed(1, "trans_disabled")) assert r.status_code == 503, r.text