feat: bulk acknowledge unread updates (#141)
This commit is contained in:
1 parent
cafc062b9f
commit
db9e51e683
6 files changed
+246
No files matched your search
@@ -17,4 +17,5 @@ def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
} == {
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/live",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
|
||||
}
|
||||
@@ -256,6 +256,55 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
||||
assert output["results"] == [False, False]
|
||||
|
||||
|
||||
def test_bulk_notification_acknowledger_deduplicates_and_keeps_partial_failures_retryable():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const original = [
|
||||
{{kind:'issue', key:'repo#1', notification_id:42, has_update:true}},
|
||||
{{kind:'update', key:'repo#2', notification_id:43, has_update:true}},
|
||||
{{kind:'update', key:'repo#3', notification_id:44, has_update:true}},
|
||||
];
|
||||
let calls = 0;
|
||||
let release;
|
||||
const states = [];
|
||||
const statuses = [];
|
||||
const controller = buildMyWork.createBulkNotificationAcknowledger({{
|
||||
markRead: ids => {{
|
||||
calls += 1;
|
||||
return new Promise(resolve => {{ release = () => resolve({{marked:[42,43], failed:[44]}}); }});
|
||||
}},
|
||||
onItems: items => states.push(items),
|
||||
onStatus: status => statuses.push(status),
|
||||
}});
|
||||
const first = controller.acknowledge(original, [42, 43, 42, 44]);
|
||||
const duplicate = controller.acknowledge(original, [42, 43, 44]);
|
||||
release();
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, states, statuses, results,
|
||||
retryIds: buildMyWork.notificationIds(states.at(-1)),
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == 1
|
||||
assert output["results"] == [
|
||||
{"marked": [42, 43], "failed": [44]},
|
||||
False,
|
||||
]
|
||||
assert output["states"] == [[
|
||||
{"kind": "issue", "key": "repo#1", "has_update": False},
|
||||
{"kind": "update", "key": "repo#3", "notification_id": 44, "has_update": True},
|
||||
]]
|
||||
assert output["retryIds"] == [44]
|
||||
assert output["statuses"] == [
|
||||
"Marking 3 updates read…",
|
||||
"2 marked read · 1 could not be updated — retry.",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
|
||||
html = await dashboard()
|
||||
@@ -288,6 +337,23 @@ 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_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="bulk-mark-read"' in html
|
||||
assert 'id="bulk-mark-read-bar"' in html
|
||||
assert 'class="my-work-bulk"' in html
|
||||
assert '.my-work-bulk { position:sticky;' in html
|
||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
|
||||
assert '.my-work-bulk button { min-height:44px; width:100%; }' in html
|
||||
assert "'Mark all ' + ids.length + ' updates read'" in html
|
||||
assert "'Confirm marking ' + ids.length + ' updates read'" in html
|
||||
assert "createBulkNotificationAcknowledger" in html
|
||||
assert "api/v1/notifications/read" in html
|
||||
assert "body: JSON.stringify({ ids })" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -49,6 +49,61 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
|
||||
assert marked == [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_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):
|
||||
|
||||
Reference in new issue
Block a user