feat: undo accidental update acknowledgements (Closes #649)
This commit is contained in:
parent
75da76c6f5
commit
b81d695585
|
|
@ -10,6 +10,9 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||||
.app-menu > summary { display:none; }
|
.app-menu > summary { display:none; }
|
||||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||||
|
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
|
||||||
|
.notification-undo[hidden] { display:none; }
|
||||||
|
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
|
||||||
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||||
.draft-capacity-sheet[hidden] { display:none; }
|
.draft-capacity-sheet[hidden] { display:none; }
|
||||||
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||||
|
|
|
||||||
|
|
@ -786,6 +786,15 @@
|
||||||
if (state.message) qs('#update-sheet-status').textContent = state.message;
|
if (state.message) qs('#update-sheet-status').textContent = state.message;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const notificationUndo = createDashboardNotificationUndo({
|
||||||
|
restore: requestNotificationUnread,
|
||||||
|
getItems: () => lastMyWork,
|
||||||
|
setItems: items => { lastMyWork = items; },
|
||||||
|
getNotifications: () => lastNotifications,
|
||||||
|
setNotifications: items => { lastNotifications = items; },
|
||||||
|
refresh: refreshMyWorkView,
|
||||||
|
select: qs,
|
||||||
|
});
|
||||||
const notificationReader = createNotificationReader({
|
const notificationReader = createNotificationReader({
|
||||||
load: fetchNotificationDetail,
|
load: fetchNotificationDetail,
|
||||||
loadConversation: fetchNotificationConversation,
|
loadConversation: fetchNotificationConversation,
|
||||||
|
|
@ -5034,6 +5043,9 @@
|
||||||
if (result?.accepted) qs('#my-work-action-status').textContent =
|
if (result?.accepted) qs('#my-work-action-status').textContent =
|
||||||
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
|
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
|
||||||
'Reply and read acknowledgement queued for sync.';
|
'Reply and read acknowledgement queued for sync.';
|
||||||
|
if (result?.delivery === 'posted' && result?.next) {
|
||||||
|
notificationUndo.offer(item, result.next.items);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
|
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||||
qs('#update-reply').focus();
|
qs('#update-reply').focus();
|
||||||
|
|
@ -5046,7 +5058,8 @@
|
||||||
qs('#mark-update-read-next').addEventListener('click', async () => {
|
qs('#mark-update-read-next').addEventListener('click', async () => {
|
||||||
qs('#mark-update-read-next').disabled = true;
|
qs('#mark-update-read-next').disabled = true;
|
||||||
try {
|
try {
|
||||||
await notificationReader.markReadAndNext(lastMyWork);
|
const result = await notificationReader.markReadAndNext(lastMyWork);
|
||||||
|
if (result) notificationUndo.offer(result.item, result.items);
|
||||||
} finally {
|
} finally {
|
||||||
qs('#mark-update-read-next').disabled = false;
|
qs('#mark-update-read-next').disabled = false;
|
||||||
}
|
}
|
||||||
|
|
@ -5056,11 +5069,17 @@
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
try {
|
try {
|
||||||
const result = await notificationReader.acknowledgeAndNext(lastMyWork);
|
const result = await notificationReader.acknowledgeAndNext(lastMyWork);
|
||||||
if (result) qs('#my-work-action-status').textContent = 'Update acknowledged with 👍.';
|
if (result) notificationUndo.offer(result.item, result.items);
|
||||||
} finally {
|
} finally {
|
||||||
button.disabled = offlineWorkMode;
|
button.disabled = offlineWorkMode;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
qs('#undo-notification').addEventListener('click', async () => {
|
||||||
|
const button = qs('#undo-notification');
|
||||||
|
button.disabled = true;
|
||||||
|
const restored = await notificationUndo.run();
|
||||||
|
button.disabled = restored;
|
||||||
|
});
|
||||||
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
|
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
|
||||||
qs('#retry-review-load').addEventListener('click', () => {
|
qs('#retry-review-load').addEventListener('click', () => {
|
||||||
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
|
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
|
||||||
|
|
|
||||||
|
|
@ -688,6 +688,11 @@
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="notification-undo" id="notification-undo" role="status" aria-live="assertive" hidden>
|
||||||
|
<span id="notification-undo-status"></span>
|
||||||
|
<button id="undo-notification" type="button" aria-label="Undo marking update read">Undo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
|
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
|
||||||
<section class="pull-sheet-panel">
|
<section class="pull-sheet-panel">
|
||||||
<div class="pull-sheet-header">
|
<div class="pull-sheet-header">
|
||||||
|
|
@ -899,6 +904,7 @@
|
||||||
<script src="static/offline-work.js"></script>
|
<script src="static/offline-work.js"></script>
|
||||||
<script src="static/offline-today.js"></script>
|
<script src="static/offline-today.js"></script>
|
||||||
<script src="static/my-work.js"></script>
|
<script src="static/my-work.js"></script>
|
||||||
|
<script src="static/notification-undo.js"></script>
|
||||||
<script src="static/card-planning.js"></script>
|
<script src="static/card-planning.js"></script>
|
||||||
<script src="static/today-work.js"></script>
|
<script src="static/today-work.js"></script>
|
||||||
<script src="static/today-timer.js"></script>
|
<script src="static/today-timer.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -362,7 +362,7 @@ function createNotificationReader({
|
||||||
onClose();
|
onClose();
|
||||||
onStatus('Inbox cleared.');
|
onStatus('Inbox cleared.');
|
||||||
}
|
}
|
||||||
return { items: updated, next: next || null };
|
return { items: updated, item: current, next: next || null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function open(item, savedDetail = null) {
|
async function open(item, savedDetail = null) {
|
||||||
|
|
|
||||||
85
frontend/notification-undo.js
Normal file
85
frontend/notification-undo.js
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
function createNotificationUndo({ restore, onItems, onStatus, onOffer = () => {}, onClear = () => {} }) {
|
||||||
|
let offered = null;
|
||||||
|
let pending = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
offer(item, items) {
|
||||||
|
if (!item || !Number.isInteger(item.notification_id)) return false;
|
||||||
|
offered = { item, items: Array.isArray(items) ? [...items] : [] };
|
||||||
|
const label = item.key || 'Update';
|
||||||
|
onStatus(label + ' marked read. Undo?');
|
||||||
|
onOffer(item);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async run() {
|
||||||
|
if (!offered || pending) return false;
|
||||||
|
const current = offered;
|
||||||
|
const label = current.item.key || 'Update';
|
||||||
|
pending = true;
|
||||||
|
onStatus('Restoring ' + label + '…');
|
||||||
|
try {
|
||||||
|
await restore(current.item.notification_id);
|
||||||
|
if (offered !== current) return false;
|
||||||
|
const restored = [current.item].concat(current.items.filter(
|
||||||
|
item => item?.notification_id !== current.item.notification_id
|
||||||
|
));
|
||||||
|
offered = null;
|
||||||
|
onItems(restored, current.item);
|
||||||
|
onStatus(label + ' is unread again.');
|
||||||
|
onClear();
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
onStatus('Could not restore ' + label + '. Retry Undo.');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
pending = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
offered = null;
|
||||||
|
onClear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestNotificationUnread(notificationId) {
|
||||||
|
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/unread', {
|
||||||
|
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(payload.error || 'Restore unread failed.');
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDashboardNotificationUndo({ restore, getItems, setItems, getNotifications, setNotifications, refresh, select }) {
|
||||||
|
let timer = null;
|
||||||
|
const controller = createNotificationUndo({
|
||||||
|
restore,
|
||||||
|
onItems: (items, restored) => {
|
||||||
|
setItems(items);
|
||||||
|
const notifications = getNotifications();
|
||||||
|
if (!notifications.some(item => item.id === restored.notification_id)) {
|
||||||
|
setNotifications([{ id:restored.notification_id }].concat(notifications));
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onStatus: message => {
|
||||||
|
select('#notification-undo-status').textContent = message;
|
||||||
|
select('#my-work-action-status').textContent = message;
|
||||||
|
},
|
||||||
|
onOffer: item => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
select('#notification-undo').hidden = false;
|
||||||
|
select('#undo-notification').disabled = false;
|
||||||
|
select('#undo-notification').setAttribute('aria-label', 'Undo marking ' + (item.key || 'update') + ' read');
|
||||||
|
timer = setTimeout(() => controller.clear(), 10000);
|
||||||
|
},
|
||||||
|
onClear: () => { select('#notification-undo').hidden = true; },
|
||||||
|
});
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = createNotificationUndo;
|
||||||
|
module.exports.createDashboardNotificationUndo = createDashboardNotificationUndo;
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ const SHELL = [
|
||||||
BASE + 'static/offline-work.js',
|
BASE + 'static/offline-work.js',
|
||||||
BASE + 'static/offline-today.js',
|
BASE + 'static/offline-today.js',
|
||||||
BASE + 'static/my-work.js',
|
BASE + 'static/my-work.js',
|
||||||
|
BASE + 'static/notification-undo.js',
|
||||||
BASE + 'static/card-planning.js',
|
BASE + 'static/card-planning.js',
|
||||||
BASE + 'static/today-work.js',
|
BASE + 'static/today-work.js',
|
||||||
BASE + 'static/today-timer.js',
|
BASE + 'static/today-timer.js',
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ FEATURE_SOURCES = {
|
||||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||||
"security-center": ("static/security-center.js",),
|
"security-center": ("static/security-center.js",),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
|
"static/mobile-task-dock.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||||
"static/today-rollover.js", "static/drafts.js", "static/unfiled-captures.js",
|
"static/today-rollover.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -804,6 +804,14 @@ async def mark_notification_read(thread_id: int) -> None:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_notification_unread(thread_id: int) -> None:
|
||||||
|
response = await _get_client().patch(
|
||||||
|
f"/api/v1/notifications/threads/{thread_id}?to-status=unread",
|
||||||
|
headers=_auth(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
async def acknowledge_notification(thread_id: int) -> dict:
|
async def acknowledge_notification(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):
|
||||||
|
|
|
||||||
21
src/main.py
21
src/main.py
|
|
@ -3452,6 +3452,27 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||||
return JSONResponse({"id": thread_id, "status": "read"})
|
return JSONResponse({"id": thread_id, "status": "read"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/v1/notifications/{thread_id}/unread")
|
||||||
|
async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
gitea_proxy.mark_notification_unread(thread_id),
|
||||||
|
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Restoring the update timed out. Retry Undo."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "The update could not be restored. Retry Undo."},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
return JSONResponse({"id": thread_id, "status": "unread"})
|
||||||
|
|
||||||
|
|
||||||
@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,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
|
||||||
|
|
||||||
|
|
||||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||||
|
NOTIFICATION_UNDO = Path(__file__).parents[1] / "frontend" / "notification-undo.js"
|
||||||
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
|
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
|
||||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||||
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
|
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
|
||||||
|
|
@ -4123,6 +4124,58 @@ const reader = buildMyWork.createNotificationReader({{
|
||||||
assert output["results"][1] is False
|
assert output["results"][1] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_notification_undo_restores_exact_item_once_and_keeps_failure_retryable():
|
||||||
|
script = f"""
|
||||||
|
const createNotificationUndo = require({json.dumps(str(NOTIFICATION_UNDO))});
|
||||||
|
const states = [];
|
||||||
|
const statuses = [];
|
||||||
|
const calls = [];
|
||||||
|
let attempts = 0;
|
||||||
|
const item = {{kind:'update', key:'repo#7', notification_id:42, has_update:true}};
|
||||||
|
const undo = createNotificationUndo({{
|
||||||
|
restore: async id => {{ calls.push(id); attempts += 1; if (attempts === 1) throw new Error('offline'); }},
|
||||||
|
onItems: items => states.push(items.map(value => value.notification_id)),
|
||||||
|
onStatus: status => statuses.push(status),
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
undo.offer(item, [{{kind:'update', notification_id:43, has_update:true}}]);
|
||||||
|
const first = undo.run();
|
||||||
|
const duplicate = undo.run();
|
||||||
|
const firstResults = await Promise.all([first, duplicate]);
|
||||||
|
const retry = await undo.run();
|
||||||
|
process.stdout.write(JSON.stringify({{calls, states, statuses, firstResults, retry}}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
output = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
).stdout)
|
||||||
|
|
||||||
|
assert output["calls"] == [42, 42]
|
||||||
|
assert output["states"] == [[42, 43]]
|
||||||
|
assert output["firstResults"] == [False, False]
|
||||||
|
assert output["retry"] is True
|
||||||
|
assert output["statuses"] == [
|
||||||
|
"repo#7 marked read. Undo?",
|
||||||
|
"Restoring repo#7…",
|
||||||
|
"Could not restore repo#7. Retry Undo.",
|
||||||
|
"Restoring repo#7…",
|
||||||
|
"repo#7 is unread again.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_update_undo_is_accessible_and_safe_area_aware():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="notification-undo"' in html
|
||||||
|
assert 'id="undo-notification" type="button"' in html
|
||||||
|
assert "requestNotificationUnread" in html
|
||||||
|
assert "createDashboardNotificationUndo({" in html
|
||||||
|
assert ".notification-undo { position:fixed;" in html
|
||||||
|
assert "env(safe-area-inset-bottom)" in html
|
||||||
|
assert ".notification-undo button { min-height:44px;" in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next():
|
async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
@ -4401,7 +4454,9 @@ reader.open(item, [item]).then(() => {{
|
||||||
)
|
)
|
||||||
output = json.loads(result.stdout)
|
output = json.loads(result.stdout)
|
||||||
|
|
||||||
assert output["result"] == {"items": [], "next": None}
|
assert output["result"] == {"items": [], "item": {
|
||||||
|
"kind": "update", "notification_id": 42, "has_update": True,
|
||||||
|
}, "next": None}
|
||||||
assert output["events"] == [
|
assert output["events"] == [
|
||||||
["status", "Marking update read…"],
|
["status", "Marking update read…"],
|
||||||
["items", []],
|
["items", []],
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,29 @@ async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monk
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mark_notification_unread_calls_supported_gitea_thread_endpoint(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
def raise_for_status(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
async def patch(self, path, headers):
|
||||||
|
calls.append((path, headers))
|
||||||
|
return Response()
|
||||||
|
|
||||||
|
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
|
||||||
|
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
|
||||||
|
|
||||||
|
await gitea_proxy.mark_notification_unread(42)
|
||||||
|
|
||||||
|
assert calls == [
|
||||||
|
("/api/v1/notifications/threads/42?to-status=unread", {"Authorization": "token test"})
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_acknowledge_notification_resolves_latest_comment_and_adds_one_reaction(monkeypatch):
|
async def test_acknowledge_notification_resolves_latest_comment_and_adds_one_reaction(monkeypatch):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
@ -172,6 +195,26 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
|
||||||
assert marked == [42]
|
assert marked == [42]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monkeypatch):
|
||||||
|
restored = []
|
||||||
|
|
||||||
|
async def mark_unread(thread_id):
|
||||||
|
restored.append(thread_id)
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "mark_notification_unread", mark_unread, raising=False)
|
||||||
|
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/42/unread")
|
||||||
|
invalid = await client.patch("/api/v1/notifications/0/unread")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"id": 42, "status": "unread"}
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert invalid.status_code == 422
|
||||||
|
assert restored == [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 = []
|
||||||
|
|
|
||||||
|
|
@ -695,6 +695,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/offline-work.js",
|
"/dashboard/static/offline-work.js",
|
||||||
"/dashboard/static/offline-today.js",
|
"/dashboard/static/offline-today.js",
|
||||||
"/dashboard/static/my-work.js",
|
"/dashboard/static/my-work.js",
|
||||||
|
"/dashboard/static/notification-undo.js",
|
||||||
"/dashboard/static/card-planning.js",
|
"/dashboard/static/card-planning.js",
|
||||||
"/dashboard/static/today-work.js",
|
"/dashboard/static/today-work.js",
|
||||||
"/dashboard/static/today-timer.js",
|
"/dashboard/static/today-timer.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user