"""邀请 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