whatsapp_notifier 0.6.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.
@@ -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
+ });