消息通知中心 3 接口(mock 阶段) + 五厂商推送(补华为/通用发送/测试三件套)
- GET /api/v1/notifications(分页,时间倒序不分组)/unread-count /read(ids|all),
对外 camelCase(PRD 前端契约);数据为内存 mock(13 类型全覆盖,重启复位),
接真实数据只换 repositories/notification_mock.py
- vendor_push 补华为 Push Kit(OAuth+messages:send),抽通用 send_notification
(任意文案+extras+mock 模式),send_accessibility_disabled 改薄封装行为不变
- 新增 /api/v1/push/{vendors,templates,test}:凭据状态/13 类 PRD 文案模板/测试发送
(默认 mock,mock=false 真发,可联动插站内 mock 消息闭环验证已读)
- .env.example 补 HUAWEI_* 键;docs/api 新增 notifications.md + push-vendor-test.md
- alembic merge 1a924c274fce 收拢 direct_vendor_push_fields/feedback_type_reply
双 head,恢复 upgrade head 可用
- tests: test_notifications + test_push_center 共 35 例
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""消息通知中心 3 接口(mock 数据阶段)。
|
||||
|
||||
覆盖:列表字段/排序/分页、未读角标、标记已读(ids / all / 幂等 / 参数校验)、
|
||||
鉴权与用户间隔离。数据来自 repositories/notification_mock.py 的内存 mock。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core import notification_catalog as catalog
|
||||
from app.repositories import notification_mock as mock_repo
|
||||
|
||||
|
||||
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 _fetch_all(client: TestClient, token: str) -> dict:
|
||||
r = client.get("/api/v1/notifications?pageSize=100", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def test_requires_auth(client: TestClient) -> None:
|
||||
assert client.get("/api/v1/notifications").status_code == 401
|
||||
assert client.get("/api/v1/notifications/unread-count").status_code == 401
|
||||
assert client.post("/api/v1/notifications/read", json={"all": True}).status_code == 401
|
||||
|
||||
|
||||
def test_list_covers_all_types_with_camel_case_fields(client: TestClient) -> None:
|
||||
token = _login(client, "13900010001")
|
||||
data = _fetch_all(client, token)
|
||||
|
||||
assert data["total"] >= 13
|
||||
assert data["unreadCount"] > 0
|
||||
items = data["items"]
|
||||
assert len(items) == data["total"]
|
||||
|
||||
# 13 种类型全覆盖
|
||||
assert {i["type"] for i in items} == set(catalog.TYPES)
|
||||
|
||||
# 字段按 PRD 契约 camelCase,且卡片要素齐全
|
||||
first = items[0]
|
||||
for key in (
|
||||
"id", "category", "categoryLabel", "type", "cardStyle", "title",
|
||||
"coins", "cashCents", "cashYuan", "infoRows", "actionText",
|
||||
"extra", "sentAt", "isRead",
|
||||
):
|
||||
assert key in first, f"missing field {key}"
|
||||
assert "+08:00" in first["sentAt"]
|
||||
|
||||
# 双金额卡:金币 + 现金都有;金币为整数、现金串两位小数
|
||||
expiring = next(i for i in items if i["type"] == "reward_expiring")
|
||||
assert isinstance(expiring["coins"], int)
|
||||
assert expiring["cashYuan"] == "12.80"
|
||||
assert expiring["cashCents"] == 1280
|
||||
assert expiring["actionText"] == "立即激活您的收益"
|
||||
assert [row["label"] for row in expiring["infoRows"]] == ["过期说明", "过期时间"]
|
||||
|
||||
# 提现成功卡:无操作行、无跳转
|
||||
ws = next(i for i in items if i["type"] == "withdraw_success")
|
||||
assert ws["actionText"] is None
|
||||
assert ws["coins"] is None
|
||||
|
||||
# 权限异常卡带 permission 参数(客户端点击时实时检测用)
|
||||
perm = next(i for i in items if i["type"] == "perm_accessibility")
|
||||
assert perm["extra"] == {"permission": "accessibility"}
|
||||
|
||||
# 反馈奖励卡:官方留言必填(PRD §3)
|
||||
reward = next(i for i in items if i["type"] == "feedback_reward")
|
||||
assert any(row["label"] == "官方留言" and row["value"] for row in reward["infoRows"])
|
||||
|
||||
|
||||
def test_list_sorted_by_time_desc_without_grouping(client: TestClient) -> None:
|
||||
"""全列表时间倒序、不按分类分组(PRD 原文的分组已确认取消)。"""
|
||||
token = _login(client, "13900010002")
|
||||
items = _fetch_all(client, token)["items"]
|
||||
|
||||
sent_ats = [i["sentAt"] for i in items]
|
||||
assert sent_ats == sorted(sent_ats, reverse=True), "全列表时间倒序,最新在前"
|
||||
|
||||
# mock 样例里「好友下单(10 分钟前)」比「昨天的提现失败」新但分类靠后 →
|
||||
# 若还在按分类分组,它不可能排进前 3;以此钉死"确实没分组"。
|
||||
newest_types = {i["type"] for i in items[:3]}
|
||||
assert "invite_order_reward" in newest_types
|
||||
|
||||
|
||||
def test_pagination(client: TestClient) -> None:
|
||||
token = _login(client, "13900010003")
|
||||
total = _fetch_all(client, token)["total"]
|
||||
|
||||
page_size = 5
|
||||
seen_ids: list[int] = []
|
||||
page = 1
|
||||
while True:
|
||||
r = client.get(
|
||||
f"/api/v1/notifications?page={page}&pageSize={page_size}", headers=_auth(token)
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["page"] == page
|
||||
assert data["pageSize"] == page_size
|
||||
assert data["total"] == total
|
||||
seen_ids.extend(i["id"] for i in data["items"])
|
||||
if not data["hasMore"]:
|
||||
assert len(data["items"]) <= page_size
|
||||
break
|
||||
assert len(data["items"]) == page_size
|
||||
page += 1
|
||||
|
||||
assert len(seen_ids) == total
|
||||
assert len(set(seen_ids)) == total, "翻页不重不漏"
|
||||
|
||||
# 超出末页 → 空页而非报错
|
||||
r = client.get("/api/v1/notifications?page=99&pageSize=50", headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
assert r.json()["items"] == []
|
||||
assert r.json()["hasMore"] is False
|
||||
|
||||
|
||||
def test_unread_count_and_badge(client: TestClient) -> None:
|
||||
token = _login(client, "13900010004")
|
||||
data = _fetch_all(client, token)
|
||||
expect_unread = sum(1 for i in data["items"] if not i["isRead"])
|
||||
|
||||
r = client.get("/api/v1/notifications/unread-count", headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["count"] == expect_unread == data["unreadCount"]
|
||||
assert body["badgeText"] == str(expect_unread) # mock 样例未读数在 1~99 区间
|
||||
|
||||
# 全部读完 → count=0,badgeText=null(整个角标隐藏)
|
||||
client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token))
|
||||
r = client.get("/api/v1/notifications/unread-count", headers=_auth(token))
|
||||
assert r.json() == {"count": 0, "badgeText": None}
|
||||
|
||||
|
||||
def test_mark_read_by_ids_idempotent(client: TestClient) -> None:
|
||||
token = _login(client, "13900010005")
|
||||
items = _fetch_all(client, token)["items"]
|
||||
unread_ids = [i["id"] for i in items if not i["isRead"]]
|
||||
before = len(unread_ids)
|
||||
picked = unread_ids[:2]
|
||||
|
||||
r = client.post("/api/v1/notifications/read", json={"ids": picked}, headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True, "markedCount": 2, "unreadCount": before - 2}
|
||||
|
||||
# 列表状态同步翻转
|
||||
items = _fetch_all(client, token)["items"]
|
||||
assert all(i["isRead"] for i in items if i["id"] in picked)
|
||||
|
||||
# 重复置读 + 不存在的 id → 幂等,不报错
|
||||
r = client.post(
|
||||
"/api/v1/notifications/read", json={"ids": [*picked, 123456789]}, headers=_auth(token)
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["markedCount"] == 0
|
||||
assert r.json()["unreadCount"] == before - 2
|
||||
|
||||
|
||||
def test_mark_read_requires_ids_or_all(client: TestClient) -> None:
|
||||
token = _login(client, "13900010006")
|
||||
r = client.post("/api/v1/notifications/read", json={}, headers=_auth(token))
|
||||
assert r.status_code == 400
|
||||
r = client.post("/api/v1/notifications/read", json={"ids": []}, headers=_auth(token))
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_stores_isolated_between_users(client: TestClient) -> None:
|
||||
token_a = _login(client, "13900010007")
|
||||
token_b = _login(client, "13900010008")
|
||||
|
||||
client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token_a))
|
||||
|
||||
r = client.get("/api/v1/notifications/unread-count", headers=_auth(token_b))
|
||||
assert r.json()["count"] > 0, "A 清零不影响 B"
|
||||
|
||||
|
||||
def test_reset_store_regenerates(client: TestClient) -> None:
|
||||
token = _login(client, "13900010009")
|
||||
client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token))
|
||||
assert client.get("/api/v1/notifications/unread-count", headers=_auth(token)).json()["count"] == 0
|
||||
|
||||
mock_repo.reset_store() # 联调辅助:清空后重新生成整套样例
|
||||
assert client.get("/api/v1/notifications/unread-count", headers=_auth(token)).json()["count"] > 0
|
||||
@@ -0,0 +1,393 @@
|
||||
"""厂商推送(5 家)+ 推送测试三件套。
|
||||
|
||||
覆盖:华为 Push Kit 发送链路(OAuth + messages:send payload)、send_notification 通用入口
|
||||
与 mock 模式、/push/vendors 配置状态、/push/templates 模板渲染、/push/test 的
|
||||
mock/真发/变量覆盖/站内联动/设备反查/参数校验。厂商 HTTP 全部 monkeypatch,不真发。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.integrations import vendor_push
|
||||
|
||||
_ALL_VENDOR_SETTINGS = [key for keys in vendor_push.REQUIRED_SETTINGS.values() for key in keys]
|
||||
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = "{}"
|
||||
|
||||
def __init__(self, data: dict) -> None:
|
||||
self._data = data
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._data
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _no_vendor_creds(monkeypatch) -> None:
|
||||
"""把 5 家厂商凭据全部清空(隔离本机 .env 里已填的真实密钥,保证用例确定性)。"""
|
||||
for key in _ALL_VENDOR_SETTINGS:
|
||||
monkeypatch.setattr(vendor_push.settings, key, "")
|
||||
|
||||
|
||||
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}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# integrations.vendor_push:华为链路 + 通用入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_huawei_auth_and_send_payload(monkeypatch) -> None:
|
||||
vendor_push._token_cache.clear()
|
||||
calls: list[dict] = []
|
||||
|
||||
def _fake_request(method, url, **kwargs): # noqa: ANN001
|
||||
calls.append({"method": method, "url": url, **kwargs})
|
||||
if url == vendor_push.settings.HUAWEI_PUSH_TOKEN_ENDPOINT:
|
||||
return _Resp({"access_token": "hw-access", "expires_in": 3600})
|
||||
return _Resp({"code": "80000000", "msg": "Success", "requestId": "req-1"})
|
||||
|
||||
monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_ID", "10086001")
|
||||
monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_SECRET", "hw-secret")
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
|
||||
|
||||
data = vendor_push.send_notification(
|
||||
"huawei",
|
||||
"hw-token",
|
||||
title="测试标题",
|
||||
body="测试内容",
|
||||
extras={"type": "withdraw_success", "notificationId": "90001"},
|
||||
)
|
||||
|
||||
assert data["code"] == "80000000"
|
||||
# OAuth:client_id 即 AppId
|
||||
assert calls[0]["url"] == vendor_push.settings.HUAWEI_PUSH_TOKEN_ENDPOINT
|
||||
assert calls[0]["data"]["grant_type"] == "client_credentials"
|
||||
assert calls[0]["data"]["client_id"] == "10086001"
|
||||
# 发送:v1 messages:send,Bearer 鉴权,token 数组 + data 透传 extras
|
||||
assert calls[1]["url"].endswith("/v1/10086001/messages:send")
|
||||
assert calls[1]["headers"]["Authorization"] == "Bearer hw-access"
|
||||
message = calls[1]["json"]["message"]
|
||||
assert message["token"] == ["hw-token"]
|
||||
assert message["android"]["notification"]["title"] == "测试标题"
|
||||
assert message["android"]["notification"]["click_action"] == {"type": 3}
|
||||
assert json.loads(message["data"]) == {"type": "withdraw_success", "notificationId": "90001"}
|
||||
|
||||
|
||||
def test_huawei_non_success_code_raises(monkeypatch) -> None:
|
||||
vendor_push._token_cache.clear()
|
||||
|
||||
def _fake_request(method, url, **kwargs): # noqa: ANN001
|
||||
if url == vendor_push.settings.HUAWEI_PUSH_TOKEN_ENDPOINT:
|
||||
return _Resp({"access_token": "hw-access", "expires_in": 3600})
|
||||
return _Resp({"code": "80300007", "msg": "all tokens are invalid"})
|
||||
|
||||
monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_ID", "10086001")
|
||||
monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_SECRET", "hw-secret")
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
|
||||
|
||||
with pytest.raises(vendor_push.VendorPushError, match="huawei push failed"):
|
||||
vendor_push.send_notification("huawei", "bad-token", title="t", body="b")
|
||||
|
||||
|
||||
def test_vendor_aliases_normalize() -> None:
|
||||
assert vendor_push.normalize_vendor("华为") == "huawei"
|
||||
assert vendor_push.normalize_vendor("HMS") == "huawei"
|
||||
assert vendor_push.normalize_vendor("荣耀") == "honor"
|
||||
assert vendor_push.normalize_vendor("小米") == "xiaomi"
|
||||
assert vendor_push.SUPPORTED_VENDORS == {"honor", "huawei", "xiaomi", "oppo", "vivo"}
|
||||
|
||||
|
||||
def test_send_notification_mock_skips_http(monkeypatch) -> None:
|
||||
def _boom(*args, **kwargs): # noqa: ANN001, ANN002, ANN003
|
||||
raise AssertionError("mock 模式不应发起任何 HTTP 请求")
|
||||
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _boom)
|
||||
|
||||
data = vendor_push.send_notification(
|
||||
"oppo", "any-token", title="标题", body="正文", extras={"type": "push_test"}, mock=True
|
||||
)
|
||||
assert data == {
|
||||
"mock": True,
|
||||
"vendor": "oppo",
|
||||
"title": "标题",
|
||||
"body": "正文",
|
||||
"extras": {"type": "push_test"},
|
||||
}
|
||||
|
||||
|
||||
def test_send_notification_rejects_unknown_vendor() -> None:
|
||||
with pytest.raises(vendor_push.VendorPushError, match="unsupported push vendor"):
|
||||
vendor_push.send_notification("nokia", "t", title="a", body="b", mock=True)
|
||||
with pytest.raises(vendor_push.VendorPushError, match="token is empty"):
|
||||
vendor_push.send_notification("huawei", " ", title="a", body="b", mock=True)
|
||||
|
||||
|
||||
def test_accessibility_wrapper_keeps_legacy_extras(monkeypatch) -> None:
|
||||
"""旧入口 send_accessibility_disabled 仍传 {"type":"accessibility_disabled"}(worker 兼容)。"""
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_request(method, url, **kwargs): # noqa: ANN001
|
||||
captured.update(method=method, url=url, **kwargs)
|
||||
return _Resp({"code": 0, "result": "ok"})
|
||||
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_PARAM_JSON", "")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_TITLE", "")
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_DESCRIPTION", "")
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
|
||||
|
||||
vendor_push.send_accessibility_disabled("xiaomi", "regid-1")
|
||||
|
||||
assert json.loads(captured["data"]["payload"]) == {"type": "accessibility_disabled"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/v1/push 三件套
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_vendors_status_reports_missing_keys(client: TestClient, monkeypatch, _no_vendor_creds) -> None:
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret")
|
||||
token = _login(client, "13900011001")
|
||||
|
||||
r = client.get("/api/v1/push/vendors", headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
vendors = {v["vendor"]: v for v in r.json()["vendors"]}
|
||||
assert list(vendors) == ["honor", "huawei", "xiaomi", "oppo", "vivo"]
|
||||
|
||||
assert vendors["xiaomi"]["configured"] is True
|
||||
assert vendors["xiaomi"]["missingKeys"] == []
|
||||
assert vendors["huawei"]["configured"] is False
|
||||
assert vendors["huawei"]["missingKeys"] == ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"]
|
||||
assert vendors["honor"]["label"] == "荣耀"
|
||||
assert vendors["vivo"]["missingKeys"] == [
|
||||
"VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET",
|
||||
]
|
||||
|
||||
|
||||
def test_templates_render_all_13_types(client: TestClient) -> None:
|
||||
token = _login(client, "13900011002")
|
||||
r = client.get("/api/v1/push/templates", headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
templates = r.json()["templates"]
|
||||
assert len(templates) == 13
|
||||
|
||||
by_type = {t["type"]: t for t in templates}
|
||||
ws = by_type["withdraw_success"]
|
||||
assert ws["pushTitle"] == "提现到账提醒"
|
||||
assert ws["pushBodySample"] == "¥0.50已存入您的微信钱包,点击查看到账详情"
|
||||
assert ws["variables"] == ["amount"]
|
||||
|
||||
expiring = by_type["reward_expiring"]
|
||||
assert "86金币" in expiring["pushBodySample"]
|
||||
assert "{coins}" in expiring["pushBodyTemplate"]
|
||||
assert expiring["sampleVars"]["cash"] == "12.80"
|
||||
|
||||
# 权限类标题按类型写死功能名
|
||||
assert by_type["perm_overlay"]["pushTitle"] == "检测到您的比价按钮已失效"
|
||||
|
||||
|
||||
def test_push_test_mock_renders_template(client: TestClient, _no_vendor_creds) -> None:
|
||||
token = _login(client, "13900011003")
|
||||
r = client.post(
|
||||
"/api/v1/push/test",
|
||||
json={"vendor": "华为", "pushToken": "hw-token-1", "type": "withdraw_success"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["mock"] is True
|
||||
assert body["vendor"] == "huawei" # 中文别名已归一化
|
||||
assert body["title"] == "提现到账提醒"
|
||||
assert body["body"] == "¥0.50已存入您的微信钱包,点击查看到账详情"
|
||||
assert body["extras"] == {"type": "withdraw_success"}
|
||||
assert body["missingKeys"] == ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"]
|
||||
assert body["vendorResponse"] is None
|
||||
|
||||
|
||||
def test_push_test_vars_override(client: TestClient, _no_vendor_creds) -> None:
|
||||
token = _login(client, "13900011004")
|
||||
r = client.post(
|
||||
"/api/v1/push/test",
|
||||
json={
|
||||
"vendor": "xiaomi",
|
||||
"pushToken": "xm-1",
|
||||
"type": "invite_order_reward",
|
||||
"vars": {"nickname": "小王", "amount": "6.66"},
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["body"] == "您的好友「小王」完成首次下单,6.66元现金已到账"
|
||||
|
||||
|
||||
def test_push_test_generic_copy_without_type(client: TestClient, _no_vendor_creds) -> None:
|
||||
token = _login(client, "13900011005")
|
||||
r = client.post(
|
||||
"/api/v1/push/test",
|
||||
json={"vendor": "oppo", "pushToken": "op-1"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["extras"]["type"] == "push_test"
|
||||
assert "OPPO" in body["body"]
|
||||
|
||||
|
||||
def test_push_test_create_notification_links_message_center(
|
||||
client: TestClient, _no_vendor_creds
|
||||
) -> None:
|
||||
token = _login(client, "13900011006")
|
||||
before = client.get("/api/v1/notifications/unread-count", headers=_auth(token)).json()["count"]
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/push/test",
|
||||
json={
|
||||
"vendor": "vivo",
|
||||
"pushToken": "vv-1",
|
||||
"type": "feedback_reward",
|
||||
"createNotification": True,
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
nid = body["notificationId"]
|
||||
assert isinstance(nid, int)
|
||||
assert body["extras"]["notificationId"] == str(nid)
|
||||
assert body["extras"]["type"] == "feedback_reward"
|
||||
assert body["extras"]["feedbackId"] # 业务参数一并带上,客户端可直达反馈详情
|
||||
|
||||
# 站内多了一条未读;按 push extras 的 id 置读 → 闭环
|
||||
after = client.get("/api/v1/notifications/unread-count", headers=_auth(token)).json()["count"]
|
||||
assert after == before + 1
|
||||
r = client.post("/api/v1/notifications/read", json={"ids": [nid]}, headers=_auth(token))
|
||||
assert r.json()["markedCount"] == 1
|
||||
|
||||
|
||||
def test_push_test_real_send_requires_credentials(client: TestClient, _no_vendor_creds) -> None:
|
||||
token = _login(client, "13900011007")
|
||||
r = client.post(
|
||||
"/api/v1/push/test",
|
||||
json={"vendor": "huawei", "pushToken": "hw-1", "mock": False},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "HUAWEI_PUSH_APP_ID" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_push_test_real_send_xiaomi(client: TestClient, monkeypatch, _no_vendor_creds) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_request(method, url, **kwargs): # noqa: ANN001
|
||||
captured.update(method=method, url=url, **kwargs)
|
||||
return _Resp({"code": 0, "result": "ok", "data": {"id": "xm-real"}})
|
||||
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret")
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
|
||||
token = _login(client, "13900011008")
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/push/test",
|
||||
json={
|
||||
"vendor": "xiaomi",
|
||||
"pushToken": "xm-regid-9",
|
||||
"type": "report_approved",
|
||||
"mock": False,
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["mock"] is False
|
||||
assert body["missingKeys"] == []
|
||||
assert body["vendorResponse"]["data"]["id"] == "xm-real"
|
||||
assert captured["data"]["registration_id"] == "xm-regid-9"
|
||||
assert captured["data"]["title"] == "爆料审核通过"
|
||||
assert "蜀大侠火锅" in captured["data"]["description"]
|
||||
assert json.loads(captured["data"]["payload"]) == {"type": "report_approved"}
|
||||
|
||||
|
||||
def test_push_test_real_send_vendor_error_maps_502(
|
||||
client: TestClient, monkeypatch, _no_vendor_creds
|
||||
) -> None:
|
||||
def _fake_request(method, url, **kwargs): # noqa: ANN001
|
||||
return _Resp({"code": 500, "result": "error", "reason": "invalid regid"})
|
||||
|
||||
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret")
|
||||
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
|
||||
token = _login(client, "13900011009")
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/push/test",
|
||||
json={"vendor": "xiaomi", "pushToken": "bad", "mock": False},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 502
|
||||
assert "厂商推送失败" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_push_test_resolves_token_from_registered_device(
|
||||
client: TestClient, _no_vendor_creds
|
||||
) -> None:
|
||||
token = _login(client, "13900011010")
|
||||
r = client.post(
|
||||
"/api/v1/device/register",
|
||||
json={"device_id": "dev-push-center-1", "push_vendor": "honor", "push_token": "honor-t1"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/push/test",
|
||||
json={"deviceId": "dev-push-center-1", "type": "perm_accessibility"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["vendor"] == "honor"
|
||||
assert body["title"] == "检测到您的比价功能已失效"
|
||||
|
||||
|
||||
def test_push_test_validation_errors(client: TestClient, _no_vendor_creds) -> None:
|
||||
token = _login(client, "13900011011")
|
||||
|
||||
# 未知厂商
|
||||
r = client.post(
|
||||
"/api/v1/push/test",
|
||||
json={"vendor": "nokia", "pushToken": "t"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
# 未知类型
|
||||
r = client.post(
|
||||
"/api/v1/push/test",
|
||||
json={"vendor": "xiaomi", "pushToken": "t", "type": "bogus"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "unknown notification type" in r.json()["detail"]
|
||||
|
||||
# 真发但没有 token 可用
|
||||
r = client.post(
|
||||
"/api/v1/push/test",
|
||||
json={"vendor": "xiaomi", "mock": False},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 409
|
||||
Reference in New Issue
Block a user