0717c09721
改动:新增厂商推送配置、设备 push_vendor/push_token 字段、device push-test 接口、心跳超时厂商直推发送逻辑和对应测试。 验证:python -m pytest tests/test_device_push.py tests/test_auth.py tests/test_health.py 通过。 --------- Co-authored-by: guke <guke@wonderable.ai> Co-authored-by: 左辰勇 <exinglang@gmail.com> Co-authored-by: lowmaster-chen <1119780489@qq.com> Reviewed-on: #118 Co-authored-by: Ghost <> Co-committed-by: Ghost <>
359 lines
14 KiB
Python
359 lines
14 KiB
Python
"""业务事件 → 站内通知 + 厂商推送 联动测试(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
|