feat: reply to unread updates in My Work (#153)
This commit is contained in:
1 parent
62ebabb132
commit
83de2a7499
8 files changed
+370
-13
No files matched your search
@@ -200,3 +200,51 @@ async def test_notification_detail_never_follows_foreign_api_urls():
|
||||
assert result["url"] == ""
|
||||
assert result["subject_body"] == ""
|
||||
assert result["latest_comment"]["body"] == ""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("subject_kind", ["issues", "pulls"])
|
||||
async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kind):
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append((request.method, str(request.url), request.content))
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"id": 42,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"type": "Pull" if subject_kind == "pulls" else "Issue",
|
||||
"url": (
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/"
|
||||
f"{subject_kind}/7"
|
||||
),
|
||||
},
|
||||
})
|
||||
return httpx.Response(201, json={
|
||||
"id": 91,
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.reply_to_notification(42, "Please retry the worker.")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
(
|
||||
"GET",
|
||||
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
|
||||
b"",
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
b'{"body":"Please retry the worker."}',
|
||||
),
|
||||
]
|
||||
assert result == {
|
||||
"id": 91,
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
@@ -596,6 +596,78 @@ async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
|
||||
assert "notificationReader.markReadAndNext(lastMyWork)" in html
|
||||
|
||||
|
||||
def test_notification_replier_preserves_failed_draft_and_clears_only_after_success():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
const statuses = [];
|
||||
let calls = 0;
|
||||
let fail = true;
|
||||
const replier = buildMyWork.createNotificationReplier({{
|
||||
storage,
|
||||
post: async (id, body) => {{
|
||||
calls += 1;
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
if (fail) throw new Error('offline');
|
||||
return {{id:91, url:'https://forge.example/comment/91'}};
|
||||
}},
|
||||
onStatus: status => statuses.push(status),
|
||||
}});
|
||||
const item = {{notification_id:42}};
|
||||
replier.saveDraft(item, 'Please retry.');
|
||||
Promise.all([replier.submit(item, 'Please retry.'), replier.submit(item, 'Please retry.')])
|
||||
.then(async first => {{
|
||||
const afterFailure = replier.loadDraft(item);
|
||||
fail = false;
|
||||
const success = await replier.submit(item, afterFailure);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
first, afterFailure, success, afterSuccess:replier.loadDraft(item), calls, statuses,
|
||||
}}));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["first"] == [False, False]
|
||||
assert output["afterFailure"] == "Please retry."
|
||||
assert output["success"]["id"] == 91
|
||||
assert output["afterSuccess"] == ""
|
||||
assert output["calls"] == 2
|
||||
assert output["statuses"] == [
|
||||
"Sending reply…",
|
||||
"Could not send reply. Your draft is safe; retry.",
|
||||
"Sending reply…",
|
||||
"Reply posted. You can mark this update read when ready.",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_sheet_has_persistent_accessible_reply_composer():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="update-reply"' in html
|
||||
assert 'maxlength="10000"' in html
|
||||
assert 'id="send-update-reply"' in html
|
||||
assert 'id="update-reply-status"' in html
|
||||
assert 'aria-live="assertive"' in html
|
||||
assert '.update-reply textarea { width:100%;' in html
|
||||
assert '.update-reply button { min-height:44px;' in html
|
||||
assert '.update-sheet-header button { min-height:44px;' in html
|
||||
assert 'createNotificationReplier' in html
|
||||
assert "method: 'POST'" in html
|
||||
assert "'/reply'" in html
|
||||
assert "notificationReplier.loadDraft(item)" in html
|
||||
assert "notificationReplier.saveDraft(selectedUpdate" in html
|
||||
assert "notificationReplier.submit(selectedUpdate" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_reply_api_validates_and_posts_without_marking_read(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def reply(thread_id, body):
|
||||
calls.append((thread_id, body))
|
||||
return {
|
||||
"id": 91,
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
|
||||
async def must_not_mark_read(_thread_id):
|
||||
raise AssertionError("replying must not mark the update read")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
|
||||
monkeypatch.setattr(main, "mark_notification_read", must_not_mark_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/reply",
|
||||
json={"body": " Please retry the worker. "},
|
||||
)
|
||||
empty = await client.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": " "}
|
||||
)
|
||||
oversized = await client.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": "x" * 10_001}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {
|
||||
"id": 91,
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert empty.status_code == 422
|
||||
assert oversized.status_code == 422
|
||||
assert calls == [(42, "Please retry the worker.")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("failure", ["timeout", "upstream"])
|
||||
async def test_notification_reply_failure_is_sanitized_retryable_and_no_store(
|
||||
monkeypatch, failure
|
||||
):
|
||||
async def reply(_thread_id, _body):
|
||||
if failure == "timeout":
|
||||
await asyncio.sleep(0.05)
|
||||
raise httpx.HTTPError("token=secret upstream exploded")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
|
||||
monkeypatch.setattr(main, "NOTIFICATION_MUTATION_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.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": "Retry it"}
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {
|
||||
"error": "The reply could not be posted. Your draft is safe; please retry."
|
||||
}
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert "secret" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_reply_cannot_be_invoked_by_a_foreign_browser_origin():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.options(
|
||||
"/api/v1/notifications/42/reply",
|
||||
headers={
|
||||
"Origin": "https://evil.example",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "content-type",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code != 200
|
||||
assert "access-control-allow-origin" not in response.headers
|
||||
Reference in new issue
Block a user