// Row 81 (#1575): `replies`, the read behind the Console's web-send receipt. The messages // answering one message, oldest first, at most REPLIES, for human and reader sessions. It // writes no delivery or read row, so the human's next message.receive (mosaic talk) still // gets each reply. import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Store } from '../src/store.mjs'; import { Broker, REPLIES } from '../src/broker.mjs'; import { views } from '../src/views.mjs'; const roles = { pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] } }, }; const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles }, other: { id: 'other', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles }, }; function setup(t) { const root = mkdtempSync(join(tmpdir(), 'bus-replies-')); const store = new Store(root); const b = new Broker({ store, businesses }); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const agent = (role, business = 'demo') => { const cap = b.bindLaunch({ business, role, run: `${business}-${role}-run`, harness: 'pi', address: `${business}-${role}-run` }); b.request(cap, { verb: 'role.claim' }); return cap; }; const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const reader = b.bindReader({ business: 'demo' }); const coder = agent('coder'); const pm = agent('pm'); // The human's web send, as row 76's Console makes it. const sent = call(b, human, 'message.send', { to: 'coder', body: 'Please look at row 81.' }); const deliveries = () => store.all('SELECT * FROM deliveries ORDER BY seq'); const count = () => ['events', 'decisions', 'decision_events', 'messages', 'deliveries', 'role_claims'].map( (table) => store.get(`SELECT count(*) AS n FROM ${table}`).n, ); return { b, store, agent, human, reader, coder, pm, sent, deliveries, count }; } const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args }); const reply = (b, cap, to, body, extra = {}) => call(b, cap, 'message.send', { to: 'human', body, class: 'RESULT', in_reply_to: to, ...extra }); test('a reply is listed for human and reader sessions, and its delivery rows are unchanged', (t) => { const { b, store, human, reader, coder, pm, sent, deliveries, count } = setup(t); call(b, coder, 'message.receive'); const answer = reply(b, coder, sent.id, 'Done: 3 files.'); // Not replies to the send: a message to the human with no in_reply_to, and a reply to another message. call(b, pm, 'message.send', { to: 'human', body: 'fyi' }); const other = call(b, human, 'message.send', { to: 'pm', body: 'Another question' }); reply(b, pm, other.id, 'Another answer'); const before = deliveries(); const counts = count(); for (const cap of [human, reader]) { const rows = call(b, cap, 'replies', { id: sent.id }); assert.deepEqual( rows.map((m) => [m.id, m.from_role, m.to_role, m.class, m.in_reply_to, m.body]), [[answer.id, 'coder', 'human', 'RESULT', sent.id, 'Done: 3 files.']], ); // The whole row is the messages row trail gives the same session for that id. const { table, ...m } = call(b, cap, 'trail', { subject: answer.id }).find((r) => r.table === 'messages'); assert.equal(table, 'messages'); assert.deepEqual({ ...rows[0] }, m); } assert.deepEqual(deliveries(), before, 'no delivered or read row, nothing else either'); assert.deepEqual(count(), counts, 'a successful read writes nothing'); assert.deepEqual( store.all("SELECT op FROM deliveries WHERE message=? ORDER BY seq", answer.id).map((d) => d.op), ['routed'], ); }); test('the reply still reaches the human message.receive (mosaic talk) after the Console showed it', (t) => { const { b, human, reader, coder, sent } = setup(t); const answer = reply(b, coder, sent.id, 'Done.'); assert.deepEqual(call(b, reader, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]); assert.deepEqual(call(b, human, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]); const got = call(b, human, 'message.receive'); assert.deepEqual(got.map((m) => [m.id, m.in_reply_to, m.body]), [[answer.id, sent.id, 'Done.']]); assert.deepEqual(call(b, human, 'message.read', { id: answer.id }), { read: true }); assert.deepEqual(call(b, human, 'message.receive'), []); // Once received and read it is still listed: the read is history, not an inbox. assert.deepEqual(call(b, reader, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]); }); test('a message with no reply gives an empty list', (t) => { const { b, human, reader, sent } = setup(t); for (const cap of [human, reader]) assert.deepEqual(call(b, cap, 'replies', { id: sent.id }), []); }); test('replies come oldest first and stop at REPLIES', (t) => { const { b, reader, coder, pm, sent } = setup(t); assert.equal(REPLIES, 50); const ids = []; for (let i = 0; i <= REPLIES; i++) ids.push(reply(b, i % 2 ? pm : coder, sent.id, `reply ${i}`).id); const rows = call(b, reader, 'replies', { id: sent.id }); assert.equal(rows.length, REPLIES); assert.deepEqual( rows.map((m) => m.id), ids.slice(0, REPLIES), ); assert.deepEqual(rows.slice(0, 3).map((m) => [m.from_role, m.body]), [ ['coder', 'reply 0'], ['pm', 'reply 1'], ['coder', 'reply 2'], ]); }); test('an id that names no message in the business refuses message-not-found, and the refusal is recorded', (t) => { const { b, store, agent, human, reader, pm, sent } = setup(t); const elsewhere = call(b, agent('coder', 'other'), 'message.send', { to: 'human', body: 'not demo' }); const refusals = () => store.get("SELECT count(*) AS n FROM events WHERE kind='action.refused'").n; for (const cap of [human, reader]) for (const id of ['00000000-0000-4000-8000-000000000000', elsewhere.id, 'vikunja:3/1']) { const before = refusals(); assert.throws(() => call(b, cap, 'replies', { id }), { code: 'message-not-found' }, id); assert.equal(refusals(), before + 1, 'the refusal is recorded'); } // A decision id is not a message id either. const d = call(b, pm, 'decision.raise', { action: 'deploy', target: 'release-1', question: 'Deploy?', options: [{ key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }], recommendation: 'yes', blocking: false, }); assert.throws(() => call(b, reader, 'replies', { id: d.id }), { code: 'message-not-found' }); // The other business's reader finds its own message, not demo's. const otherReader = b.bindReader({ business: 'other' }); assert.deepEqual(call(b, otherReader, 'replies', { id: elsewhere.id }), []); assert.throws(() => call(b, otherReader, 'replies', { id: sent.id }), { code: 'message-not-found' }); }); test('strict arguments; a role session gets human-required', (t) => { const { b, human, reader, coder, pm, sent } = setup(t); for (const cap of [human, reader]) for (const args of [{}, { id: '' }, { id: 7 }, { id: [sent.id] }, { id: '' }, { id: sent.id, limit: 5 }, { subject: sent.id }]) assert.throws(() => call(b, cap, 'replies', args), { code: 'invalid-request' }, JSON.stringify(args)); // A role can't read the human's replies, even to a message it sent itself. const own = call(b, pm, 'message.send', { to: 'coder', body: 'hi' }); for (const [cap, id] of [[coder, sent.id], [pm, own.id]]) assert.throws(() => call(b, cap, 'replies', { id }), { code: 'human-required' }); // The reader's other limits stand. assert.throws(() => call(b, reader, 'message.receive'), { code: 'read-only' }); }); test('views passes replies through as one call with the id', async () => { const calls = []; const read = views({ call: async (verb, args) => (calls.push([verb, args]), []) }); assert.deepEqual(await read.replies('m-1'), []); assert.deepEqual(calls, [['replies', { id: 'm-1' }]]); });