Files
Haven/test/forumBlog.test.js
T

225 lines
11 KiB
JavaScript

/**
* Blog mode for forums (#5742): a per-forum switch. With it on, the topic
* author's own follow-ups are part of the post and everyone else's replies
* are comments; the cards count only the comments. The server decides which
* is which from the account that wrote each reply.
*
* Boots its own Haven server on a scratch port and data dir.
*/
const test = require('node:test');
const assert = require('node:assert');
const http = require('node:http');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const { spawn } = require('node:child_process');
const { io } = require('socket.io-client');
const PORT = 3439;
const BASE = `http://localhost:${PORT}`;
const DATA = path.join(os.tmpdir(), `haven-forumblog-${Date.now()}`);
let server;
const post = (p, body) => new Promise((res, rej) => {
const d = JSON.stringify(body);
const r = http.request({ host: 'localhost', port: PORT, path: p, method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(d) } },
(x) => { let b = ''; x.on('data', (c) => (b += c)); x.on('end', () => { try { res(JSON.parse(b)); } catch { res({ raw: b }); } }); });
r.on('error', rej); r.write(d); r.end();
});
const register = (username) => post('/api/auth/register', { username, password: 'blogtest123', eulaVersion: '2.0', ageVerified: true });
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
function connect(token) {
const s = io(BASE, { auth: { token }, transports: ['websocket'], forceNew: true });
return new Promise((res, rej) => { s.on('connect', () => res(s)); s.on('connect_error', rej); });
}
function next(sock, event, filter = () => true, ms = 4000) {
return new Promise((res) => {
const t = setTimeout(() => { sock.off(event, h); res(null); }, ms);
const h = (data) => { if (!filter(data)) return; clearTimeout(t); sock.off(event, h); res(data); };
sock.on(event, h);
});
}
function history(sock, code) {
const p = next(sock, 'message-history', (d) => d && d.channelCode === code);
sock.emit('get-messages', { code });
return p;
}
function thread(sock, parentId) {
const p = next(sock, 'thread-messages', (d) => d && d.parentId === parentId);
sock.emit('get-thread-messages', { parentId });
return p;
}
const reply = (sock, parentId, content, replyTo) => new Promise((res) => sock.emit('send-thread-message', { parentId, content, replyTo }, res));
test.before(async () => {
fs.mkdirSync(DATA, { recursive: true });
server = spawn(process.execPath, ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, PORT: String(PORT), HAVEN_DATA_DIR: DATA, ADMIN_USERNAME: 'admin', FORCE_HTTP: 'true' },
stdio: 'ignore',
});
for (let i = 0; i < 60; i++) {
try {
await new Promise((res, rej) => http.get(`${BASE}/api/health`, (r) => (r.statusCode === 200 ? res() : rej())).on('error', rej));
return;
} catch { await wait(500); }
}
throw new Error('server did not start');
});
test.after(async () => {
// Wait for the server to let go of its database before removing the dir.
if (server && server.exitCode === null) { const gone = new Promise((r) => server.once('exit', r)); server.kill(); await gone; }
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (err) { console.warn('[forumBlog test] could not remove the scratch data dir:', err.message); }
});
test('blog mode on forum topics', async (t) => {
const admin = await register('admin');
const bob = await register('bob');
assert.ok(admin.token && bob.token, 'accounts registered');
const A = await connect(admin.token);
const B = await connect(bob.token);
let list = next(A, 'channels-list', (chs) => Array.isArray(chs) && chs.some((c) => c.name === 'gallery'));
A.emit('create-channel', { name: 'gallery' });
const code = (await list).find((c) => c.name === 'gallery').code;
list = next(A, 'channels-list', (chs) => Array.isArray(chs) && chs.some((c) => c.code === code && c.is_forum === 1));
A.emit('toggle-channel-permission', { code, permission: 'forum' });
assert.ok(await list, 'forum mode on');
A.emit('enter-channel', { code });
B.emit('join-channel', { code });
await wait(200);
B.emit('enter-channel', { code });
await wait(200);
// Bob starts a topic: starting topics is the same in blog mode.
B.emit('send-message', { code, content: 'my trip', title: 'Trip photos' });
await wait(200);
const topic = (await history(B, code)).messages.find((m) => m.content === 'my trip');
assert.ok(topic, 'topic posted');
// Bob adds to his post, the admin comments, and Bob answers the comment
// (an answer to a comment is a comment, even from the author).
await reply(B, topic.id, 'day two');
await reply(A, topic.id, 'nice shots');
const before = await thread(B, topic.id);
const comment = before.messages.find((m) => m.content === 'nice shots');
await reply(B, topic.id, 'thanks!', comment.id);
await t.test('off by default: no blog fields at all', async () => {
const th = await thread(A, topic.id);
assert.strictEqual(th.blog, false);
assert.ok(th.messages.every((m) => !('post_part' in m)), 'no reply is marked');
const card = (await history(A, code)).messages.find((m) => m.id === topic.id);
assert.strictEqual(card.thread.count, 3);
assert.strictEqual(card.thread.comments, undefined, 'no comment count on the card');
});
await t.test('only someone who can change the channel settings turns it on', async () => {
const refused = next(B, 'error-msg');
B.emit('toggle-channel-permission', { code, permission: 'forum_blog' });
assert.ok(await refused, 'a member is refused');
const seen = next(B, 'channel-permission-updated', (d) => d && d.code === code && d.permission === 'forum_blog');
list = next(A, 'channels-list', (chs) => Array.isArray(chs) && chs.some((c) => c.code === code && c.forum_blog === 1));
A.emit('toggle-channel-permission', { code, permission: 'forum_blog' });
assert.ok(await list, 'the channel list carries the setting');
assert.strictEqual((await seen).enabled, true, 'members in the forum hear about it');
});
await t.test('the author\'s follow-ups are the post, everyone else comments', async () => {
const th = await thread(A, topic.id);
assert.strictEqual(th.blog, true);
const parts = Object.fromEntries(th.messages.map((m) => [m.content, m.post_part]));
assert.deepStrictEqual(parts, { 'day two': true, 'nice shots': false, 'thanks!': false },
'the author\'s answer to a comment is a comment');
const card = (await history(A, code)).messages.find((m) => m.id === topic.id);
assert.strictEqual(card.thread.comments, 2, 'the card counts the comments');
assert.strictEqual(card.thread.count, 3, 'the reply count is unchanged');
});
await t.test('live: new replies carry the server\'s verdict and the comment count', async () => {
let msg = next(A, 'new-thread-message', (d) => d && d.parentId === topic.id);
let upd = next(A, 'thread-updated', (d) => d && d.parentId === topic.id);
await reply(B, topic.id, 'day three');
assert.strictEqual((await msg).message.post_part, true);
assert.strictEqual((await upd).thread.comments, 2);
msg = next(B, 'new-thread-message', (d) => d && d.parentId === topic.id);
upd = next(B, 'thread-updated', (d) => d && d.parentId === topic.id);
await reply(A, topic.id, 'post more!');
assert.strictEqual((await msg).message.post_part, false);
assert.strictEqual((await upd).thread.comments, 3);
});
await t.test('the author comments by answering a comment or writing in a piece\'s comments', async () => {
const th = await thread(B, topic.id);
const other = th.messages.find((m) => m.content === 'post more!');
let msg = next(A, 'new-thread-message', (d) => d && d.parentId === topic.id);
await reply(B, topic.id, 'soon', other.id);
assert.strictEqual((await msg).message.post_part, false, 'an answer to a comment');
const day = th.messages.find((m) => m.content === 'day two');
msg = next(A, 'new-thread-message', (d) => d && d.parentId === topic.id);
await reply(B, topic.id, 'about day two', day.id);
assert.strictEqual((await msg).message.post_part, false, 'a comment on one of their own pictures');
msg = next(A, 'new-thread-message', (d) => d && d.parentId === topic.id);
await new Promise((res) => B.emit('send-thread-message', { parentId: topic.id, content: 'on the first one', replyTo: topic.id, asComment: true }, res));
assert.strictEqual((await msg).message.post_part, false, 'a comment on the opening post');
msg = next(A, 'new-thread-message', (d) => d && d.parentId === topic.id);
await reply(B, topic.id, 'day four', topic.id);
assert.strictEqual((await msg).message.post_part, true, 'pointing at the topic alone still adds to the post');
// Someone else cannot use it to slip a reply into the post either way.
msg = next(B, 'new-thread-message', (d) => d && d.parentId === topic.id);
await new Promise((res) => A.emit('send-thread-message', { parentId: topic.id, content: 'admin', replyTo: topic.id, asComment: false }, res));
assert.strictEqual((await msg).message.post_part, false);
});
await t.test('a client cannot claim a reply is part of the post', async () => {
const msg = next(B, 'new-thread-message', (d) => d && d.parentId === topic.id);
await new Promise((res) => A.emit('send-thread-message', { parentId: topic.id, content: 'sneaky', post_part: true, user_id: topic.user_id }, res));
assert.strictEqual((await msg).message.post_part, false);
});
await t.test('a bot reply is a comment, whatever name it uses', async () => {
const created = next(A, 'webhook-created', (w) => w && w.name === 'bob');
A.emit('create-webhook', { channelCode: code, name: 'bob' });
const hook = await created;
assert.ok(hook && hook.token, 'webhook made');
const upd = next(A, 'thread-updated', (d) => d && d.parentId === topic.id);
const r = await post(`/api/webhooks/${hook.token}`, { content: 'bot says hi', username: 'bob', thread_id: topic.id });
assert.ok(r.success, 'bot posted');
assert.strictEqual((await upd).thread.comments, 9);
const th = await thread(A, topic.id);
assert.strictEqual(th.messages.find((m) => m.content === 'bot says hi').post_part, false);
});
await t.test('moderators still delete comments, and the count follows', async () => {
const th = await thread(A, topic.id);
const target = th.messages.find((m) => m.content === 'nice shots');
const gone = next(B, 'message-deleted', (d) => d && d.messageId === target.id);
A.emit('delete-message', { messageId: target.id });
assert.ok(await gone, 'comment deleted');
const card = (await history(A, code)).messages.find((m) => m.id === topic.id);
assert.strictEqual(card.thread.comments, 8);
const after = await thread(A, topic.id);
assert.strictEqual(after.messages.find((m) => m.content === 'thanks!').post_part, false, 'the author\'s answer to it stays a comment');
});
await t.test('turning it off puts everything back', async () => {
list = next(A, 'channels-list', (chs) => Array.isArray(chs) && chs.some((c) => c.code === code && c.forum_blog === 0));
A.emit('toggle-channel-permission', { code, permission: 'forum_blog' });
assert.ok(await list, 'off again');
const th = await thread(A, topic.id);
assert.strictEqual(th.blog, false);
assert.ok(th.messages.every((m) => !('post_part' in m)));
});
for (const s of [A, B]) s.close();
});