feat: paginate unread updates inbox (#147)
This commit is contained in:
parent
953f59fb19
commit
a86de5cbf4
|
|
@ -66,6 +66,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.my-work-card:hover { border-color:var(--accent); }
|
.my-work-card:hover { border-color:var(--accent); }
|
||||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||||
.mark-update-read { min-height:44px; width:100%; }
|
.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 { 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-bulk button { min-height:44px; width:100%; }
|
||||||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
||||||
|
|
@ -141,6 +143,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-work-list" id="my-work-list"></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="small" id="my-work-action-status" aria-live="assertive"></div>
|
||||||
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
||||||
<button id="bulk-mark-read" type="button"></button>
|
<button id="bulk-mark-read" type="button"></button>
|
||||||
|
|
@ -318,6 +322,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
let lastMyWork = [];
|
let lastMyWork = [];
|
||||||
let lastNotifications = [];
|
let lastNotifications = [];
|
||||||
|
let lastContextSnapshot = null;
|
||||||
|
let notificationPagination = { page: 1, total: 0, has_more: false };
|
||||||
let hasContextSnapshot = false;
|
let hasContextSnapshot = false;
|
||||||
let selectedReview = null;
|
let selectedReview = null;
|
||||||
let reviewTrigger = null;
|
let reviewTrigger = null;
|
||||||
|
|
@ -367,6 +373,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
return payload;
|
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({
|
const notificationAcknowledger = createNotificationAcknowledger({
|
||||||
markRead: markNotificationRead,
|
markRead: markNotificationRead,
|
||||||
onItems: items => {
|
onItems: items => {
|
||||||
|
|
@ -383,10 +398,30 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
},
|
},
|
||||||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
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) {
|
function renderContextSnapshot(data) {
|
||||||
liveMode = true;
|
liveMode = true;
|
||||||
hasContextSnapshot = true;
|
hasContextSnapshot = true;
|
||||||
|
lastContextSnapshot = data;
|
||||||
if (data.error && lastMyWork.length) markMyWorkStale();
|
if (data.error && lastMyWork.length) markMyWorkStale();
|
||||||
else paintMyWork(data);
|
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>' +
|
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 bulkBar = qs('#bulk-mark-read-bar');
|
||||||
const bulkButton = qs('#bulk-mark-read');
|
const bulkButton = qs('#bulk-mark-read');
|
||||||
bulkBar.hidden = selectedWorkFilter !== 'update' || ids.length === 0;
|
bulkBar.hidden = selectedWorkFilter !== 'update' || ids.length === 0;
|
||||||
|
qs('#load-more-notifications').hidden =
|
||||||
|
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
|
||||||
bulkButton.disabled = bulkMarkPending;
|
bulkButton.disabled = bulkMarkPending;
|
||||||
|
const bulkLabel = allIds.length > ids.length ?
|
||||||
|
'next ' + ids.length + ' of ' + allIds.length + ' loaded updates' :
|
||||||
|
'all ' + ids.length + ' updates';
|
||||||
bulkButton.textContent = bulkConfirmationPending ?
|
bulkButton.textContent = bulkConfirmationPending ?
|
||||||
'Confirm marking ' + ids.length + ' updates read' :
|
'Confirm marking ' + bulkLabel + ' read' :
|
||||||
'Mark all ' + ids.length + ' updates read';
|
'Mark ' + bulkLabel + ' read';
|
||||||
}
|
}
|
||||||
|
|
||||||
function reviewFileElement(filename) {
|
function reviewFileElement(filename) {
|
||||||
|
|
@ -645,7 +686,24 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
|
|
||||||
function renderLiveSnapshot(snapshot) {
|
function renderLiveSnapshot(snapshot) {
|
||||||
const notificationsFresh = Array.isArray(snapshot.notifications);
|
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) {
|
if (snapshot.context) {
|
||||||
snapshot.context.notifications = lastNotifications;
|
snapshot.context.notifications = lastNotifications;
|
||||||
renderContextSnapshot(snapshot.context);
|
renderContextSnapshot(snapshot.context);
|
||||||
|
|
@ -793,8 +851,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
function load() { return contextPoller.refresh(); }
|
function load() { return contextPoller.refresh(); }
|
||||||
|
|
||||||
qs('#refresh').addEventListener('click', load);
|
qs('#refresh').addEventListener('click', load);
|
||||||
|
qs('#load-more-notifications').addEventListener('click', () =>
|
||||||
|
notificationPager.loadMore(lastNotifications)
|
||||||
|
);
|
||||||
qs('#bulk-mark-read').addEventListener('click', async () => {
|
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 (!ids.length || bulkMarkPending) return;
|
||||||
if (!bulkConfirmationPending) {
|
if (!bulkConfirmationPending) {
|
||||||
bulkConfirmationPending = true;
|
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) {
|
function filterMyWork(items, selectedFilter) {
|
||||||
if (selectedFilter === 'all') return items;
|
if (selectedFilter === 'all') return items;
|
||||||
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
|
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
|
||||||
|
|
@ -164,5 +204,6 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||||
buildMyWork.notificationIds = notificationIds;
|
buildMyWork.notificationIds = notificationIds;
|
||||||
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
||||||
|
buildMyWork.createNotificationPager = createNotificationPager;
|
||||||
module.exports = buildMyWork;
|
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 ""
|
return value if parsed.scheme in {"http", "https"} and parsed.netloc else ""
|
||||||
|
|
||||||
|
|
||||||
async def notifications() -> list[dict]:
|
def _normalize_notifications(threads: Any) -> list[dict]:
|
||||||
threads = await fetch("notifications?status-types=unread&limit=50")
|
|
||||||
if not isinstance(threads, list):
|
if not isinstance(threads, list):
|
||||||
raise ValueError("Gitea notification response was not a list")
|
raise ValueError("Gitea notification response was not a list")
|
||||||
normalized = []
|
normalized = []
|
||||||
|
|
@ -181,6 +180,29 @@ async def notifications() -> list[dict]:
|
||||||
return normalized
|
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:
|
async def mark_notification_read(thread_id: int) -> None:
|
||||||
response = await _get_client().patch(
|
response = await _get_client().patch(
|
||||||
f"/api/v1/notifications/threads/{thread_id}?to-status=read",
|
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 contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
@ -53,6 +53,7 @@ EVENT_STREAM_TIMEOUT_SECONDS = 5.0
|
||||||
READINESS_TIMEOUT_SECONDS = 5.0
|
READINESS_TIMEOUT_SECONDS = 5.0
|
||||||
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
|
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
|
||||||
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
|
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
|
||||||
|
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
|
||||||
BULK_NOTIFICATION_CONCURRENCY = 5
|
BULK_NOTIFICATION_CONCURRENCY = 5
|
||||||
BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
|
BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
|
||||||
LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
|
LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
|
||||||
|
|
@ -135,7 +136,7 @@ app.include_router(frontend_router)
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def prevent_live_api_caching(request, call_next):
|
async def prevent_live_api_caching(request, call_next):
|
||||||
response = await call_next(request)
|
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/")
|
request.url.path.startswith("/api/v1/repos/")
|
||||||
and request.url.path.endswith("/review")
|
and request.url.path.endswith("/review")
|
||||||
) or (
|
) or (
|
||||||
|
|
@ -263,10 +264,21 @@ async def _build_live_snapshot() -> dict:
|
||||||
context_ok = not isinstance(context_result, BaseException)
|
context_ok = not isinstance(context_result, BaseException)
|
||||||
events_ok = not isinstance(events_result, BaseException)
|
events_ok = not isinstance(events_result, BaseException)
|
||||||
notifications_ok = not isinstance(notifications_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 {
|
return {
|
||||||
"context": context_result if context_ok else None,
|
"context": context_result if context_ok else None,
|
||||||
"events": events_result if events_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": {
|
"sections": {
|
||||||
"context": "fresh" if context_ok else "temporarily unavailable",
|
"context": "fresh" if context_ok else "temporarily unavailable",
|
||||||
"events": "fresh" if events_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")
|
@app.patch("/api/v1/notifications/{thread_id}/read")
|
||||||
async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||||
try:
|
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/live",
|
||||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
|
"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",
|
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,49 @@
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src import gitea_proxy
|
from src import gitea_proxy
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handoff(monkeypatch):
|
async def test_notification_page_preserves_upstream_total_without_loading_other_pages():
|
||||||
requested_paths = []
|
requests = []
|
||||||
|
|
||||||
async def fake_fetch(path):
|
def upstream(request):
|
||||||
requested_paths.append(path)
|
requests.append(str(request.url))
|
||||||
return [
|
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,
|
"id": 42,
|
||||||
"unread": True,
|
"unread": True,
|
||||||
|
|
@ -31,12 +65,7 @@ async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handof
|
||||||
},
|
},
|
||||||
"malformed",
|
"malformed",
|
||||||
]
|
]
|
||||||
|
)
|
||||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
|
||||||
|
|
||||||
result = await gitea_proxy.notifications()
|
|
||||||
|
|
||||||
assert requested_paths == ["notifications?status-types=unread&limit=50"]
|
|
||||||
assert result == [
|
assert result == [
|
||||||
{
|
{
|
||||||
"id": 42,
|
"id": 42,
|
||||||
|
|
@ -78,11 +107,6 @@ async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handof
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_notification_collection_rejects_non_list_payload(monkeypatch):
|
async def test_notification_collection_rejects_non_list_payload():
|
||||||
async def fake_fetch(_path):
|
|
||||||
return {"message": "unexpected"}
|
|
||||||
|
|
||||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="notification response was not a list"):
|
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"}]
|
return [{"type": "push"}]
|
||||||
|
|
||||||
async def updates():
|
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, "current_user", user)
|
||||||
monkeypatch.setattr(main, "repos", empty)
|
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["context"]["user"]["login"] == "timmy"
|
||||||
assert result["events"] == [{"type": "push"}]
|
assert result["events"] == [{"type": "push"}]
|
||||||
assert result["notifications"] == [{"id": 42, "title": "Mentioned you"}]
|
assert result["notifications"] == [{"id": 42, "title": "Mentioned you"}]
|
||||||
|
assert result["notification_pagination"] == {
|
||||||
|
"page": 1, "total": 125, "has_more": True
|
||||||
|
}
|
||||||
assert result["sections"] == {
|
assert result["sections"] == {
|
||||||
"context": "fresh", "events": "fresh", "notifications": "fresh"
|
"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
|
@pytest.mark.anyio
|
||||||
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
|
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
|
||||||
html = await dashboard()
|
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 '.my-work-bulk { position:sticky;' in html
|
||||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' 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 '.my-work-bulk button { min-height:44px; width:100%; }' in html
|
||||||
assert "'Mark all ' + ids.length + ' updates read'" in html
|
assert "'next ' + ids.length + ' of ' + allIds.length + ' loaded updates'" in html
|
||||||
assert "'Confirm marking ' + ids.length + ' updates read'" in html
|
assert "'Confirm marking ' + bulkLabel + ' read'" in html
|
||||||
|
assert "const ids = allIds.slice(0, 50)" in html
|
||||||
assert "createBulkNotificationAcknowledger" in html
|
assert "createBulkNotificationAcknowledger" in html
|
||||||
assert "api/v1/notifications/read" in html
|
assert "api/v1/notifications/read" in html
|
||||||
assert "body: JSON.stringify({ ids })" 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
|
@pytest.mark.anyio
|
||||||
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||||
html = await dashboard()
|
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