"""Integration tests for the per-row read-state machinery added 2026-04-29. The bell-icon dropdown used to drive its "unread" badge from a ``localStorage[helix:notif:lastRead:]`` timestamp that the client passed back as ``?since=``. That meant every cache wipe / new device / new browser-profile resurrected weeks of stale "unread" notifications. Migration 085 added ``notification_outbox.read_at`` and the ``POST /notifications/inbox/mark-read`` endpoint to make the badge DB-backed. These tests guard the new contract. """ from __future__ import annotations import uuid import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.notifications.models import ( STATUS_DELIVERED, STATUS_PARTIAL, NotificationOutbox, ) async def _seed_inbox_row( db_session: AsyncSession, *, recipient_id: uuid.UUID, title: str = "test notification", status: str = STATUS_DELIVERED, template_key: str | None = None, ) -> NotificationOutbox: """Insert a delivered outbox row addressed to ``recipient_id``. The inbox endpoints filter on ``status IN (delivered, partial)`` so every fixture must land in one of those states — pending rows would silently fail to show up and produce confusing test failures. """ row = NotificationOutbox( recipient_person_id=recipient_id, title=title, body_text="body", channels=["email"], status=status, template_key=template_key, ) db_session.add(row) await db_session.commit() await db_session.refresh(row) return row @pytest.mark.asyncio async def test_inbox_summary_unread_drops_to_zero_after_mark_all_read( client, employee_user, db_session, ) -> None: """The badge MUST clear after a bulk mark-read. Pre-085 the badge was driven by a localStorage timestamp; the server-side count is what survives a cache wipe, so the test exercises the new DB column directly.""" user, token = employee_user headers = {"Authorization": f"Bearer {token}"} await _seed_inbox_row(db_session, recipient_id=user.id) await _seed_inbox_row(db_session, recipient_id=user.id) summary = await client.get( "/api/v1/notifications/inbox/summary", headers=headers, ) assert summary.status_code == 200, summary.text assert summary.json()["data"]["unread"] == 2 mark = await client.post( "/api/v1/notifications/inbox/mark-read", headers=headers, json={"all": True}, ) assert mark.status_code == 200, mark.text assert mark.json()["data"]["marked"] == 2 summary = await client.get( "/api/v1/notifications/inbox/summary", headers=headers, ) assert summary.json()["data"]["unread"] == 0 @pytest.mark.asyncio async def test_inbox_returns_read_at_for_each_row( client, employee_user, db_session, ) -> None: """The list endpoint surfaces the per-row read timestamp so the dropdown can render the unread highlight without a second round-trip. ``read_at`` is null for fresh rows, ISO-8601 once the user marks them read.""" user, token = employee_user headers = {"Authorization": f"Bearer {token}"} await _seed_inbox_row(db_session, recipient_id=user.id) listing = await client.get( "/api/v1/notifications/inbox", headers=headers, ) assert listing.status_code == 200, listing.text rows = listing.json()["data"] assert len(rows) == 1 assert rows[0]["read_at"] is None await client.post( "/api/v1/notifications/inbox/mark-read", headers=headers, json={"all": True}, ) listing = await client.get( "/api/v1/notifications/inbox", headers=headers, ) rows = listing.json()["data"] assert rows[0]["read_at"] is not None @pytest.mark.asyncio async def test_mark_read_by_ids_only_touches_specified_rows( client, employee_user, db_session, ) -> None: """Per-id mode is reserved for future "mark this single item read" UX. It must not bleed into other rows — a buggy client sending one ID shouldn't silently clear the entire inbox.""" user, token = employee_user headers = {"Authorization": f"Bearer {token}"} target = await _seed_inbox_row(db_session, recipient_id=user.id, title="A") await _seed_inbox_row(db_session, recipient_id=user.id, title="B") mark = await client.post( "/api/v1/notifications/inbox/mark-read", headers=headers, json={"ids": [str(target.id)]}, ) assert mark.status_code == 200, mark.text assert mark.json()["data"]["marked"] == 1 summary = await client.get( "/api/v1/notifications/inbox/summary", headers=headers, ) # The other row is still unread, so the badge sits at 1. assert summary.json()["data"]["unread"] == 1 @pytest.mark.asyncio async def test_mark_read_cannot_touch_other_users_rows( client, employee_user, admin_user, db_session, ) -> None: """Recipient scoping — the ``WHERE recipient_person_id = me`` clause on the UPDATE statement must hold even when the client sends an ID belonging to another user. Otherwise a malicious client could mark another user's notifications read by guessing UUIDs.""" user, token = employee_user other_user, _ = admin_user headers = {"Authorization": f"Bearer {token}"} foreign_row = await _seed_inbox_row( db_session, recipient_id=other_user.id, title="not yours", ) mark = await client.post( "/api/v1/notifications/inbox/mark-read", headers=headers, json={"ids": [str(foreign_row.id)]}, ) # The request is well-formed — the endpoint just refuses to flip # rows that don't belong to the caller. ``marked`` reflects that. assert mark.status_code == 200, mark.text assert mark.json()["data"]["marked"] == 0 # Confirm the other user's row is still unread. await db_session.refresh(foreign_row) assert foreign_row.read_at is None @pytest.mark.asyncio async def test_mark_read_rejects_both_modes_simultaneously( client, employee_headers, ) -> None: """``{"ids": [...], "all": true}`` must 422 — the two paths target different row sets, so silently picking one would mask the bug.""" resp = await client.post( "/api/v1/notifications/inbox/mark-read", headers=employee_headers, json={"all": True, "ids": [str(uuid.uuid4())]}, ) assert resp.status_code == 422, resp.text @pytest.mark.asyncio async def test_mark_read_rejects_empty_body( client, employee_headers, ) -> None: """An empty body is ambiguous — refuse rather than guess. This makes a misconfigured frontend fail fast in dev instead of silently no-opping in prod.""" resp = await client.post( "/api/v1/notifications/inbox/mark-read", headers=employee_headers, json={}, ) assert resp.status_code == 422, resp.text @pytest.mark.asyncio async def test_mark_read_is_idempotent( client, employee_user, db_session, ) -> None: """Calling mark-read twice must not bump ``read_at`` on already-read rows — the original timestamp is preserved (so any future "when did the user first see this?" audit query stays accurate). The second call simply reports ``marked: 0``.""" user, token = employee_user headers = {"Authorization": f"Bearer {token}"} row = await _seed_inbox_row(db_session, recipient_id=user.id) first = await client.post( "/api/v1/notifications/inbox/mark-read", headers=headers, json={"all": True}, ) assert first.json()["data"]["marked"] == 1 await db_session.refresh(row) first_ts = row.read_at assert first_ts is not None second = await client.post( "/api/v1/notifications/inbox/mark-read", headers=headers, json={"all": True}, ) assert second.json()["data"]["marked"] == 0 await db_session.refresh(row) # Same timestamp — the WHERE clause filters out already-read rows. assert row.read_at == first_ts @pytest.mark.asyncio async def test_partial_status_rows_count_as_unread( client, employee_user, db_session, ) -> None: """The inbox surfaces both ``delivered`` and ``partial`` rows (the user got *some* delivery, e.g. email worked but Feishu didn't). Both must count toward the unread badge until marked read.""" user, token = employee_user headers = {"Authorization": f"Bearer {token}"} await _seed_inbox_row( db_session, recipient_id=user.id, status=STATUS_PARTIAL, ) summary = await client.get( "/api/v1/notifications/inbox/summary", headers=headers, ) assert summary.json()["data"]["unread"] == 1 @pytest.mark.asyncio async def test_summary_splits_actionable_from_social_celebration( client, employee_user, db_session, ) -> None: """The bell red-badge must reflect only *actionable* unread. Social / ceremonial notifications (work-anniversary self-greetings, birthday blessings — ``feed.celebration.*`` / ``feed.blessing.*``) stay in the inbox but must NOT manufacture false urgency on the badge. Regression guard for the 2026-06-15 employee-experience pass: a test account had 13 unread, all of them anniversary / birthday greetings, so the bell screamed "13" for zero things the employee had to do. """ user, token = employee_user headers = {"Authorization": f"Bearer {token}"} # 1 real action item … await _seed_inbox_row( db_session, recipient_id=user.id, title="Approve asset return", template_key="asset.return.reminder", ) # … plus an un-classified row (NULL template_key) which must count as # actionable (fail-towards-visible). await _seed_inbox_row( db_session, recipient_id=user.id, title="legacy notification", template_key=None, ) # … drowned in ceremonial greetings. await _seed_inbox_row( db_session, recipient_id=user.id, title="Happy anniversary!", template_key="feed.celebration.self.anniversary", ) await _seed_inbox_row( db_session, recipient_id=user.id, title="A birthday blessing", template_key="feed.blessing.received.birthday", ) await _seed_inbox_row( db_session, recipient_id=user.id, title="Another blessing", template_key="feed.blessing.received", ) resp = await client.get( "/api/v1/notifications/inbox/summary", headers=headers, ) assert resp.status_code == 200, resp.text data = resp.json()["data"] # ``unread`` stays honest (every row), the badge field excludes social. assert data["unread"] == 5 assert data["unread_actionable"] == 2 @pytest.mark.asyncio async def test_summary_excludes_whole_social_feed_family( client, employee_user, db_session, ) -> None: """The social/non-actionable set is the whole Moments feed family — not just celebration/blessing. Likes, comments and @-mentions are social engagement that must NOT redden the IT bell either. Company announcements (``feed.announcements.*``) stay actionable/visible. """ user, token = employee_user headers = {"Authorization": f"Bearer {token}"} # Social feed engagement — all excluded from the badge. for tk in ( "feed.comment.added", "feed.like.aggregated", "feed.mention.post", "feed.mention.comment", "feed.celebration.self.birthday", ): await _seed_inbox_row(db_session, recipient_id=user.id, template_key=tk) # Announcements + a real action item — both actionable. await _seed_inbox_row( db_session, recipient_id=user.id, template_key="feed.announcements.batch_status", ) await _seed_inbox_row( db_session, recipient_id=user.id, template_key="provisioning.task.assigned", ) resp = await client.get( "/api/v1/notifications/inbox/summary", headers=headers, ) data = resp.json()["data"] assert data["unread"] == 7 # 5 social-feed rows excluded; announcement + provisioning stay. assert data["unread_actionable"] == 2 @pytest.mark.asyncio async def test_summary_actionable_never_exceeds_unread_when_all_social( client, employee_user, db_session, ) -> None: """All-social inbox → ``unread_actionable`` is 0 even though ``unread`` is non-zero. This is the exact shape that triggered the fix.""" user, token = employee_user headers = {"Authorization": f"Bearer {token}"} for _ in range(3): await _seed_inbox_row( db_session, recipient_id=user.id, template_key="feed.celebration.self.anniversary", ) resp = await client.get( "/api/v1/notifications/inbox/summary", headers=headers, ) data = resp.json()["data"] assert data["unread"] == 3 assert data["unread_actionable"] == 0 @pytest.mark.asyncio async def test_summary_ignores_legacy_since_query_param( client, employee_user, db_session, ) -> None: """The pre-085 client passed ``?since=``. That parameter is now deprecated and silently ignored — the server reads ``read_at`` instead. We verify by seeding two unread rows and confirming the count is 2 regardless of what ``since`` says (a future-dated value would have hidden everything under the old logic).""" user, token = employee_user headers = {"Authorization": f"Bearer {token}"} await _seed_inbox_row(db_session, recipient_id=user.id) await _seed_inbox_row(db_session, recipient_id=user.id) resp = await client.get( "/api/v1/notifications/inbox/summary", headers=headers, params={"since": "2099-01-01T00:00:00Z"}, ) assert resp.status_code == 200, resp.text assert resp.json()["data"]["unread"] == 2