Files
memind/server/portal-notification-routes.test.mjs

469 lines
9.6 KiB
JavaScript

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);
});