feat: triage unread updates in My Work (#151)
This commit is contained in:
1 parent
5b864c028e
commit
f7073e1393
7 files changed
+628
-8
No files matched your search
@@ -110,3 +110,93 @@ async def test_unread_notifications_are_normalized_for_mobile_handoff():
|
||||
async def test_notification_collection_rejects_non_list_payload():
|
||||
with pytest.raises(ValueError, match="notification response was not a list"):
|
||||
gitea_proxy._normalize_notifications({"message": "unexpected"})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_reader():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
if request.url.path.endswith("/notifications/threads/42"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 42,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"title": "Retry failed deploy",
|
||||
"type": "Issue",
|
||||
"state": "open",
|
||||
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
|
||||
"latest_comment_url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
})
|
||||
if request.url.path.endswith("/issues/7"):
|
||||
return httpx.Response(200, json={"body": "Deploy fails after **three** retries."})
|
||||
if request.url.path.endswith("/issues/comments/9"):
|
||||
return httpx.Response(200, json={
|
||||
"body": "Logs point to the worker timeout.",
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"user": {"login": "alexander"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
})
|
||||
return httpx.Response(404)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.notification_detail(42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
|
||||
]
|
||||
assert result == {
|
||||
"id": 42,
|
||||
"repository": "stackchain/api",
|
||||
"title": "Retry failed deploy",
|
||||
"subject_type": "Issue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"subject_body": "Deploy fails after **three** retries.",
|
||||
"latest_comment": {
|
||||
"author": "alexander",
|
||||
"body": "Logs point to the worker timeout.",
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_never_follows_foreign_api_urls():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(200, json={
|
||||
"id": 42,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"title": "Suspicious update",
|
||||
"url": "https://evil.example/api/v1/user",
|
||||
"latest_comment_url": "https://evil.example/api/v1/admin/users",
|
||||
"html_url": "javascript:alert(1)",
|
||||
},
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.notification_detail(42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/notifications/threads/42"
|
||||
]
|
||||
assert result["url"] == ""
|
||||
assert result["subject_body"] == ""
|
||||
assert result["latest_comment"]["body"] == ""
|
||||
@@ -384,6 +384,165 @@ pager.loadMore([{{id:1}}]).then(result =>
|
||||
}
|
||||
|
||||
|
||||
def test_notification_reader_marks_current_read_and_opens_next_update():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const original = [
|
||||
{{kind:'update', key:'repo#1', notification_id:42, has_update:true, title:'First'}},
|
||||
{{kind:'issue', key:'repo#2', notification_id:43, has_update:true, title:'Second'}},
|
||||
];
|
||||
const loaded = [];
|
||||
const opened = [];
|
||||
const details = [];
|
||||
const states = [];
|
||||
const statuses = [];
|
||||
const closed = [];
|
||||
const reader = buildMyWork.createNotificationReader({{
|
||||
load: async id => {{ loaded.push(id); return {{id, latest_comment:{{body:'Comment ' + id}}}}; }},
|
||||
markRead: async id => id,
|
||||
onOpen: item => opened.push(item.notification_id),
|
||||
onDetail: detail => details.push(detail.id),
|
||||
onItems: items => states.push(items),
|
||||
onStatus: status => statuses.push(status),
|
||||
onClose: () => closed.push(true),
|
||||
}});
|
||||
reader.open(original[0], original).then(() =>
|
||||
reader.markReadAndNext(original).then(result =>
|
||||
process.stdout.write(JSON.stringify({{loaded, opened, details, states, statuses, closed, result}}))
|
||||
)
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["loaded"] == [42, 43]
|
||||
assert output["opened"] == [42, 43]
|
||||
assert output["details"] == [42, 43]
|
||||
assert output["states"] == [[
|
||||
{"kind": "issue", "key": "repo#2", "notification_id": 43,
|
||||
"has_update": True, "title": "Second"},
|
||||
]]
|
||||
assert output["statuses"] == [
|
||||
"Loading update…", "Update ready.", "Marking update read…",
|
||||
"Loading update…", "Update ready.",
|
||||
]
|
||||
assert output["closed"] == []
|
||||
assert output["result"]["next"]["notification_id"] == 43
|
||||
|
||||
|
||||
def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const item = {{kind:'update', notification_id:42, has_update:true}};
|
||||
const events = [];
|
||||
const reader = buildMyWork.createNotificationReader({{
|
||||
load: async () => {{ throw new Error('offline'); }},
|
||||
markRead: async () => {{}}, onOpen: () => events.push('open'),
|
||||
onDetail: () => events.push('detail'), onItems: () => events.push('items'),
|
||||
onStatus: status => events.push(status), onClose: () => events.push('close'),
|
||||
}});
|
||||
reader.open(item, [item]).then(result =>
|
||||
process.stdout.write(JSON.stringify({{result, events}}))
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"result": False,
|
||||
"events": [
|
||||
"open", "Loading update…",
|
||||
"Could not load update. Retry or open it in Gitea.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_notification_reader_does_not_advance_or_remove_item_when_mark_read_fails():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const item = {{kind:'update', notification_id:42, has_update:true}};
|
||||
const events = [];
|
||||
const reader = buildMyWork.createNotificationReader({{
|
||||
load: async id => ({{id}}), markRead: async () => {{ throw new Error('offline'); }},
|
||||
onOpen: () => events.push('open'), onDetail: () => events.push('detail'),
|
||||
onItems: () => events.push('items'), onStatus: status => events.push(status),
|
||||
onClose: () => events.push('close'),
|
||||
}});
|
||||
reader.open(item, [item]).then(() =>
|
||||
reader.markReadAndNext([item]).then(result =>
|
||||
process.stdout.write(JSON.stringify({{result, events}}))
|
||||
)
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"result": False,
|
||||
"events": [
|
||||
"open", "Loading update…", "detail", "Update ready.",
|
||||
"Marking update read…", "Could not mark update read. Retry.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_notification_reader_wraps_to_an_earlier_visible_unread_update():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = [
|
||||
{{kind:'update', notification_id:42, has_update:true}},
|
||||
{{kind:'update', notification_id:43, has_update:true}},
|
||||
];
|
||||
const opened = [];
|
||||
const reader = buildMyWork.createNotificationReader({{
|
||||
load: async id => ({{id}}), markRead: async () => {{}},
|
||||
onOpen: item => opened.push(item.notification_id), onDetail: () => {{}},
|
||||
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
|
||||
}});
|
||||
reader.open(items[1], items).then(() => reader.markReadAndNext(items)).then(result =>
|
||||
process.stdout.write(JSON.stringify({{opened, next:result.next.notification_id}}))
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"opened": [43, 42], "next": 42}
|
||||
|
||||
|
||||
def test_notification_reader_closes_and_announces_when_final_update_is_cleared():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const item = {{kind:'update', notification_id:42, has_update:true}};
|
||||
const events = [];
|
||||
const reader = buildMyWork.createNotificationReader({{
|
||||
load: async id => ({{id}}), markRead: async () => {{}},
|
||||
onOpen: () => {{}}, onDetail: () => {{}}, onItems: items => events.push(['items', items]),
|
||||
onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
|
||||
}});
|
||||
reader.open(item, [item]).then(() => {{
|
||||
events.length = 0;
|
||||
return reader.markReadAndNext([item]);
|
||||
}}).then(result => process.stdout.write(JSON.stringify({{result, events}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["result"] == {"items": [], "next": None}
|
||||
assert output["events"] == [
|
||||
["status", "Marking update read…"],
|
||||
["items", []],
|
||||
["close"],
|
||||
["status", "Inbox cleared."],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
|
||||
html = await dashboard()
|
||||
@@ -416,6 +575,27 @@ async def test_unread_cards_offer_accessible_mobile_mark_read_without_nested_act
|
||||
assert '<a class="my-work-card"' not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="update-sheet"' in html and 'aria-modal="true"' in html
|
||||
assert 'class="read-update"' in html
|
||||
assert 'id="keep-update-unread"' in html
|
||||
assert 'id="mark-update-read-next"' in html
|
||||
assert 'id="retry-update-load"' in html
|
||||
assert 'id="update-comment-body"' in html
|
||||
assert 'id="update-subject-body"' in html
|
||||
assert 'id="open-update-gitea"' in html
|
||||
assert '.update-sheet-panel { width:min(560px,100%);' in html
|
||||
assert '.update-sheet-content { overflow-wrap:anywhere;' in html
|
||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
|
||||
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
|
||||
assert "createNotificationReader" in html
|
||||
assert "api/v1/notifications/" in html
|
||||
assert "notificationReader.markReadAndNext(lastMyWork)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_api_is_bounded_and_never_cacheable(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def detail(thread_id):
|
||||
calls.append(thread_id)
|
||||
return {
|
||||
"id": thread_id,
|
||||
"repository": "stackchain/api",
|
||||
"title": "Retry failed deploy",
|
||||
"subject_type": "Issue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"subject_body": "Deploy fails after three retries.",
|
||||
"latest_comment": {
|
||||
"author": "alexander",
|
||||
"body": "Logs point to the worker timeout.",
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "notification_detail", detail, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/notifications/42")
|
||||
invalid = await client.get("/api/v1/notifications/0")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["latest_comment"]["author"] == "alexander"
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert invalid.status_code == 422
|
||||
assert calls == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_timeout_is_sanitized_and_retryable(monkeypatch):
|
||||
async def detail(_thread_id):
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
monkeypatch.setattr(main, "notification_detail", detail)
|
||||
monkeypatch.setattr(main, "NOTIFICATION_DETAIL_TIMEOUT_SECONDS", 0.01)
|
||||
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/notifications/42")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"error": "Loading the update timed out. Please retry."}
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_upstream_failure_does_not_leak_exception(monkeypatch):
|
||||
async def detail(_thread_id):
|
||||
raise httpx.HTTPError("token=secret upstream exploded")
|
||||
|
||||
monkeypatch.setattr(main, "notification_detail", detail)
|
||||
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/notifications/42")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {
|
||||
"error": "The update is temporarily unavailable. Please retry."
|
||||
}
|
||||
assert "secret" not in response.text
|
||||
assert response.headers["retry-after"] == "1"
|
||||
Reference in new issue
Block a user