"""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