469 lines
9.6 KiB
JavaScript
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);
|
|
});
|