mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 00:54:56 +00:00
225 lines
11 KiB
JavaScript
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();
|
|
});
|