Files
hub/tools/chat-test.js
T
nmemmertandClaude Sonnet 5.5 41aa4e676d
build-container / build (push) Successful in 8m12s
Family Chat phases 2 and 3, family extras; genre search on the roadmap
- Ticks (sent, delivered, read), typing, presence, disappearing messages, @mentions, pins, forwarding
- Voice messages with a waveform, link previews, polls, emoji picker
- Message to task, shopping item or calendar event; Share to Chat from recipes and songs; quiet hours; family announcement on Home
- Roadmap: chat items ticked off, and Browse and search by genre added

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 11:22:51 -04:00

95 lines
18 KiB
JavaScript

// Family Chat: conversations, messages, attachments, replies, reactions, edit and delete, unread counts, search, mute and archive, live events, and who may see what.
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-chat-'));
const users = require('../users'), chat = require('../chat');
const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['chat'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['chat'], handle: 'Yw' }), d: users.create({ name: 'Di', role: 'user', tabs: ['music'], handle: 'ZA' }) };
const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => chat.handle(req, res))).listen(0, async () => {
const B = 'http://127.0.0.1:' + hub.address().port + '/api/chat', call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json();
try {
// who is in the family: only people with the chat tab
assert.deepEqual((await j(await call('/people', {}, 'a'))).map(x => x.name).sort(), ['Bob', 'Cy'], 'people without Chat are not offered');
let r = await post('/new', { type: 'direct', with: P.d.id }, 'a'); assert.equal(r.status, 400, 'cannot chat with someone who has no Chat');
let dm = (await j(await post('/new', { type: 'direct', with: P.b.id }, 'a'))).conv; assert.equal(dm.name, 'Bob'); assert.equal((await j(await post('/new', { type: 'direct', with: P.a.id }, 'b'))).conv.id, dm.id, 'the same chat from the other side, with the other name'); assert.equal((await j(await post('/new', { type: 'direct', with: P.a.id }, 'b'))).conv.name, 'Ann');
// live events
const events = []; const ctl = new AbortController(); const es = await fetch(B + '/events', { headers: { 'x-user': 'b' }, signal: ctl.signal }); const reader = es.body.getReader(); (async () => { const dec = new TextDecoder(); let buf = ''; try { for (;;) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value); let i; while ((i = buf.indexOf('\n\n')) >= 0) { const chunk = buf.slice(0, i); buf = buf.slice(i + 2); const d = /^data: (.*)$/m.exec(chunk); if (d) events.push(JSON.parse(d[1])); } } } catch { /* closed */ } })();
await new Promise(ok => setTimeout(ok, 100));
// sending
r = await post('/send', { conv: dm.id, text: ' Hello Bob ' }, 'a'); const m1 = (await j(r)).msg; assert.equal(m1.text, 'Hello Bob'); assert.equal(m1.out, true);
await new Promise(ok => setTimeout(ok, 150)); const ev = events.find(e => e.t === 'msg'); assert.ok(ev && ev.out === false && ev.name === 'Ann' && ev.preview === 'Hello Bob' && ev.unread === 1, 'Bob is told at once, with the unread count');
assert.equal((await post('/send', { conv: dm.id, text: '' }, 'a')).status, 400); assert.equal((await post('/send', { conv: dm.id, text: 'x' }, 'c')).status, 404, 'a stranger cannot write in it');
let list = await j(await call('/convs', {}, 'b')); assert.equal(list.unread, 1); assert.equal(list.convs[0].unread, 1); assert.equal(list.convs[0].preview, 'Ann: '.slice(0, 0) + 'Hello Bob', 'a direct chat preview has no name'); assert.equal((await j(await call('/convs', {}, 'c'))).convs.length, 0, 'Cy sees nothing');
assert.equal((await j(await call('/conv/' + encodeURIComponent(dm.id), {}, 'b'))).msgs.length, 1, 'a chat id works with its colons written as %3A'); await post('/read', { id: dm.id }, 'b'); assert.equal((await j(await call('/convs', {}, 'b'))).unread, 0, 'reading clears it');
// replies, reactions
const m2 = (await j(await post('/send', { conv: dm.id, text: 'Sure!', quote: m1.id }, 'b'))).msg; assert.equal(m2.quote.text, 'Hello Bob'); assert.equal(m2.quote.name, 'Ann');
assert.equal((await post('/react', { conv: dm.id, msg: m1.id, emoji: '👍' }, 'b')).status, 200); assert.equal((await post('/react', { conv: dm.id, msg: m1.id, emoji: '<b>' }, 'b')).status, 400, 'only emoji');
let th = await j(await call('/conv/' + dm.id, {}, 'a')); assert.deepEqual(th.msgs[0].react, { [P.b.id]: '👍' }); await post('/react', { conv: dm.id, msg: m1.id, emoji: '' }, 'b'); assert.deepEqual((await j(await call('/conv/' + dm.id, {}, 'a'))).msgs[0].react, {}, 'an empty emoji takes it back');
// attachments
const png = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64');
r = await call('/upload?conv=' + dm.id + '&name=../../pic.gif', { method: 'POST', body: png, headers: { 'content-type': 'image/gif' } }, 'a'); const up = await j(r); assert.equal(up.name, 'pic.gif', 'a file name cannot reach other folders'); assert.equal(up.size, png.length);
assert.equal((await call('/upload?conv=' + dm.id + '&name=x', { method: 'POST', body: png }, 'c')).status, 404);
let ma = (await j(await post('/send', { conv: dm.id, text: 'a photo', files: [up.id] }, 'a'))).msg; assert.equal(ma.att[0].type, 'image/gif'); assert.equal((await post('/send', { conv: dm.id, text: 'again', files: [up.id] }, 'a')).status, 400, 'an upload is attached once');
r = await call('/file/' + up.id, {}, 'b'); assert.equal(r.status, 200); assert.equal(r.headers.get('content-type'), 'image/gif'); assert.equal(Buffer.from(await r.arrayBuffer()).length, png.length); assert.equal((await call('/file/' + up.id, {}, 'c')).status, 404, 'only the chat sees its files');
const html = (await j(await call('/upload?conv=' + dm.id + '&name=x.html', { method: 'POST', body: '<script>alert(1)</script>', headers: { 'content-type': 'text/html' } }, 'a'))); r = await call('/file/' + html.id, {}, 'b'); assert.equal(r.headers.get('content-type'), 'application/octet-stream', 'a web page is never shown inline'); assert.match(r.headers.get('content-disposition'), /attachment/);
r = await call('/file/' + up.id, { headers: { range: 'bytes=0-3' } }, 'b'); assert.equal(r.status, 206);
// edit and delete
assert.equal((await post('/edit', { conv: dm.id, msg: m1.id, text: 'Hello again' }, 'b')).status, 403, 'only the author edits'); assert.equal((await post('/edit', { conv: dm.id, msg: m1.id, text: 'Hello again' }, 'a')).status, 200);
assert.equal((await j(await call('/conv/' + dm.id, {}, 'b'))).msgs[0].edited, true); assert.equal((await post('/delete', { conv: dm.id, msg: ma.id }, 'a')).status, 200); th = await j(await call('/conv/' + dm.id, {}, 'b')); const gone = th.msgs.find(x => x.id === ma.id); assert.equal(gone.deleted, true); assert.equal(gone.att, undefined); assert.equal((await call('/file/' + up.id, {}, 'b')).status, 404, 'its file is gone too');
// groups
assert.equal((await post('/new', { type: 'group', name: '', members: [P.b.id] }, 'a')).status, 400);
const g = (await j(await post('/new', { type: 'group', name: 'Family', members: [P.b.id, P.c.id, P.d.id] }, 'a'))).conv; assert.deepEqual(g.members.map(x => x.name).sort(), ['Ann', 'Bob', 'Cy'], 'someone without Chat is left out');
await post('/send', { conv: g.id, text: 'Dinner at 6' }, 'a'); const gl = (await j(await call('/convs', {}, 'c'))).convs; assert.equal(gl[0].name, 'Family'); assert.equal(gl[0].preview, 'Ann: Dinner at 6', 'a group preview names who wrote it'); assert.equal(gl[0].unread, 1);
assert.equal((await post('/conv/update', { id: g.id, remove: [P.c.id] }, 'b')).status, 403, 'only the maker or an admin removes people'); assert.equal((await post('/conv/update', { id: g.id, name: 'Our Family', remove: [P.c.id] }, 'a')).status, 200); assert.equal((await j(await call('/convs', {}, 'c'))).convs.length, 0, 'Cy no longer has the group');
assert.equal((await post('/conv/update', { id: g.id, leave: true }, 'b')).status, 200); assert.deepEqual((await j(await call('/conv/' + g.id, {}, 'a'))).conv.members.map(x => x.name), ['Ann']);
// mute, archive, search
await post('/conv/mute', { id: dm.id, on: true }, 'b'); await post('/send', { conv: dm.id, text: 'psst' }, 'a'); let bl = await j(await call('/convs', {}, 'b')); assert.equal(bl.convs.find(x => x.id === dm.id).muted, true); assert.equal(bl.unread, 0, 'a muted chat does not add to the badge'); assert.equal(bl.convs.find(x => x.id === dm.id).unread, 1, 'but still counts in itself');
await post('/conv/archive', { id: dm.id, on: true }, 'b'); assert.equal((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === dm.id).archived, true);
const s = await j(await call('/search?q=hello', {}, 'b')); assert.equal(s.results.length, 1); assert.equal(s.results[0].convName, 'Ann'); assert.equal((await j(await call('/search?q=h', {}, 'b'))).results.length, 0, 'too short');
// ---- phase 2: ticks, typing, presence, disappearing messages, mentions, pins, forwarding
const g2 = (await j(await post('/new', { type: 'group', name: 'Crew', members: [P.b.id, P.c.id] }, 'a'))).conv; const st = async (who, conv) => (await j(await call('/conv/' + encodeURIComponent(conv), {}, who)));
let sm = (await j(await post('/send', { conv: g2.id, text: 'Hi @Bob and @Cy!' }, 'a'))).msg; assert.deepEqual(sm.mentions.sort(), [P.b.id, P.c.id].sort(), 'mentions are found by name'); assert.equal(sm.status, 'sent', 'nobody has the app open: just sent');
await st('b', g2.id); let a1 = (await st('a', g2.id)).msgs.find(x => x.id === sm.id); assert.equal(a1.status, 'sent', 'delivered needs everyone in a group'); await st('c', g2.id); a1 = (await st('a', g2.id)).msgs.find(x => x.id === sm.id); assert.equal(a1.status, 'delivered', 'everyone has fetched it');
await post('/read', { id: g2.id }, 'b'); assert.equal((await st('a', g2.id)).msgs.find(x => x.id === sm.id).status, 'delivered', 'read by one is not read by all'); await post('/read', { id: g2.id }, 'c'); assert.equal((await st('a', g2.id)).msgs.find(x => x.id === sm.id).status, 'read');
assert.equal((await st('b', g2.id)).msgs.find(x => x.id === sm.id).status, undefined, 'ticks are only for what you sent');
// typing and presence (Bob has the live stream open)
const t0 = events.length; await post('/typing', { conv: dm.id }, 'a'); await new Promise(ok => setTimeout(ok, 120)); const ty = events.slice(t0).find(e => e.t === 'typing'); assert.ok(ty && ty.name === 'Ann' && ty.conv === dm.id, 'Bob sees that Ann is typing');
let pv = (await j(await call('/convs', {}, 'a'))).convs.find(x => x.id === dm.id); assert.equal(pv.presence.online, true, 'Bob has the app open'); assert.ok(pv.presence.seen > 0);
// disappearing messages
assert.equal((await post('/conv/ttl', { id: g2.id, ttl: 5 }, 'a')).status, 400); assert.equal((await post('/conv/ttl', { id: g2.id, ttl: 3600 }, 'b')).status, 200);
let gv = await st('a', g2.id); assert.equal(gv.conv.ttl, 3600); const sys = gv.msgs.at(-1); assert.equal(sys.sys, true); assert.match(sys.text, /Bob set messages to disappear after 1 hour/); assert.equal((await j(await call('/convs', {}, 'a'))).convs.find(x => x.id === g2.id).preview, 'Bob set messages to disappear after 1 hour', 'a notice has no name in front');
const upg = await j(await call('/upload?conv=' + g2.id + '&name=t.gif', { method: 'POST', body: png, headers: { 'content-type': 'image/gif' } }, 'a')); const tm = (await j(await post('/send', { conv: g2.id, text: 'secret', files: [upg.id] }, 'a'))).msg; assert.ok(tm.exp > Date.now(), 'it has a time to go');
assert.equal((await call('/file/' + upg.id, {}, 'b')).status, 200); const realNow = Date.now; Date.now = () => realNow() + 2 * 3600e3; chat.purge(); Date.now = realNow;
gv = await st('a', g2.id); assert.ok(!gv.msgs.some(x => x.id === tm.id), 'it is gone when its time is up'); assert.ok(gv.msgs.some(x => x.id === sm.id), 'older messages from before the timer stay'); assert.equal((await call('/file/' + upg.id, {}, 'b')).status, 404, 'and so is its file');
// pins
assert.equal((await post('/pin', { conv: g2.id, msg: sm.id, on: true }, 'c')).status, 200); gv = await st('b', g2.id); assert.deepEqual(gv.pinned.map(x => x.id), [sm.id]); await post('/pin', { conv: g2.id, msg: sm.id, on: false }, 'b'); assert.deepEqual((await st('a', g2.id)).pinned, []);
assert.equal((await post('/pin', { conv: g2.id, msg: (await st('a', g2.id)).msgs.find(x => x.sys).id, on: true }, 'a')).status, 404, 'a notice cannot be pinned');
// forwarding copies the message and its file into another chat
const fp = await j(await call('/upload?conv=' + g2.id + '&name=f.gif', { method: 'POST', body: png, headers: { 'content-type': 'image/gif' } }, 'a')); const fm = (await j(await post('/send', { conv: g2.id, text: 'look at this', files: [fp.id] }, 'a'))).msg;
const fw = (await j(await post('/forward', { from: g2.id, to: dm.id, msg: fm.id }, 'a'))).msg; assert.equal(fw.text, 'look at this'); assert.equal(fw.fwd.name, 'Ann'); assert.notEqual(fw.att[0].id, fp.id, 'the file is copied, not shared'); assert.equal((await call('/file/' + fw.att[0].id, {}, 'b')).status, 200);
assert.equal((await post('/forward', { from: g2.id, to: dm.id, msg: fm.id }, 'c')).status, 404, 'only into chats you are in');
// ---- phase 3: voice notes, polls, link previews
const vp = await j(await call('/upload?conv=' + g2.id + '&name=Voice.m4a&voice=1&wave=10,50,200,-5,abc,80&dur=12', { method: 'POST', body: 'audio-bytes', headers: { 'content-type': 'audio/mp4' } }, 'a')); assert.equal(vp.voice, true);
const vm = (await j(await post('/send', { conv: g2.id, files: [vp.id] }, 'a'))).msg; assert.deepEqual(vm.att[0].wave, [10, 50, 100, 0, 80], 'the waveform is cleaned (0 to 100)'); assert.equal(vm.att[0].dur, 12); assert.equal(vm.att[0].voice, true);
assert.equal((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === g2.id).preview, 'Ann: 🎤 Voice message', 'a voice note with no words is listed as one');
const notV = await j(await call('/upload?conv=' + g2.id + '&name=x.gif&voice=1', { method: 'POST', body: png, headers: { 'content-type': 'image/gif' } }, 'a')); assert.equal(notV.voice, undefined, 'only audio can be a voice note');
assert.equal((await post('/poll', { conv: g2.id, question: 'Pizza?', options: ['Yes'] }, 'a')).status, 400, 'a poll needs two choices'); const pm = (await j(await post('/poll', { conv: g2.id, question: 'Pizza or tacos?', options: ['Pizza', 'Tacos', ' ', 'Salad'] }, 'a'))).msg; assert.deepEqual(pm.poll.options.map(o => o.text), ['Pizza', 'Tacos', 'Salad']); assert.equal(pm.poll.multi, false);
assert.equal((await post('/vote', { conv: g2.id, msg: pm.id, opts: ['0', '1', '9'] }, 'b')).status, 200); let pv2 = (await st('a', g2.id)).msgs.find(x => x.id === pm.id); assert.deepEqual(pv2.poll.votes, { [P.b.id]: ['0'] }, 'a single-choice poll keeps one vote');
await post('/vote', { conv: g2.id, msg: pm.id, opts: ['1'] }, 'c'); await post('/vote', { conv: g2.id, msg: pm.id, opts: ['2'] }, 'b'); pv2 = (await st('a', g2.id)).msgs.find(x => x.id === pm.id); assert.deepEqual(pv2.poll.votes, { [P.b.id]: ['2'], [P.c.id]: ['1'] }, 'changing a vote replaces it'); await post('/vote', { conv: g2.id, msg: pm.id, opts: [] }, 'c'); assert.deepEqual((await st('a', g2.id)).msgs.find(x => x.id === pm.id).poll.votes, { [P.b.id]: ['2'] }, 'an empty vote takes it back');
const mp = (await j(await post('/poll', { conv: g2.id, question: 'Which days?', options: ['Sat', 'Sun'], multi: true }, 'a'))).msg; await post('/vote', { conv: g2.id, msg: mp.id, opts: ['0', '1'] }, 'b'); assert.deepEqual((await st('a', g2.id)).msgs.find(x => x.id === mp.id).poll.votes[P.b.id], ['0', '1'], 'a multiple-choice poll keeps both');
assert.equal((await post('/poll/close', { conv: g2.id, msg: pm.id }, 'b')).status, 403, 'only the asker closes it'); assert.equal((await post('/poll/close', { conv: g2.id, msg: pm.id }, 'a')).status, 200); assert.equal((await post('/vote', { conv: g2.id, msg: pm.id, opts: ['0'] }, 'b')).status, 400, 'a closed poll takes no votes');
assert.equal((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === g2.id).preview, 'Ann: 📊 Which days?', 'a poll is listed by its question');
const pp = chat.parsePreview('<html><head><title>Fallback</title><meta property="og:title" content="Pot &amp; Pan Recipes"><meta property="og:description" content="Best stew"><meta property="og:image" content="/img/a.jpg"><meta property="og:site_name" content="Cook Site"></head></html>', 'https://cook.example.com/stew'); assert.deepEqual(pp, { url: 'https://cook.example.com/stew', site: 'Cook Site', title: 'Pot & Pan Recipes', desc: 'Best stew', image: 'https://cook.example.com/img/a.jpg' }, 'a preview reads the page\'s own words');
assert.equal(chat.parsePreview('<title>Plain</title>', 'https://x.example.com/').title, 'Plain'); assert.equal(chat.parsePreview('<meta property="og:title" content="T"><meta property="og:image" content="http://x.example.com/a.jpg">', 'https://x.example.com/').image, '', 'only https pictures'); assert.equal(chat.parsePreview('<html></html>', 'https://x.example.com/'), null);
// ---- family extras: announcement and quiet hours
assert.equal(await j(await call('/announcement', {}, 'b')), null); assert.equal((await post('/announcement', { text: 'Dinner at 6' }, 'b')).status, 403, 'only an admin posts it'); const an = await j(await post('/announcement', { text: ' Dinner at 6 ' }, 'a')); assert.equal(an.text, 'Dinner at 6'); assert.equal((await j(await call('/announcement', {}, 'b'))).by, 'Ann'); await post('/announcement', { text: '' }, 'a'); assert.equal(await j(await call('/announcement', {}, 'b')), null, 'an empty one clears it');
assert.deepEqual((await j(await call('/prefs', {}, 'b'))).quiet, { on: false, from: '21:00', to: '07:00' }); const qp = await j(await post('/prefs', { quiet: { on: true, from: '20:30', to: 'nope' } }, 'b')); assert.deepEqual(qp.quiet, { on: true, from: '20:30', to: '07:00' }, 'a bad time is ignored'); assert.equal((await j(await call('/prefs', {}, 'c'))).quiet.on, false, 'each person has their own');
ctl.abort(); console.log('chat ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 80);
});