"""消息通知中心 3 接口(落库版)。 覆盖:空列表(虚拟数据已清除)、列表字段/派生/排序/分页、未读角标、标记已读(ids / all / 幂等 / 参数校验)、鉴权与用户隔离、去重键部分唯一索引。数据直接写 notification 表 (repositories/notification),不再有内存 mock。 """ from __future__ import annotations from datetime import datetime, timedelta, timezone import pytest from fastapi.testclient import TestClient from sqlalchemy.exc import IntegrityError from app.core.security import decode_token from app.db.session import SessionLocal from app.repositories import notification as notif_repo _CST = timezone(timedelta(hours=8)) 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 _seed_sample(token: str, type_key: str): """按类型插一条样例通知(复用 repo 的样例卡片内容),返回落库行。""" with SessionLocal() as db: return notif_repo.insert_sample(db, _uid(token), type_key) def _seed(token: str, type_key: str, **kw): """按显式内容插一条通知(排序/分页用,可指定 sent_at)。""" with SessionLocal() as db: return notif_repo.create_notification(db, user_id=_uid(token), type_key=type_key, **kw) 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_fresh_user_has_no_notifications(client: TestClient) -> None: """虚拟数据已清除:新用户初始为空列表、未读 0、角标隐藏。""" token = _login(client, "13900010000") data = _fetch_all(client, token) assert data["items"] == [] assert data["total"] == 0 assert data["unreadCount"] == 0 assert data["hasMore"] is False r = client.get("/api/v1/notifications/unread-count", headers=_auth(token)) assert r.json() == {"count": 0, "badgeText": None} def test_list_item_fields_camel_case_and_derived(client: TestClient) -> None: token = _login(client, "13900010001") _seed_sample(token, "reward_expiring") _seed_sample(token, "withdraw_success") _seed_sample(token, "perm_accessibility") _seed_sample(token, "feedback_reward") items = _fetch_all(client, token)["items"] assert len(items) == 4 # 字段按 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"] # 落库后仍恒带 +08:00 by_type = {i["type"]: i for i in items} # 双金额卡:金币整数 + 现金两位小数;category/cardStyle/title/actionText 均由 catalog 派生 expiring = by_type["reward_expiring"] assert expiring["category"] == "withdraw_assistant" assert expiring["categoryLabel"] == "提现助手" assert expiring["cardStyle"] == "dual_amount" assert expiring["title"] == "金币现金奖励即将失效" assert expiring["actionText"] == "立即激活您的收益" assert isinstance(expiring["coins"], int) assert expiring["cashCents"] == 1280 assert expiring["cashYuan"] == "12.80" assert [row["label"] for row in expiring["infoRows"]] == ["过期说明", "过期时间"] # 提现成功卡:无操作行、无金币 ws = by_type["withdraw_success"] assert ws["actionText"] is None assert ws["coins"] is None # 权限异常卡带 permission 参数(客户端点击时实时检测用) assert by_type["perm_accessibility"]["extra"] == {"permission": "accessibility"} # 反馈奖励卡:官方留言必填(PRD §3) reward = by_type["feedback_reward"] assert any(row["label"] == "官方留言" and row["value"] for row in reward["infoRows"]) # 新插入默认未读 assert all(i["isRead"] is False for i in items) def test_list_sorted_by_time_desc(client: TestClient) -> None: """全列表 sent_at 倒序(最新在前),不分组。""" token = _login(client, "13900010002") base = datetime(2026, 7, 1, 12, 0, tzinfo=_CST) _seed(token, "withdraw_success", sent_at=base - timedelta(days=2)) newest = _seed(token, "invite_order_reward", sent_at=base) _seed(token, "feedback_reply", sent_at=base - timedelta(days=1)) items = _fetch_all(client, token)["items"] sent_ats = [i["sentAt"] for i in items] assert sent_ats == sorted(sent_ats, reverse=True), "最新在前" assert items[0]["id"] == newest.id assert items[0]["type"] == "invite_order_reward" def test_pagination(client: TestClient) -> None: token = _login(client, "13900010003") base = datetime(2026, 7, 1, 12, 0, tzinfo=_CST) n = 12 for i in range(n): _seed(token, "withdraw_success", sent_at=base - timedelta(minutes=i)) total = _fetch_all(client, token)["total"] assert total == n 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") for _ in range(3): _seed_sample(token, "withdraw_success") r = client.get("/api/v1/notifications/unread-count", headers=_auth(token)) assert r.json() == {"count": 3, "badgeText": "3"} # 全部读完 → 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") ids = [_seed_sample(token, "withdraw_success").id for _ in range(3)] picked = 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": 1} # 列表状态同步翻转 items = {i["id"]: i for i in _fetch_all(client, token)["items"]} assert all(items[i]["isRead"] for i in picked) assert items[ids[2]]["isRead"] is False # 重复置读 + 不存在的 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"] == 1 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_isolated_between_users(client: TestClient) -> None: token_a = _login(client, "13900010007") token_b = _login(client, "13900010008") _seed_sample(token_a, "withdraw_success") _seed_sample(token_b, "withdraw_success") client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token_a)) assert client.get("/api/v1/notifications/unread-count", headers=_auth(token_a)).json()["count"] == 0 assert ( client.get("/api/v1/notifications/unread-count", headers=_auth(token_b)).json()["count"] == 1 ), "A 清零不影响 B" def test_dedup_key_blocks_duplicate_unread(client: TestClient) -> None: """同一 (user, type, dedup_key) 未读期间只允许一条(部分唯一索引拦重复未读)。""" token = _login(client, "13900010009") uid = _uid(token) with SessionLocal() as db: notif_repo.create_notification( db, user_id=uid, type_key="perm_accessibility", dedup_key="accessibility" ) # 同键第二条(仍未读)→ 唯一索引拦截 with pytest.raises(IntegrityError): with SessionLocal() as db: notif_repo.create_notification( db, user_id=uid, type_key="perm_accessibility", dedup_key="accessibility" ) # 只落了一条 assert _fetch_all(client, token)["total"] == 1