diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/admin.py | 16 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/groups.py | 28 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/hub.py | 10 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/notifications.py | 93 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/app.py | 2 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/db/models.py | 20 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 115 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 9 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 64 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_notifications.py | 181 |
10 files changed, 520 insertions, 18 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py index c3fa8c2..88e231a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -134,11 +134,17 @@ async def admin_patch_user( if user.id == current_user.id: raise HTTPException(status_code=400, detail="Cannot modify your own account") + from meshbay_hub.api.notifications import create_notification + if body.role is not None: if body.role not in ("user", "moderator", "admin"): raise HTTPException(status_code=422, detail="role must be user, moderator, or admin") user.role = body.role log.info("User %s role changed to %s by %s", user.username, body.role, current_user.username) + await create_notification( + db, user.id, "role_change", + f"Your role has been changed to {body.role}", + ) if body.status is not None: if body.status not in ("active", "suspended", "revoked"): @@ -146,6 +152,16 @@ async def admin_patch_user( user.status = body.status log.info("User %s status changed to %s by %s", user.username, body.status, current_user.username) + if body.status == "suspended": + await create_notification( + db, user.id, "account_suspended", + "Your account has been suspended", + ) + elif body.status == "active": + await create_notification( + db, user.id, "account_restored", + "Your account has been restored", + ) db.add(IPLog( user_id=current_user.id, diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 6dd4275..a8a45c9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -69,16 +69,17 @@ async def group_online_nodes( @router.get("") async def list_public_groups( db: AsyncSession = Depends(get_db), + q: str = "", limit: int = 50, offset: int = 0, include_federated: bool = True, ): - """List public groups — local and optionally federated. No auth required.""" + """List/search public groups — local and optionally federated. No auth required.""" + query = select(Group).where(Group.visibility == "public", Group.status == "active") + if q: + query = query.where(Group.name.ilike(f"%{q}%")) result = await db.execute( - select(Group) - .where(Group.visibility == "public", Group.status == "active") - .order_by(Group.created_at.desc()) - .limit(limit).offset(offset) + query.order_by(Group.created_at.desc()).limit(limit).offset(offset) ) local = result.scalars().all() groups = [ @@ -90,10 +91,11 @@ async def list_public_groups( ] if include_federated: + fed_query = select(FederatedGroup) + if q: + fed_query = fed_query.where(FederatedGroup.name.ilike(f"%{q}%")) fed_result = await db.execute( - select(FederatedGroup) - .order_by(FederatedGroup.updated_at.desc()) - .limit(limit) + fed_query.order_by(FederatedGroup.updated_at.desc()).limit(limit) ) for fg in fed_result.scalars().all(): groups.append({ @@ -217,6 +219,7 @@ async def store_gek_bundle( # Upsert GEK bundle existing = await db.get(GEKBundle, (group_id, target.id)) + new_member = False if existing: existing.pk_eph_b64 = body.pk_eph_b64 existing.nonce_b64 = body.nonce_b64 @@ -233,6 +236,15 @@ async def store_gek_bundle( mem = await db.get(GroupMember, (group_id, target.id)) if not mem: db.add(GroupMember(group_id=group_id, user_id=target.id)) + new_member = True + + if new_member: + from meshbay_hub.api.notifications import create_notification + await create_notification( + db, target.id, "group_invite", + f"You were added to {group.name}", + link=f"#/group/{group_id}", + ) await db.commit() return {"status": "stored", "group_id": group_id, "username": username} diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py index 00aba28..6e2db9e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py @@ -24,3 +24,13 @@ async def hub_info(): async def hub_pubkey(): """Hub Ed25519 public key PEM — cached by nodes on first contact.""" return {"pk_hub_pem": hub_public_key_pem().decode()} + + +@router.get("/version") +async def hub_version(): + """Version check endpoint for clients to detect updates.""" + return { + "hub": __version__, + "mnp": MNP_VERSION, + "mhp": MHP_VERSION, + } diff --git a/packages/meshbay-hub/src/meshbay_hub/api/notifications.py b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py new file mode 100644 index 0000000..e7d36d2 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py @@ -0,0 +1,93 @@ +"""Notification endpoints — /v1/notifications/*""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select, func, update +from sqlalchemy.ext.asyncio import AsyncSession + +from meshbay_hub.api.deps import get_current_user +from meshbay_hub.db.engine import get_db +from meshbay_hub.db.models import Notification, User + +router = APIRouter(prefix="/v1/notifications", tags=["notifications"]) + + +@router.get("") +async def list_notifications( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + limit: int = 50, + offset: int = 0, + unread_only: bool = False, +): + query = select(Notification).where(Notification.user_id == current_user.id) + if unread_only: + query = query.where(Notification.read == False) # noqa: E712 + result = await db.execute( + query.order_by(Notification.created_at.desc()).limit(limit).offset(offset) + ) + notifs = result.scalars().all() + + count_q = select(func.count()).select_from(Notification).where( + Notification.user_id == current_user.id, Notification.read == False # noqa: E712 + ) + unread = (await db.execute(count_q)).scalar() or 0 + + return { + "notifications": [ + { + "id": n.id, + "kind": n.kind, + "title": n.title, + "detail": n.detail, + "link": n.link, + "read": n.read, + "created_at": n.created_at.isoformat(), + } + for n in notifs + ], + "unread_count": unread, + } + + +@router.post("/{notification_id}/read") +async def mark_read( + notification_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + notif = await db.get(Notification, notification_id) + if not notif or notif.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Notification not found") + notif.read = True + await db.commit() + return {"status": "ok"} + + +@router.post("/read-all") +async def mark_all_read( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await db.execute( + update(Notification) + .where(Notification.user_id == current_user.id, Notification.read == False) # noqa: E712 + .values(read=True) + ) + await db.commit() + return {"status": "ok"} + + +async def create_notification( + db: AsyncSession, + user_id: str, + kind: str, + title: str, + detail: str | None = None, + link: str | None = None, +) -> Notification: + notif = Notification( + user_id=user_id, kind=kind, title=title, detail=detail, link=link, + ) + db.add(notif) + await db.flush() + return notif diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index d7f95cf..7011bf0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -34,6 +34,7 @@ from meshbay_hub.api.health import router as health_router from meshbay_hub.api.relay import router as relay_router from meshbay_hub.api.signaling import router as signaling_router from meshbay_hub.api.admin import router as admin_router +from meshbay_hub.api.notifications import router as notifications_router from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR from meshbay_hub.api.middleware import limiter @@ -117,6 +118,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: app.include_router(relay_router) app.include_router(signaling_router) app.include_router(admin_router) + app.include_router(notifications_router) app.include_router(webapp_router) from starlette.staticfiles import StaticFiles diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index d06bcda..c420661 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -195,6 +195,26 @@ class SwarmSource(Base): __table_args__ = (Index("ix_swarm_hash", "content_hash"),) +class Notification(Base): + __tablename__ = "notifications" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) + kind: Mapped[str] = mapped_column(String(32), nullable=False) + title: Mapped[str] = mapped_column(String(256), nullable=False) + detail: Mapped[str | None] = mapped_column(String(512)) + link: Mapped[str | None] = mapped_column(String(256)) + read: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + user: Mapped["User"] = relationship() + + __table_args__ = ( + Index("ix_notifications_user", "user_id"), + Index("ix_notifications_created", "created_at"), + ) + + class ContentReport(Base): """ Report of a public content hash for moderation. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index d36a0a1..4ef334b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -85,7 +85,7 @@ function useAuth() { return useContext(AuthContext); } // ── Nav ────────────────────────────────────────────────────────────────────── -function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle }) { +function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle, unreadCount }) { return html` <nav class="nav"> <div class="nav-left"> @@ -96,6 +96,11 @@ function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle }) { <a class="nav-brand" href="#/">MeshBay</a> </div> <div class="nav-right"> + ${user && html` + <a class="nav-notif" href="#/" title=${t('notif.title')}> + ${'\u{1F514}'}${unreadCount > 0 && html`<span class="notif-badge">${unreadCount}</span>`} + </a> + `} <button class="nav-theme" onClick=${onThemeToggle} aria-label="${t('nav.toggle_menu')}" title=${theme === 'dark' ? t('nav.light_mode') : t('nav.dark_mode')}> ${theme === 'dark' ? '☀' : '☾'} @@ -273,11 +278,32 @@ function RegisterPage() { // ── Home Page ──────────────────────────────────────────────────────────────── -function HomePage({ groups }) { +function NotificationFeed({ notifications, onMarkRead }) { + if (!notifications.length) return null; + return html` + <div class="notif-feed"> + <h3>${t('notif.title')}</h3> + ${notifications.map(n => html` + <div key=${n.id} class="notif-item ${n.read ? '' : 'notif-unread'}" + onClick=${() => { + if (!n.read) onMarkRead(n.id); + if (n.link) navigate(n.link); + }}> + <span class="notif-kind">${n.kind}</span> + <span class="notif-text">${n.title}</span> + <span class="notif-time">${new Date(n.created_at).toLocaleDateString()}</span> + </div> + `)} + </div> + `; +} + +function HomePage({ groups, notifications, onMarkRead }) { if (groups.length === 0) { return html` <div> <h2>${t('home.welcome')}</h2> + <${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} /> <p class="page-message"> ${t('home.no_groups')} ${' '}${t('home.browse_prefix')}<a href="#/explore">${t('home.browse_link')}</a>${t('home.browse_suffix')} @@ -289,6 +315,7 @@ function HomePage({ groups }) { return html` <div> <h2>${t('home.my_groups')}</h2> + <${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} /> <div class="group-grid"> ${groups.map(g => html` <a key=${g.id} class="group-card" href="#/group/${g.id}"> @@ -309,17 +336,32 @@ function HomePage({ groups }) { function ExplorePage({ token }) { const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); - useEffect(() => { - hubFetch('/v1/groups', { token }) + const doSearch = useCallback((q) => { + setLoading(true); + const url = q ? `/v1/groups?q=${encodeURIComponent(q)}` : '/v1/groups'; + hubFetch(url, { token }) .then(data => setGroups(data.groups || [])) .catch(() => {}) .finally(() => setLoading(false)); }, [token]); + useEffect(() => { doSearch(''); }, [token]); + + const onSearch = useCallback((e) => { + const q = e.target.value; + setSearch(q); + doSearch(q); + }, [doSearch]); + return html` <div> <h2>${t('explore.title')}</h2> + <div class="file-toolbar" style="margin-bottom:16px"> + <input type="text" class="admin-search" placeholder="${t('explore.search')}" + value=${search} onInput=${onSearch} /> + </div> ${loading ? html`<p class="page-message">${t('explore.loading')}</p>` : groups.length === 0 @@ -938,8 +980,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { const THEME_OPTIONS = ['light', 'dark', 'system']; -function SettingsPage({ user, theme, onThemeChange }) { +function SettingsPage({ user, theme, onThemeChange, groups }) { const [locale, setLoc] = useState(getLocale); + const [muted, setMuted] = useState(() => { + try { return JSON.parse(localStorage.getItem('mb_muted') || '{}'); } + catch { return {}; } + }); const onLocaleChange = useCallback((e) => { const code = e.target.value; @@ -952,6 +998,14 @@ function SettingsPage({ user, theme, onThemeChange }) { onThemeChange(e.target.value); }, [onThemeChange]); + const toggleMute = useCallback((gid) => { + setMuted(prev => { + const next = { ...prev, [gid]: !prev[gid] }; + localStorage.setItem('mb_muted', JSON.stringify(next)); + return next; + }); + }, []); + return html` <div> <h2>${t('settings.title')}</h2> @@ -962,6 +1016,10 @@ function SettingsPage({ user, theme, onThemeChange }) { <span class="settings-label">${t('settings.username')}</span> <span class="settings-value">${user.username}</span> </div> + <div class="settings-row"> + <span class="settings-label">${t('settings.role')}</span> + <span class="settings-value">${user.role || 'user'}</span> + </div> </div> <div class="settings-section"> @@ -984,6 +1042,22 @@ function SettingsPage({ user, theme, onThemeChange }) { </div> </div> + ${groups.length > 0 && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.groups')}</h3> + ${groups.map(g => html` + <div class="settings-row" key=${g.id}> + <span class="settings-label">${g.name}</span> + <label class="settings-value" style="cursor:pointer"> + <input type="checkbox" checked=${!muted[g.id]} + onChange=${() => toggleMute(g.id)} /> + ${' '}${t('settings.notifications')} + </label> + </div> + `)} + </div> + `} + <div class="settings-section"> <h3 class="settings-heading">${t('settings.about')}</h3> <div class="settings-row"> @@ -1339,6 +1413,8 @@ function App() { const [user, setUser] = useState(loadAuth); const [groups, setGroups] = useState([]); const [menuOpen, setMenuOpen] = useState(false); + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); const resolved = resolveTheme(theme); @@ -1347,13 +1423,31 @@ function App() { localStorage.setItem(THEME_KEY, theme); }, [theme, resolved]); + const fetchNotifications = useCallback(() => { + if (!user) return; + hubFetch('/v1/notifications?limit=20', { token: user.token }) + .then(data => { + setNotifications(data.notifications || []); + setUnreadCount(data.unread_count || 0); + }) + .catch(() => {}); + }, [user]); + useEffect(() => { - if (!user) { setGroups([]); return; } + if (!user) { setGroups([]); setNotifications([]); setUnreadCount(0); return; } hubFetch('/v1/groups/mine', { token: user.token }) .then(data => setGroups(data.groups || [])) .catch(() => setGroups([])); + fetchNotifications(); }, [user]); + const markRead = useCallback((id) => { + if (!user) return; + hubFetch(`/v1/notifications/${id}/read`, { method: 'POST', token: user.token }) + .then(() => fetchNotifications()) + .catch(() => {}); + }, [user, fetchNotifications]); + useEffect(() => { setMenuOpen(false); }, [route]); const toggleTheme = useCallback(() => { @@ -1408,12 +1502,12 @@ function App() { } else if (route === '/admin') { page = (user.role === 'moderator' || user.role === 'admin') ? html`<${AdminPage} token=${user.token} />` - : html`<${HomePage} groups=${groups} />`; + : html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} />`; } else if (route === '/settings') { page = html`<${SettingsPage} user=${user} theme=${theme} - onThemeChange=${setTheme} />`; + onThemeChange=${setTheme} groups=${groups} />`; } else { - page = html`<${HomePage} groups=${groups} />`; + page = html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} />`; } return html` @@ -1423,7 +1517,8 @@ function App() { theme=${resolved} onThemeToggle=${toggleTheme} onLogout=${authCtx.logout} - onMenuToggle=${() => setMenuOpen(o => !o)} /> + onMenuToggle=${() => setMenuOpen(o => !o)} + unreadCount=${unreadCount} /> <div class="layout"> ${user && html`<${Sidebar} groups=${groups} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index cb5133e..11543fd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -60,6 +60,7 @@ const en = { // Explore 'explore.title': 'Public Groups', + 'explore.search': 'Search groups...', 'explore.loading': 'Loading...', 'explore.empty': 'No public groups available.', @@ -114,6 +115,9 @@ const en = { 'settings.about': 'About', 'settings.version': 'Version', 'settings.protocol': 'Protocol', + 'settings.role': 'Role', + 'settings.groups': 'Group notifications', + 'settings.notifications': 'Notifications', // Sidebar 'sidebar.settings': 'Settings', @@ -177,6 +181,11 @@ const en = { 'admin.reason_placeholder': 'Reason', 'admin.btn_block': 'Block', 'admin.btn_unblock': 'Unblock', + + // Notifications + 'notif.title': 'Notifications', + 'notif.empty': 'No notifications', + 'notif.mark_all_read': 'Mark all read', }; // ── Locale registry ───────────────────────────────────────────────────────── diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index b1b1a7e..3aea648 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -1003,3 +1003,67 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .th-type, .td-type { display: none; } .th-date, .td-date { display: none; } } + +/* ── Notification bell ───────────────────────────────────────────────────── */ + +.nav-notif { + position: relative; + color: var(--text); + text-decoration: none; + font-size: 1.1rem; + margin-right: 4px; +} +.notif-badge { + position: absolute; + top: -6px; + right: -8px; + background: var(--danger); + color: #fff; + font-size: 0.65rem; + padding: 1px 5px; + border-radius: 10px; + font-weight: 700; +} + +/* ── Notification feed ───────────────────────────────────────────────────── */ + +.notif-feed { + margin-bottom: 24px; +} +.notif-feed h3 { + margin: 0 0 8px; + font-size: 1rem; +} +.notif-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: var(--radius); + background: var(--bg-surface); + margin-bottom: 4px; + cursor: pointer; + border: 1px solid var(--border); +} +.notif-item:hover { + background: var(--bg-raised); +} +.notif-unread { + border-left: 3px solid var(--primary); + font-weight: 600; +} +.notif-kind { + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + white-space: nowrap; +} +.notif-text { + flex: 1; + font-size: 0.9rem; +} +.notif-time { + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; +} diff --git a/packages/meshbay-hub/tests/test_notifications.py b/packages/meshbay-hub/tests/test_notifications.py new file mode 100644 index 0000000..8da589a --- /dev/null +++ b/packages/meshbay-hub/tests/test_notifications.py @@ -0,0 +1,181 @@ +"""Integration tests for the notification system.""" + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + +from meshbay_common.crypto import pk_to_b64 +from meshbay_hub.api.deps import set_admin_usernames + + +def _gen_user_keys(): + sk_ed = Ed25519PrivateKey.generate() + sk_x = X25519PrivateKey.generate() + return pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key()) + + +async def _register(client, username, email="test@x.com", password="testpass99"): + pk_ed, pk_x = _gen_user_keys() + r = await client.post("/v1/users/register", json={ + "username": username, "email": email, "password": password, + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x, + }) + assert r.status_code == 201 + return r.json()["user_id"] + + +async def _login(client, username, password="testpass99"): + r = await client.post("/v1/users/login", json={ + "username": username, "password": password, + }) + assert r.status_code == 200 + return r.json()["access_token"] + + +async def _setup_admin(client, admin_name="admin"): + user_id = await _register(client, admin_name, email=f"{admin_name}@x.com") + set_admin_usernames([admin_name]) + token = await _login(client, admin_name) + return user_id, token + + +@pytest.mark.asyncio +async def test_notifications_empty(client): + await _register(client, "alice", email="a@x.com") + token = await _login(client, "alice") + r = await client.get("/v1/notifications", headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200 + data = r.json() + assert data["notifications"] == [] + assert data["unread_count"] == 0 + + +@pytest.mark.asyncio +async def test_notification_on_role_change(client): + _, admin_token = await _setup_admin(client) + uid = await _register(client, "alice", email="a@x.com") + alice_token = await _login(client, "alice") + + await client.patch(f"/v1/admin/users/{uid}", + json={"role": "moderator"}, + headers={"Authorization": f"Bearer {admin_token}"}) + + r = await client.get("/v1/notifications", + headers={"Authorization": f"Bearer {alice_token}"}) + assert r.status_code == 200 + data = r.json() + assert data["unread_count"] == 1 + assert data["notifications"][0]["kind"] == "role_change" + assert "moderator" in data["notifications"][0]["title"] + + +@pytest.mark.asyncio +async def test_notification_on_suspend(client): + _, admin_token = await _setup_admin(client) + uid = await _register(client, "bob", email="b@x.com") + bob_token = await _login(client, "bob") + + await client.patch(f"/v1/admin/users/{uid}", + json={"status": "suspended"}, + headers={"Authorization": f"Bearer {admin_token}"}) + + await client.patch(f"/v1/admin/users/{uid}", + json={"status": "active"}, + headers={"Authorization": f"Bearer {admin_token}"}) + + r = await client.get("/v1/notifications", + headers={"Authorization": f"Bearer {bob_token}"}) + assert r.status_code == 200 + kinds = [n["kind"] for n in r.json()["notifications"]] + assert "account_suspended" in kinds + assert "account_restored" in kinds + + +@pytest.mark.asyncio +async def test_mark_notification_read(client): + _, admin_token = await _setup_admin(client) + uid = await _register(client, "alice", email="a@x.com") + alice_token = await _login(client, "alice") + + await client.patch(f"/v1/admin/users/{uid}", + json={"role": "moderator"}, + headers={"Authorization": f"Bearer {admin_token}"}) + + r = await client.get("/v1/notifications", + headers={"Authorization": f"Bearer {alice_token}"}) + nid = r.json()["notifications"][0]["id"] + + r = await client.post(f"/v1/notifications/{nid}/read", + headers={"Authorization": f"Bearer {alice_token}"}) + assert r.status_code == 200 + + r = await client.get("/v1/notifications", + headers={"Authorization": f"Bearer {alice_token}"}) + assert r.json()["unread_count"] == 0 + assert r.json()["notifications"][0]["read"] is True + + +@pytest.mark.asyncio +async def test_mark_all_read(client): + _, admin_token = await _setup_admin(client) + uid = await _register(client, "alice", email="a@x.com") + alice_token = await _login(client, "alice") + + await client.patch(f"/v1/admin/users/{uid}", + json={"role": "moderator"}, + headers={"Authorization": f"Bearer {admin_token}"}) + await client.patch(f"/v1/admin/users/{uid}", + json={"role": "admin"}, + headers={"Authorization": f"Bearer {admin_token}"}) + + r = await client.get("/v1/notifications", + headers={"Authorization": f"Bearer {alice_token}"}) + assert r.json()["unread_count"] == 2 + + r = await client.post("/v1/notifications/read-all", + headers={"Authorization": f"Bearer {alice_token}"}) + assert r.status_code == 200 + + r = await client.get("/v1/notifications", + headers={"Authorization": f"Bearer {alice_token}"}) + assert r.json()["unread_count"] == 0 + + +@pytest.mark.asyncio +async def test_notification_unread_filter(client): + _, admin_token = await _setup_admin(client) + uid = await _register(client, "alice", email="a@x.com") + alice_token = await _login(client, "alice") + + await client.patch(f"/v1/admin/users/{uid}", + json={"role": "moderator"}, + headers={"Authorization": f"Bearer {admin_token}"}) + await client.patch(f"/v1/admin/users/{uid}", + json={"role": "admin"}, + headers={"Authorization": f"Bearer {admin_token}"}) + + r = await client.get("/v1/notifications", + headers={"Authorization": f"Bearer {alice_token}"}) + nid = r.json()["notifications"][0]["id"] + await client.post(f"/v1/notifications/{nid}/read", + headers={"Authorization": f"Bearer {alice_token}"}) + + r = await client.get("/v1/notifications?unread_only=true", + headers={"Authorization": f"Bearer {alice_token}"}) + assert len(r.json()["notifications"]) == 1 + + +@pytest.mark.asyncio +async def test_notification_requires_auth(client): + r = await client.get("/v1/notifications") + assert r.status_code in (401, 403, 422) + + +@pytest.mark.asyncio +async def test_hub_version_endpoint(client): + r = await client.get("/v1/hub/version") + assert r.status_code == 200 + data = r.json() + assert "hub" in data + assert "mnp" in data + assert "mhp" in data |