"""业务事件 → 站内通知 + 厂商推送 联动测试(services/notification_events)。 覆盖 PRD 六类真实触发: #3 提现成功 / #4 提现失败(含审核拒绝) / #9 官方回复 / #10 反馈奖励 / #11 爆料审核通过 / #12 好友下单到账。 厂商推送不真发:测试环境无凭据默认跳过;推送链路用 monkeypatch 捕获/注错验证 「有设备则推、推挂了业务不受影响」。wxpay 网络调用照旧全 monkeypatch。 """ from __future__ import annotations import pytest from fastapi.testclient import TestClient from sqlalchemy import select from app.admin.main import admin_app from app.admin.repositories import admin_user as admin_repo from app.core.rewards import INVITE_COMPARE_REWARD_CENTS, PRICE_REPORT_REWARD_COINS from app.core.security import decode_token, hash_password from app.db.session import SessionLocal from app.models.feedback import Feedback from app.models.price_report import PriceReport from app.models.wallet import CoinAccount, WithdrawOrder from app.repositories import device as device_repo from app.repositories import wallet as crud_wallet from app.services import notification_events # ===== 用户侧 helpers(同 test_withdraw / test_notifications)===== def _login(client: TestClient, 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 _uid(token: str) -> int: return int(decode_token(token, expected_type="access")["sub"]) def _notifications(client: TestClient, token: str) -> list[dict]: r = client.get("/api/v1/notifications?pageSize=50", headers=_auth(token)) assert r.status_code == 200, r.text return r.json()["items"] def _seed_cash(client: TestClient, token: str, cents: int) -> None: """先访问 /account 触发建账户,再直接灌现金余额。""" client.get("/api/v1/wallet/account", headers=_auth(token)) with SessionLocal() as db: acc = db.get(CoinAccount, _uid(token)) acc.cash_balance_cents = cents db.commit() def _create_withdraw(client: TestClient, token: str, monkeypatch, cents: int = 50) -> str: """绑微信 + 发起提现(进入 reviewing),返回 out_bill_no。""" monkeypatch.setattr( "app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": f"openid_{_uid(token)}", "nickname": None, "avatar_url": None, "raw": {}}, ) _seed_cash(client, token, cents * 2) client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": cents}, headers=_auth(token)) assert r.status_code == 200, r.text assert r.json()["status"] == "reviewing" return r.json()["out_bill_no"] # ===== admin 侧 helpers(同 test_admin_write)===== @pytest.fixture() def admin_client() -> TestClient: return TestClient(admin_app) @pytest.fixture() def operator_token() -> str: with SessionLocal() as db: a = admin_repo.get_by_username(db, "ne_operator") if a is None: admin_repo.create_admin(db, username="ne_operator", password="pass1234", role="operator") else: a.password_hash = hash_password("pass1234") a.role = "operator" a.status = "active" db.commit() c = TestClient(admin_app) return c.post( "/admin/api/auth/login", json={"username": "ne_operator", "password": "pass1234"} ).json()["access_token"] def _seed_feedback(uid: int) -> int: with SessionLocal() as db: fb = Feedback(user_id=uid, content="比价按钮找不到", contact="wx123", status="pending") db.add(fb) db.commit() return fb.id def _seed_price_report(uid: int, store: str = "蜀大侠火锅") -> int: with SessionLocal() as db: rep = PriceReport( user_id=uid, store_name=store, reported_platform_id="mt", reported_platform_name="美团", reported_price_cents=990, images=[], status="pending", ) db.add(rep) db.commit() return rep.id # ===== #4 提现失败(审核拒绝路径,_refund_withdraw 收口)===== def test_withdraw_reject_creates_failed_notification(client: TestClient, monkeypatch) -> None: token = _login(client, "13800005001") bill = _create_withdraw(client, token, monkeypatch) with SessionLocal() as db: crud_wallet.reject_withdraw(db, bill, "微信零钱未实名") items = _notifications(client, token) failed = [i for i in items if i["type"] == "withdraw_failed"] assert len(failed) == 1 n = failed[0] assert n["cashCents"] == 50 assert n["cashYuan"] == "0.50" assert n["title"] == "提现失败,款项已退回" assert n["extra"]["withdrawId"] == bill assert n["isRead"] is False rows = {r["label"]: r["value"] for r in n["infoRows"]} assert rows["失败原因"] == "微信零钱未实名" assert rows["退回说明"] == "款项已原路退回现金余额" def test_withdraw_failed_event_dedup_single_unread(client: TestClient, monkeypatch) -> None: """同一提现单重复触发失败事件(并发查单等)→ 未读期间只落一条。""" token = _login(client, "13800005002") bill = _create_withdraw(client, token, monkeypatch) with SessionLocal() as db: crud_wallet.reject_withdraw(db, bill, "审核未通过") order = db.execute( select(WithdrawOrder).where(WithdrawOrder.out_bill_no == bill) ).scalar_one() notification_events.notify_withdraw_failed(db, order) # 人为重复触发 items = [i for i in _notifications(client, token) if i["type"] == "withdraw_failed"] assert len(items) == 1 # ===== #3 提现成功(查单归一化路径)===== def test_withdraw_success_notification_on_status_query(client: TestClient, monkeypatch) -> None: token = _login(client, "13800005003") monkeypatch.setattr( "app.integrations.wxpay.create_transfer", lambda openid, amount_fen, out_bill_no, user_name=None: { "status_code": 200, "data": {"state": "WAIT_USER_CONFIRM", "package_info": "pkg", "transfer_bill_no": "tb"}, }, ) bill = _create_withdraw(client, token, monkeypatch) with SessionLocal() as db: crud_wallet.approve_withdraw(db, bill) # 审核通过 → 转账进 pending(等用户确认) assert [i for i in _notifications(client, token) if i["type"] == "withdraw_success"] == [] # 用户确认后查单 → SUCCESS → success + 下发「提现到账」通知 monkeypatch.setattr( "app.integrations.wxpay.query_transfer", lambda out_bill_no: {"status_code": 200, "data": {"state": "SUCCESS"}}, ) r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token)) assert r.json()["status"] == "success" ok = [i for i in _notifications(client, token) if i["type"] == "withdraw_success"] assert len(ok) == 1 n = ok[0] assert n["cashCents"] == 50 assert n["actionText"] is None # PRD:提现成功卡无操作行 rows = {r["label"]: r["value"] for r in n["infoRows"]} assert rows["到账账户"] == "微信钱包" assert "到账时间" in rows # 再查一次(已终态,早退)→ 不重复下发 client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token)) assert len([i for i in _notifications(client, token) if i["type"] == "withdraw_success"]) == 1 # ===== #10 反馈奖励(admin 采纳发金币)===== def test_feedback_approve_sends_reward_notification( client: TestClient, admin_client: TestClient, operator_token: str ) -> None: token = _login(client, "13800005004") fb_id = _seed_feedback(_uid(token)) r = admin_client.post( f"/admin/api/feedbacks/{fb_id}/approve", json={"reward_coins": 300, "note": "好建议", "reply": "问题已修复上线,送您的金币请查收~"}, headers=_auth(operator_token), ) assert r.status_code == 200, r.text items = [i for i in _notifications(client, token) if i["type"] == "feedback_reward"] assert len(items) == 1 n = items[0] assert n["coins"] == 300 assert n["extra"]["feedbackId"] == str(fb_id) rows = {r["label"]: r["value"] for r in n["infoRows"]} assert rows["官方留言"] == "问题已修复上线,送您的金币请查收~" # PRD:发奖必带官方留言 assert "到账时间" in rows # ===== #9 官方回复(admin 拒绝,原因/留言用户可见)===== def test_feedback_reject_sends_reply_notification( client: TestClient, admin_client: TestClient, operator_token: str ) -> None: token = _login(client, "13800005005") fb_id = _seed_feedback(_uid(token)) r = admin_client.post( f"/admin/api/feedbacks/{fb_id}/reject", json={"reason": "无法复现", "reply": "麻烦补个录屏,我们再看看~"}, headers=_auth(operator_token), ) assert r.status_code == 200, r.text items = [i for i in _notifications(client, token) if i["type"] == "feedback_reply"] assert len(items) == 1 n = items[0] assert n["title"] == "傻瓜比价官方回复了您的反馈" assert n["extra"]["feedbackId"] == str(fb_id) assert n["coins"] is None # ===== #11 爆料审核通过(admin 通过发固定金币)===== def test_price_report_approve_sends_notification( client: TestClient, admin_client: TestClient, operator_token: str ) -> None: token = _login(client, "13800005006") rep_id = _seed_price_report(_uid(token), store="蜀大侠火锅") r = admin_client.post( f"/admin/api/price-reports/{rep_id}/approve", headers=_auth(operator_token) ) assert r.status_code == 200, r.text items = [i for i in _notifications(client, token) if i["type"] == "report_approved"] assert len(items) == 1 n = items[0] assert n["coins"] == PRICE_REPORT_REWARD_COINS assert n["extra"]["reportId"] == str(rep_id) rows = {r["label"]: r["value"] for r in n["infoRows"]} assert "蜀大侠火锅" in rows["奖励说明"] # ===== #12 好友下单到账(好友首次成功比价 → 通知邀请人)===== def test_invite_compare_reward_sends_notification(client: TestClient) -> None: a = _login(client, "13800005007") b = _login(client, "13800005008") code = client.get("/api/v1/invite/me", headers=_auth(a)).json()["invite_code"] client.post("/api/v1/invite/bind", json={"invite_code": code}, headers=_auth(b)) r = client.post( "/api/v1/compare/record", json={"trace_id": "trace-notif-1", "status": "success"}, headers=_auth(b), ) assert r.status_code == 200, r.text items = [i for i in _notifications(client, a) if i["type"] == "invite_order_reward"] assert len(items) == 1 n = items[0] assert n["cashCents"] == INVITE_COMPARE_REWARD_CENTS assert n["extra"]["inviteeNickname"] # 好友昵称兜底(昵称/尾号/「好友」)非空 # 被邀请人自己不收该通知 assert [i for i in _notifications(client, b) if i["type"] == "invite_order_reward"] == [] # 好友再比价 → 不再发奖也不再通知(发奖幂等 + 通知 dedup 双保险) client.post( "/api/v1/compare/record", json={"trace_id": "trace-notif-2", "status": "success"}, headers=_auth(b), ) assert len([i for i in _notifications(client, a) if i["type"] == "invite_order_reward"]) == 1 # ===== 厂商推送联动(有设备则推;推送失败不伤业务)===== def _register_device(uid: int, vendor: str = "xiaomi", token: str = "regid-1") -> None: with SessionLocal() as db: device_repo.register_or_update( db, user_id=uid, device_id=f"dev_{uid}", push_vendor=vendor, push_token=token ) def test_push_sent_to_registered_device(client: TestClient, monkeypatch) -> None: token = _login(client, "13800005009") _register_device(_uid(token)) sent: list[dict] = [] monkeypatch.setattr(notification_events.vendor_push, "missing_settings", lambda vendor: []) def _capture(vendor, push_token, *, title, body, extras=None, mock=False): sent.append({"vendor": vendor, "token": push_token, "title": title, "body": body, "extras": extras}) return {"ok": True} monkeypatch.setattr(notification_events.vendor_push, "send_notification", _capture) bill = _create_withdraw(client, token, monkeypatch) with SessionLocal() as db: crud_wallet.reject_withdraw(db, bill, "微信零钱未实名") assert len(sent) == 1 p = sent[0] assert p["vendor"] == "xiaomi" and p["token"] == "regid-1" assert p["title"] == "提现失败,款项已退回" assert "0.50" in p["body"] and "微信零钱未实名" in p["body"] # PRD §4 push 已读联动:extras 带 type + notificationId + 跳转参数 assert p["extras"]["type"] == "withdraw_failed" assert p["extras"]["withdrawId"] == bill nid = int(p["extras"]["notificationId"]) assert any(i["id"] == nid for i in _notifications(client, token)) def test_push_failure_does_not_break_business(client: TestClient, monkeypatch) -> None: """推送炸了(哪怕不是 VendorPushError)→ 提现拒绝照常退款,站内消息照常落库。""" token = _login(client, "13800005010") _register_device(_uid(token), token="regid-2") monkeypatch.setattr(notification_events.vendor_push, "missing_settings", lambda vendor: []) def _boom(*args, **kwargs): raise RuntimeError("vendor api down") monkeypatch.setattr(notification_events.vendor_push, "send_notification", _boom) bill = _create_withdraw(client, token, monkeypatch) with SessionLocal() as db: crud_wallet.reject_withdraw(db, bill, "审核未通过") r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token)) assert r.json()["status"] == "rejected" # 业务不受影响 r = client.get("/api/v1/wallet/account", headers=_auth(token)) assert r.json()["cash_balance_cents"] == 100 # 已退回(seed 100 扣 50 退 50) assert len([i for i in _notifications(client, token) if i["type"] == "withdraw_failed"]) == 1