import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; // Isolate state/cache/Maildir before importing (store.js reads XDG_* on load). const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wamail-test-')); process.env.XDG_STATE_HOME = path.join(tmp, 'state'); process.env.XDG_CACHE_HOME = path.join(tmp, 'cache'); const { syncAccount, wamailConfig } = await import('../src/wamail/sync.js'); const { loadState } = await import('../src/wamail/store.js'); const { mailboxesPath } = await import('../src/wamail/neomutt.js'); const { planSend } = await import('../src/wamail/send.js'); const { parseMessage } = await import('../src/wamail/mime.js'); const ME = '5491100000000@c.us'; const NOW = 1789500000; const A = '5491122334455@c.us'; const B = '5491166667777@c.us'; const G = '120363000000@g.us'; function fakeWhatsApp() { const chats = new Map(); const msgs = new Map(); const calls = []; const msg = (chatId, stanza, ts, extra = {}) => { const fromMe = Boolean(extra.fromMe); const sender = fromMe ? ME : (extra.author ?? chatId); const m = { id: `${fromMe}_${chatId}_${stanza}`, stanzaId: stanza, chatId, chatName: chats.get(chatId)?.name ?? null, fromMe, from: fromMe ? ME : chatId, to: fromMe ? chatId : ME, author: extra.author ?? null, sender, senderName: fromMe ? 'Yo' : 'Otro', timestamp: ts, date: new Date(ts * 1000).toISOString(), type: 'chat', body: '', hasMedia: false, media: null, hasQuotedMsg: false, quotedStanzaId: null, quotedParticipant: null, isForwarded: false, isStarred: false, isStatus: false, ack: 1, mentionedIds: [], links: [], ...extra, }; (msgs.get(chatId) ?? msgs.set(chatId, []).get(chatId)).push(m); const c = chats.get(chatId); c.timestamp = Math.max(c.timestamp, ts); return m; }; const rpc = async (method, p = {}) => { calls.push([method, p]); const list = (id) => msgs.get(id) ?? []; switch (method) { case 'auth.status': return { me: { id: ME, name: 'Yo' } }; case 'chats.list': return [...chats.values()].map((c) => ({ ...c })); case 'chats.get': return { ...chats.get(p.chatId) }; case 'messages.list': { const l = list(p.chatId); return p.since !== undefined ? l.filter((m) => m.timestamp >= p.since) : l.slice(-p.limit); } case 'messages.get': { const m = [...msgs.values()].flat().find((x) => x.id === p.messageId); return p.includeMedia && m.hasMedia ? { ...m, media: { ...m.media, data: Buffer.from('IMG').toString('base64') } } : m; } case 'chats.read': chats.get(p.chatId).unreadCount = 0; return { ok: true }; case 'chats.unread': chats.get(p.chatId).unreadCount = -1; return { ok: true }; case 'messages.star': case 'messages.unstar': return { ok: true }; default: throw new Error(`rpc inesperado ${method}`); } }; const chat = (id, name, extra = {}) => chats.set(id, { id, name, isGroup: id.endsWith('@g.us'), unreadCount: 0, timestamp: 0, archived: false, pinned: false, ...extra }); return { chats, msgs, calls, rpc, msg, chat, called: (m) => calls.filter(([x]) => x === m) }; } const config = wamailConfig('t', { maildir: path.join(tmp, 'Mail') }); const files = (dirName) => { const d = path.join(tmp, 'Mail', dirName); return ['new', 'cur'].flatMap((sub) => (fs.existsSync(path.join(d, sub)) ? fs.readdirSync(path.join(d, sub)).map((f) => `${sub}/${f}`) : [])).sort(); }; const run = (wa, opts = {}) => syncAccount({ account: 't', rpc: wa.rpc, config, now: () => NOW * 1000, ...opts }); test('full sync scenario', async () => { const wa = fakeWhatsApp(); wa.chat(A, 'Juan Pérez'); wa.chat(B, 'Viejo'); wa.chat(G, 'Familia `rm -rf` $HOME', { pinned: false }); wa.msg(A, 'M1', NOW - 300, { body: 'hola' }); wa.msg(A, 'M2', NOW - 200, { fromMe: true, body: 'qué tal' }); wa.msg(A, 'M3', NOW - 100, { type: 'image', hasMedia: true, media: { mimetype: 'image/jpeg', filename: 'foto.jpg', size: 3 } }); wa.chats.get(A).unreadCount = 1; wa.msg(B, 'OLD', NOW - 60 * 86400, { body: 'hace dos meses' }); wa.msg(G, 'G1', NOW - 50, { author: A, body: 'en el grupo' }); wa.msg(G, 'N1', NOW - 40, { type: 'e2e_notification' }); // 1. first sync: backfill of recent chats, flags from unreadCount let stats = await run(wa); let st = loadState('t'); const dirA = st.chats[A].dir; assert.equal(stats.nuevos, 4); assert.equal(st.chats[B].dir, null, 'old chat not backfilled'); assert.deepEqual(files(dirA).map((f) => f.replace(/^\w+\/\d+\./, '')), ['M1.wa:2,S', 'M2.wa:2,S', 'M3.wa'].sort()); assert.ok(files(dirA).includes(`new/${NOW - 100}.M3.wa`)); const eml = fs.readFileSync(path.join(tmp, 'Mail', dirA, 'new', `${NOW - 100}.M3.wa`)); assert.equal(parseMessage(eml).attachments[0].filename, 'foto.jpg'); assert.ok(!files(st.chats[G].dir).some((f) => f.includes('N1')), 'notifications left out'); const mbox = fs.readFileSync(mailboxesPath('t'), 'utf8'); assert.match(mbox, /named-mailboxes ' Familia rm -rf HOME'/, 'sanitized name'); assert.doesNotMatch(mbox, /Viejo/); assert.match(mbox, new RegExp(`spoolfile = '.*${st.chats[G].dir}'`), 'spoolfile = most recent chat'); assert.match(mbox, /alternates '\^5491100000000@c\\.us\$'/); // 2. reading M3 in neomutt -> chats.read; the next sync does not mark it again const d = path.join(tmp, 'Mail', dirA); fs.renameSync(path.join(d, 'new', `${NOW - 100}.M3.wa`), path.join(d, 'cur', `${NOW - 100}.M3.wa:2,S`)); await run(wa); assert.equal(wa.called('chats.read').length, 1); await run(wa); assert.equal(wa.called('chats.read').length, 1, 'does not repeat'); assert.ok(files(dirA).includes(`cur/${NOW - 100}.M3.wa:2,S`)); // 3. M4 arrives (hook: --chat) -> new/ wa.msg(A, 'M4', NOW - 10, { body: 'nuevo' }); wa.chats.get(A).unreadCount = 1; stats = await run(wa, { onlyChat: A }); assert.equal(stats.nuevos, 1); assert.ok(files(dirA).includes(`new/${NOW - 10}.M4.wa`)); // 4. read on the phone -> marked read locally without calling WhatsApp wa.chats.get(A).unreadCount = 0; await run(wa); assert.ok(files(dirA).includes(`cur/${NOW - 10}.M4.wa:2,S`)); assert.equal(wa.called('chats.read').length, 1); // 5. star M1, delete M2 (not downloaded again), mark M1 unread fs.renameSync(path.join(d, 'cur', `${NOW - 300}.M1.wa:2,S`), path.join(d, 'cur', `${NOW - 300}.M1.wa:2,F`)); fs.rmSync(path.join(d, 'cur', `${NOW - 200}.M2.wa:2,S`)); stats = await run(wa); assert.deepEqual(wa.called('messages.star').map(([, p]) => p.messageId), [`false_${A}_M1`]); assert.equal(wa.called('chats.unread').length, 1); assert.equal(stats.borrados, 1); st = loadState('t'); st.chats[A].lastTs = 0; // force a re-backfill: M2 is in tombstones fs.writeFileSync(path.join(tmp, 'state', 'wamail', 't.json'), JSON.stringify(st)); await run(wa); assert.ok(!files(dirA).some((f) => f.includes('.M2.')), 'deleted message does not come back'); assert.ok(files(dirA).includes(`cur/${NOW - 300}.M1.wa:2,F`), 'still unread after the remote unread (-1)'); // 6. partially read: no push, and the next sync does not overwrite it wa.msg(A, 'M5', NOW - 5, { body: 'a' }); wa.msg(A, 'M6', NOW - 4, { body: 'b' }); wa.chats.get(A).unreadCount = 3; await run(wa); const readsBefore = wa.called('chats.read').length; fs.renameSync(path.join(d, 'new', `${NOW - 5}.M5.wa`), path.join(d, 'cur', `${NOW - 5}.M5.wa:2,S`)); await run(wa); await run(wa); assert.equal(wa.called('chats.read').length, readsBefore); assert.ok(files(dirA).includes(`cur/${NOW - 5}.M5.wa:2,S`)); }); test('send: quoted reply to a group, attachment as a document, new message with ">"', () => { const state = { msgs: { '3EB0AAA1': { chat: G, id: `false_${G}_3EB0AAA1_${A}` } }, tombstones: {} }; const fixture = (n) => fs.readFileSync(new URL(`./fixtures/${n}`, import.meta.url)); const reply = planSend({ raw: fixture('neomutt-respuesta.eml'), argv: ['--', A, G], state }); assert.deepEqual(reply.chatIds, [G], 'group-reply goes to the group only'); assert.equal(reply.steps.length, 1); assert.equal(reply.steps[0].quotedMessageId, `false_${G}_3EB0AAA1_${A}`); assert.doesNotMatch(reply.steps[0].body, /wrote:|>/); assert.match(reply.steps[0].body, /^Dale, nos vemos mañana/); const withFile = planSend({ raw: fixture('neomutt-adjunto.eml'), argv: ['--', A], state }); assert.equal(withFile.steps.length, 2); assert.equal(withFile.steps[0].body, 'hola, ¿qué tal? ñandú 🎉\n\n> línea citada', 'without In-Reply-To it is not a quote; signature left out'); assert.equal(withFile.steps[1].attachment.filename, 'informe año.pdf'); assert.throws(() => planSend({ raw: 'To: juan@gmail.com\n\nhola', argv: [], state }), /is not a WhatsApp recipient/); }); test.after(() => fs.rmSync(tmp, { recursive: true, force: true }));