diff options
| author | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 19:26:33 -0300 |
|---|---|---|
| committer | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 19:26:33 -0300 |
| commit | d80640d01eeb84907e36ff4af33add9081eda17c (patch) | |
| tree | 27698b34e06e28d111c2196700015d9c82f78274 /test/wamail-sync.test.js | |
| download | wa-cli-main.tar.gz wa-cli-main.zip | |
Diffstat (limited to 'test/wamail-sync.test.js')
| -rw-r--r-- | test/wamail-sync.test.js | 172 |
1 files changed, 172 insertions, 0 deletions
diff --git a/test/wamail-sync.test.js b/test/wamail-sync.test.js new file mode 100644 index 0000000..909a42d --- /dev/null +++ b/test/wamail-sync.test.js @@ -0,0 +1,172 @@ +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 })); |