import assert from 'node:assert/strict'; import test from 'node:test'; import { attachPortalNotificationRoutes, } from './portal-notification-routes.mjs'; function createResponse() { return { statusCode: 200, headers: {}, body: undefined, ended: false, destroyed: false, writes: [], flushed: false, status(code) { this.statusCode = code; return this; }, setHeader(name, value) { this.headers[name] = value; }, flushHeaders() { this.flushed = true; }, json(body) { this.body = body; return this; }, write(chunk) { this.writes.push(chunk); return true; }, end() { this.ended = true; return this; }, }; } function createSetup(overrides = {}) { const routes = []; const calls = []; const timers = []; const clearedTimers = []; let notifications = [ { id: 'notification-1', title: '提醒', }, ]; let userAuth = { async getMe(token) { calls.push(['get-me', token]); return { id: 'user-1' }; }, }; let scheduleService = { async listUserNotifications(options) { calls.push(['list', options]); return notifications; }, async markUserNotificationRead(options) { calls.push(['mark-read', options]); return true; }, async markAllUserNotificationsRead(options) { calls.push(['mark-all-read', options]); return 3; }, async deleteUserNotification(options) { calls.push(['delete-one', options]); return true; }, async clearUserNotifications(options) { calls.push(['clear-all', options]); return 4; }, }; const app = { get(path, ...handlers) { routes.push({ method: 'get', path, handlers, }); }, post(path, ...handlers) { routes.push({ method: 'post', path, handlers, }); }, delete(path, ...handlers) { routes.push({ method: 'delete', path, handlers, }); }, }; const options = { app, userAuthReady: Promise.resolve(), getUserAuth: () => userAuth, getScheduleService: () => scheduleService, userToken: () => 'request-token', logger: { warn(...args) { calls.push(['warn', ...args]); }, }, setIntervalFn(callback, delay) { const timer = { callback, delay, id: `timer-${timers.length + 1}`, }; timers.push(timer); return timer; }, clearIntervalFn(timer) { clearedTimers.push(timer); }, now: () => 123456789, ...overrides, }; attachPortalNotificationRoutes(options); return { routes, calls, timers, clearedTimers, setNotifications(value) { notifications = value; }, setUserAuth(value) { userAuth = value; }, setScheduleService(value) { scheduleService = value; }, route(method, path) { return routes.find( (route) => route.method === method && route.path === path, ); }, }; } function createRequest(overrides = {}) { const listeners = {}; return { body: {}, query: {}, params: {}, ip: '127.0.0.1', on(event, handler) { listeners[event] = handler; }, emit(event) { listeners[event]?.(); }, ...overrides, }; } async function invoke( setup, method, path, req = createRequest(), ) { const route = setup.route(method, path); assert.ok( route, `${method.toUpperCase()} ${path}`, ); const res = createResponse(); await route.handlers.at(-1)(req, res); return res; } test('registers the notification route inventory in order', () => { const setup = createSetup(); assert.deepEqual( setup.routes.map(({ method, path }) => [ method, path, ]), [ ['get', '/auth/notifications'], ['get', '/auth/notifications/events'], [ 'post', '/auth/notifications/:id/read', ], ['post', '/auth/notifications/read-all'], ['delete', '/auth/notifications/:id'], ['delete', '/auth/notifications'], ], ); }); test('preserves notification list filters, limits, and errors', async () => { const setup = createSetup(); const res = await invoke( setup, 'get', '/auth/notifications', createRequest({ query: { status: 'unexpected', limit: '500', }, }), ); assert.deepEqual(res.body, { notifications: [ { id: 'notification-1', title: '提醒', }, ], }); assert.deepEqual( setup.calls.find( ([name]) => name === 'list', ), [ 'list', { userId: 'user-1', status: 'all', limit: 100, }, ], ); const failing = createSetup(); failing.setScheduleService({ async listUserNotifications() { throw new Error('database down'); }, }); const failingRes = await invoke( failing, 'get', '/auth/notifications', ); assert.equal(failingRes.statusCode, 500); assert.deepEqual(failingRes.body, { message: '通知列表加载失败', }); }); test('preserves notification availability and authentication gates', async () => { const unavailable = createSetup(); unavailable.setScheduleService(null); const unavailableRes = await invoke( unavailable, 'get', '/auth/notifications', ); assert.equal(unavailableRes.statusCode, 503); const unauthorized = createSetup(); unauthorized.setUserAuth({ async getMe() { return null; }, }); const unauthorizedRes = await invoke( unauthorized, 'get', '/auth/notifications', ); assert.equal(unauthorizedRes.statusCode, 401); }); test('preserves SSE ready, notification dedupe, polling, heartbeat, and cleanup', async () => { const setup = createSetup(); const req = createRequest(); const res = await invoke( setup, 'get', '/auth/notifications/events', req, ); assert.equal(res.statusCode, 200); assert.deepEqual(res.headers, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', }); assert.equal(res.flushed, true); assert.match( res.writes.join(''), /event: ready[\s\S]*event: notification/, ); assert.deepEqual( setup.timers.map(({ delay }) => delay), [2500, 25000], ); const beforeDuplicate = res.writes.length; setup.timers[0].callback(); await new Promise((resolve) => setImmediate(resolve), ); assert.equal( res.writes.length, beforeDuplicate, ); setup.setNotifications([ { id: 'notification-2', title: '新提醒', }, ]); setup.timers[0].callback(); await new Promise((resolve) => setImmediate(resolve), ); assert.match( res.writes.join(''), /notification-2/, ); setup.timers[1].callback(); assert.match( res.writes.join(''), /event: ping[\s\S]*123456789/, ); const beforeClose = res.writes.length; req.emit('close'); assert.deepEqual( setup.clearedTimers, setup.timers, ); setup.timers[1].callback(); assert.equal(res.writes.length, beforeClose); }); test('preserves SSE sync fallback when unread checks fail', async () => { const setup = createSetup(); setup.setScheduleService({ async listUserNotifications() { throw new Error('temporary failure'); }, }); const res = await invoke( setup, 'get', '/auth/notifications/events', createRequest(), ); assert.match( res.writes.join(''), /event: sync[\s\S]*check_failed/, ); assert.equal(setup.timers.length, 2); }); test('preserves single and bulk read operations', async () => { const setup = createSetup(); const single = await invoke( setup, 'post', '/auth/notifications/:id/read', createRequest({ params: { id: 'notification-1' }, }), ); assert.deepEqual(single.body, { ok: true }); assert.ok( setup.calls.some( (call) => call[0] === 'mark-read' && call[1].userId === 'user-1' && call[1].notificationId === 'notification-1', ), ); const bulk = await invoke( setup, 'post', '/auth/notifications/read-all', ); assert.deepEqual(bulk.body, { ok: true, updated: 3, }); const missing = createSetup(); missing.setScheduleService({ async markUserNotificationRead() { return false; }, }); const missingRes = await invoke( missing, 'post', '/auth/notifications/:id/read', createRequest({ params: { id: 'missing' }, }), ); assert.equal(missingRes.statusCode, 404); }); test('preserves single deletion and scoped clear operations', async () => { const setup = createSetup(); const single = await invoke( setup, 'delete', '/auth/notifications/:id', createRequest({ params: { id: 'notification-1' }, }), ); assert.equal(single.statusCode, 204); assert.equal(single.ended, true); const cleared = await invoke( setup, 'delete', '/auth/notifications', createRequest({ query: { status: 'read' }, }), ); assert.deepEqual(cleared.body, { ok: true, deleted: 4, }); assert.ok( setup.calls.some( (call) => call[0] === 'clear-all' && call[1].status === 'read', ), ); const missing = createSetup(); missing.setScheduleService({ async deleteUserNotification() { return false; }, }); const missingRes = await invoke( missing, 'delete', '/auth/notifications/:id', createRequest({ params: { id: 'missing' }, }), ); assert.equal(missingRes.statusCode, 404); });