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.
@@ -34,7 +34,7 @@ export interface MediaMeta {
34
34
  capturedAt: number; // epoch ms
35
35
  }
36
36
 
37
- export type MediaSkipReason = 'unsupported_type' | 'too_large' | 'disk_full';
37
+ export type MediaSkipReason = 'unsupported_type' | 'too_large';
38
38
  export type MediaFailureReason = MediaSkipReason | 'expired' | 'download_failed' | 'invalid_id';
39
39
 
40
40
  // The verdict captureInbound merges into the inbound payload. Structurally
@@ -76,6 +76,13 @@ export function maxDiskBytes(): number {
76
76
  return envLimit('WHATSAPP_MEDIA_MAX_DISK_BYTES', 5 * 1024 * 1024 * 1024); // 5GB
77
77
  }
78
78
 
79
+ // Per-user rolling cap (default 1GB). This is the cap that actually shapes disk
80
+ // use: maxDiskBytes() is only an absolute global backstop now. NaN-safe like
81
+ // the others so a malformed env can't silently disable eviction.
82
+ export function maxUserBytes(): number {
83
+ return envLimit('WHATSAPP_MEDIA_MAX_USER_BYTES', 1024 * 1024 * 1024); // 1GB
84
+ }
85
+
79
86
  // ── Root + cap accounting ──
80
87
 
81
88
  // index.ts wires this to <SESSION_BASE_DIR>/media; tests to a tmp dir.
@@ -110,6 +117,24 @@ function computeDiskBytes(): number {
110
117
  return total;
111
118
  }
112
119
 
120
+ // Payload bytes stored for ONE user — sidecars excluded, exactly as
121
+ // computeDiskBytes counts them, so the per-user total and the global total
122
+ // stay on the same scale and the eviction decrement keeps cachedDiskBytes
123
+ // honest.
124
+ export function userDirBytes(userId: string): number {
125
+ const safeUser = sanitizeId(userId);
126
+ if (!safeUser) return 0;
127
+ let total = 0;
128
+ try {
129
+ const dir = join(mediaRootResolver(), safeUser);
130
+ for (const file of readdirSync(dir)) {
131
+ if (isSidecarName(file)) continue;
132
+ try { total += statSync(join(dir, file)).size; } catch (_) { /* raced a delete */ }
133
+ }
134
+ } catch (_) { /* user dir not created yet → nothing stored */ }
135
+ return total;
136
+ }
137
+
113
138
  // ── Id sanitization + path layout ──
114
139
 
115
140
  // Both route params become path segments, so they must be reduced to a safe
@@ -170,6 +195,12 @@ export function writeMedia(
170
195
  };
171
196
  writeFileSync(paths.metaPath, JSON.stringify(sidecar));
172
197
  if (cachedDiskBytes !== null) cachedDiskBytes += data.byteLength;
198
+ // WhatsApp's own model: keep the recent media, roll the old off. The
199
+ // newly written file pushed this user over their cap? Evict their
200
+ // oldest until they fit again. An evicted item is not lost — the host
201
+ // re-downloads it on demand via POST /media/:userId/refetch when an
202
+ // operator opens that bubble.
203
+ enforceUserCap(userId);
173
204
  return true;
174
205
  } catch (e) {
175
206
  console.error(`Failed to persist media ${messageId} for ${userId}`, e);
@@ -177,6 +208,53 @@ export function writeMedia(
177
208
  }
178
209
  }
179
210
 
211
+ // Roll the user back under maxUserBytes() by deleting their OLDEST media pair
212
+ // (by file mtime — same pairing/ordering logic as sweepExpired) one at a time.
213
+ // Bounded by the file count: each pass either deletes one pair or stops, so it
214
+ // can never spin (a user already at/under the cap exits immediately, and a
215
+ // single oversize file that alone exceeds the cap is deleted once and the loop
216
+ // ends with the dir empty). cachedDiskBytes is decremented per eviction so
217
+ // downloadPolicy's global backstop and mediaDiskBytes stay accurate.
218
+ export function enforceUserCap(userId: string): number {
219
+ const safeUser = sanitizeId(userId);
220
+ if (!safeUser) return 0;
221
+ const cap = maxUserBytes();
222
+ const dir = join(mediaRootResolver(), safeUser);
223
+
224
+ let evicted = 0;
225
+ let total = userDirBytes(userId);
226
+ // Bound the loop by the directory's file count — defensive belt over the
227
+ // total-shrinks-each-pass invariant.
228
+ let guard = 0;
229
+ while (total > cap) {
230
+ const oldest = oldestMediaName(dir);
231
+ if (!oldest) break; // nothing left to evict (cap smaller than 0 bytes is impossible)
232
+ // deleteMedia decrements cachedDiskBytes by the payload size for us.
233
+ deleteMedia(userId, oldest);
234
+ evicted += 1;
235
+ total = userDirBytes(userId);
236
+ guard += 1;
237
+ if (guard > 100000) break; // pathological safety valve
238
+ }
239
+ return evicted;
240
+ }
241
+
242
+ // The user's oldest media payload by capturedAt (sidecar) / mtime (fallback) —
243
+ // same age source sweepExpired uses — or null when the dir holds no payloads.
244
+ // Sidecars are skipped; an orphaned sidecar is left for sweepExpired to reap.
245
+ function oldestMediaName(dir: string): string | null {
246
+ let oldestName: string | null = null;
247
+ let oldestAt = Infinity;
248
+ try {
249
+ for (const file of readdirSync(dir)) {
250
+ if (isSidecarName(file)) continue;
251
+ const at = capturedAtFor(join(dir, file), `${join(dir, file)}${SIDECAR_SUFFIX}`);
252
+ if (at < oldestAt) { oldestAt = at; oldestName = file; }
253
+ }
254
+ } catch (_) { /* dir gone → nothing to evict */ }
255
+ return oldestName;
256
+ }
257
+
180
258
  // Returns the sidecar when BOTH the bytes and the sidecar are present —
181
259
  // captureInbound uses this to skip re-downloading on a reconnect backfill.
182
260
  export function mediaExists(userId: string, messageId: string): MediaMeta | null {
@@ -257,7 +335,14 @@ export function downloadPolicy(type: string, size: number, viewOnce = false): Me
257
335
  if (viewOnce || !DOWNLOADABLE_TYPES.has(type)) return { download: false, reason: 'unsupported_type' };
258
336
  const cap = type === 'document' ? maxDocumentBytes() : INLINE_MEDIA_MAX_BYTES;
259
337
  if (size > cap) return { download: false, reason: 'too_large' };
260
- if (mediaDiskBytes() + size > maxDiskBytes()) return { download: false, reason: 'disk_full' };
338
+ // No disk check here any more. A user is NEVER skipped on disk grounds:
339
+ // the per-user 1GB cap is enforced AFTER each successful write by
340
+ // enforceUserCap, which rolls the user's OLDEST media off (WhatsApp's
341
+ // tap-to-download model — an evicted item re-downloads on demand). Because
342
+ // we download-then-evict, we only ever write at most one per-message cap
343
+ // (≤25MB) over the limit before rolling back under it — harmless. The
344
+ // per-MESSAGE size gate above still stands; maxDiskBytes() remains only an
345
+ // absolute global backstop that the per-user cap keeps us far beneath.
261
346
  return { download: true };
262
347
  }
263
348
 
@@ -320,8 +405,13 @@ export async function resolveMediaForMessage(
320
405
  deps: { timeoutMs?: number } = {}
321
406
  ): Promise<MediaResolution> {
322
407
  // Must mirror normalizeInbound's messageId fallback (inbound.ts) so the
323
- // stored file is addressable by the id the host received.
324
- const messageId = (msg?.id && msg.id._serialized) || `${msg?.from || ''}-${msg?.timestamp}`;
408
+ // stored file is addressable by the id the host received. Like there, the
409
+ // fallback keys on the COUNTERPARTY: on fromMe the `from` is the
410
+ // operator's own jid, shared by every chat — keying on it would store the
411
+ // bytes under one id while the wire advertises another (host GET 404s),
412
+ // and two same-second sends to different customers would collide on disk.
413
+ const counterparty = (msg?.fromMe ? msg?.to : msg?.from) || '';
414
+ const messageId = (msg?.id && msg.id._serialized) || `${counterparty}-${msg?.timestamp}`;
325
415
  if (!mediaPaths(userId, messageId)) {
326
416
  return { mediaStatus: 'unavailable', mediaError: 'invalid_id' };
327
417
  }
@@ -0,0 +1,20 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { sentMessageId } from './send';
3
+
4
+ // The id the host stores against its outbound record — it MUST be the real
5
+ // serialized WhatsApp id so the fromMe echo of this send dedupes on it.
6
+ test('sentMessageId returns the serialized id of the sent message', () => {
7
+ const sent = { id: { _serialized: 'true_919999000001@c.us_ABC' } };
8
+ expect(sentMessageId(sent)).toBe('true_919999000001@c.us_ABC');
9
+ });
10
+
11
+ // Null fallback, never a fabricated id: a made-up id matches no echo but
12
+ // would still occupy the host's unique message-id slot, blocking the echo
13
+ // from being adopted onto the right record.
14
+ test('sentMessageId falls back to null when no id is available', () => {
15
+ expect(sentMessageId(undefined)).toBeNull(); // library resolved nothing
16
+ expect(sentMessageId(null)).toBeNull();
17
+ expect(sentMessageId({})).toBeNull(); // Message without an id
18
+ expect(sentMessageId({ id: {} })).toBeNull(); // id without a serialization
19
+ expect(sentMessageId({ id: { _serialized: '' } })).toBeNull(); // empty id is no id
20
+ });
@@ -0,0 +1,17 @@
1
+ // /send response helpers (pure, unit-testable — see send.test.ts).
2
+ //
3
+ // Kept separate from index.ts (which calls Bun.serve() at import time) so the
4
+ // wire shape can be tested without booting the server or whatsapp-web.js.
5
+
6
+ // The real WhatsApp id of a just-sent message, for the /send response. Hosts
7
+ // store it on their outbound record so the message_create echo of this very
8
+ // send (two-way capture replays our own messages too) dedupes on messageId
9
+ // instead of duplicating as an "operator app" bubble.
10
+ //
11
+ // Null — never a fabricated id — when the library hands nothing back: a
12
+ // made-up id matches no echo, yet would still occupy the host's unique
13
+ // message-id slot and block the echo from being adopted onto the right
14
+ // record.
15
+ export function sentMessageId(sent: any): string | null {
16
+ return (sent && sent.id && sent.id._serialized) || null;
17
+ }
@@ -1,4 +1,4 @@
1
1
  module WhatsAppNotifier
2
- VERSION = "0.7.0"
2
+ VERSION = "0.8.0"
3
3
 
4
4
  end
@@ -18,9 +18,13 @@ module WhatsAppNotifier
18
18
  }.freeze
19
19
 
20
20
  # Optional inbound keys introduced by the 0.7.0 service (media verdict +
21
- # sender display name). Mapped ONLY when the wire payload carries them, so
22
- # hosts can key-gate on has_media presence: a missing key means "0.6.0
23
- # service, no media support", while has_media: false means "text message".
21
+ # sender display name) and the 0.8.0 service (two-way capture). Mapped
22
+ # ONLY when the wire payload carries them, so hosts can key-gate on
23
+ # presence: a missing has_media means "0.6.0 service, no media support"
24
+ # (while has_media: false means "text message"), and a missing from_me
25
+ # means "customer message or pre-0.8.0 service". `to` carries the
26
+ # counterparty chat id on operator-sent (from_me) messages — the id the
27
+ # host threads the conversation on.
24
28
  INBOUND_OPTIONAL_KEYS = {
25
29
  has_media: %w[hasMedia has_media],
26
30
  media_status: %w[mediaStatus media_status],
@@ -28,7 +32,9 @@ module WhatsAppNotifier
28
32
  media_mime: %w[mediaMime media_mime],
29
33
  media_filename: %w[mediaFilename media_filename],
30
34
  media_size: %w[mediaSize media_size],
31
- sender_name: %w[senderName sender_name]
35
+ sender_name: %w[senderName sender_name],
36
+ to: %w[to],
37
+ from_me: %w[fromMe from_me]
32
38
  }.freeze
33
39
 
34
40
  def self.default_base_url
@@ -54,7 +60,12 @@ module WhatsAppNotifier
54
60
  response = request(:post, "/send/#{user_id}", body: body)
55
61
  {
56
62
  success: response.fetch("success"),
57
- message_id: payload[:idempotency_key] || "local-#{Time.now.to_i}",
63
+ # Prefer the service-issued WhatsApp message id (0.8.0): it is the key
64
+ # the host dedupes the send's own fromMe echo on, so a real id must
65
+ # win over the locally fabricated one. The fallback keeps 0.7.0
66
+ # services (no messageId in the response) working unchanged.
67
+ message_id: response["messageId"] || response["message_id"] ||
68
+ payload[:idempotency_key] || "local-#{Time.now.to_i}",
58
69
  session: session,
59
70
  error_message: response["error"]
60
71
  }
@@ -109,6 +120,29 @@ module WhatsAppNotifier
109
120
  }
110
121
  end
111
122
 
123
+ # On-demand re-download (WhatsApp tap-to-download). The host calls this when
124
+ # an operator opens a media bubble whose bytes the service no longer holds
125
+ # (rolled off by the per-user cap or expired by TTL): the service re-pulls
126
+ # THAT one message's media and stores it, after which the host fetches it
127
+ # with the usual fetch_media GET. Returns { mime:, filename:, size:, status: }
128
+ # on success, or nil when the media is gone upstream (404) — same nil-on-404
129
+ # contract as fetch_media, so a host that gets nil can grey the bubble out.
130
+ # A 0.7.0 service mid-rollout has no /refetch route and also answers 404 →
131
+ # nil, indistinguishable from gone, which is the safe degrade.
132
+ def refetch_media(message_id:, chat_id:, metadata: {})
133
+ user_id = user_id_from(metadata)
134
+ body = { messageId: message_id, chatId: chat_id }
135
+ response = request(:post, "/media/#{user_id}/refetch", body: body, allow_404: true)
136
+ return nil unless response["success"]
137
+
138
+ {
139
+ mime: response["mediaMime"] || response["media_mime"],
140
+ filename: response["mediaFilename"] || response["media_filename"],
141
+ size: response["mediaSize"] || response["media_size"],
142
+ status: response["mediaStatus"] || response["media_status"]
143
+ }
144
+ end
145
+
112
146
  # Removes the service's copy after the host has attached the bytes.
113
147
  # Idempotent on the service side: deleting absent media still succeeds.
114
148
  # A 0.6.0 service mid-rollout has no /media routes and answers 404 —
@@ -120,6 +154,31 @@ module WhatsAppNotifier
120
154
  { success: response.fetch("success", false) }
121
155
  end
122
156
 
157
+ # Lists the paired number's 1:1 chats for history-sync discovery. Returns
158
+ # [{ id:, name:, last_message_at: }] newest-first; the service caps the
159
+ # list at its newest 500 and excludes groups/status/privacy chats. The
160
+ # route is token-gated like /media and raises the standard error on any
161
+ # non-2xx (401 when the user never paired or isn't ready).
162
+ def list_chats(metadata: {})
163
+ user_id = user_id_from(metadata)
164
+ response = request(:get, "/chats/#{user_id}")
165
+ Array(response["chats"]).map { |chat| map_chat_summary(chat) }
166
+ end
167
+
168
+ # Replays one chat's history through the service's live-capture
169
+ # normalizer and returns it synchronously (no queue, no webhook) —
170
+ # oldest-first, mapped exactly like fetch_inbound messages, including
171
+ # from_me/to on the operator's side of the conversation. History media
172
+ # arrives marked unavailable by design (media_error "history"): the
173
+ # service never bulk-downloads old media; live capture handles bytes
174
+ # going forward.
175
+ def fetch_history(chat_id:, limit: 50, metadata: {})
176
+ user_id = user_id_from(metadata)
177
+ body = { chatId: chat_id, limit: clamp_history_limit(limit) }
178
+ response = request(:post, "/history/#{user_id}", body: body)
179
+ Array(response["messages"]).map { |m| map_inbound_message(m) }
180
+ end
181
+
123
182
  # Logs the user out of WhatsApp and clears their saved session on the service.
124
183
  def logout(metadata: {})
125
184
  user_id = user_id_from(metadata)
@@ -127,12 +186,33 @@ module WhatsAppNotifier
127
186
  { success: response.fetch("success", false) }
128
187
  end
129
188
 
189
+ # Mirrors the service-side clamp (history.ts) so a host-passed limit can
190
+ # never balloon one request into a session-stalling bulk fetch.
191
+ HISTORY_LIMIT_DEFAULT = 50
192
+ HISTORY_LIMIT_RANGE = (1..200).freeze
193
+
130
194
  private
131
195
 
132
196
  def user_id_from(metadata)
133
197
  (metadata[:user_id] || metadata["user_id"] || "default").to_s
134
198
  end
135
199
 
200
+ def clamp_history_limit(limit)
201
+ Integer(limit).clamp(HISTORY_LIMIT_RANGE.min, HISTORY_LIMIT_RANGE.max)
202
+ rescue ArgumentError, TypeError
203
+ # Non-integer garbage falls back to the default — the service does the
204
+ # same, so both layers agree on the effective page size.
205
+ HISTORY_LIMIT_DEFAULT
206
+ end
207
+
208
+ def map_chat_summary(chat)
209
+ {
210
+ id: chat["id"],
211
+ name: chat["name"],
212
+ last_message_at: chat.key?("lastMessageAt") ? chat["lastMessageAt"] : chat["last_message_at"]
213
+ }
214
+ end
215
+
136
216
  def map_inbound_message(message)
137
217
  mapped = {
138
218
  from: message["from"],
@@ -74,6 +74,18 @@ module WhatsAppNotifier
74
74
  client.delete_media(message_id: message_id, provider: provider, metadata: metadata)
75
75
  end
76
76
 
77
+ def refetch_media(message_id:, chat_id:, provider: nil, metadata: {})
78
+ client.refetch_media(message_id: message_id, chat_id: chat_id, provider: provider, metadata: metadata)
79
+ end
80
+
81
+ def list_chats(provider: nil, metadata: {})
82
+ client.list_chats(provider: provider, metadata: metadata)
83
+ end
84
+
85
+ def fetch_history(chat_id:, limit: 50, provider: nil, metadata: {})
86
+ client.fetch_history(chat_id: chat_id, limit: limit, provider: provider, metadata: metadata)
87
+ end
88
+
77
89
  def logout(provider: nil, metadata: {})
78
90
  client.logout(provider: provider, metadata: metadata)
79
91
  end
data/spec/client_spec.rb CHANGED
@@ -77,7 +77,8 @@ RSpec.describe WhatsAppNotifier::Client do
77
77
  fetch_qr_code: "qr",
78
78
  connection_status: { state: "AUTHENTICATED", authenticated: true },
79
79
  fetch_media: { body: "bytes", mime: "image/jpeg", filename: nil, size: 5 },
80
- delete_media: { success: true }
80
+ delete_media: { success: true },
81
+ refetch_media: { mime: "image/jpeg", filename: nil, size: 5, status: "available" }
81
82
  )
82
83
  client = described_class.new(configuration: config)
83
84
 
@@ -85,6 +86,32 @@ RSpec.describe WhatsAppNotifier::Client do
85
86
  .to include(body: "bytes", size: 5)
86
87
  expect(client.delete_media(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 }))
87
88
  .to eq(success: true)
89
+ expect(client.refetch_media(message_id: "m1", chat_id: "919@c.us", provider: :web_automation, metadata: { user_id: 1 }))
90
+ .to include(status: "available")
91
+ end
92
+ end
93
+
94
+ it "delegates list_chats and fetch_history to the provider" do
95
+ Dir.mktmpdir do |dir|
96
+ config.provider = :web_automation
97
+ config.web_automation_enabled = true
98
+ config.web_session_path = File.join(dir, "session.json")
99
+ adapter = double(
100
+ send_message: { success: true, session: {} },
101
+ fetch_qr_code: "qr",
102
+ connection_status: { state: "AUTHENTICATED", authenticated: true },
103
+ list_chats: [{ id: "919@c.us", name: "Asha", last_message_at: 9 }],
104
+ fetch_history: [{ from: "919@c.us", body: "old", message_id: "h1" }]
105
+ )
106
+ config.web_adapter = adapter
107
+ client = described_class.new(configuration: config)
108
+
109
+ expect(client.list_chats(provider: :web_automation, metadata: { user_id: 1 }))
110
+ .to eq([{ id: "919@c.us", name: "Asha", last_message_at: 9 }])
111
+ expect(client.fetch_history(chat_id: "919@c.us", provider: :web_automation, metadata: { user_id: 1 }))
112
+ .to eq([{ from: "919@c.us", body: "old", message_id: "h1" }])
113
+ # The default page size survives the delegation chain untouched.
114
+ expect(adapter).to have_received(:fetch_history).with(chat_id: "919@c.us", limit: 50, metadata: { user_id: 1 })
88
115
  end
89
116
  end
90
117
 
@@ -58,7 +58,7 @@ RSpec.describe "WhatsAppNotifier::Generators::InstallServiceGenerator" do
58
58
  generator.copy_service_files
59
59
 
60
60
  sources = generator.copied.map(&:first)
61
- expect(sources).to match_array(%w[index.ts inbound.ts init_gate.ts media.ts metrics.ts sessions.ts package.json bun.lock])
61
+ expect(sources).to match_array(%w[index.ts history.ts inbound.ts init_gate.ts media.ts metrics.ts send.ts sessions.ts package.json bun.lock])
62
62
  expect(sources.grep(/test|node_modules|\.wwebjs|\.puppeteer/)).to be_empty
63
63
  expect(generator.copied.map(&:last)).to all(start_with("whatsapp_service/"))
64
64
  end
@@ -137,31 +137,36 @@ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
137
137
  end
138
138
  end
139
139
 
140
- it "fetches and deletes media via the adapter when enabled" do
140
+ it "fetches, deletes and refetches media via the adapter when enabled" do
141
141
  Dir.mktmpdir do |dir|
142
142
  adapter = double(
143
143
  fetch_qr_code: "qr", connection_status: {},
144
144
  fetch_media: { body: "bytes", mime: "image/jpeg", filename: "a.jpg", size: 5 },
145
- delete_media: { success: true }
145
+ delete_media: { success: true },
146
+ refetch_media: { mime: "image/jpeg", filename: "a.jpg", size: 5, status: "available" }
146
147
  )
147
148
  config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
148
149
  provider = described_class.new(configuration: config)
149
150
 
150
151
  expect(provider.fetch_media(message_id: "m1", metadata: { user_id: 1 })).to include(mime: "image/jpeg")
151
152
  expect(provider.delete_media(message_id: "m1", metadata: { user_id: 1 })).to eq(success: true)
153
+ expect(provider.refetch_media(message_id: "m1", chat_id: "919@c.us", metadata: { user_id: 1 }))
154
+ .to include(status: "available")
152
155
  expect(adapter).to have_received(:fetch_media).with(message_id: "m1", metadata: { user_id: 1 })
153
156
  expect(adapter).to have_received(:delete_media).with(message_id: "m1", metadata: { user_id: 1 })
157
+ expect(adapter).to have_received(:refetch_media).with(message_id: "m1", chat_id: "919@c.us", metadata: { user_id: 1 })
154
158
  end
155
159
  end
156
160
 
157
161
  it "raises on the media helpers when the provider is disabled" do
158
162
  Dir.mktmpdir do |dir|
159
- adapter = double(fetch_qr_code: "qr", connection_status: {}, fetch_media: nil, delete_media: { success: true })
163
+ adapter = double(fetch_qr_code: "qr", connection_status: {}, fetch_media: nil, delete_media: { success: true }, refetch_media: nil)
160
164
  config = build_config(path: File.join(dir, "session.json"), adapter: adapter, enabled: false)
161
165
  provider = described_class.new(configuration: config)
162
166
 
163
167
  expect { provider.fetch_media(message_id: "m1") }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
164
168
  expect { provider.delete_media(message_id: "m1") }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
169
+ expect { provider.refetch_media(message_id: "m1", chat_id: "919@c.us") }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
165
170
  end
166
171
  end
167
172
 
@@ -173,6 +178,59 @@ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
173
178
 
174
179
  expect { provider.fetch_media(message_id: "m1") }.to raise_error(WhatsAppNotifier::ConfigurationError, /media fetch/)
175
180
  expect { provider.delete_media(message_id: "m1") }.to raise_error(WhatsAppNotifier::ConfigurationError, /media deletion/)
181
+ expect { provider.refetch_media(message_id: "m1", chat_id: "919@c.us") }.to raise_error(WhatsAppNotifier::ConfigurationError, /media refetch/)
182
+ end
183
+ end
184
+
185
+ it "lists chats and fetches history via the adapter when enabled" do
186
+ Dir.mktmpdir do |dir|
187
+ adapter = double(
188
+ fetch_qr_code: "qr", connection_status: {},
189
+ list_chats: [{ id: "919@c.us", name: "Asha", last_message_at: 9 }],
190
+ fetch_history: [{ from: "919@c.us", body: "old", message_id: "h1" }]
191
+ )
192
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
193
+ provider = described_class.new(configuration: config)
194
+
195
+ expect(provider.list_chats(metadata: { user_id: 1 })).to eq([{ id: "919@c.us", name: "Asha", last_message_at: 9 }])
196
+ expect(provider.fetch_history(chat_id: "919@c.us", limit: 20, metadata: { user_id: 1 }))
197
+ .to eq([{ from: "919@c.us", body: "old", message_id: "h1" }])
198
+ expect(adapter).to have_received(:list_chats).with(metadata: { user_id: 1 })
199
+ expect(adapter).to have_received(:fetch_history).with(chat_id: "919@c.us", limit: 20, metadata: { user_id: 1 })
200
+ end
201
+ end
202
+
203
+ it "defaults the history limit to 50 through the provider" do
204
+ Dir.mktmpdir do |dir|
205
+ adapter = double(fetch_qr_code: "qr", connection_status: {}, fetch_history: [])
206
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
207
+ provider = described_class.new(configuration: config)
208
+
209
+ provider.fetch_history(chat_id: "919@c.us")
210
+
211
+ expect(adapter).to have_received(:fetch_history).with(chat_id: "919@c.us", limit: 50, metadata: {})
212
+ end
213
+ end
214
+
215
+ it "raises on the history helpers when the provider is disabled" do
216
+ Dir.mktmpdir do |dir|
217
+ adapter = double(fetch_qr_code: "qr", connection_status: {}, list_chats: [], fetch_history: [])
218
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter, enabled: false)
219
+ provider = described_class.new(configuration: config)
220
+
221
+ expect { provider.list_chats }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
222
+ expect { provider.fetch_history(chat_id: "919@c.us") }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
223
+ end
224
+ end
225
+
226
+ it "raises on the history helpers when the adapter lacks history support" do
227
+ Dir.mktmpdir do |dir|
228
+ adapter = double(fetch_qr_code: "qr", connection_status: {})
229
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
230
+ provider = described_class.new(configuration: config)
231
+
232
+ expect { provider.list_chats }.to raise_error(WhatsAppNotifier::ConfigurationError, /chat listing/)
233
+ expect { provider.fetch_history(chat_id: "919@c.us") }.to raise_error(WhatsAppNotifier::ConfigurationError, /history replay/)
176
234
  end
177
235
  end
178
236