feat: mute future updates during triage (Closes #751)
This commit is contained in:
parent
b93e63ab70
commit
8dc3880167
|
|
@ -401,6 +401,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.update-ownership-actions button { min-width:0; width:100%; }
|
.update-ownership-actions button { min-width:0; width:100%; }
|
||||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||||
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
|
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
|
||||||
|
.update-sheet-actions #mute-update-next { min-height:44px; width:100%; }
|
||||||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||||
|
|
|
||||||
|
|
@ -719,6 +719,18 @@
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function muteNotification(notificationId) {
|
||||||
|
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
|
||||||
|
'/mute', { method: 'POST', headers: { Accept: 'application/json' } });
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(payload.error || 'Muting future updates failed.');
|
||||||
|
error.muted = payload.muted === true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
async function markNotificationsRead(ids) {
|
async function markNotificationsRead(ids) {
|
||||||
const response = await fetch('api/v1/notifications/read', {
|
const response = await fetch('api/v1/notifications/read', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|
@ -927,6 +939,7 @@
|
||||||
qs('#send-update-reply').disabled = false;
|
qs('#send-update-reply').disabled = false;
|
||||||
qs('#send-update-reply-read-next').disabled = false;
|
qs('#send-update-reply-read-next').disabled = false;
|
||||||
qs('#acknowledge-update-next').hidden = true;
|
qs('#acknowledge-update-next').hidden = true;
|
||||||
|
qs('#mute-update-next').hidden = true;
|
||||||
qs('#update-ownership-action').hidden = true;
|
qs('#update-ownership-action').hidden = true;
|
||||||
qs('#update-ownership-start').hidden = true;
|
qs('#update-ownership-start').hidden = true;
|
||||||
qs('#create-update-follow-up').hidden = true;
|
qs('#create-update-follow-up').hidden = true;
|
||||||
|
|
@ -942,6 +955,7 @@
|
||||||
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
|
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
|
||||||
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
|
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
|
||||||
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;
|
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;
|
||||||
|
qs('#mute-update-next').hidden = !detail.mute_supported;
|
||||||
qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type);
|
qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type);
|
||||||
if (offlineWorkMode) {
|
if (offlineWorkMode) {
|
||||||
setOfflineUpdateControls(true);
|
setOfflineUpdateControls(true);
|
||||||
|
|
@ -2218,6 +2232,7 @@
|
||||||
qs('#mark-update-read-next').disabled = false;
|
qs('#mark-update-read-next').disabled = false;
|
||||||
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
|
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
|
||||||
qs('#acknowledge-update-next').disabled = offline;
|
qs('#acknowledge-update-next').disabled = offline;
|
||||||
|
qs('#mute-update-next').disabled = offline;
|
||||||
qs('#update-ownership-action').disabled = offline;
|
qs('#update-ownership-action').disabled = offline;
|
||||||
qs('#update-ownership-start').disabled = offline;
|
qs('#update-ownership-start').disabled = offline;
|
||||||
qs('#load-older-update-comments').disabled = offline;
|
qs('#load-older-update-comments').disabled = offline;
|
||||||
|
|
@ -5593,6 +5608,27 @@
|
||||||
button.disabled = offlineWorkMode;
|
button.disabled = offlineWorkMode;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
qs('#mute-update-next').addEventListener('click', async () => {
|
||||||
|
if (!selectedUpdate || offlineWorkMode) return;
|
||||||
|
const button = qs('#mute-update-next');
|
||||||
|
const item = selectedUpdate;
|
||||||
|
button.disabled = true;
|
||||||
|
qs('#update-sheet-status').textContent = 'Muting future updates…';
|
||||||
|
try {
|
||||||
|
await muteNotification(item.notification_id);
|
||||||
|
const result = await notificationReader.acceptReadAndNext(lastMyWork, item);
|
||||||
|
if (result && updateTriage.active()) updateTriage.acceptCompleted();
|
||||||
|
} catch (error) {
|
||||||
|
qs('#update-sheet-status').textContent = error.muted ?
|
||||||
|
'Future updates are muted; current item is still unread. Retry mark read & next.' : error.message;
|
||||||
|
if (error.muted) {
|
||||||
|
button.hidden = true;
|
||||||
|
qs('#mark-update-read-next').focus();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
button.disabled = offlineWorkMode;
|
||||||
|
}
|
||||||
|
});
|
||||||
qs('#undo-notification').addEventListener('click', async () => {
|
qs('#undo-notification').addEventListener('click', async () => {
|
||||||
const button = qs('#undo-notification');
|
const button = qs('#undo-notification');
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
|
|
|
||||||
|
|
@ -794,6 +794,7 @@
|
||||||
</div>
|
</div>
|
||||||
<button class="share-work-route" type="button">Share</button>
|
<button class="share-work-route" type="button">Share</button>
|
||||||
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
||||||
|
<button id="mute-update-next" type="button" hidden>Mute future updates & next</button>
|
||||||
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
|
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
|
||||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import re
|
||||||
import shlex
|
import shlex
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urljoin, urlsplit
|
from urllib.parse import quote, urljoin, urlsplit
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
@ -916,6 +916,14 @@ def _gitea_api_path(value: Any) -> str:
|
||||||
return parsed.path[len(prefix):] + (("?" + parsed.query) if parsed.query else "")
|
return parsed.path[len(prefix):] + (("?" + parsed.query) if parsed.query else "")
|
||||||
|
|
||||||
|
|
||||||
|
async def _notification_subscription(repository: str, number: str) -> dict:
|
||||||
|
try:
|
||||||
|
value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check")
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
async def notification_detail(thread_id: int) -> dict:
|
async def notification_detail(thread_id: int) -> dict:
|
||||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||||
if not isinstance(thread, dict):
|
if not isinstance(thread, dict):
|
||||||
|
|
@ -940,18 +948,20 @@ async def notification_detail(thread_id: int) -> dict:
|
||||||
)
|
)
|
||||||
if supported_conversation:
|
if supported_conversation:
|
||||||
assert conversation_match is not None
|
assert conversation_match is not None
|
||||||
subject_detail, comment, conversation = await asyncio.gather(
|
subject_detail, comment, conversation, subscription = await asyncio.gather(
|
||||||
fetch(subject_path),
|
fetch(subject_path),
|
||||||
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
||||||
issue_conversation_page(
|
issue_conversation_page(
|
||||||
conversation_match.group(1), int(conversation_match.group(3))
|
conversation_match.group(1), int(conversation_match.group(3))
|
||||||
),
|
),
|
||||||
|
_notification_subscription(conversation_match.group(1), conversation_match.group(3)),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
subject_detail, comment, conversation = await asyncio.gather(
|
subject_detail, comment, conversation, subscription = await asyncio.gather(
|
||||||
fetch(subject_path) if subject_path else asyncio.sleep(0, result={}),
|
fetch(subject_path) if subject_path else asyncio.sleep(0, result={}),
|
||||||
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
||||||
asyncio.sleep(0, result={"comments": [], "page": 1, "older_page": None, "total": 0}),
|
asyncio.sleep(0, result={"comments": [], "page": 1, "older_page": None, "total": 0}),
|
||||||
|
asyncio.sleep(0, result={}),
|
||||||
)
|
)
|
||||||
subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
|
subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
|
||||||
comment = comment if isinstance(comment, dict) else {}
|
comment = comment if isinstance(comment, dict) else {}
|
||||||
|
|
@ -1012,6 +1022,12 @@ async def notification_detail(thread_id: int) -> dict:
|
||||||
and comment_match
|
and comment_match
|
||||||
and comment_match.group(1) == repository_name
|
and comment_match.group(1) == repository_name
|
||||||
),
|
),
|
||||||
|
"mute_supported": bool(
|
||||||
|
supported_conversation
|
||||||
|
and isinstance(subscription, dict)
|
||||||
|
and subscription.get("subscribed") is True
|
||||||
|
and subscription.get("ignored") is not True
|
||||||
|
),
|
||||||
"conversation": conversation,
|
"conversation": conversation,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1058,6 +1074,21 @@ async def notification_conversation_target(thread_id: int) -> tuple[str, int]:
|
||||||
return match.group(1), int(match.group(3))
|
return match.group(1), int(match.group(3))
|
||||||
|
|
||||||
|
|
||||||
|
async def mute_notification(thread_id: int) -> dict:
|
||||||
|
"""Unsubscribe the current operator from the trusted notification conversation."""
|
||||||
|
repository, number = await notification_conversation_target(thread_id)
|
||||||
|
user = await fetch("user")
|
||||||
|
login = user.get("login") if isinstance(user, dict) else None
|
||||||
|
if not isinstance(login, str) or not login:
|
||||||
|
raise ValueError("Gitea did not identify the current operator")
|
||||||
|
response = await _get_client().delete(
|
||||||
|
f"/api/v1/repos/{repository}/issues/{number}/subscriptions/{quote(login, safe='')}",
|
||||||
|
headers=_auth(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return {"id": thread_id, "repository": repository, "number": number, "muted": True}
|
||||||
|
|
||||||
|
|
||||||
async def reply_to_notification(thread_id: int, body: str) -> dict:
|
async def reply_to_notification(thread_id: int, body: str) -> dict:
|
||||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||||
if not isinstance(thread, dict):
|
if not isinstance(thread, dict):
|
||||||
|
|
|
||||||
37
src/main.py
37
src/main.py
|
|
@ -3685,6 +3685,43 @@ async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||||
return JSONResponse({"id": thread_id, "status": "unread"})
|
return JSONResponse({"id": thread_id, "status": "unread"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/notifications/{thread_id}/mute")
|
||||||
|
async def mute_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
gitea_proxy.mute_notification(thread_id),
|
||||||
|
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Muting future updates timed out. Please retry."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Future updates could not be muted. Please retry."},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
mark_notification_read(thread_id),
|
||||||
|
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"id": thread_id,
|
||||||
|
"muted": True,
|
||||||
|
"status": "unread",
|
||||||
|
"error": "Future updates are muted; current item is still unread. Retry mark read & next.",
|
||||||
|
},
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
await _remove_notifications_from_live_snapshot([thread_id])
|
||||||
|
return JSONResponse({"id": thread_id, "muted": True, "status": "read"})
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/v1/notifications/{thread_id}/later")
|
@app.patch("/api/v1/notifications/{thread_id}/later")
|
||||||
async def defer_notification(
|
async def defer_notification(
|
||||||
payload: NotificationLaterRequest,
|
payload: NotificationLaterRequest,
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||||
"number": 7, "state": "open", "assignees": [],
|
"number": 7, "state": "open", "assignees": [],
|
||||||
"body": "Deploy fails after **three** retries.",
|
"body": "Deploy fails after **three** retries.",
|
||||||
})
|
})
|
||||||
|
if request.url.path.endswith("/issues/7/subscriptions/check"):
|
||||||
|
return httpx.Response(200, json={"subscribed": True, "ignored": False})
|
||||||
if request.url.path.endswith("/issues/comments/9"):
|
if request.url.path.endswith("/issues/comments/9"):
|
||||||
return httpx.Response(200, json={
|
return httpx.Response(200, json={
|
||||||
"id": 9,
|
"id": 9,
|
||||||
|
|
@ -283,6 +285,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||||
"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/7",
|
||||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
|
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
|
||||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1",
|
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1",
|
||||||
|
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/subscriptions/check",
|
||||||
]
|
]
|
||||||
assert result == {
|
assert result == {
|
||||||
"id": 42,
|
"id": 42,
|
||||||
|
|
@ -300,6 +303,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||||
},
|
},
|
||||||
"issue": {"number": 7, "assignees": [], "claimable": True},
|
"issue": {"number": 7, "assignees": [], "claimable": True},
|
||||||
"acknowledge_supported": True,
|
"acknowledge_supported": True,
|
||||||
|
"mute_supported": True,
|
||||||
"conversation": {
|
"conversation": {
|
||||||
"comments": [{
|
"comments": [{
|
||||||
"id": 9,
|
"id": 9,
|
||||||
|
|
@ -398,3 +402,37 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
|
||||||
"created_at": "2026-08-07T19:00:00Z",
|
"created_at": "2026-08-07T19:00:00Z",
|
||||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mute_notification_unsubscribes_current_user_from_trusted_thread_target():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def upstream(request):
|
||||||
|
requests.append((request.method, request.url.path))
|
||||||
|
if request.url.path.endswith("/notifications/threads/42"):
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"repository": {"full_name": "stackchain/api"},
|
||||||
|
"subject": {
|
||||||
|
"type": "Pull",
|
||||||
|
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/pulls/7",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if request.url.path.endswith("/user"):
|
||||||
|
return httpx.Response(200, json={"login": "timmy"})
|
||||||
|
if request.method == "DELETE":
|
||||||
|
return httpx.Response(204)
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||||
|
try:
|
||||||
|
result = await gitea_proxy.mute_notification(42)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert result == {"id": 42, "repository": "stackchain/api", "number": 7, "muted": True}
|
||||||
|
assert requests == [
|
||||||
|
("GET", "/api/v1/notifications/threads/42"),
|
||||||
|
("GET", "/api/v1/user"),
|
||||||
|
("DELETE", "/api/v1/repos/stackchain/api/issues/7/subscriptions/timmy"),
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -4958,6 +4958,21 @@ async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next(
|
||||||
assert '.update-sheet-actions #acknowledge-update-next { min-height:44px;' in html
|
assert '.update-sheet-actions #acknowledge-update-next { min-height:44px;' in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_update_sheet_mutes_future_updates_and_advances_only_on_read_success():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="mute-update-next" type="button" hidden' in html
|
||||||
|
assert ">Mute future updates & next</button>" in html
|
||||||
|
assert "qs('#mute-update-next').hidden = !detail.mute_supported;" in html
|
||||||
|
assert "async function muteNotification(notificationId)" in html
|
||||||
|
assert "'/mute', { method: 'POST'" in html
|
||||||
|
assert "notificationReader.acceptReadAndNext(lastMyWork, item)" in html
|
||||||
|
assert "Future updates are muted; current item is still unread" in html
|
||||||
|
assert "qs('#mute-update-next').disabled = offline;" in html
|
||||||
|
assert ".update-sheet-actions #mute-update-next" in html
|
||||||
|
|
||||||
|
|
||||||
def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
|
def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
|
||||||
script = f"""
|
script = f"""
|
||||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,34 @@ async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monke
|
||||||
assert restored == [42]
|
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
|
@pytest.mark.anyio
|
||||||
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
|
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user