e2ecd3db9e
- 发奖触发点从「比价成功上报」(compare/record)挪到「实际下单上报」 (order/report):被邀请人完成比价并真实下单才给邀请人发 2 元邀请奖励金 (冰 2026-07-02 拍板);仅完成比价不再发奖。幂等闸 compare_reward_granted 不变。 - 新增 POST /api/v1/invite/dev-reset:把固定测试号(TEST_ACCOUNT_PHONE)打回 出厂态(清邀请关系/奖励金/savings/流水 + 刷 created_at 骗过 72h 新用户闸), 便于反复当新用户测发奖。双门控:生产 404 + 只认配置的测试号,无鉴权。 - 测试:8 例改为下单驱动 + 新增 2 例覆盖 dev-reset(可反复邀请 / 开关关闭 400)。
192 lines
8.1 KiB
Python
192 lines
8.1 KiB
Python
"""邀请 v3 发奖测试:好友完成比价【并实际下单】→ 邀请人得邀请奖励金(独立账户),幂等只发一次,
|
|
账户隔离不串金币/现金。被邀请人绑定不得任何奖励由 test_invite.py 覆盖。
|
|
|
|
⚠️ 触发口径(冰拍板):被邀请人仅完成比价【不】发奖,须实际下单(客户端归因后 POST
|
|
/api/v1/order/report)才发。故本文件用下单上报驱动发奖,而非比价上报。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.core.rewards import INVITE_COMPARE_REWARD_CENTS
|
|
from app.db.session import SessionLocal
|
|
from app.models.wallet import CoinAccount, InviteCashTransaction
|
|
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 _my_code(client, token: str) -> str:
|
|
return client.get("/api/v1/invite/me", headers=_auth(token)).json()["invite_code"]
|
|
|
|
|
|
def _bind(client, token: str, code: str):
|
|
return client.post("/api/v1/invite/bind", json={"invite_code": code}, headers=_auth(token))
|
|
|
|
|
|
def _report_order(client, token: str, event_id: str):
|
|
"""上报一笔归因订单(视为实际下单)→ 触发邀请发奖。event_id 为幂等键。"""
|
|
return client.post(
|
|
"/api/v1/order/report",
|
|
json={
|
|
"client_event_id": event_id,
|
|
"platform": "美团",
|
|
"pay_channel": "wechat",
|
|
"compared_price_cents": 2016,
|
|
"paid_amount_cents": 2016,
|
|
},
|
|
headers=_auth(token),
|
|
)
|
|
|
|
|
|
def _invite_cash(phone: str) -> int:
|
|
"""被试用户的邀请奖励金余额(分)。专用查询端点见小步3,这里直接查 DB。"""
|
|
with SessionLocal() as db:
|
|
u = get_user_by_phone(db, phone)
|
|
acc = db.get(CoinAccount, u.id) if u else None
|
|
return acc.invite_cash_balance_cents if acc else 0
|
|
|
|
|
|
def test_order_reward_granted_to_inviter(client) -> None:
|
|
"""B 被 A 邀请后实际下单一笔 → A 得邀请奖励金 2 元(独立账户),B 不得该奖。"""
|
|
a = _login(client, "13800003001")
|
|
b = _login(client, "13800003002")
|
|
_bind(client, b, _my_code(client, a))
|
|
|
|
assert _invite_cash("13800003001") == 0 # 发奖前
|
|
|
|
r = _report_order(client, b, "evt-reward-1")
|
|
assert r.status_code == 200, r.text
|
|
|
|
assert _invite_cash("13800003001") == INVITE_COMPARE_REWARD_CENTS # 邀请人到账
|
|
assert _invite_cash("13800003002") == 0 # 被邀请人不得此奖
|
|
|
|
|
|
def test_order_reward_idempotent(client) -> None:
|
|
"""好友下单多次 → 只发一次(compare_reward_granted 幂等)。"""
|
|
a = _login(client, "13800003003")
|
|
b = _login(client, "13800003004")
|
|
_bind(client, b, _my_code(client, a))
|
|
|
|
_report_order(client, b, "evt-idem-1")
|
|
_report_order(client, b, "evt-idem-2") # 第二笔下单(不同 event)
|
|
_report_order(client, b, "evt-idem-1") # 重复上报同 event
|
|
|
|
assert _invite_cash("13800003003") == INVITE_COMPARE_REWARD_CENTS # 仍只发一次
|
|
|
|
|
|
def test_invitees_is_compared_flag_flips_after_order(client) -> None:
|
|
"""好友列表 is_compared:好友下单前 False(在途/去提醒)、下单后 True(邀请成功)。"""
|
|
a = _login(client, "13800003021")
|
|
b = _login(client, "13800003022")
|
|
_bind(client, b, _my_code(client, a))
|
|
with SessionLocal() as db:
|
|
b_name = get_user_by_phone(db, "13800003022").nickname
|
|
|
|
def _b_item():
|
|
items = client.get("/api/v1/invite/invitees", headers=_auth(a)).json()["items"]
|
|
return next(it for it in items if it["display_name"] == b_name)
|
|
|
|
assert _b_item()["is_compared"] is False # 下单前:未完成(在途 / 去提醒)
|
|
_report_order(client, b, "evt-flag-1") # B 实际下单一笔
|
|
assert _b_item()["is_compared"] is True # 下单后:已完成(邀请成功)
|
|
|
|
|
|
def test_order_no_relation_no_reward(client) -> None:
|
|
"""没有邀请关系的人下单 → 不发奖(没人是他的邀请人)。"""
|
|
x = _login(client, "13800003005")
|
|
r = _report_order(client, x, "evt-norel-1")
|
|
assert r.status_code == 200, r.text
|
|
assert _invite_cash("13800003005") == 0
|
|
|
|
|
|
def test_compare_alone_no_reward(client) -> None:
|
|
"""仅完成比价、未下单 → 不触发发奖(新口径:比价不再发奖)。"""
|
|
a = _login(client, "13800003006")
|
|
b = _login(client, "13800003007")
|
|
_bind(client, b, _my_code(client, a))
|
|
|
|
r = client.post(
|
|
"/api/v1/compare/record",
|
|
json={"trace_id": "trace-noorder-1", "status": "success"},
|
|
headers=_auth(b),
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert _invite_cash("13800003006") == 0 # 只比价没下单:不发奖
|
|
|
|
|
|
def test_order_reward_isolated_from_coin_cash(client) -> None:
|
|
"""账户隔离:邀请奖励金进 invite_cash,不串 cash_balance_cents;流水落 invite_cash_transaction。"""
|
|
a = _login(client, "13800003008")
|
|
b = _login(client, "13800003009")
|
|
_bind(client, b, _my_code(client, a))
|
|
_report_order(client, b, "evt-iso-1")
|
|
|
|
with SessionLocal() as db:
|
|
ua = get_user_by_phone(db, "13800003008")
|
|
acc = db.get(CoinAccount, ua.id)
|
|
assert acc.invite_cash_balance_cents == INVITE_COMPARE_REWARD_CENTS # 奖励金到账
|
|
assert acc.cash_balance_cents == 0 # 没串进金币现金
|
|
txns = db.execute(
|
|
select(InviteCashTransaction).where(
|
|
InviteCashTransaction.user_id == ua.id,
|
|
InviteCashTransaction.biz_type == "invite_reward",
|
|
)
|
|
).scalars().all()
|
|
assert len(txns) == 1
|
|
assert txns[0].amount_cents == INVITE_COMPARE_REWARD_CENTS
|
|
|
|
|
|
def test_dev_reset_makes_test_account_fresh_again(client, monkeypatch) -> None:
|
|
"""dev-reset 让固定测试号 B 可【反复】当新用户:发奖 → reset → 换个邀请人再发奖成功。
|
|
|
|
验证 POST /api/v1/invite/dev-reset(方案 B)把 B 的 invite_relation / invite_cash /
|
|
savings 清掉并刷 created_at,于是它能再次绑定 + 下单触发第二次发奖(第一次的 already_bound /
|
|
savings 幂等 / 72h 闸都被 reset 解除)。
|
|
"""
|
|
from app.core.config import settings
|
|
|
|
test_phone = "11111111111"
|
|
# 端点按 settings.test_account_phone 认号 → 用 monkeypatch 在本用例内启用该测试号
|
|
monkeypatch.setattr(settings, "TEST_ACCOUNT_PHONE", test_phone)
|
|
|
|
a1 = _login(client, "13800003101")
|
|
b = _login(client, test_phone)
|
|
|
|
# 第一轮:B 绑 A1 → 下单 → A1 得奖
|
|
assert _bind(client, b, _my_code(client, a1)).json()["status"] == "success"
|
|
_report_order(client, b, "evt-reset-r1")
|
|
assert _invite_cash("13800003101") == INVITE_COMPARE_REWARD_CENTS
|
|
assert _invite_cash(test_phone) == 0 # 被邀请人 B 不得此奖
|
|
|
|
# dev-reset:把 B 打回出厂态(无需鉴权)
|
|
r = client.post("/api/v1/invite/dev-reset")
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["status"] == "ok" and body["phone"] == test_phone
|
|
assert body["cleared"]["invite_relation_as_invitee"] == 1 # 上一轮那条被邀请关系被清
|
|
|
|
# 第二轮:同一个 B 绑【另一个】邀请人 A2 → 下单 → A2 也得奖(证明 B 又是新用户了)
|
|
a2 = _login(client, "13800003102")
|
|
assert _bind(client, b, _my_code(client, a2)).json()["status"] == "success" # 不再 already_bound
|
|
_report_order(client, b, "evt-reset-r2")
|
|
assert _invite_cash("13800003102") == INVITE_COMPARE_REWARD_CENTS # 第二个邀请人到账
|
|
|
|
|
|
def test_dev_reset_blocked_when_test_account_disabled(client, monkeypatch) -> None:
|
|
"""测试号总开关没配(TEST_ACCOUNT_PHONE 空)→ dev-reset 返回 400(功能未启用)。"""
|
|
from app.core.config import settings
|
|
|
|
monkeypatch.setattr(settings, "TEST_ACCOUNT_PHONE", "")
|
|
r = client.post("/api/v1/invite/dev-reset")
|
|
assert r.status_code == 400, r.text
|