Files
shaguabijia-app-server/tests/test_invite.py
T
marco bc10432f52 feat(invite): 好友邀请后端(注册即生效,邀请人+被邀请人各发1万金币)
- user.invite_code 列 + invite_relation 表(invitee_user_id 唯一 = 幂等防重复发奖)
- GET /api/v1/invite/me(我的码+分享链接+战绩)、POST /api/v1/invite/bind
- 复用 wallet.grant_coins 同事务发币;自邀屏蔽 / 无效码 / 新用户闸(注册72h内才发)
- alembic 迁移(11a1d08c6f55 -> invite_code_and_relation)+ 8 个测试(钱路/幂等/并发兜底全覆盖)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 03:21:03 +08:00

161 lines
5.9 KiB
Python

"""好友邀请测试:邀请码、绑定、双方发金币、幂等、自邀/无效码屏蔽。
用 sms mock 登录拿 token(同 test_welfare),再跑邀请闭环。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from app.core.rewards import (
INVITE_INVITEE_COINS,
INVITE_INVITER_COINS,
INVITE_NEW_USER_WINDOW_HOURS,
)
from app.db.session import SessionLocal
from app.repositories.user import get_user_by_phone
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 _coin_balance(client, token: str) -> int:
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.status_code == 200, r.text
return r.json()["coin_balance"]
def _my_code(client, token: str) -> str:
r = client.get("/api/v1/invite/me", headers=_auth(token))
assert r.status_code == 200, r.text
return r.json()["invite_code"]
def test_invite_me_returns_stable_code(client) -> None:
"""/invite/me 返回邀请码 + 分享链接;同一用户多次取码稳定。"""
token = _login(client, "13800002001")
r = client.get("/api/v1/invite/me", headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
assert body["invite_code"] # 非空
assert f"ref={body['invite_code']}" in body["share_url"]
assert body["invited_count"] == 0
assert body["coins_earned"] == 0
# 再取一次,码不变(懒生成 + 持久化)
assert _my_code(client, token) == body["invite_code"]
def test_bind_flow_both_get_coins(client) -> None:
"""B 用 A 的码绑定 → 双方各得 1 万金币;A 战绩 +1。"""
a = _login(client, "13800002002")
b = _login(client, "13800002003")
a_code = _my_code(client, a)
assert _coin_balance(client, a) == 0
assert _coin_balance(client, b) == 0
r = client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(b))
assert r.status_code == 200, r.text
res = r.json()
assert res["status"] == "success"
assert res["coins_awarded"] == INVITE_INVITEE_COINS
# 双方金币到账
assert _coin_balance(client, b) == INVITE_INVITEE_COINS
assert _coin_balance(client, a) == INVITE_INVITER_COINS
# A 的战绩:已邀 1 人,累计获得 = 邀请人那份
r = client.get("/api/v1/invite/me", headers=_auth(a))
assert r.json()["invited_count"] == 1
assert r.json()["coins_earned"] == INVITE_INVITER_COINS
def test_bind_idempotent_no_double_reward(client) -> None:
"""同一被邀请人二次绑定 → already_bound,不重复发奖。"""
a = _login(client, "13800002004")
b = _login(client, "13800002005")
a_code = _my_code(client, a)
r1 = client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(b))
assert r1.json()["status"] == "success"
bal_b = _coin_balance(client, b)
bal_a = _coin_balance(client, a)
# 再绑一次(甚至换个码也不行,已被绑过)
c = _login(client, "13800002006")
c_code = _my_code(client, c)
r2 = client.post("/api/v1/invite/bind", json={"invite_code": c_code}, headers=_auth(b))
assert r2.json()["status"] == "already_bound"
assert r2.json()["coins_awarded"] == 0
# 余额没变(没二次发奖),C 也没拿到邀请奖励
assert _coin_balance(client, b) == bal_b
assert _coin_balance(client, a) == bal_a
assert _coin_balance(client, c) == 0
def test_self_invite_blocked(client) -> None:
"""填自己的邀请码 → self_invite,不发奖。"""
a = _login(client, "13800002007")
a_code = _my_code(client, a)
r = client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(a))
assert r.status_code == 200, r.text
assert r.json()["status"] == "self_invite"
assert _coin_balance(client, a) == 0
def test_invalid_code(client) -> None:
"""无效邀请码 → invalid_code,不发奖。"""
b = _login(client, "13800002008")
r = client.post("/api/v1/invite/bind", json={"invite_code": "ZZZZZZ"}, headers=_auth(b))
assert r.status_code == 200, r.text
assert r.json()["status"] == "invalid_code"
assert _coin_balance(client, b) == 0
def test_manual_channel_recorded(client) -> None:
"""channel=manual 也能绑定(手动填码兜底路径)。"""
a = _login(client, "13800002009")
b = _login(client, "13800002010")
a_code = _my_code(client, a)
r = client.post(
"/api/v1/invite/bind",
json={"invite_code": a_code, "channel": "manual"},
headers=_auth(b),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "success"
def test_invite_requires_auth(client) -> None:
"""不带 token → 401。"""
assert client.get("/api/v1/invite/me").status_code == 401
assert client.post("/api/v1/invite/bind", json={"invite_code": "ABCDEF"}).status_code == 401
def test_old_user_not_eligible(client) -> None:
"""新用户闸:被邀请人注册超过窗口 → not_eligible,双方都不发奖。"""
a = _login(client, "13800002030")
a_code = _my_code(client, a)
b = _login(client, "13800002031")
# 把 b 的注册时间改成超出窗口 → 变"老用户"
with SessionLocal() as db:
u = get_user_by_phone(db, "13800002031")
assert u is not None
u.created_at = datetime.now(timezone.utc) - timedelta(hours=INVITE_NEW_USER_WINDOW_HOURS + 1)
db.commit()
r = client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(b))
assert r.status_code == 200, r.text
assert r.json()["status"] == "not_eligible"
assert r.json()["coins_awarded"] == 0
assert _coin_balance(client, b) == 0
assert _coin_balance(client, a) == 0