fix: use production IDs for unread Web Push (Closes #565)
This commit is contained in:
parent
92b0fd56ca
commit
8e74f022eb
|
|
@ -1739,9 +1739,9 @@ async def subscribe_push(payload: PushSubscriptionPayload, request: Request):
|
||||||
headers={"Retry-After": "1"},
|
headers={"Retry-After": "1"},
|
||||||
) from error
|
) from error
|
||||||
existing_ids = {
|
existing_ids = {
|
||||||
int(item["notification_id"])
|
int(item["id"])
|
||||||
for item in current.get("items", [])
|
for item in current.get("items", [])
|
||||||
if isinstance(item, dict) and str(item.get("notification_id", "")).isdigit()
|
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
|
||||||
}
|
}
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
_push_subscription_store.mark_delivered, device_id, existing_ids
|
_push_subscription_store.mark_delivered, device_id, existing_ids
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,9 @@ async def dispatch_unread_updates(
|
||||||
try:
|
try:
|
||||||
page = await unread()
|
page = await unread()
|
||||||
thread_ids = {
|
thread_ids = {
|
||||||
int(item["notification_id"])
|
int(item["id"])
|
||||||
for item in page.get("items", [])
|
for item in page.get("items", [])
|
||||||
if isinstance(item, dict) and str(item.get("notification_id", "")).isdigit()
|
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
|
||||||
}
|
}
|
||||||
deliveries = await asyncio.to_thread(store.claim_unseen, thread_ids)
|
deliveries = await asyncio.to_thread(store.claim_unseen, thread_ids)
|
||||||
if session_active is not None:
|
if session_active is not None:
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ import asyncio
|
||||||
import os
|
import os
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src import dashboard_auth, main
|
from src import dashboard_auth, gitea_proxy, main
|
||||||
from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
||||||
from src.push_subscription_store import PushSubscriptionStore
|
from src.push_subscription_store import PushSubscriptionStore
|
||||||
|
|
||||||
|
|
@ -127,7 +128,7 @@ async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path
|
||||||
|
|
||||||
async def unread():
|
async def unread():
|
||||||
return {"items": [
|
return {"items": [
|
||||||
{"notification_id": 42, "repository": "private/repo", "title": "Secret title"},
|
{"id": 42, "repository": "private/repo", "title": "Secret title"},
|
||||||
]}
|
]}
|
||||||
|
|
||||||
async def send(subscription, payload):
|
async def send(subscription, payload):
|
||||||
|
|
@ -147,6 +148,52 @@ async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path
|
||||||
assert "Secret title" not in json.dumps(sent)
|
assert "Secret title" not in json.dumps(sent)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_production_notification_page_dispatches_one_unread_push(tmp_path):
|
||||||
|
def upstream(_request):
|
||||||
|
return httpx.Response(200, json=[{
|
||||||
|
"id": 42,
|
||||||
|
"unread": True,
|
||||||
|
"repository": {"full_name": "private/repo"},
|
||||||
|
"subject": {
|
||||||
|
"title": "Secret title",
|
||||||
|
"type": "Issue",
|
||||||
|
"html_url": "https://forge.example/private/repo/issues/7",
|
||||||
|
},
|
||||||
|
}])
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||||
|
try:
|
||||||
|
page = await gitea_proxy.notification_page(1)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
store.upsert("session-a", {
|
||||||
|
"endpoint": "https://push.example/device-a",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def unread():
|
||||||
|
return page
|
||||||
|
|
||||||
|
async def send(_subscription, payload):
|
||||||
|
sent.append(json.loads(payload))
|
||||||
|
|
||||||
|
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||||
|
assert await dispatch_unread_updates(store, config, unread, send) == 1
|
||||||
|
assert sent == [{
|
||||||
|
"title": "New work update",
|
||||||
|
"body": "Tap to review it in Stackchain.",
|
||||||
|
"route": "#/my-work/update/42",
|
||||||
|
"tag": "stackchain-update-42",
|
||||||
|
"notification_id": 42,
|
||||||
|
}]
|
||||||
|
assert "private/repo" not in json.dumps(sent)
|
||||||
|
assert "Secret title" not in json.dumps(sent)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_concurrent_workers_do_not_dispatch_the_same_update(tmp_path):
|
async def test_concurrent_workers_do_not_dispatch_the_same_update(tmp_path):
|
||||||
path = tmp_path / "push.sqlite3"
|
path = tmp_path / "push.sqlite3"
|
||||||
|
|
@ -161,7 +208,7 @@ async def test_concurrent_workers_do_not_dispatch_the_same_update(tmp_path):
|
||||||
sent = []
|
sent = []
|
||||||
|
|
||||||
async def unread():
|
async def unread():
|
||||||
return {"items": [{"notification_id": 42}]}
|
return {"items": [{"id": 42}]}
|
||||||
|
|
||||||
async def send(_subscription, payload):
|
async def send(_subscription, payload):
|
||||||
sent.append(json.loads(payload)["tag"])
|
sent.append(json.loads(payload)["tag"])
|
||||||
|
|
@ -193,7 +240,7 @@ async def test_dispatch_removes_an_expired_push_endpoint(tmp_path):
|
||||||
response = SimpleNamespace(status_code=410)
|
response = SimpleNamespace(status_code=410)
|
||||||
|
|
||||||
async def unread():
|
async def unread():
|
||||||
return {"items": [{"notification_id": 8}]}
|
return {"items": [{"id": 8}]}
|
||||||
|
|
||||||
async def send(_subscription, _payload):
|
async def send(_subscription, _payload):
|
||||||
raise Expired()
|
raise Expired()
|
||||||
|
|
@ -214,7 +261,7 @@ async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_pat
|
||||||
sent = []
|
sent = []
|
||||||
|
|
||||||
async def unread():
|
async def unread():
|
||||||
return {"items": [{"notification_id": 8}]}
|
return {"items": [{"id": 8}]}
|
||||||
|
|
||||||
async def send(subscription, _payload):
|
async def send(subscription, _payload):
|
||||||
if subscription["endpoint"].endswith("session-a"):
|
if subscription["endpoint"].endswith("session-a"):
|
||||||
|
|
@ -239,8 +286,8 @@ async def test_transient_failure_stops_that_device_until_the_next_poll(tmp_path)
|
||||||
|
|
||||||
async def unread():
|
async def unread():
|
||||||
return {"items": [
|
return {"items": [
|
||||||
{"notification_id": 8},
|
{"id": 8},
|
||||||
{"notification_id": 9},
|
{"id": 9},
|
||||||
]}
|
]}
|
||||||
|
|
||||||
async def send(subscription, payload):
|
async def send(subscription, payload):
|
||||||
|
|
@ -270,7 +317,7 @@ async def test_timed_out_endpoint_does_not_stall_push_fanout(tmp_path):
|
||||||
sent = []
|
sent = []
|
||||||
|
|
||||||
async def unread():
|
async def unread():
|
||||||
return {"items": [{"notification_id": 13}]}
|
return {"items": [{"id": 13}]}
|
||||||
|
|
||||||
async def send(subscription, _payload):
|
async def send(subscription, _payload):
|
||||||
if subscription["endpoint"].endswith("session-a"):
|
if subscription["endpoint"].endswith("session-a"):
|
||||||
|
|
@ -303,7 +350,7 @@ async def test_dispatches_devices_concurrently_with_a_strict_bound(tmp_path):
|
||||||
release = asyncio.Event()
|
release = asyncio.Event()
|
||||||
|
|
||||||
async def unread():
|
async def unread():
|
||||||
return {"items": [{"notification_id": 13}]}
|
return {"items": [{"id": 13}]}
|
||||||
|
|
||||||
async def send(_subscription, _payload):
|
async def send(_subscription, _payload):
|
||||||
nonlocal active, peak
|
nonlocal active, peak
|
||||||
|
|
@ -339,7 +386,7 @@ async def test_dispatch_removes_inactive_sessions_without_blocking_active_device
|
||||||
sent = []
|
sent = []
|
||||||
|
|
||||||
async def unread():
|
async def unread():
|
||||||
return {"items": [{"notification_id": 21}]}
|
return {"items": [{"id": 21}]}
|
||||||
|
|
||||||
async def session_active(management_id):
|
async def session_active(management_id):
|
||||||
return management_id == "active-device"
|
return management_id == "active-device"
|
||||||
|
|
@ -366,7 +413,7 @@ async def test_dispatch_fails_closed_and_retains_subscriptions_when_session_regi
|
||||||
sent = []
|
sent = []
|
||||||
|
|
||||||
async def unread():
|
async def unread():
|
||||||
return {"items": [{"notification_id": 22}]}
|
return {"items": [{"id": 22}]}
|
||||||
|
|
||||||
async def registry_unavailable(_management_id):
|
async def registry_unavailable(_management_id):
|
||||||
raise RuntimeError("session registry unavailable")
|
raise RuntimeError("session registry unavailable")
|
||||||
|
|
@ -417,6 +464,35 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||||
assert (await main.push_status(request))["subscribed"] is False
|
assert (await main.push_status(request))["subscribed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_subscription_baselines_production_notification_ids(tmp_path, monkeypatch):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main,
|
||||||
|
"_push_configuration",
|
||||||
|
lambda: PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def management_id(_session):
|
||||||
|
return "device-a"
|
||||||
|
|
||||||
|
async def current_unread():
|
||||||
|
return {"items": [{"id": 42}]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||||
|
monkeypatch.setattr(main, "notifications", current_unread)
|
||||||
|
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||||
|
payload = main.PushSubscriptionPayload(
|
||||||
|
endpoint="https://push.example/device-a",
|
||||||
|
keys={"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||||
|
assert store.claim_unseen({42}) == []
|
||||||
|
assert store.claim_unseen({42, 43})[0].thread_ids == (43,)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_subscription_fails_closed_when_existing_unread_baseline_is_unavailable(tmp_path, monkeypatch):
|
async def test_subscription_fails_closed_when_existing_unread_baseline_is_unavailable(tmp_path, monkeypatch):
|
||||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user