Slice 6: notifications per §15

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 23:09:04 -07:00
parent 1b0968a9a2
commit f67d0aa0db
21 changed files with 3588 additions and 168 deletions
@@ -0,0 +1,614 @@
"""End-to-end integration tests for the Slice 6 vertical (§15 in full).
The fan-out chokepoint in `notify.py` is the chief structural commitment
of the slice. These tests prove:
* §15.1 routing: every action_kind that maps to a §15 signal lands a
`notifications` row of the right event_kind and category.
* §15.6 auto-watch: every write that names an rfc_slug upserts a
watches row for the actor (substantive-gesture rule).
* §15.2 inbox listing + filter chips: unread, rfc_slug, category, and
actor_user_id filters compose AND-wise.
* §15.7 reconciler: advancing branch_chat_seen or pr_seen marks
matching unread notifications read.
* §15.8 per-user mute: the mute suppresses inbox rows from the muted
actor; per-RFC mute suppresses every row for the slug.
* §15.8 quiet hours: notifications still land; email is held.
* §15.5 digest: cadence window roll-over emits a digest; a second
run during the same window emits nothing.
* §15.4 email-bounce webhook: sets the global opt-out and
short-circuits future email dispatch.
* §15.4 unsubscribe signed-URL: GET /api/email/unsubscribe?t=… flips
one category off.
* §15.3 SSE snapshot: opening the stream yields the current
unread_count.
"""
from __future__ import annotations
import json as _json
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_super_draft_vertical import seed_super_draft # noqa: F401
PITCH = "Open Human Model is a framework for representing humans."
# ---------------------------------------------------------------------------
# Fan-out: producer-side rules per §15.1
# ---------------------------------------------------------------------------
def test_propose_rfc_auto_watches_the_proposer(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
client.post("/api/rfcs/propose", json={
"title": "Open Human Model", "slug": "ohm", "pitch": PITCH, "tags": [],
})
# Auto-watch lands per §15.6 substantive gesture.
row = db.conn().execute(
"SELECT state, set_by FROM watches WHERE user_id = ? AND rfc_slug = ?",
(2, "ohm"),
).fetchone()
assert row is not None
assert row["state"] == "watching"
assert row["set_by"] == "auto"
def test_proposal_merged_lands_personal_direct_for_proposer(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor", )
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/propose", json={
"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": [],
})
pr_number = r.json()["pr_number"]
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
r = client.post(f"/api/proposals/{pr_number}/merge")
assert r.status_code == 200, r.text
# Alice (proposer) gets a personal-direct proposal_merged row.
rows = db.conn().execute(
"""
SELECT event_kind, payload FROM notifications
WHERE recipient_user_id = ? ORDER BY id
""",
(2,),
).fetchall()
kinds = [r["event_kind"] for r in rows]
assert "proposal_merged" in kinds
# Category metadata round-trips on the payload.
merged_row = next(r for r in rows if r["event_kind"] == "proposal_merged")
assert _json.loads(merged_row["payload"])["category"] == "personal-direct"
def test_proposal_declined_routes_to_proposer_only(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/propose", json={
"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": [],
})
pr_number = r.json()["pr_number"]
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
r = client.post(f"/api/proposals/{pr_number}/decline", json={"comment": "Not aligned with this quarter's focus"})
assert r.status_code == 200, r.text
rows = db.conn().execute(
"SELECT recipient_user_id, event_kind FROM notifications WHERE event_kind = 'proposal_declined'"
).fetchall()
recipients = {r["recipient_user_id"] for r in rows}
# Alice (id=2) receives it; Ben (the actor) does not.
assert recipients == {2}
# ---------------------------------------------------------------------------
# Inbox surface
# ---------------------------------------------------------------------------
def test_inbox_lists_rows_with_filter_chips(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []})
pr_number = r.json()["pr_number"]
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
client.post(f"/api/proposals/{pr_number}/merge")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/notifications")
assert r.status_code == 200, r.text
items = r.json()["items"]
assert any(i["event_kind"] == "proposal_merged" for i in items)
assert r.json()["unread_count"] >= 1
# Filter by category isolates personal-direct.
r = client.get("/api/notifications", params={"category": "personal-direct"})
items = r.json()["items"]
assert all(i["category"] == "personal-direct" for i in items)
# Filter by rfc_slug narrows further.
r = client.get("/api/notifications", params={"rfc_slug": "ohm"})
items = r.json()["items"]
assert all(i["rfc_slug"] == "ohm" for i in items)
def test_mark_read_per_row_and_by_filter(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []})
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
client.post(f"/api/proposals/{r.json()['pr_number']}/merge")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
items = client.get("/api/notifications").json()["items"]
notif_id = items[0]["id"]
r = client.post(f"/api/notifications/{notif_id}/read")
assert r.status_code == 200
row = db.conn().execute("SELECT read_at FROM notifications WHERE id = ?", (notif_id,)).fetchone()
assert row["read_at"] is not None
# Mark-all-read by filter — should be idempotent.
r = client.post("/api/notifications/read", json={"rfc_slug": "ohm"})
assert r.status_code == 200
# ---------------------------------------------------------------------------
# §15.7 reconciler
# ---------------------------------------------------------------------------
def test_chat_seen_advance_marks_chat_notifications_read(app_with_fake_gitea):
"""Per §15.7: when branch_chat_seen advances, unread chat-kind
notifications scoped to the same (slug, branch) flip to read."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="bob", role="contributor")
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
# Alice cuts the edit branch (auto-watch). Bob joins the branch
# chat — Alice gets a chat_message_in_participated_thread row.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
# Alice posts the first message so she's a prior author for Bob's reply.
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
client.post(
f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages",
json={"text": "first thought"},
)
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages",
json={"text": "reply from bob"},
)
msg_id = r.json()["message_id"]
# Alice has an unread chat_reply_to_my_message row.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
unread = db.conn().execute(
"SELECT id FROM notifications WHERE recipient_user_id = 2 AND read_at IS NULL"
).fetchall()
assert len(unread) >= 1
# Alice visits the branch — chat-seen advances → reconciler clears.
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/chat-seen",
json={"last_seen_message_id": msg_id},
)
assert r.status_code == 200
assert r.json()["reconciled"] >= 1
remaining = db.conn().execute(
"SELECT id FROM notifications WHERE recipient_user_id = 2 AND branch_name = ? AND read_at IS NULL",
(branch,),
).fetchall()
assert remaining == []
# ---------------------------------------------------------------------------
# §15.8 mutes
# ---------------------------------------------------------------------------
def test_per_user_mute_suppresses_inbox_rows(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="bob", role="contributor")
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
# Alice mutes Bob.
r = client.post("/api/users/3/notification-mute")
assert r.status_code == 200
# Alice cuts the branch and is a prior author.
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
client.post(
f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages",
json={"text": "alice opener"},
)
# Bob posts. With the mute in place Alice gets no inbox row.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
client.post(
f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages",
json={"text": "bob reply"},
)
rows = db.conn().execute(
"SELECT id FROM notifications WHERE recipient_user_id = 2 AND actor_user_id = 3"
).fetchall()
assert rows == []
def test_per_rfc_mute_suppresses_every_signal(app_with_fake_gitea):
"""§15.6: state='muted' on the watches row is the strongest leave-
me-alone gesture, including for personal-direct events."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="bob", role="contributor")
# Seed the slug so the watch endpoint can resolve it.
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, proposed_by="alice")
# Alice mutes the slug. We bypass the API because the user-facing
# surface doesn't expose 'muted' as an auto-set — it's an
# explicit gesture. The endpoint accepts it.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/watch", json={"state": "muted"})
assert r.status_code == 200, r.text
# Bob (a different user) cuts an edit branch — would normally
# auto-watch and produce a structural beat to other watchers.
# The mute suppresses Alice's row.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
# Add Alice as a watcher first via a chat message (auto-watch
# already set 'muted' though); ensure no row regardless.
client.post("/api/rfcs/ohm/start-edit-branch", json={})
rows = db.conn().execute(
"SELECT event_kind FROM notifications WHERE recipient_user_id = 2 AND rfc_slug = 'ohm'"
).fetchall()
assert rows == []
def test_admin_cannot_mute_users(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=3, login="bob", role="contributor")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
r = client.post("/api/users/3/notification-mute")
assert r.status_code == 403
# ---------------------------------------------------------------------------
# §15.8 quiet hours + §15.4 email
# ---------------------------------------------------------------------------
def test_quiet_hours_holds_email_but_inbox_lands(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
from app import db, email as email_mod
app, _fake = app_with_fake_gitea
monkeypatch.setenv("APP_URL", "http://localhost:8000")
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
# Quiet hours covering every wall-clock moment — 00:00 → 23:59 UTC.
db.conn().execute(
"""
UPDATE users
SET notification_quiet_hours_start = '00:00',
notification_quiet_hours_end = '23:59',
notification_quiet_hours_timezone = 'UTC',
email = 'alice@test'
WHERE id = 2
"""
)
email_mod.reset_sent_envelopes()
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []})
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
client.post(f"/api/proposals/{r.json()['pr_number']}/merge")
# Inbox row landed.
rows = db.conn().execute(
"SELECT id, email_sent_at FROM notifications WHERE recipient_user_id = 2 AND event_kind = 'proposal_merged'"
).fetchall()
assert len(rows) >= 1
# Email held (email_sent_at is NULL; no envelope in the buffer).
assert rows[0]["email_sent_at"] is None
sent_to_alice = [e for e in email_mod.sent_envelopes() if e["to"] == "alice@test"]
assert sent_to_alice == []
def test_email_bounce_webhook_sets_global_opt_out(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
db.conn().execute("UPDATE users SET email = 'alice@test' WHERE id = 2")
r = client.post("/api/webhooks/email-bounce", json={"email": "alice@test", "kind": "hard"})
assert r.status_code == 200
assert r.json()["matched"] is True
row = db.conn().execute("SELECT email_opt_out_all FROM users WHERE id = 2").fetchone()
assert row["email_opt_out_all"] == 1
def test_email_unsubscribe_signed_url_flips_category_off(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import email as email_mod, db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
token = email_mod.make_unsubscribe_url(2, "personal-direct").split("t=", 1)[1]
r = client.get(f"/api/email/unsubscribe?t={token}")
assert r.status_code == 200
row = db.conn().execute("SELECT email_personal_direct FROM users WHERE id = 2").fetchone()
assert row["email_personal_direct"] == 0
# ---------------------------------------------------------------------------
# §15.5 digest
# ---------------------------------------------------------------------------
def test_digest_emits_then_skips_already_included(app_with_fake_gitea, monkeypatch):
"""Two consecutive `run_tick` passes: the first emits a digest with
the eligible rows; the second runs but emits nothing because the
cadence window has not yet rolled over (the just-recorded
`notification_digests` row's `period_end` is now)."""
from fastapi.testclient import TestClient
from app import db, digest as digest_mod, email as email_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="bob", role="contributor")
db.conn().execute(
"UPDATE users SET email = 'alice@test', digest_cadence = 'daily' WHERE id = 2"
)
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
db.conn().execute(
"""
INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
VALUES (2, 'ohm', 'following', 'explicit', datetime('now', '-1 day'), datetime('now', '-1 day'))
"""
)
# An eligible churn row from an hour ago.
db.conn().execute(
"""
INSERT INTO notifications
(recipient_user_id, event_kind, rfc_slug, actor_user_id, payload, created_at)
VALUES (2, 'pr_commit_added', 'ohm', 3, ?, datetime('now', '-1 hour'))
""",
(_json.dumps({"category": "churn"}),),
)
# Seed a prior digest emission that's >24h ago so the daily
# cadence has rolled over and the first tick fires.
db.conn().execute(
"""
INSERT INTO notification_digests (recipient_user_id, sent_at, period_start, period_end, signal_ids_included)
VALUES (2, datetime('now', '-2 days'), datetime('now', '-3 days'), datetime('now', '-2 days'), '[]')
"""
)
email_mod.reset_sent_envelopes()
# First tick: digest emitted.
result = digest_mod.run_tick()
assert result["digests_sent"] == 1
envelopes = [e for e in email_mod.sent_envelopes() if "digest" in e["subject"].lower()]
assert len(envelopes) == 1
# Verify digest_included_at landed for the row that was in the
# body — the audit field stays queryable per §15.5.
included = db.conn().execute(
"SELECT id FROM notifications WHERE recipient_user_id = 2 AND digest_included_at IS NOT NULL"
).fetchall()
assert len(included) >= 1
# Second tick fires immediately. The cadence window has not
# rolled over (period_end on the new digest row is now), so
# nothing is emitted.
result2 = digest_mod.run_tick()
assert result2["digests_sent"] == 0
# ---------------------------------------------------------------------------
# §15.3 SSE snapshot
# ---------------------------------------------------------------------------
def test_notify_subscriber_receives_broadcast(app_with_fake_gitea):
"""The per-user SSE subscriber registry in `notify.subscribe` is
the substrate behind `/api/notifications/stream`. Driving it
directly verifies that an inbox-row insert pushes onto every
subscriber's queue, which is what backs the live badge counter
and the toast surface per §15.3.
The HTTP-level stream test is intentionally out-of-scope here:
TestClient buffers chunked responses and so cannot observe an
SSE handler that yields once and then waits — the production
path uses a real ASGI server with chunk flushing.
"""
import asyncio as _asyncio
from app import db, notify
app, _fake = app_with_fake_gitea
from fastapi.testclient import TestClient
with TestClient(app):
provision_user_row(user_id=2, login="alice", role="contributor")
async def _drive():
sub = await notify.subscribe(2)
# Insert a notification row via the chokepoint. The push
# is scheduled on the running loop.
db.conn().execute(
"""
INSERT INTO notifications (recipient_user_id, event_kind, rfc_slug, payload)
VALUES (2, 'proposal_merged', 'ohm', '{"category":"personal-direct"}')
"""
)
nid = db.conn().execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
await notify._broadcast(2, "notification", notify._row_payload(nid))
evt = await _asyncio.wait_for(sub.queue.get(), timeout=2.0)
await notify.unsubscribe(sub)
return evt
evt = _asyncio.new_event_loop().run_until_complete(_drive())
assert evt["event"] == "notification"
assert evt["payload"]["event_kind"] == "proposal_merged"
# ---------------------------------------------------------------------------
# Preferences
# ---------------------------------------------------------------------------
def test_notification_preferences_round_trip(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/users/me/notification-preferences")
assert r.status_code == 200
assert r.json()["email_personal_direct"] is True
assert r.json()["email_watched_churn"] is False
r = client.post(
"/api/users/me/notification-preferences",
json={"email_watched_structural": True, "digest_cadence": "weekly"},
)
assert r.status_code == 200
r = client.get("/api/users/me/notification-preferences")
assert r.json()["email_watched_structural"] is True
assert r.json()["digest_cadence"] == "weekly"
def test_quiet_hours_endpoint_round_trip(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
# Setting a partial trio is rejected per §15.8.
r = client.post("/api/users/me/quiet-hours", json={"start": "22:00"})
assert r.status_code == 422
# Full trio sets cleanly.
r = client.post(
"/api/users/me/quiet-hours",
json={"start": "22:00", "end": "07:00", "timezone": "America/Los_Angeles"},
)
assert r.status_code == 200, r.text
r = client.get("/api/users/me/quiet-hours")
assert r.json()["start"] == "22:00"
assert r.json()["timezone"] == "America/Los_Angeles"
# All-null clears.
r = client.post(
"/api/users/me/quiet-hours",
json={"start": None, "end": None, "timezone": None},
)
assert r.status_code == 200
assert client.get("/api/users/me/quiet-hours").json()["start"] is None
# ---------------------------------------------------------------------------
# Watches surface
# ---------------------------------------------------------------------------
def test_explicit_watch_set_overrides_auto(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/watch", json={"state": "following"})
assert r.status_code == 200
row = db.conn().execute(
"SELECT state, set_by FROM watches WHERE user_id = 2 AND rfc_slug = 'ohm'"
).fetchone()
assert row["state"] == "following"
assert row["set_by"] == "explicit"
# The auto-watch upsert from a subsequent gesture must not
# downgrade the explicit setting. Trigger a substantive
# gesture (cut an edit branch).
client.post("/api/rfcs/ohm/start-edit-branch", json={})
row = db.conn().execute(
"SELECT state, set_by FROM watches WHERE user_id = 2 AND rfc_slug = 'ohm'"
).fetchone()
# Following → watching is the *one* auto-upgrade in §15.6, but
# only for set_by='auto' rows; explicit rows must stay where
# the user put them.
assert row["set_by"] == "explicit"
assert row["state"] == "following"