aboutsummaryrefslogtreecommitdiffstats
path: root/test/wamail-sync.test.js
diff options
context:
space:
mode:
Diffstat (limited to 'test/wamail-sync.test.js')
-rw-r--r--test/wamail-sync.test.js172
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 }));