a8ac5dc0c7
- 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>
194 lines
7.6 KiB
Python
194 lines
7.6 KiB
Python
"""消息通知中心 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
|