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.
@@ -6,6 +6,36 @@ module WhatsAppNotifier
6
6
  class WebAdapter
7
7
  DEFAULT_OPEN_TIMEOUT = 5
8
8
  DEFAULT_READ_TIMEOUT = 30
9
+ # Media bytes can be tens of MB over a slow link — give the binary fetch a
10
+ # longer read window than the JSON control plane.
11
+ MEDIA_OPEN_TIMEOUT = 5
12
+ MEDIA_READ_TIMEOUT = 60
13
+
14
+ HTTP_CLASSES = {
15
+ post: Net::HTTP::Post,
16
+ get: Net::HTTP::Get,
17
+ delete: Net::HTTP::Delete
18
+ }.freeze
19
+
20
+ # Optional inbound keys introduced by the 0.7.0 service (media verdict +
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.
28
+ INBOUND_OPTIONAL_KEYS = {
29
+ has_media: %w[hasMedia has_media],
30
+ media_status: %w[mediaStatus media_status],
31
+ media_error: %w[mediaError media_error],
32
+ media_mime: %w[mediaMime media_mime],
33
+ media_filename: %w[mediaFilename media_filename],
34
+ media_size: %w[mediaSize media_size],
35
+ sender_name: %w[senderName sender_name],
36
+ to: %w[to],
37
+ from_me: %w[fromMe from_me]
38
+ }.freeze
9
39
 
10
40
  def self.default_base_url
11
41
  ENV["WHATSAPP_NOTIFIER_SERVICE_URL"] || ENV["WHATSAPP_SERVICE_URL"] || "http://127.0.0.1:3001"
@@ -30,7 +60,12 @@ module WhatsAppNotifier
30
60
  response = request(:post, "/send/#{user_id}", body: body)
31
61
  {
32
62
  success: response.fetch("success"),
33
- 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}",
34
69
  session: session,
35
70
  error_message: response["error"]
36
71
  }
@@ -60,15 +95,88 @@ module WhatsAppNotifier
60
95
  user_id = user_id_from(metadata)
61
96
  response = request(:get, "/inbound/#{user_id}")
62
97
  raw = response.is_a?(Hash) ? response["messages"] : response
63
- Array(raw).map do |m|
64
- {
65
- from: m["from"],
66
- body: m["body"],
67
- message_id: m["messageId"] || m["message_id"],
68
- timestamp: m["timestamp"],
69
- type: m["type"]
70
- }
71
- end
98
+ Array(raw).map { |m| map_inbound_message(m) }
99
+ end
100
+
101
+ # Fetches the raw bytes of a downloaded inbound media file. Returns
102
+ # { body:, mime:, filename:, size: } or nil when the service has no copy
103
+ # (never downloaded, swept by TTL, or already deleted).
104
+ #
105
+ # Deliberately NOT routed through #request: that path JSON-parses the
106
+ # response body (and host apps are known to patch it further), which would
107
+ # corrupt binary payloads.
108
+ def fetch_media(message_id:, metadata: {})
109
+ user_id = user_id_from(metadata)
110
+ res = binary_get("/media/#{user_id}/#{path_id(message_id)}")
111
+ return nil if res.code.to_s == "404"
112
+ raise "service request failed (#{res.code}): #{res.body}" unless res.is_a?(Net::HTTPSuccess)
113
+
114
+ body = res.body.to_s
115
+ {
116
+ body: body,
117
+ mime: res["Content-Type"],
118
+ filename: filename_from(res["Content-Disposition"]),
119
+ size: body.bytesize
120
+ }
121
+ end
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
+
146
+ # Removes the service's copy after the host has attached the bytes.
147
+ # Idempotent on the service side: deleting absent media still succeeds.
148
+ # A 0.6.0 service mid-rollout has no /media routes and answers 404 —
149
+ # degrade to { success: false } instead of raising, mirroring
150
+ # fetch_media's nil-on-404.
151
+ def delete_media(message_id:, metadata: {})
152
+ user_id = user_id_from(metadata)
153
+ response = request(:delete, "/media/#{user_id}/#{path_id(message_id)}", allow_404: true)
154
+ { success: response.fetch("success", false) }
155
+ end
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) }
72
180
  end
73
181
 
74
182
  # Logs the user out of WhatsApp and clears their saved session on the service.
@@ -78,24 +186,102 @@ module WhatsAppNotifier
78
186
  { success: response.fetch("success", false) }
79
187
  end
80
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
+
81
194
  private
82
195
 
83
196
  def user_id_from(metadata)
84
197
  (metadata[:user_id] || metadata["user_id"] || "default").to_s
85
198
  end
86
199
 
87
- def request(method, path, body: nil)
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
+
216
+ def map_inbound_message(message)
217
+ mapped = {
218
+ from: message["from"],
219
+ body: message["body"],
220
+ message_id: message["messageId"] || message["message_id"],
221
+ timestamp: message["timestamp"],
222
+ type: message["type"]
223
+ }
224
+ INBOUND_OPTIONAL_KEYS.each do |key, wire_keys|
225
+ wire = wire_keys.find { |candidate| message.key?(candidate) }
226
+ mapped[key] = message[wire] if wire
227
+ end
228
+ mapped
229
+ end
230
+
231
+ # Mirror the service-side sanitizeId charset so a hostile message_id can
232
+ # never smuggle path separators or a query string into the request URL.
233
+ def path_id(message_id)
234
+ message_id.to_s.gsub(/[^A-Za-z0-9@._-]/, "")
235
+ end
236
+
237
+ def filename_from(content_disposition)
238
+ content_disposition.to_s[/filename="([^"]*)"/, 1]
239
+ end
240
+
241
+ # The /media routes are token-gated when the service has
242
+ # WHATSAPP_WEBHOOK_TOKEN set — the same shared secret the service uses to
243
+ # sign its webhook pushes, reused in the other direction.
244
+ def webhook_token
245
+ token = ENV["WHATSAPP_WEBHOOK_TOKEN"].to_s
246
+ token.empty? ? nil : token
247
+ end
248
+
249
+ # Net::HTTP does NOT infer TLS from the URL scheme — without an explicit
250
+ # use_ssl a https:// service URL would silently speak plaintext to port
251
+ # 443. Both request paths (JSON control plane + binary media fetch) must
252
+ # honor the scheme.
253
+ def use_ssl?(uri)
254
+ uri.scheme == "https"
255
+ end
256
+
257
+ def binary_get(path)
258
+ uri = URI.parse("#{@base_url}#{path}")
259
+ req = Net::HTTP::Get.new(uri.request_uri)
260
+ req["X-WA-Token"] = webhook_token if webhook_token
261
+
262
+ Net::HTTP.start(uri.host, uri.port,
263
+ use_ssl: use_ssl?(uri),
264
+ open_timeout: MEDIA_OPEN_TIMEOUT,
265
+ read_timeout: MEDIA_READ_TIMEOUT) { |http| http.request(req) }
266
+ end
267
+
268
+ def request(method, path, body: nil, allow_404: false)
88
269
  uri = URI.parse("#{@base_url}#{path}")
89
- klass = method == :post ? Net::HTTP::Post : Net::HTTP::Get
90
- req = klass.new(uri.request_uri)
270
+ req = HTTP_CLASSES.fetch(method).new(uri.request_uri)
91
271
  req["Content-Type"] = "application/json"
272
+ req["X-WA-Token"] = webhook_token if webhook_token
92
273
  req.body = JSON.generate(body) if body
93
274
 
94
275
  res = Net::HTTP.start(uri.host, uri.port,
276
+ use_ssl: use_ssl?(uri),
95
277
  open_timeout: @open_timeout,
96
278
  read_timeout: @read_timeout) { |http| http.request(req) }
97
279
  parsed = parse_body(res.body)
98
280
  return parsed if res.is_a?(Net::HTTPSuccess)
281
+ # Callers opting in treat "route/resource not there" as a soft miss
282
+ # (e.g. delete_media against a 0.6.0 service) — the parsed error body
283
+ # carries no "success" key, so they degrade rather than raise.
284
+ return parsed if allow_404 && res.code.to_s == "404"
99
285
 
100
286
  raise "service request failed (#{res.code}): #{parsed["error"] || res.body}"
101
287
  end
@@ -66,6 +66,26 @@ module WhatsAppNotifier
66
66
  client.fetch_inbound(provider: provider, metadata: metadata)
67
67
  end
68
68
 
69
+ def fetch_media(message_id:, provider: nil, metadata: {})
70
+ client.fetch_media(message_id: message_id, provider: provider, metadata: metadata)
71
+ end
72
+
73
+ def delete_media(message_id:, provider: nil, metadata: {})
74
+ client.delete_media(message_id: message_id, provider: provider, metadata: metadata)
75
+ end
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
+
69
89
  def logout(provider: nil, metadata: {})
70
90
  client.logout(provider: provider, metadata: metadata)
71
91
  end
data/spec/client_spec.rb CHANGED
@@ -67,6 +67,54 @@ RSpec.describe WhatsAppNotifier::Client do
67
67
  end
68
68
  end
69
69
 
70
+ it "delegates fetch_media and delete_media to the provider" do
71
+ Dir.mktmpdir do |dir|
72
+ config.provider = :web_automation
73
+ config.web_automation_enabled = true
74
+ config.web_session_path = File.join(dir, "session.json")
75
+ config.web_adapter = double(
76
+ send_message: { success: true, session: {} },
77
+ fetch_qr_code: "qr",
78
+ connection_status: { state: "AUTHENTICATED", authenticated: true },
79
+ fetch_media: { body: "bytes", mime: "image/jpeg", filename: nil, size: 5 },
80
+ delete_media: { success: true },
81
+ refetch_media: { mime: "image/jpeg", filename: nil, size: 5, status: "available" }
82
+ )
83
+ client = described_class.new(configuration: config)
84
+
85
+ expect(client.fetch_media(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 }))
86
+ .to include(body: "bytes", size: 5)
87
+ expect(client.delete_media(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 }))
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 })
115
+ end
116
+ end
117
+
70
118
  it "delegates logout to the provider" do
71
119
  Dir.mktmpdir do |dir|
72
120
  config.provider = :web_automation
@@ -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 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
@@ -69,6 +69,17 @@ RSpec.describe "WhatsAppNotifier::Generators::InstallServiceGenerator" do
69
69
  "SERVICE_FILES lists #{file} but it is missing from the service dir"
70
70
  end
71
71
  end
72
+
73
+ # An ejected service that is missing a module index.ts imports dies on
74
+ # boot — pin the file list to the imports so a new module (like media.ts
75
+ # in 0.7.0) can never be forgotten again.
76
+ it "ships every local module index.ts imports" do
77
+ index = File.read(File.join(generator_class.source_root, "index.ts"), encoding: "UTF-8")
78
+ local_imports = index.scan(%r{from '\./([A-Za-z0-9_]+)'}).flatten.map { |name| "#{name}.ts" }
79
+
80
+ expect(local_imports).not_to be_empty
81
+ expect(generator_class.const_get(:SERVICE_FILES)).to include(*local_imports)
82
+ end
72
83
  end
73
84
 
74
85
  describe "#add_to_gitignore" do
@@ -137,6 +137,103 @@ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
137
137
  end
138
138
  end
139
139
 
140
+ it "fetches, deletes and refetches media via the adapter when enabled" do
141
+ Dir.mktmpdir do |dir|
142
+ adapter = double(
143
+ fetch_qr_code: "qr", connection_status: {},
144
+ fetch_media: { body: "bytes", mime: "image/jpeg", filename: "a.jpg", size: 5 },
145
+ delete_media: { success: true },
146
+ refetch_media: { mime: "image/jpeg", filename: "a.jpg", size: 5, status: "available" }
147
+ )
148
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
149
+ provider = described_class.new(configuration: config)
150
+
151
+ expect(provider.fetch_media(message_id: "m1", metadata: { user_id: 1 })).to include(mime: "image/jpeg")
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")
155
+ expect(adapter).to have_received(:fetch_media).with(message_id: "m1", metadata: { user_id: 1 })
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 })
158
+ end
159
+ end
160
+
161
+ it "raises on the media helpers when the provider is disabled" do
162
+ Dir.mktmpdir do |dir|
163
+ adapter = double(fetch_qr_code: "qr", connection_status: {}, fetch_media: nil, delete_media: { success: true }, refetch_media: nil)
164
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter, enabled: false)
165
+ provider = described_class.new(configuration: config)
166
+
167
+ expect { provider.fetch_media(message_id: "m1") }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
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/)
170
+ end
171
+ end
172
+
173
+ it "raises on the media helpers when the adapter lacks media support" do
174
+ Dir.mktmpdir do |dir|
175
+ adapter = double(fetch_qr_code: "qr", connection_status: {})
176
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
177
+ provider = described_class.new(configuration: config)
178
+
179
+ expect { provider.fetch_media(message_id: "m1") }.to raise_error(WhatsAppNotifier::ConfigurationError, /media fetch/)
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/)
234
+ end
235
+ end
236
+
140
237
  it "logs out via adapter when enabled" do
141
238
  Dir.mktmpdir do |dir|
142
239
  adapter = double(fetch_qr_code: "qr", connection_status: {}, logout: { success: true })