stackchain-dashboard/tests/test_notification_read.py
timmy 8dc3880167
All checks were successful
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
feat: mute future updates during triage (Closes #751)
2026-08-13 15:55:02 +00:00

520 lines
17 KiB
Python

import asyncio
import time
import httpx
import pytest
from src import gitea_proxy, main
from src.live_snapshot_store import LiveSnapshotStore
@pytest.fixture(autouse=True)
def isolated_live_snapshot_store(tmp_path, monkeypatch):
monkeypatch.setattr(
main,
"_live_snapshot_store",
LiveSnapshotStore(tmp_path / "live.sqlite3", clock=lambda: main._live_snapshot_clock()),
)
monkeypatch.setattr(main, "_read_notification_ids", set())
@pytest.mark.anyio
async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monkeypatch):
calls = []
class Response:
def raise_for_status(self):
return None
class Client:
async def patch(self, path, headers):
calls.append((path, headers))
return Response()
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
await gitea_proxy.mark_notification_read(42)
assert calls == [
("/api/v1/notifications/threads/42?to-status=read", {"Authorization": "token test"})
]
@pytest.mark.anyio
async def test_mark_notification_unread_calls_supported_gitea_thread_endpoint(monkeypatch):
calls = []
class Response:
def raise_for_status(self):
return None
class Client:
async def patch(self, path, headers):
calls.append((path, headers))
return Response()
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
await gitea_proxy.mark_notification_unread(42)
assert calls == [
("/api/v1/notifications/threads/42?to-status=unread", {"Authorization": "token test"})
]
@pytest.mark.anyio
async def test_acknowledge_notification_resolves_latest_comment_and_adds_one_reaction(monkeypatch):
calls = []
async def fake_fetch(path):
calls.append(("fetch", path))
if path == "notifications/threads/42":
return {
"repository": {"full_name": "stackchain/api"},
"subject": {
"type": "Issue",
"url": "https://forge.example/api/v1/repos/stackchain/api/issues/7",
"latest_comment_url": (
"https://forge.example/api/v1/repos/stackchain/api/issues/comments/91"
),
},
}
if path == "user":
return {"login": "timmy"}
raise AssertionError(path)
class Response:
def __init__(self, payload=None):
self._payload = payload
def raise_for_status(self):
return None
def json(self):
return self._payload
class Client:
async def get(self, path, headers):
calls.append(("get", path))
return Response([])
async def post(self, path, headers, json):
calls.append(("post", path, json))
return Response({"content": "+1"})
async def patch(self, path, headers):
calls.append(("patch", path))
return Response()
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
result = await gitea_proxy.acknowledge_notification(42)
reaction_path = "/api/v1/repos/stackchain/api/issues/comments/91/reactions"
assert result == {"id": 42, "reaction": "created", "status": "read"}
assert calls == [
("fetch", "notifications/threads/42"),
("fetch", "user"),
("get", reaction_path),
("post", reaction_path, {"content": "+1"}),
("patch", "/api/v1/notifications/threads/42?to-status=read"),
]
@pytest.mark.anyio
async def test_acknowledge_notification_reuses_existing_operator_reaction_on_retry(monkeypatch):
posts = []
async def fake_fetch(path):
if path == "user":
return {"login": "timmy"}
return {
"repository": {"full_name": "stackchain/api"},
"subject": {
"type": "Pull",
"url": "https://forge.example/api/v1/repos/stackchain/api/pulls/7",
"latest_comment_url": (
"https://forge.example/api/v1/repos/stackchain/api/issues/comments/91"
),
},
}
class Response:
def __init__(self, payload=None):
self.payload = payload
def raise_for_status(self):
return None
def json(self):
return self.payload
class Client:
async def get(self, path, headers):
return Response([{"content": "+1", "user": {"login": "timmy"}}])
async def post(self, path, headers, json):
posts.append((path, json))
return Response()
async def patch(self, path, headers):
return Response()
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
result = await gitea_proxy.acknowledge_notification(42)
assert result == {"id": 42, "reaction": "existing", "status": "read"}
assert posts == []
@pytest.mark.anyio
async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeypatch):
marked = []
async def mark(thread_id):
marked.append(thread_id)
monkeypatch.setattr(main, "mark_notification_read", mark)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch("/api/v1/notifications/42/read")
invalid = await client.patch("/api/v1/notifications/0/read")
assert response.status_code == 200
assert response.json() == {"id": 42, "status": "read"}
assert response.headers["cache-control"] == "no-store"
assert invalid.status_code == 422
assert marked == [42]
@pytest.mark.anyio
async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monkeypatch):
restored = []
async def mark_unread(thread_id):
restored.append(thread_id)
monkeypatch.setattr(main.gitea_proxy, "mark_notification_unread", mark_unread, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch("/api/v1/notifications/42/unread")
invalid = await client.patch("/api/v1/notifications/0/unread")
assert response.status_code == 200
assert response.json() == {"id": 42, "status": "unread"}
assert response.headers["cache-control"] == "no-store"
assert invalid.status_code == 422
assert restored == [42]
@pytest.mark.anyio
async def test_mute_notification_then_marks_read_and_reports_partial_success(monkeypatch):
calls = []
async def mute(thread_id):
calls.append(("mute", thread_id))
return {"id": thread_id, "muted": True}
async def fail_read(thread_id):
calls.append(("read", thread_id))
raise httpx.HTTPError("unavailable")
monkeypatch.setattr(main.gitea_proxy, "mute_notification", mute, raising=False)
monkeypatch.setattr(main, "mark_notification_read", fail_read)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/api/v1/notifications/42/mute")
assert response.status_code == 409
assert response.json() == {
"id": 42,
"muted": True,
"status": "unread",
"error": "Future updates are muted; current item is still unread. Retry mark read & next.",
}
assert calls == [("mute", 42), ("read", 42)]
@pytest.mark.anyio
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
calls = []
async def acknowledge(thread_id):
calls.append(thread_id)
return {"id": thread_id, "reaction": "created", "status": "read"}
monkeypatch.setattr(main.gitea_proxy, "acknowledge_notification", acknowledge, raising=False)
monkeypatch.setattr(
main,
"_live_snapshot_value",
{"notifications": [{"id": 42}, {"id": 43}]},
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/api/v1/notifications/42/acknowledge")
invalid = await client.post("/api/v1/notifications/0/acknowledge")
assert response.status_code == 200
assert response.json() == {"id": 42, "reaction": "created", "status": "read"}
assert response.headers["cache-control"] == "no-store"
assert invalid.status_code == 422
assert calls == [42]
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
@pytest.mark.anyio
async def test_snapshot_maintenance_does_not_block_the_event_loop(monkeypatch):
async def mark(_thread_id):
return None
calls = []
class BlockingStore:
def remove_notification(self, thread_id):
calls.append([thread_id])
time.sleep(0.08)
def remove_notifications(self, thread_ids):
calls.append(list(thread_ids))
time.sleep(0.08)
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(main, "_live_snapshot_store", BlockingStore())
monkeypatch.setattr(main, "_live_snapshot_value", None)
started_at = asyncio.get_running_loop().time()
request = asyncio.create_task(main.read_notification(42))
await asyncio.sleep(0.01)
heartbeat_delay = asyncio.get_running_loop().time() - started_at
response = await request
assert heartbeat_delay < 0.05
assert response.status_code == 200
assert calls == [[42]]
@pytest.mark.anyio
async def test_bulk_mark_read_reports_partial_progress_and_retains_only_failures(monkeypatch):
calls = []
async def mark(thread_id):
calls.append(thread_id)
if thread_id == 43:
raise httpx.HTTPError("upstream unavailable")
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(
main,
"_live_snapshot_value",
{"notifications": [{"id": 42}, {"id": 43}, {"id": 44}]},
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/notifications/read", json={"ids": [42, 43, 42, 44]}
)
assert response.status_code == 200
assert response.json() == {"marked": [42, 44], "failed": [43]}
assert response.headers["cache-control"] == "no-store"
assert sorted(calls) == [42, 43, 44]
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
@pytest.mark.anyio
async def test_bulk_mark_read_updates_shared_snapshot_once_for_successes(monkeypatch):
async def mark(thread_id):
if thread_id == 43:
raise httpx.HTTPError("upstream unavailable")
calls = []
class RecordingStore:
def remove_notifications(self, thread_ids):
calls.append(tuple(sorted(thread_ids)))
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(main, "_live_snapshot_store", RecordingStore())
monkeypatch.setattr(
main,
"_live_snapshot_value",
{"notifications": [{"id": 42}, {"id": 43}, {"id": 44}]},
)
response = await main.read_notifications(main.NotificationReadBatch(ids=[42, 43, 44]))
assert response.status_code == 200
assert calls == [(42, 44)]
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
@pytest.mark.anyio
async def test_bulk_mark_read_limits_upstream_concurrency(monkeypatch):
active = 0
peak_active = 0
started = asyncio.Event()
release = asyncio.Event()
calls = []
monkeypatch.setattr(main, "_read_notification_ids", set())
monkeypatch.setattr(main, "_live_snapshot_value", None)
async def mark(thread_id):
nonlocal active, peak_active
calls.append(thread_id)
active += 1
peak_active = max(peak_active, active)
if active == 5:
started.set()
try:
await release.wait()
finally:
active -= 1
monkeypatch.setattr(main, "mark_notification_read", mark)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
request = asyncio.create_task(
client.patch("/api/v1/notifications/read", json={"ids": list(range(1, 51))})
)
await asyncio.wait_for(started.wait(), timeout=1)
await asyncio.sleep(0)
assert peak_active == 5
assert len(calls) == 5
release.set()
response = await request
assert response.status_code == 200
assert response.json() == {"marked": list(range(1, 51)), "failed": []}
assert sorted(calls) == list(range(1, 51))
@pytest.mark.anyio
async def test_bulk_mark_read_cancels_unfinished_work_at_batch_deadline(monkeypatch):
active = 0
cancelled = []
async def mark(thread_id):
nonlocal active
active += 1
try:
await asyncio.sleep(0.08)
except asyncio.CancelledError:
cancelled.append(thread_id)
raise
finally:
active -= 1
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(main, "BULK_NOTIFICATION_DEADLINE_SECONDS", 0.01, raising=False)
monkeypatch.setattr(main, "_read_notification_ids", set())
monkeypatch.setattr(main, "_live_snapshot_value", None)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await asyncio.wait_for(
client.patch(
"/api/v1/notifications/read", json={"ids": list(range(1, 11))}
),
timeout=0.5,
)
assert response.status_code == 200
assert response.json() == {"marked": [], "failed": list(range(1, 11))}
assert response.headers["retry-after"] == "1"
assert active == 0
assert sorted(cancelled) == list(range(1, 6))
@pytest.mark.anyio
async def test_bulk_mark_read_rejects_empty_invalid_and_oversized_batches(monkeypatch):
marked = []
async def mark(thread_id):
marked.append(thread_id)
monkeypatch.setattr(main, "mark_notification_read", mark)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
empty = await client.patch("/api/v1/notifications/read", json={"ids": []})
invalid = await client.patch(
"/api/v1/notifications/read", json={"ids": [42, 0]}
)
oversized = await client.patch(
"/api/v1/notifications/read", json={"ids": list(range(1, 52))}
)
assert [empty.status_code, invalid.status_code, oversized.status_code] == [
422,
422,
422,
]
assert all(response.headers["cache-control"] == "no-store" for response in [empty, invalid, oversized])
assert marked == []
@pytest.mark.anyio
async def test_mark_notification_read_removes_thread_from_retained_live_snapshot(monkeypatch):
async def mark(_thread_id):
return None
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(
main,
"_live_snapshot_value",
{
"context": {},
"events": [],
"notifications": [{"id": 42}, {"id": 43}],
"sections": {"notifications": "fresh"},
},
)
response = await main.read_notification(42)
assert response.status_code == 200
assert main._live_snapshot_value is not None
assert main._live_snapshot_value["notifications"] == [{"id": 43}]
@pytest.mark.anyio
async def test_inflight_refresh_cannot_restore_a_notification_marked_read(monkeypatch):
refresh_started = asyncio.Event()
release_refresh = asyncio.Event()
async def mark(_thread_id):
return None
async def stale_upstream_snapshot(_sections):
refresh_started.set()
await release_refresh.wait()
return {
"context": {},
"events": [],
"notifications": [{"id": 42}, {"id": 43}],
"sections": {"notifications": "fresh"},
}
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(
main, "_build_live_snapshot_before_deadline", stale_upstream_snapshot
)
monkeypatch.setattr(main, "_live_snapshot_task", None)
monkeypatch.setattr(main, "_live_snapshot_value", {"notifications": [{"id": 42}]})
refresh = main._start_live_snapshot_refresh(set(main.LIVE_SNAPSHOT_SECTIONS))
await refresh_started.wait()
await main.read_notification(42)
release_refresh.set()
await refresh
assert main._live_snapshot_value is not None
assert main._live_snapshot_value["notifications"] == [{"id": 43}]