Paginate unread updates without slowing live refresh #148
|
|
@ -66,6 +66,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.mark-update-read { min-height:44px; width:100%; }
|
||||
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
|
||||
.load-more-notifications[hidden] { display:none; }
|
||||
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.my-work-bulk button { min-height:44px; width:100%; }
|
||||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
||||
|
|
@ -141,6 +143,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
</div>
|
||||
</div>
|
||||
<div class="my-work-list" id="my-work-list"></div>
|
||||
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
||||
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
||||
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
||||
<button id="bulk-mark-read" type="button"></button>
|
||||
|
|
@ -318,6 +322,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
let lastMyWork = [];
|
||||
let lastNotifications = [];
|
||||
let lastContextSnapshot = null;
|
||||
let notificationPagination = { page: 1, total: 0, has_more: false };
|
||||
let hasContextSnapshot = false;
|
||||
let selectedReview = null;
|
||||
let reviewTrigger = null;
|
||||
|
|
@ -367,6 +373,15 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
return payload;
|
||||
}
|
||||
|
||||
async function fetchNotificationPage(page) {
|
||||
const response = await fetch('api/v1/notifications?page=' + encodeURIComponent(page), {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Loading unread updates failed.');
|
||||
return payload;
|
||||
}
|
||||
|
||||
const notificationAcknowledger = createNotificationAcknowledger({
|
||||
markRead: markNotificationRead,
|
||||
onItems: items => {
|
||||
|
|
@ -383,10 +398,30 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
},
|
||||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
const notificationPager = createNotificationPager({
|
||||
load: fetchNotificationPage,
|
||||
onNotifications: items => {
|
||||
lastNotifications = items;
|
||||
if (lastContextSnapshot) {
|
||||
lastContextSnapshot.notifications = lastNotifications;
|
||||
paintMyWork(lastContextSnapshot);
|
||||
}
|
||||
},
|
||||
onPagination: pagination => {
|
||||
notificationPagination = pagination;
|
||||
const loaded = Math.min(pagination.total, pagination.page * 50);
|
||||
qs('#notification-page-status').textContent = pagination.total ?
|
||||
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
|
||||
qs('#load-more-notifications').hidden =
|
||||
selectedWorkFilter !== 'update' || !pagination.has_more;
|
||||
},
|
||||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
|
||||
function renderContextSnapshot(data) {
|
||||
liveMode = true;
|
||||
hasContextSnapshot = true;
|
||||
lastContextSnapshot = data;
|
||||
if (data.error && lastMyWork.length) markMyWorkStale();
|
||||
else paintMyWork(data);
|
||||
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
|
||||
|
|
@ -476,14 +511,20 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
});
|
||||
});
|
||||
const ids = notificationIds(visible);
|
||||
const allIds = notificationIds(visible);
|
||||
const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
|
||||
const bulkBar = qs('#bulk-mark-read-bar');
|
||||
const bulkButton = qs('#bulk-mark-read');
|
||||
bulkBar.hidden = selectedWorkFilter !== 'update' || ids.length === 0;
|
||||
qs('#load-more-notifications').hidden =
|
||||
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
|
||||
bulkButton.disabled = bulkMarkPending;
|
||||
const bulkLabel = allIds.length > ids.length ?
|
||||
'next ' + ids.length + ' of ' + allIds.length + ' loaded updates' :
|
||||
'all ' + ids.length + ' updates';
|
||||
bulkButton.textContent = bulkConfirmationPending ?
|
||||
'Confirm marking ' + ids.length + ' updates read' :
|
||||
'Mark all ' + ids.length + ' updates read';
|
||||
'Confirm marking ' + bulkLabel + ' read' :
|
||||
'Mark ' + bulkLabel + ' read';
|
||||
}
|
||||
|
||||
function reviewFileElement(filename) {
|
||||
|
|
@ -645,7 +686,24 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
|
||||
function renderLiveSnapshot(snapshot) {
|
||||
const notificationsFresh = Array.isArray(snapshot.notifications);
|
||||
if (notificationsFresh) lastNotifications = snapshot.notifications;
|
||||
if (notificationsFresh) {
|
||||
if (notificationPagination.page > 1) {
|
||||
const byId = new Map(lastNotifications.map(item => [item.id, item]));
|
||||
snapshot.notifications.forEach(item => byId.set(item.id, item));
|
||||
lastNotifications = Array.from(byId.values());
|
||||
} else {
|
||||
lastNotifications = snapshot.notifications;
|
||||
}
|
||||
if (snapshot.notification_pagination) {
|
||||
const page = notificationPagination.page > 1 ? notificationPagination.page :
|
||||
snapshot.notification_pagination.page;
|
||||
notificationPager.reset({
|
||||
page,
|
||||
total: snapshot.notification_pagination.total,
|
||||
has_more: page * 50 < snapshot.notification_pagination.total,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (snapshot.context) {
|
||||
snapshot.context.notifications = lastNotifications;
|
||||
renderContextSnapshot(snapshot.context);
|
||||
|
|
@ -793,8 +851,12 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
function load() { return contextPoller.refresh(); }
|
||||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
qs('#load-more-notifications').addEventListener('click', () =>
|
||||
notificationPager.loadMore(lastNotifications)
|
||||
);
|
||||
qs('#bulk-mark-read').addEventListener('click', async () => {
|
||||
const ids = notificationIds(filterMyWork(lastMyWork, 'update'));
|
||||
const allIds = notificationIds(filterMyWork(lastMyWork, 'update'));
|
||||
const ids = allIds.slice(0, 50);
|
||||
if (!ids.length || bulkMarkPending) return;
|
||||
if (!bulkConfirmationPending) {
|
||||
bulkConfirmationPending = true;
|
||||
|
|
|
|||
|
|
@ -129,6 +129,46 @@ function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
|
|||
};
|
||||
}
|
||||
|
||||
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let pending = false;
|
||||
return {
|
||||
reset(next) {
|
||||
pagination = { ...pagination, ...(next || {}) };
|
||||
onPagination(pagination);
|
||||
},
|
||||
async loadMore(existing) {
|
||||
if (pending || !pagination.has_more) return false;
|
||||
pending = true;
|
||||
onStatus('Loading older updates…');
|
||||
try {
|
||||
const result = await load(pagination.page + 1);
|
||||
const byId = new Map((existing || [])
|
||||
.filter(item => item && Number.isInteger(item.id))
|
||||
.map(item => [item.id, item]));
|
||||
(result.items || []).forEach(item => {
|
||||
if (item && Number.isInteger(item.id) && !byId.has(item.id)) byId.set(item.id, item);
|
||||
});
|
||||
pagination = {
|
||||
page: result.page,
|
||||
total: result.total,
|
||||
has_more: result.has_more === true,
|
||||
};
|
||||
const loaded = Math.min(pagination.total, pagination.page * 50);
|
||||
onNotifications(Array.from(byId.values()));
|
||||
onPagination(pagination);
|
||||
onStatus(loaded + ' of ' + pagination.total + ' unread updates loaded.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus('Could not load older updates. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function filterMyWork(items, selectedFilter) {
|
||||
if (selectedFilter === 'all') return items;
|
||||
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
|
||||
|
|
@ -164,5 +204,6 @@ if (typeof module !== 'undefined' && module.exports) {
|
|||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||
buildMyWork.notificationIds = notificationIds;
|
||||
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
||||
buildMyWork.createNotificationPager = createNotificationPager;
|
||||
module.exports = buildMyWork;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,8 +125,7 @@ def _safe_web_url(value: Any) -> str:
|
|||
return value if parsed.scheme in {"http", "https"} and parsed.netloc else ""
|
||||
|
||||
|
||||
async def notifications() -> list[dict]:
|
||||
threads = await fetch("notifications?status-types=unread&limit=50")
|
||||
def _normalize_notifications(threads: Any) -> list[dict]:
|
||||
if not isinstance(threads, list):
|
||||
raise ValueError("Gitea notification response was not a list")
|
||||
normalized = []
|
||||
|
|
@ -181,6 +180,29 @@ async def notifications() -> list[dict]:
|
|||
return normalized
|
||||
|
||||
|
||||
async def notifications() -> dict:
|
||||
return await notification_page(1)
|
||||
|
||||
|
||||
async def notification_page(page: int, limit: int = 50) -> dict:
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/notifications?status-types=unread&limit={limit}&page={page}",
|
||||
headers=_auth(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
items = _normalize_notifications(response.json())
|
||||
try:
|
||||
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
|
||||
except (TypeError, ValueError):
|
||||
total = len(items)
|
||||
return {
|
||||
"items": items,
|
||||
"page": page,
|
||||
"total": total,
|
||||
"has_more": page * limit < total,
|
||||
}
|
||||
|
||||
|
||||
async def mark_notification_read(thread_id: int) -> None:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/notifications/threads/{thread_id}?to-status=read",
|
||||
|
|
|
|||
40
src/main.py
40
src/main.py
|
|
@ -4,7 +4,7 @@ import time
|
|||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Path as PathParam
|
||||
from fastapi import FastAPI, HTTPException, Path as PathParam, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
|
@ -53,6 +53,7 @@ EVENT_STREAM_TIMEOUT_SECONDS = 5.0
|
|||
READINESS_TIMEOUT_SECONDS = 5.0
|
||||
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
|
||||
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
|
||||
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
|
||||
BULK_NOTIFICATION_CONCURRENCY = 5
|
||||
BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
|
||||
LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
|
||||
|
|
@ -135,7 +136,7 @@ app.include_router(frontend_router)
|
|||
@app.middleware("http")
|
||||
async def prevent_live_api_caching(request, call_next):
|
||||
response = await call_next(request)
|
||||
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live"} or (
|
||||
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/notifications"} or (
|
||||
request.url.path.startswith("/api/v1/repos/")
|
||||
and request.url.path.endswith("/review")
|
||||
) or (
|
||||
|
|
@ -263,10 +264,21 @@ async def _build_live_snapshot() -> dict:
|
|||
context_ok = not isinstance(context_result, BaseException)
|
||||
events_ok = not isinstance(events_result, BaseException)
|
||||
notifications_ok = not isinstance(notifications_result, BaseException)
|
||||
notification_items = notifications_result
|
||||
notification_pagination = None
|
||||
if notifications_ok and isinstance(notifications_result, dict):
|
||||
notification_items = notifications_result.get("items")
|
||||
notification_pagination = {
|
||||
"page": notifications_result.get("page", 1),
|
||||
"total": notifications_result.get("total", 0),
|
||||
"has_more": notifications_result.get("has_more") is True,
|
||||
}
|
||||
notifications_ok = isinstance(notification_items, list)
|
||||
return {
|
||||
"context": context_result if context_ok else None,
|
||||
"events": events_result if events_ok else None,
|
||||
"notifications": notifications_result if notifications_ok else None,
|
||||
"notifications": notification_items if notifications_ok else None,
|
||||
"notification_pagination": notification_pagination if notifications_ok else None,
|
||||
"sections": {
|
||||
"context": "fresh" if context_ok else "temporarily unavailable",
|
||||
"events": "fresh" if events_ok else "temporarily unavailable",
|
||||
|
|
@ -479,6 +491,28 @@ async def read_notifications(batch: NotificationReadBatch) -> JSONResponse:
|
|||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/notifications")
|
||||
async def notification_page(page: int = Query(default=1, ge=1)) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.notification_page(page),
|
||||
timeout=NOTIFICATION_PAGE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Unread updates timed out. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Unread updates are temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/notifications/{thread_id}/read")
|
||||
async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -17,5 +17,6 @@ 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?page=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,49 @@
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handoff(monkeypatch):
|
||||
requested_paths = []
|
||||
async def test_notification_page_preserves_upstream_total_without_loading_other_pages():
|
||||
requests = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
requested_paths.append(path)
|
||||
return [
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "125"},
|
||||
json=[{
|
||||
"id": 42,
|
||||
"unread": True,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"title": "Retry failed deploy",
|
||||
"type": "Issue",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
},
|
||||
}],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.notification_page(page=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/notifications?status-types=unread&limit=50&page=2"
|
||||
]
|
||||
assert result["page"] == 2
|
||||
assert result["total"] == 125
|
||||
assert result["has_more"] is True
|
||||
assert [item["id"] for item in result["items"]] == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notifications_are_normalized_for_mobile_handoff():
|
||||
result = gitea_proxy._normalize_notifications(
|
||||
[
|
||||
{
|
||||
"id": 42,
|
||||
"unread": True,
|
||||
|
|
@ -31,12 +65,7 @@ async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handof
|
|||
},
|
||||
"malformed",
|
||||
]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
result = await gitea_proxy.notifications()
|
||||
|
||||
assert requested_paths == ["notifications?status-types=unread&limit=50"]
|
||||
)
|
||||
assert result == [
|
||||
{
|
||||
"id": 42,
|
||||
|
|
@ -78,11 +107,6 @@ async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handof
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_collection_rejects_non_list_payload(monkeypatch):
|
||||
async def fake_fetch(_path):
|
||||
return {"message": "unexpected"}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
async def test_notification_collection_rejects_non_list_payload():
|
||||
with pytest.raises(ValueError, match="notification response was not a list"):
|
||||
await gitea_proxy.notifications()
|
||||
gitea_proxy._normalize_notifications({"message": "unexpected"})
|
||||
|
|
|
|||
|
|
@ -37,7 +37,12 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
|
|||
return [{"type": "push"}]
|
||||
|
||||
async def updates():
|
||||
return [{"id": 42, "title": "Mentioned you"}]
|
||||
return {
|
||||
"items": [{"id": 42, "title": "Mentioned you"}],
|
||||
"page": 1,
|
||||
"total": 125,
|
||||
"has_more": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty)
|
||||
|
|
@ -53,6 +58,9 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
|
|||
assert result["context"]["user"]["login"] == "timmy"
|
||||
assert result["events"] == [{"type": "push"}]
|
||||
assert result["notifications"] == [{"id": 42, "title": "Mentioned you"}]
|
||||
assert result["notification_pagination"] == {
|
||||
"page": 1, "total": 125, "has_more": True
|
||||
}
|
||||
assert result["sections"] == {
|
||||
"context": "fresh", "events": "fresh", "notifications": "fresh"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -305,6 +305,85 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
|||
]
|
||||
|
||||
|
||||
def test_notification_pager_is_single_flight_and_merges_unique_updates():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let calls = 0;
|
||||
let release;
|
||||
const pages = [];
|
||||
const notifications = [];
|
||||
const statuses = [];
|
||||
const pager = buildMyWork.createNotificationPager({{
|
||||
load: page => {{
|
||||
calls += 1;
|
||||
return new Promise(resolve => {{ release = () => resolve({{
|
||||
items: [{{id:50, title:'duplicate'}}, {{id:51, title:'older'}}],
|
||||
page, total: 75, has_more: false,
|
||||
}}); }});
|
||||
}},
|
||||
onNotifications: items => notifications.push(items),
|
||||
onPagination: page => pages.push(page),
|
||||
onStatus: status => statuses.push(status),
|
||||
}});
|
||||
pager.reset({{page:1, total:75, has_more:true}});
|
||||
const existing = [{{id:50, title:'newer'}}];
|
||||
const first = pager.loadMore(existing);
|
||||
const duplicate = pager.loadMore(existing);
|
||||
release();
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, pages, notifications, statuses, results
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == 1
|
||||
assert output["notifications"] == [[
|
||||
{"id": 50, "title": "newer"},
|
||||
{"id": 51, "title": "older"},
|
||||
]]
|
||||
assert output["pages"][-1] == {"page": 2, "total": 75, "has_more": False}
|
||||
assert output["statuses"] == ["Loading older updates…", "75 of 75 unread updates loaded."]
|
||||
assert output["results"] == [True, False]
|
||||
|
||||
|
||||
def test_notification_pager_keeps_loaded_updates_and_retries_the_same_page_after_failure():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const states = [];
|
||||
const statuses = [];
|
||||
const requested = [];
|
||||
const pager = buildMyWork.createNotificationPager({{
|
||||
load: async page => {{ requested.push(page); throw new Error('offline'); }},
|
||||
onNotifications: items => states.push(items),
|
||||
onPagination: () => {{}},
|
||||
onStatus: status => statuses.push(status),
|
||||
}});
|
||||
pager.reset({{page:2, total:125, has_more:true}});
|
||||
pager.loadMore([{{id:1}}]).then(result =>
|
||||
pager.loadMore([{{id:1}}]).then(retry =>
|
||||
process.stdout.write(JSON.stringify({{requested, states, statuses, result, retry}}))
|
||||
)
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"requested": [3, 3],
|
||||
"states": [],
|
||||
"statuses": [
|
||||
"Loading older updates…", "Could not load older updates. Retry.",
|
||||
"Loading older updates…", "Could not load older updates. Retry.",
|
||||
],
|
||||
"result": False,
|
||||
"retry": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
|
||||
html = await dashboard()
|
||||
|
|
@ -347,13 +426,28 @@ async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement(
|
|||
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 "'next ' + ids.length + ' of ' + allIds.length + ' loaded updates'" in html
|
||||
assert "'Confirm marking ' + bulkLabel + ' read'" in html
|
||||
assert "const ids = allIds.slice(0, 50)" 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_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="notification-page-status"' in html
|
||||
assert 'id="load-more-notifications"' in html
|
||||
assert '.load-more-notifications' in html and 'min-height:44px' in html
|
||||
assert "createNotificationPager" in html
|
||||
assert "api/v1/notifications?page=" in html
|
||||
assert "snapshot.notification_pagination" in html
|
||||
assert "notificationPager.loadMore(lastNotifications)" in html
|
||||
assert "Math.min(lastNotifications.length, 50)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||
html = await dashboard()
|
||||
|
|
|
|||
33
tests/test_notification_pages.py
Normal file
33
tests/test_notification_pages.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_page_endpoint_loads_only_requested_page_and_is_not_cacheable(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def page_loader(page):
|
||||
requested.append(page)
|
||||
return {
|
||||
"items": [{"id": 51, "title": "Older update"}],
|
||||
"page": page,
|
||||
"total": 125,
|
||||
"has_more": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "notification_page", page_loader)
|
||||
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?page=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"items": [{"id": 51, "title": "Older update"}],
|
||||
"page": 2,
|
||||
"total": 125,
|
||||
"has_more": True,
|
||||
}
|
||||
assert requested == [2]
|
||||
Loading…
Reference in New Issue
Block a user