"""Integration tests for the admin notification-outbox API. Two regressions guarded here (2026-05-27): 1. ``GET /notifications/outbox`` must serialize ``body_text`` (and ``url``) so the admin drawer can show the *actual* pushed content. Before the fix the schema exposed only ``title`` + ``template_key``, so the drawer rendered a bare i18n-looking key and an operator debugging "what did we send this person?" had no way to see the body. 2. ``status=unhealthy`` must resolve server-side to ``status IN (pending, partial, failed, dead)`` so BOTH the returned rows AND ``meta.total`` exclude delivered rows. The previous implementation passed ``status=undefined`` and filtered the page client-side, so the (default) unhealthy view undercounted the page while ``meta.total`` still counted delivered rows — pagination claimed pages that rendered near-empty. """ from __future__ import annotations import uuid import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.notifications.models import ( STATUS_DEAD, STATUS_DELIVERED, STATUS_DELIVERING, STATUS_DELIVERY_UNCERTAIN, STATUS_FAILED, STATUS_PARTIAL, STATUS_PENDING, NotificationOutbox, ) async def _seed_outbox_row( db_session: AsyncSession, *, status: str, title: str = "row", body_text: str = "body", body_html: str | None = None, url: str | None = None, template_key: str = "t.key", recipient_id: uuid.UUID | None = None, ) -> NotificationOutbox: row = NotificationOutbox( recipient_person_id=recipient_id, recipient_email="r@test.com", title=title, body_text=body_text, body_html=body_html, url=url, template_key=template_key, channels=["feishu", "email"], status=status, ) db_session.add(row) await db_session.commit() await db_session.refresh(row) return row @pytest.mark.asyncio async def test_outbox_list_serializes_body_text_and_url( client, admin_headers, db_session, ) -> None: """The drawer's 内容 card renders ``body_text`` + ``url``; the API must therefore return them. Regression for the "drawer only shows title + template_key" bug.""" await _seed_outbox_row( db_session, status=STATUS_DELIVERED, title="你的 Navan 账号已自动创建", body_text="Hi 朱云,\n\n你的账号已创建。\n\n— Helix", url="/me/settings", ) resp = await client.get( "/api/v1/notifications/outbox?status=delivered", headers=admin_headers, ) assert resp.status_code == 200, resp.text rows = resp.json()["data"] assert len(rows) == 1 row = rows[0] assert row["body_text"] == "Hi 朱云,\n\n你的账号已创建。\n\n— Helix" assert row["url"] == "/me/settings" assert row["title"] == "你的 Navan 账号已自动创建" @pytest.mark.asyncio async def test_outbox_list_serializes_body_html( client, admin_headers, db_session, ) -> None: """The rich Day-1 onboarding welcome stores a ``body_html``; the API must expose it so the drawer renders the actual rich mail instead of the plain-text fallback. Regression for "detail shows plain text, not the rich content" (2026-07-14).""" await _seed_outbox_row( db_session, status=STATUS_DELIVERED, title="🎉 Welcome to PingCAP!", body_text="Hi Robert Wood, plain fallback", body_html="

Welcome

", template_key="hire_day_it_info", ) resp = await client.get( "/api/v1/notifications/outbox?status=delivered", headers=admin_headers, ) assert resp.status_code == 200, resp.text rows = resp.json()["data"] assert len(rows) == 1 assert rows[0]["body_html"] == "

Welcome

" @pytest.mark.asyncio async def test_outbox_secret_key_masks_body_and_nulls_html( db_session, ) -> None: """A secret-body row (google_account_password) must mask ``body_text`` AND null ``body_html`` for a non-revealing reader — a future HTML-bodied secret must never leak the credential past the mask.""" from app.notifications.router import _serialize row = await _seed_outbox_row( db_session, status=STATUS_DELIVERED, body_text="Temporary password: hunter2", body_html="password: hunter2", template_key="google_account_password", ) masked = _serialize(row, reveal_secret=False) assert "hunter2" not in masked["body_text"] assert masked["body_html"] is None revealed = _serialize(row, reveal_secret=True) assert "hunter2" in revealed["body_text"] # (real password mails are plain-text; the html here is only to prove # the reveal path returns whatever was stored.) assert revealed["body_html"] == "password: hunter2" @pytest.mark.asyncio async def test_outbox_unhealthy_filter_excludes_delivered( client, admin_headers, db_session, ) -> None: """``status=unhealthy`` returns only pending/partial/failed/dead and ``meta.total`` counts only those — never the delivered rows.""" # 2 delivered (must be excluded) + 4 unhealthy (one of each state). await _seed_outbox_row(db_session, status=STATUS_DELIVERED, title="d1") await _seed_outbox_row(db_session, status=STATUS_DELIVERED, title="d2") await _seed_outbox_row(db_session, status=STATUS_PENDING, title="p1") await _seed_outbox_row(db_session, status=STATUS_PARTIAL, title="pa1") await _seed_outbox_row(db_session, status=STATUS_FAILED, title="f1") await _seed_outbox_row(db_session, status=STATUS_DEAD, title="x1") resp = await client.get( "/api/v1/notifications/outbox?status=unhealthy", headers=admin_headers, ) assert resp.status_code == 200, resp.text body = resp.json() rows = body["data"] # Exactly the 4 unhealthy rows — delivered excluded. assert body["meta"]["total"] == 4 assert len(rows) == 4 returned_statuses = {r["status"] for r in rows} assert returned_statuses == { STATUS_PENDING, STATUS_PARTIAL, STATUS_FAILED, STATUS_DEAD, } assert STATUS_DELIVERED not in returned_statuses @pytest.mark.asyncio async def test_outbox_exact_status_filter_unaffected( client, admin_headers, db_session, ) -> None: """A concrete status still does an exact match (the 'unhealthy' branch must not swallow normal status filtering).""" await _seed_outbox_row(db_session, status=STATUS_DELIVERED, title="d1") await _seed_outbox_row(db_session, status=STATUS_PENDING, title="p1") resp = await client.get( "/api/v1/notifications/outbox?status=pending", headers=admin_headers, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["meta"]["total"] == 1 assert body["data"][0]["status"] == STATUS_PENDING @pytest.mark.asyncio async def test_uncertain_delivery_is_actionable_and_exposes_reconciliation_key( client, admin_headers, db_session, ) -> None: row = await _seed_outbox_row( db_session, status=STATUS_DELIVERY_UNCERTAIN, title="unknown outcome", ) row.delivery_claim_count = 2 row.delivery_claimed_channels = ["email"] row.provider_delivery_keys = { "email": f"", } await db_session.commit() await _seed_outbox_row( db_session, status=STATUS_DELIVERING, title="active claim", ) needs_action = await client.get( "/api/v1/notifications/outbox?status=needs_action", headers=admin_headers, ) assert needs_action.status_code == 200, needs_action.text payload = needs_action.json() assert payload["meta"]["total"] == 1 assert payload["data"][0]["status"] == STATUS_DELIVERY_UNCERTAIN assert payload["data"][0]["delivery_claim_count"] == 2 assert payload["data"][0]["provider_delivery_keys"]["email"].endswith( "@helix.local>", ) in_flight = await client.get( "/api/v1/notifications/outbox?status=in_flight", headers=admin_headers, ) assert in_flight.status_code == 200, in_flight.text assert [item["status"] for item in in_flight.json()["data"]] == [ STATUS_DELIVERING, ] @pytest.mark.asyncio async def test_outbox_requires_admin( client, employee_headers, db_session, ) -> None: """The outbox is an admin surface — a plain employee must be refused (403), never shown other people's notification payloads.""" await _seed_outbox_row(db_session, status=STATUS_DELIVERED) resp = await client.get( "/api/v1/notifications/outbox", headers=employee_headers, ) assert resp.status_code == 403, resp.text