whatsapp_notifier 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/generators/whatsapp_notifier/install_service_generator.rb +2 -0
- data/lib/whatsapp_notifier/client.rb +12 -0
- data/lib/whatsapp_notifier/providers/web_automation.rb +33 -0
- data/lib/whatsapp_notifier/services/web_automation/history.test.ts +695 -0
- data/lib/whatsapp_notifier/services/web_automation/history.ts +323 -0
- data/lib/whatsapp_notifier/services/web_automation/inbound.test.ts +209 -2
- data/lib/whatsapp_notifier/services/web_automation/inbound.ts +138 -16
- data/lib/whatsapp_notifier/services/web_automation/index.ts +81 -12
- data/lib/whatsapp_notifier/services/web_automation/media.test.ts +175 -9
- data/lib/whatsapp_notifier/services/web_automation/media.ts +94 -4
- data/lib/whatsapp_notifier/services/web_automation/send.test.ts +20 -0
- data/lib/whatsapp_notifier/services/web_automation/send.ts +17 -0
- data/lib/whatsapp_notifier/version.rb +1 -1
- data/lib/whatsapp_notifier/web_adapter.rb +85 -5
- data/lib/whatsapp_notifier.rb +12 -0
- data/spec/client_spec.rb +28 -1
- data/spec/generators/install_service_generator_spec.rb +1 -1
- data/spec/providers/web_automation_spec.rb +61 -3
- data/spec/web_adapter_spec.rb +232 -1
- data/spec/whatsapp_notifier_spec.rb +27 -0
- metadata +5 -1
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
import { test, expect, beforeEach, afterAll } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import {
|
|
6
|
+
CHAT_LIST_CAP,
|
|
7
|
+
DEFAULT_HISTORY_LIMIT,
|
|
8
|
+
MAX_HISTORY_LIMIT,
|
|
9
|
+
HISTORY_MEDIA_ERROR,
|
|
10
|
+
summarizeChats,
|
|
11
|
+
clampHistoryLimit,
|
|
12
|
+
normalizeHistoryChatId,
|
|
13
|
+
historyMediaInfo,
|
|
14
|
+
replayHistory,
|
|
15
|
+
chatsResponse,
|
|
16
|
+
historyResponse,
|
|
17
|
+
findMessage,
|
|
18
|
+
refetchResponse,
|
|
19
|
+
type GatedClient,
|
|
20
|
+
type HistoryDeps,
|
|
21
|
+
type RefetchDeps
|
|
22
|
+
} from './history';
|
|
23
|
+
import { configureInbound, loadTargets, resetInboundState, type ChatLike } from './inbound';
|
|
24
|
+
import {
|
|
25
|
+
configureMedia,
|
|
26
|
+
resetMediaState,
|
|
27
|
+
mediaExists,
|
|
28
|
+
userDirBytes,
|
|
29
|
+
resolveMediaForMessage,
|
|
30
|
+
type MediaResolution
|
|
31
|
+
} from './media';
|
|
32
|
+
|
|
33
|
+
const root = mkdtempSync(join(tmpdir(), 'wa-history-'));
|
|
34
|
+
const dirFor = (userId: string) => join(root, `session-user-${userId}`);
|
|
35
|
+
|
|
36
|
+
let mediaCase = 0;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
resetInboundState();
|
|
40
|
+
configureInbound(dirFor);
|
|
41
|
+
// Each example gets a fresh media root so the refetch store tests don't
|
|
42
|
+
// bleed bytes into one another. The resolver must be PURE (no side effect
|
|
43
|
+
// per call) — bump the case index once here, not inside the closure.
|
|
44
|
+
const mediaRoot = join(root, `media-${mediaCase++}`);
|
|
45
|
+
configureMedia(() => mediaRoot);
|
|
46
|
+
resetMediaState();
|
|
47
|
+
delete process.env.WHATSAPP_MEDIA_MAX_USER_BYTES;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
rmSync(root, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const CUST = '919999000001@c.us';
|
|
55
|
+
const OPERATOR = '919000000001@c.us';
|
|
56
|
+
|
|
57
|
+
function chat(id: string, overrides: any = {}) {
|
|
58
|
+
return { id: { _serialized: id }, name: 'Asha', timestamp: 1717000000, ...overrides };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function msg(overrides: any = {}) {
|
|
62
|
+
return {
|
|
63
|
+
from: CUST,
|
|
64
|
+
body: 'hello',
|
|
65
|
+
fromMe: false,
|
|
66
|
+
isStatus: false,
|
|
67
|
+
id: { _serialized: 'true_919999000001@c.us_ABC' },
|
|
68
|
+
timestamp: 1717000000,
|
|
69
|
+
type: 'chat',
|
|
70
|
+
...overrides
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── summarizeChats ──
|
|
75
|
+
|
|
76
|
+
test('summarizeChats keeps 1:1 @c.us chats only', () => {
|
|
77
|
+
const summaries = summarizeChats([
|
|
78
|
+
chat(CUST),
|
|
79
|
+
chat('12036304@g.us'), // group
|
|
80
|
+
chat('status@broadcast'), // status
|
|
81
|
+
chat('125417440686124@lid'), // privacy chat — no phone to thread on
|
|
82
|
+
{ name: 'no id at all' }, // junk
|
|
83
|
+
{ id: { _serialized: 42 }, name: 'junk' } // non-string id
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
expect(summaries).toEqual([{ id: CUST, name: 'Asha', lastMessageAt: 1717000000 }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('summarizeChats maps name and timestamp best-effort (null-safe)', () => {
|
|
90
|
+
const summaries = summarizeChats([
|
|
91
|
+
chat(CUST, { name: '', timestamp: undefined }),
|
|
92
|
+
chat('918@c.us', { name: 7, timestamp: 'soon' })
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
expect(summaries).toEqual([
|
|
96
|
+
{ id: CUST, name: null, lastMessageAt: null },
|
|
97
|
+
{ id: '918@c.us', name: null, lastMessageAt: null }
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('summarizeChats orders newest first and caps the list at CHAT_LIST_CAP', () => {
|
|
102
|
+
const many = [];
|
|
103
|
+
for (let i = 0; i < CHAT_LIST_CAP + 20; i++) {
|
|
104
|
+
many.push(chat(`91${i}@c.us`, { timestamp: i }));
|
|
105
|
+
}
|
|
106
|
+
many.push(chat('oldest@c.us', { timestamp: null })); // no timestamp sorts oldest
|
|
107
|
+
|
|
108
|
+
const summaries = summarizeChats(many);
|
|
109
|
+
|
|
110
|
+
expect(summaries.length).toBe(CHAT_LIST_CAP);
|
|
111
|
+
expect(summaries[0].lastMessageAt).toBe(CHAT_LIST_CAP + 19); // newest first
|
|
112
|
+
expect(summaries[summaries.length - 1].lastMessageAt).toBe(20); // oldest 20 + null cut
|
|
113
|
+
expect(summaries.find((s) => s.id === 'oldest@c.us')).toBeUndefined(); // capped away
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('summarizeChats tolerates a non-array result', () => {
|
|
117
|
+
expect(summarizeChats(undefined as any)).toEqual([]);
|
|
118
|
+
expect(summarizeChats(null as any)).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ── clampHistoryLimit ──
|
|
122
|
+
|
|
123
|
+
test('clampHistoryLimit defaults absent and non-numeric input to 50', () => {
|
|
124
|
+
expect(clampHistoryLimit(undefined)).toBe(DEFAULT_HISTORY_LIMIT);
|
|
125
|
+
expect(clampHistoryLimit(null)).toBe(DEFAULT_HISTORY_LIMIT);
|
|
126
|
+
expect(clampHistoryLimit('a lot')).toBe(DEFAULT_HISTORY_LIMIT);
|
|
127
|
+
expect(clampHistoryLimit({})).toBe(DEFAULT_HISTORY_LIMIT);
|
|
128
|
+
expect(clampHistoryLimit(NaN)).toBe(DEFAULT_HISTORY_LIMIT);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('clampHistoryLimit clamps to 1..200 and floors floats', () => {
|
|
132
|
+
expect(clampHistoryLimit(0)).toBe(1);
|
|
133
|
+
expect(clampHistoryLimit(-5)).toBe(1);
|
|
134
|
+
expect(clampHistoryLimit(1)).toBe(1);
|
|
135
|
+
expect(clampHistoryLimit(75.9)).toBe(75);
|
|
136
|
+
expect(clampHistoryLimit('120')).toBe(120);
|
|
137
|
+
expect(clampHistoryLimit(MAX_HISTORY_LIMIT)).toBe(200);
|
|
138
|
+
expect(clampHistoryLimit(201)).toBe(200);
|
|
139
|
+
expect(clampHistoryLimit(100000)).toBe(200);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── normalizeHistoryChatId ──
|
|
143
|
+
|
|
144
|
+
test('normalizeHistoryChatId appends @c.us to bare numbers like /send', () => {
|
|
145
|
+
expect(normalizeHistoryChatId('919999000001')).toBe(CUST);
|
|
146
|
+
expect(normalizeHistoryChatId(` ${CUST} `)).toBe(CUST); // already suffixed + trimmed
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('normalizeHistoryChatId rejects empty, non-string and non-1:1 ids', () => {
|
|
150
|
+
expect(normalizeHistoryChatId(undefined)).toBeNull();
|
|
151
|
+
expect(normalizeHistoryChatId(null)).toBeNull();
|
|
152
|
+
expect(normalizeHistoryChatId(42)).toBeNull();
|
|
153
|
+
expect(normalizeHistoryChatId('')).toBeNull();
|
|
154
|
+
expect(normalizeHistoryChatId(' ')).toBeNull();
|
|
155
|
+
expect(normalizeHistoryChatId('12036304@g.us')).toBeNull(); // group
|
|
156
|
+
expect(normalizeHistoryChatId('status@broadcast')).toBeNull(); // status
|
|
157
|
+
expect(normalizeHistoryChatId('125417440686124@lid')).toBeNull(); // privacy id
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── replayHistory ──
|
|
161
|
+
|
|
162
|
+
function chatWith(messages: any[], onFetch?: (opts: { limit: number }) => void): ChatLike {
|
|
163
|
+
return {
|
|
164
|
+
fetchMessages: async (opts) => {
|
|
165
|
+
if (onFetch) onFetch(opts);
|
|
166
|
+
return messages;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
test('replayHistory normalizes BOTH directions like live capture', async () => {
|
|
172
|
+
const history = await replayHistory('1', chatWith([
|
|
173
|
+
msg({ body: 'customer says hi' }),
|
|
174
|
+
msg({
|
|
175
|
+
fromMe: true, from: OPERATOR, to: CUST, body: 'operator replies',
|
|
176
|
+
id: { _serialized: 'true_919999000001@c.us_OP1' }, timestamp: 1717000100
|
|
177
|
+
})
|
|
178
|
+
]), 50);
|
|
179
|
+
|
|
180
|
+
expect(history.length).toBe(2);
|
|
181
|
+
// Inbound keeps the exact live wire shape — no fromMe/to keys.
|
|
182
|
+
expect(history[0]).toEqual({
|
|
183
|
+
from: CUST, body: 'customer says hi',
|
|
184
|
+
messageId: 'true_919999000001@c.us_ABC', timestamp: 1717000000, type: 'chat'
|
|
185
|
+
});
|
|
186
|
+
// Operator-sent carries fromMe + the counterparty at `to`.
|
|
187
|
+
expect(history[1]).toEqual({
|
|
188
|
+
from: OPERATOR, to: CUST, fromMe: true, body: 'operator replies',
|
|
189
|
+
messageId: 'true_919999000001@c.us_OP1', timestamp: 1717000100, type: 'chat'
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('replayHistory marks media unavailable WITHOUT downloading', async () => {
|
|
194
|
+
let downloads = 0;
|
|
195
|
+
const history = await replayHistory('1', chatWith([
|
|
196
|
+
msg({
|
|
197
|
+
type: 'image', hasMedia: true, body: '',
|
|
198
|
+
downloadMedia: async () => { downloads += 1; return null; }
|
|
199
|
+
})
|
|
200
|
+
]), 50);
|
|
201
|
+
|
|
202
|
+
expect(downloads).toBe(0);
|
|
203
|
+
expect(historyMediaInfo()).toEqual({ mediaStatus: 'unavailable', mediaError: 'history' });
|
|
204
|
+
expect(history[0]).toEqual({
|
|
205
|
+
from: CUST, body: '', messageId: 'true_919999000001@c.us_ABC',
|
|
206
|
+
timestamp: 1717000000, type: 'image',
|
|
207
|
+
hasMedia: true, mediaStatus: 'unavailable', mediaError: HISTORY_MEDIA_ERROR
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('replayHistory skips messages that fail shouldCapture', async () => {
|
|
212
|
+
const history = await replayHistory('1', chatWith([
|
|
213
|
+
msg(),
|
|
214
|
+
msg({ type: 'e2e_notification', id: { _serialized: 'sys1' } }), // system event
|
|
215
|
+
msg({ isStatus: true, id: { _serialized: 'st1' } }), // status
|
|
216
|
+
msg({ from: '12@g.us', id: { _serialized: 'g1' } }), // group post
|
|
217
|
+
null // junk entry
|
|
218
|
+
]), 50);
|
|
219
|
+
|
|
220
|
+
expect(history.length).toBe(1);
|
|
221
|
+
expect(history[0].messageId).toBe('true_919999000001@c.us_ABC');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('replayHistory returns oldest-first even when the chat yields newest-first', async () => {
|
|
225
|
+
const history = await replayHistory('1', chatWith([
|
|
226
|
+
msg({ id: { _serialized: 'm3' }, timestamp: 1717000300 }),
|
|
227
|
+
msg({ id: { _serialized: 'm1' }, timestamp: 1717000100 }),
|
|
228
|
+
msg({ id: { _serialized: 'm2' }, timestamp: 1717000200 })
|
|
229
|
+
]), 50);
|
|
230
|
+
|
|
231
|
+
expect(history.map((m) => m.messageId)).toEqual(['m1', 'm2', 'm3']);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('replayHistory passes the limit through and tolerates a non-array result', async () => {
|
|
235
|
+
let seenLimit = 0;
|
|
236
|
+
await replayHistory('1', chatWith([], ({ limit }) => { seenLimit = limit; }), 37);
|
|
237
|
+
expect(seenLimit).toBe(37);
|
|
238
|
+
|
|
239
|
+
expect(await replayHistory('1', chatWith(undefined as any), 5)).toEqual([]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ── route gating (shared by both responses) ──
|
|
243
|
+
|
|
244
|
+
function readyClient(overrides: any = {}): GatedClient {
|
|
245
|
+
return {
|
|
246
|
+
state: 'AUTHENTICATED',
|
|
247
|
+
ready: true,
|
|
248
|
+
lastUsed: 0,
|
|
249
|
+
client: {
|
|
250
|
+
getChats: async () => [chat(CUST)],
|
|
251
|
+
getChatById: async (_id: string) => chatWith([msg()]),
|
|
252
|
+
getContactById: async () => { throw new Error('not needed'); }
|
|
253
|
+
},
|
|
254
|
+
...overrides
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function depsWith(data: GatedClient, overrides: Partial<HistoryDeps> = {}): HistoryDeps & { getClientCalls: number } {
|
|
259
|
+
const deps: any = {
|
|
260
|
+
getClientCalls: 0,
|
|
261
|
+
hasPaired: () => true,
|
|
262
|
+
getClient: async () => { deps.getClientCalls += 1; return data; },
|
|
263
|
+
resolveChat: async (client: any, chatId: string) => {
|
|
264
|
+
try { return await client.getChatById(chatId); } catch (_) { return null; }
|
|
265
|
+
},
|
|
266
|
+
rememberTarget: (userId: string, chatId: string) => loadTargets(userId).add(chatId),
|
|
267
|
+
...overrides
|
|
268
|
+
};
|
|
269
|
+
return deps;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
test('both routes enforce X-WA-Token before touching any client', async () => {
|
|
273
|
+
const deps = depsWith(readyClient(), { hasPaired: () => { throw new Error('gate must not run'); } });
|
|
274
|
+
|
|
275
|
+
const chats = await chatsResponse('1', 'wrong', 'expected', deps);
|
|
276
|
+
expect(chats.status).toBe(401);
|
|
277
|
+
expect(await chats.json()).toEqual({ success: false, error: 'unauthorized' });
|
|
278
|
+
|
|
279
|
+
const history = await historyResponse('1', { chatId: CUST }, undefined, 'expected', deps);
|
|
280
|
+
expect(history.status).toBe(401);
|
|
281
|
+
expect(deps.getClientCalls).toBe(0);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('both routes accept a matching token and stay open when none is configured', async () => {
|
|
285
|
+
const deps = depsWith(readyClient());
|
|
286
|
+
|
|
287
|
+
expect((await chatsResponse('1', 'secret', 'secret', deps)).status).toBe(200);
|
|
288
|
+
expect((await chatsResponse('1', undefined, undefined, deps)).status).toBe(200);
|
|
289
|
+
expect((await historyResponse('1', { chatId: CUST }, 'secret', 'secret', deps)).status).toBe(200);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('both routes fast-reject a never-paired user WITHOUT creating a client', async () => {
|
|
293
|
+
const deps = depsWith(readyClient(), { hasPaired: () => false });
|
|
294
|
+
|
|
295
|
+
const chats = await chatsResponse('1', undefined, undefined, deps);
|
|
296
|
+
expect(chats.status).toBe(401);
|
|
297
|
+
expect((await chats.json()).error).toMatch(/pair via QR/);
|
|
298
|
+
|
|
299
|
+
const history = await historyResponse('1', { chatId: CUST }, undefined, undefined, deps);
|
|
300
|
+
expect(history.status).toBe(401);
|
|
301
|
+
expect(deps.getClientCalls).toBe(0); // the /send no-Chromium rule
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('both routes answer 401 for a paired but not-ready client', async () => {
|
|
305
|
+
for (const data of [
|
|
306
|
+
readyClient({ ready: false }), // AUTHENTICATED but unready
|
|
307
|
+
readyClient({ state: 'QR_REQUIRED', ready: false }) // pairing-screen zombie
|
|
308
|
+
]) {
|
|
309
|
+
const deps = depsWith(data);
|
|
310
|
+
const chats = await chatsResponse('1', undefined, undefined, deps);
|
|
311
|
+
expect(chats.status).toBe(401);
|
|
312
|
+
expect((await chats.json()).error).toBe('User not authenticated');
|
|
313
|
+
|
|
314
|
+
const history = await historyResponse('1', { chatId: CUST }, undefined, undefined, deps);
|
|
315
|
+
expect(history.status).toBe(401);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ── GET /chats route contract ──
|
|
320
|
+
|
|
321
|
+
test('chatsResponse lists summarized chats and refreshes the idle clock', async () => {
|
|
322
|
+
const data = readyClient({
|
|
323
|
+
client: {
|
|
324
|
+
getChats: async () => [
|
|
325
|
+
chat('917@c.us', { timestamp: 200 }),
|
|
326
|
+
chat('12@g.us'),
|
|
327
|
+
chat(CUST, { timestamp: 300 })
|
|
328
|
+
]
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
const deps = depsWith(data);
|
|
332
|
+
|
|
333
|
+
const res = await chatsResponse('1', undefined, undefined, deps);
|
|
334
|
+
|
|
335
|
+
expect(res.status).toBe(200);
|
|
336
|
+
expect(await res.json()).toEqual({
|
|
337
|
+
success: true,
|
|
338
|
+
chats: [
|
|
339
|
+
{ id: CUST, name: 'Asha', lastMessageAt: 300 },
|
|
340
|
+
{ id: '917@c.us', name: 'Asha', lastMessageAt: 200 }
|
|
341
|
+
]
|
|
342
|
+
});
|
|
343
|
+
expect(data.lastUsed).toBeGreaterThan(0);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('chatsResponse maps a getChats failure to a 500 with the message', async () => {
|
|
347
|
+
const deps = depsWith(readyClient({
|
|
348
|
+
client: { getChats: async () => { throw new Error('store not hydrated'); } }
|
|
349
|
+
}));
|
|
350
|
+
|
|
351
|
+
const res = await chatsResponse('1', undefined, undefined, deps);
|
|
352
|
+
expect(res.status).toBe(500);
|
|
353
|
+
expect(await res.json()).toEqual({ success: false, error: 'store not hydrated' });
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ── POST /history route contract ──
|
|
357
|
+
|
|
358
|
+
test('historyResponse rejects a missing or non-1:1 chatId before the gate', async () => {
|
|
359
|
+
const deps = depsWith(readyClient(), { hasPaired: () => { throw new Error('gate must not run'); } });
|
|
360
|
+
|
|
361
|
+
for (const body of [undefined, {}, { chatId: '' }, { chatId: '12@g.us' }, { chatId: '9@lid' }]) {
|
|
362
|
+
const res = await historyResponse('1', body, undefined, undefined, deps);
|
|
363
|
+
expect(res.status).toBe(422);
|
|
364
|
+
expect((await res.json()).error).toMatch(/chatId/);
|
|
365
|
+
}
|
|
366
|
+
expect(deps.getClientCalls).toBe(0);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('historyResponse replays the chat, clamps the limit and allowlists the chat', async () => {
|
|
370
|
+
let seenChatId = '';
|
|
371
|
+
let seenLimit = 0;
|
|
372
|
+
const data = readyClient({
|
|
373
|
+
client: {
|
|
374
|
+
getChatById: async (id: string) => {
|
|
375
|
+
seenChatId = id;
|
|
376
|
+
return chatWith([
|
|
377
|
+
msg({ id: { _serialized: 'm2' }, timestamp: 2 }),
|
|
378
|
+
msg({ id: { _serialized: 'm1' }, timestamp: 1, type: 'image', hasMedia: true })
|
|
379
|
+
], ({ limit }) => { seenLimit = limit; });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
const deps = depsWith(data);
|
|
384
|
+
|
|
385
|
+
// Bare number body: normalized to @c.us; limit 100000 clamped to 200.
|
|
386
|
+
const res = await historyResponse('7', { chatId: '919999000001', limit: 100000 }, undefined, undefined, deps);
|
|
387
|
+
|
|
388
|
+
expect(res.status).toBe(200);
|
|
389
|
+
const payload = await res.json();
|
|
390
|
+
expect(payload.success).toBe(true);
|
|
391
|
+
expect(payload.messages.map((m: any) => m.messageId)).toEqual(['m1', 'm2']); // oldest first
|
|
392
|
+
expect(payload.messages[0]).toMatchObject({
|
|
393
|
+
hasMedia: true, mediaStatus: 'unavailable', mediaError: 'history'
|
|
394
|
+
});
|
|
395
|
+
expect(seenChatId).toBe(CUST);
|
|
396
|
+
expect(seenLimit).toBe(200);
|
|
397
|
+
expect(loadTargets('7').has(CUST)).toBe(true); // joins the reconnect allowlist
|
|
398
|
+
expect(data.lastUsed).toBeGreaterThan(0);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('historyResponse defaults the limit to 50 when the body omits it', async () => {
|
|
402
|
+
let seenLimit = 0;
|
|
403
|
+
const data = readyClient({
|
|
404
|
+
client: { getChatById: async () => chatWith([], ({ limit }) => { seenLimit = limit; }) }
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const res = await historyResponse('1', { chatId: CUST }, undefined, undefined, depsWith(data));
|
|
408
|
+
|
|
409
|
+
expect(res.status).toBe(200);
|
|
410
|
+
expect(await res.json()).toEqual({ success: true, messages: [] });
|
|
411
|
+
expect(seenLimit).toBe(DEFAULT_HISTORY_LIMIT);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('historyResponse answers 404 (and does not allowlist) when the chat never materialized', async () => {
|
|
415
|
+
let remembered = 0;
|
|
416
|
+
const deps = depsWith(readyClient(), {
|
|
417
|
+
resolveChat: async () => null,
|
|
418
|
+
rememberTarget: () => { remembered += 1; }
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const res = await historyResponse('1', { chatId: CUST }, undefined, undefined, deps);
|
|
422
|
+
|
|
423
|
+
expect(res.status).toBe(404);
|
|
424
|
+
expect(await res.json()).toEqual({ success: false, error: 'chat not found' });
|
|
425
|
+
expect(remembered).toBe(0);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test('historyResponse maps a fetch failure to a 500 with the message', async () => {
|
|
429
|
+
const deps = depsWith(readyClient(), {
|
|
430
|
+
resolveChat: async () => ({ fetchMessages: async () => { throw new Error('chat hydration failed'); } })
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const res = await historyResponse('1', { chatId: CUST }, undefined, undefined, deps);
|
|
434
|
+
expect(res.status).toBe(500);
|
|
435
|
+
expect(await res.json()).toEqual({ success: false, error: 'chat hydration failed' });
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// ── findMessage ──
|
|
439
|
+
|
|
440
|
+
const MSG_ID = 'true_919999000001@c.us_ABC';
|
|
441
|
+
|
|
442
|
+
function refetchDepsWith(
|
|
443
|
+
data: GatedClient,
|
|
444
|
+
overrides: Partial<RefetchDeps> = {}
|
|
445
|
+
): RefetchDeps & { getClientCalls: number } {
|
|
446
|
+
const deps: any = {
|
|
447
|
+
getClientCalls: 0,
|
|
448
|
+
hasPaired: () => true,
|
|
449
|
+
getClient: async () => { deps.getClientCalls += 1; return data; },
|
|
450
|
+
getMessageById: async (client: any, id: string) => client.getMessageById(id),
|
|
451
|
+
resolveChat: async (client: any, chatId: string) => {
|
|
452
|
+
try { return await client.getChatById(chatId); } catch (_) { return null; }
|
|
453
|
+
},
|
|
454
|
+
rememberTarget: () => {},
|
|
455
|
+
// Default fake media pipeline — overridden per test.
|
|
456
|
+
resolveMedia: async (): Promise<MediaResolution> => ({
|
|
457
|
+
mediaStatus: 'available', mediaMime: 'image/jpeg', mediaFilename: 'beach.jpg', mediaSize: 10
|
|
458
|
+
}),
|
|
459
|
+
...overrides
|
|
460
|
+
};
|
|
461
|
+
return deps;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
test('findMessage returns the direct getMessageById hit without scanning the chat', async () => {
|
|
465
|
+
let scanned = 0;
|
|
466
|
+
const deps = refetchDepsWith(readyClient(), {
|
|
467
|
+
resolveChat: async () => { scanned += 1; return null; }
|
|
468
|
+
});
|
|
469
|
+
const client = { getMessageById: async (id: string) => ({ id: { _serialized: id }, hasMedia: true }) };
|
|
470
|
+
|
|
471
|
+
const found = await findMessage(deps, client, MSG_ID, CUST);
|
|
472
|
+
expect(found.id._serialized).toBe(MSG_ID);
|
|
473
|
+
expect(scanned).toBe(0); // fast path hit → no fallback scan
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test('findMessage falls back to a chat scan when getMessageById misses', async () => {
|
|
477
|
+
const target = { id: { _serialized: MSG_ID }, hasMedia: true };
|
|
478
|
+
const deps = refetchDepsWith(readyClient(), {
|
|
479
|
+
getMessageById: async () => { throw new Error('not hydrated'); },
|
|
480
|
+
resolveChat: async () => chatWith([
|
|
481
|
+
{ id: { _serialized: 'other' } },
|
|
482
|
+
target
|
|
483
|
+
])
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const found = await findMessage(deps, {}, MSG_ID, CUST);
|
|
487
|
+
expect(found).toBe(target);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('findMessage returns null when neither path turns the message up', async () => {
|
|
491
|
+
// getMessageById returns null, chat scan finds no matching id.
|
|
492
|
+
const nullDirect = refetchDepsWith(readyClient(), {
|
|
493
|
+
getMessageById: async () => null,
|
|
494
|
+
resolveChat: async () => chatWith([{ id: { _serialized: 'someone-else' } }])
|
|
495
|
+
});
|
|
496
|
+
expect(await findMessage(nullDirect, {}, MSG_ID, CUST)).toBeNull();
|
|
497
|
+
|
|
498
|
+
// Chat never materialized.
|
|
499
|
+
const noChat = refetchDepsWith(readyClient(), {
|
|
500
|
+
getMessageById: async () => null,
|
|
501
|
+
resolveChat: async () => null
|
|
502
|
+
});
|
|
503
|
+
expect(await findMessage(noChat, {}, MSG_ID, CUST)).toBeNull();
|
|
504
|
+
|
|
505
|
+
// fetchMessages throws → swallowed → null (not a 500).
|
|
506
|
+
const throwing = refetchDepsWith(readyClient(), {
|
|
507
|
+
getMessageById: async () => null,
|
|
508
|
+
resolveChat: async () => ({ fetchMessages: async () => { throw new Error('boom'); } })
|
|
509
|
+
});
|
|
510
|
+
expect(await findMessage(throwing, {}, MSG_ID, CUST)).toBeNull();
|
|
511
|
+
|
|
512
|
+
// Non-array fetchMessages result tolerated.
|
|
513
|
+
const nonArray = refetchDepsWith(readyClient(), {
|
|
514
|
+
getMessageById: async () => null,
|
|
515
|
+
resolveChat: async () => ({ fetchMessages: async () => undefined as any })
|
|
516
|
+
});
|
|
517
|
+
expect(await findMessage(nonArray, {}, MSG_ID, CUST)).toBeNull();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// ── refetchResponse gating ──
|
|
521
|
+
|
|
522
|
+
test('refetchResponse enforces X-WA-Token before touching any client', async () => {
|
|
523
|
+
const deps = refetchDepsWith(readyClient(), { hasPaired: () => { throw new Error('gate must not run'); } });
|
|
524
|
+
|
|
525
|
+
const res = await refetchResponse('1', { messageId: MSG_ID, chatId: CUST }, 'wrong', 'expected', deps);
|
|
526
|
+
expect(res.status).toBe(401);
|
|
527
|
+
expect(await res.json()).toEqual({ success: false, error: 'unauthorized' });
|
|
528
|
+
expect(deps.getClientCalls).toBe(0);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test('refetchResponse rejects a missing/unsanitizable messageId or non-1:1 chatId before the gate', async () => {
|
|
532
|
+
const deps = refetchDepsWith(readyClient(), { hasPaired: () => { throw new Error('gate must not run'); } });
|
|
533
|
+
|
|
534
|
+
// Bad messageId.
|
|
535
|
+
for (const body of [undefined, {}, { messageId: '', chatId: CUST }, { messageId: '//', chatId: CUST }]) {
|
|
536
|
+
const res = await refetchResponse('1', body, undefined, undefined, deps);
|
|
537
|
+
expect(res.status).toBe(422);
|
|
538
|
+
expect((await res.json()).error).toMatch(/messageId/);
|
|
539
|
+
}
|
|
540
|
+
// Good messageId, bad chatId.
|
|
541
|
+
for (const body of [{ messageId: MSG_ID }, { messageId: MSG_ID, chatId: '12@g.us' }]) {
|
|
542
|
+
const res = await refetchResponse('1', body, undefined, undefined, deps);
|
|
543
|
+
expect(res.status).toBe(422);
|
|
544
|
+
expect((await res.json()).error).toMatch(/chatId/);
|
|
545
|
+
}
|
|
546
|
+
expect(deps.getClientCalls).toBe(0);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test('refetchResponse fast-rejects a never-paired user WITHOUT creating a client', async () => {
|
|
550
|
+
const deps = refetchDepsWith(readyClient(), { hasPaired: () => false });
|
|
551
|
+
const res = await refetchResponse('1', { messageId: MSG_ID, chatId: CUST }, undefined, undefined, deps);
|
|
552
|
+
expect(res.status).toBe(401);
|
|
553
|
+
expect((await res.json()).error).toMatch(/pair via QR/);
|
|
554
|
+
expect(deps.getClientCalls).toBe(0);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test('refetchResponse answers 401 for a paired but not-ready client', async () => {
|
|
558
|
+
const deps = refetchDepsWith(readyClient({ ready: false }));
|
|
559
|
+
const res = await refetchResponse('1', { messageId: MSG_ID, chatId: CUST }, undefined, undefined, deps);
|
|
560
|
+
expect(res.status).toBe(401);
|
|
561
|
+
expect((await res.json()).error).toBe('User not authenticated');
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// ── refetchResponse logic ──
|
|
565
|
+
|
|
566
|
+
test('refetchResponse downloads, stores (cap-enforced) and reports the media available', async () => {
|
|
567
|
+
process.env.WHATSAPP_MEDIA_MAX_USER_BYTES = '12';
|
|
568
|
+
// A live message whose downloadMedia yields 10 bytes — routed through the
|
|
569
|
+
// REAL resolveMediaForMessage so the store + cap path is exercised end to end.
|
|
570
|
+
const liveMsg = {
|
|
571
|
+
from: CUST,
|
|
572
|
+
id: { _serialized: MSG_ID },
|
|
573
|
+
timestamp: 1717000000,
|
|
574
|
+
type: 'image',
|
|
575
|
+
hasMedia: true,
|
|
576
|
+
_data: { size: 1024, mimetype: 'image/jpeg' },
|
|
577
|
+
downloadMedia: async () => ({
|
|
578
|
+
data: Buffer.from('jpeg-bytes').toString('base64'),
|
|
579
|
+
mimetype: 'image/jpeg',
|
|
580
|
+
filename: 'beach.jpg'
|
|
581
|
+
})
|
|
582
|
+
};
|
|
583
|
+
const data = readyClient({ client: { getMessageById: async () => liveMsg } });
|
|
584
|
+
const deps = refetchDepsWith(data, { resolveMedia: resolveMediaForMessage });
|
|
585
|
+
|
|
586
|
+
const res = await refetchResponse('7', { messageId: MSG_ID, chatId: CUST }, undefined, undefined, deps);
|
|
587
|
+
|
|
588
|
+
expect(res.status).toBe(200);
|
|
589
|
+
expect(await res.json()).toEqual({
|
|
590
|
+
success: true,
|
|
591
|
+
messageId: MSG_ID,
|
|
592
|
+
mediaStatus: 'available',
|
|
593
|
+
mediaMime: 'image/jpeg',
|
|
594
|
+
mediaFilename: 'beach.jpg',
|
|
595
|
+
mediaSize: 10
|
|
596
|
+
});
|
|
597
|
+
expect(mediaExists('7', MSG_ID)).not.toBeNull(); // bytes are on disk for GET /media
|
|
598
|
+
expect(userDirBytes('7')).toBe(10);
|
|
599
|
+
expect(data.lastUsed).toBeGreaterThan(0);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test('refetchResponse evicts the user oldest when the refetch blows the per-user cap', async () => {
|
|
603
|
+
process.env.WHATSAPP_MEDIA_MAX_USER_BYTES = '12';
|
|
604
|
+
// Pre-fill the user with an older 8-byte item via the real pipeline.
|
|
605
|
+
const older = {
|
|
606
|
+
from: CUST, id: { _serialized: 'older-msg' }, timestamp: 1, type: 'image', hasMedia: true,
|
|
607
|
+
_data: { mimetype: 'image/png' },
|
|
608
|
+
downloadMedia: async () => ({ data: Buffer.from('12345678').toString('base64'), mimetype: 'image/png' })
|
|
609
|
+
};
|
|
610
|
+
await resolveMediaForMessage('9', older);
|
|
611
|
+
expect(mediaExists('9', 'older-msg')).not.toBeNull();
|
|
612
|
+
|
|
613
|
+
const liveMsg = {
|
|
614
|
+
from: CUST, id: { _serialized: MSG_ID }, timestamp: 1717000000, type: 'image', hasMedia: true,
|
|
615
|
+
_data: { mimetype: 'image/jpeg' },
|
|
616
|
+
downloadMedia: async () => ({ data: Buffer.from('jpeg-bytes').toString('base64'), mimetype: 'image/jpeg' })
|
|
617
|
+
};
|
|
618
|
+
const deps = refetchDepsWith(
|
|
619
|
+
readyClient({ client: { getMessageById: async () => liveMsg } }),
|
|
620
|
+
{ resolveMedia: resolveMediaForMessage }
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
const res = await refetchResponse('9', { messageId: MSG_ID, chatId: CUST }, undefined, undefined, deps);
|
|
624
|
+
|
|
625
|
+
expect(res.status).toBe(200);
|
|
626
|
+
// 8 + 10 = 18 > 12 → the older item rolled off, the refetched one kept.
|
|
627
|
+
expect(mediaExists('9', MSG_ID)).not.toBeNull();
|
|
628
|
+
expect(mediaExists('9', 'older-msg')).toBeNull();
|
|
629
|
+
expect(userDirBytes('9')).toBe(10);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test('refetchResponse 404s gone when the message is absent upstream', async () => {
|
|
633
|
+
const data = readyClient({ client: { getMessageById: async () => null, getChatById: async () => chatWith([]) } });
|
|
634
|
+
const deps = refetchDepsWith(data);
|
|
635
|
+
|
|
636
|
+
const res = await refetchResponse('1', { messageId: MSG_ID, chatId: CUST }, undefined, undefined, deps);
|
|
637
|
+
expect(res.status).toBe(404);
|
|
638
|
+
expect(await res.json()).toEqual({ success: false, mediaStatus: 'unavailable', mediaError: 'gone' });
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test('refetchResponse 404s gone when the found message carries no media', async () => {
|
|
642
|
+
const liveMsg = { id: { _serialized: MSG_ID }, hasMedia: false };
|
|
643
|
+
const deps = refetchDepsWith(readyClient({ client: { getMessageById: async () => liveMsg } }));
|
|
644
|
+
|
|
645
|
+
const res = await refetchResponse('1', { messageId: MSG_ID, chatId: CUST }, undefined, undefined, deps);
|
|
646
|
+
expect(res.status).toBe(404);
|
|
647
|
+
expect(await res.json()).toEqual({ success: false, mediaStatus: 'unavailable', mediaError: 'gone' });
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test('refetchResponse maps an unavailable download verdict to a 404 with its typed reason', async () => {
|
|
651
|
+
const liveMsg = { id: { _serialized: MSG_ID }, hasMedia: true };
|
|
652
|
+
const deps = refetchDepsWith(
|
|
653
|
+
readyClient({ client: { getMessageById: async () => liveMsg } }),
|
|
654
|
+
{ resolveMedia: async () => ({ mediaStatus: 'unavailable', mediaError: 'expired' }) }
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
const res = await refetchResponse('1', { messageId: MSG_ID, chatId: CUST }, undefined, undefined, deps);
|
|
658
|
+
expect(res.status).toBe(404);
|
|
659
|
+
expect(await res.json()).toEqual({ success: false, mediaStatus: 'unavailable', mediaError: 'expired' });
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test('refetchResponse defaults a missing mediaError to gone and omits an absent filename', async () => {
|
|
663
|
+
// Unavailable verdict with NO mediaError → host still gets a typed 'gone'.
|
|
664
|
+
const liveMsg = { id: { _serialized: MSG_ID }, hasMedia: true };
|
|
665
|
+
const goneless = refetchDepsWith(
|
|
666
|
+
readyClient({ client: { getMessageById: async () => liveMsg } }),
|
|
667
|
+
{ resolveMedia: async () => ({ mediaStatus: 'unavailable' }) }
|
|
668
|
+
);
|
|
669
|
+
expect(await (await refetchResponse('1', { messageId: MSG_ID, chatId: CUST }, undefined, undefined, goneless)).json())
|
|
670
|
+
.toEqual({ success: false, mediaStatus: 'unavailable', mediaError: 'gone' });
|
|
671
|
+
|
|
672
|
+
// Available verdict with no filename → mediaFilename omitted from the body.
|
|
673
|
+
const noName = refetchDepsWith(
|
|
674
|
+
readyClient({ client: { getMessageById: async () => liveMsg } }),
|
|
675
|
+
{ resolveMedia: async () => ({ mediaStatus: 'available', mediaMime: 'audio/ogg', mediaSize: 3 }) }
|
|
676
|
+
);
|
|
677
|
+
const body = await (await refetchResponse('1', { messageId: MSG_ID, chatId: CUST }, undefined, undefined, noName)).json();
|
|
678
|
+
expect(body).toEqual({ success: true, messageId: MSG_ID, mediaStatus: 'available', mediaMime: 'audio/ogg', mediaSize: 3 });
|
|
679
|
+
expect('mediaFilename' in body).toBe(false);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test('refetchResponse maps an unexpected error to a 500', async () => {
|
|
683
|
+
const deps = refetchDepsWith(
|
|
684
|
+
readyClient({ client: { getMessageById: async () => { throw new Error('store exploded'); } } }),
|
|
685
|
+
// getMessageById throwing is swallowed by findMessage; force the 500 via resolveMedia.
|
|
686
|
+
{
|
|
687
|
+
getMessageById: async () => ({ id: { _serialized: MSG_ID }, hasMedia: true }),
|
|
688
|
+
resolveMedia: async () => { throw new Error('store exploded'); }
|
|
689
|
+
}
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
const res = await refetchResponse('1', { messageId: MSG_ID, chatId: CUST }, undefined, undefined, deps);
|
|
693
|
+
expect(res.status).toBe(500);
|
|
694
|
+
expect(await res.json()).toEqual({ success: false, error: 'store exploded' });
|
|
695
|
+
});
|