whatsapp_notifier 0.8.2 → 0.9.1

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,107 @@
1
+ require "spec_helper"
2
+ require "net/http"
3
+
4
+ RSpec.describe WhatsAppNotifier::ErrorCode do
5
+ describe ".from_exception" do
6
+ it "classifies a service error by its HTTP status, not its body text" do
7
+ # A 401 body that happens to mention a number must still read as auth.
8
+ error = WhatsAppNotifier::ServiceError.new(
9
+ "service request failed (401): No saved WhatsApp session for this user — pair via QR first",
10
+ status: "401"
11
+ )
12
+
13
+ expect(described_class.from_exception(error)).to eq(:auth_required)
14
+ end
15
+
16
+ it "classifies a 403 as auth and a 429 as rate limiting" do
17
+ forbidden = WhatsAppNotifier::ServiceError.new("nope", status: 403)
18
+ throttled = WhatsAppNotifier::ServiceError.new("slow down", status: "429")
19
+
20
+ expect(described_class.from_exception(forbidden)).to eq(:auth_required)
21
+ expect(described_class.from_exception(throttled)).to eq(:rate_limited)
22
+ end
23
+
24
+ # The service 422s a malformed REQUEST (a host bug), never a bad number,
25
+ # so it must NOT be classified as :invalid_phone.
26
+ it "leaves a 422 unclassified" do
27
+ error = WhatsAppNotifier::ServiceError.new("service request failed (422): `to` is required", status: 422)
28
+
29
+ expect(described_class.from_exception(error)).to eq(:delivery_exception)
30
+ end
31
+
32
+ it "classifies socket failures as service_unreachable" do
33
+ expect(described_class.from_exception(Errno::ECONNREFUSED.new)).to eq(:service_unreachable)
34
+ expect(described_class.from_exception(SocketError.new("getaddrinfo"))).to eq(:service_unreachable)
35
+ expect(described_class.from_exception(Net::OpenTimeout.new)).to eq(:service_unreachable)
36
+ end
37
+
38
+ # A read timeout is NOT the same as a connect timeout: the request went
39
+ # out, so the send may well have landed.
40
+ it "classifies a read timeout as :timeout, separate from :service_unreachable" do
41
+ expect(described_class.from_exception(Net::ReadTimeout.new)).to eq(:timeout)
42
+ end
43
+
44
+ it "falls back to the message when the class and status say nothing" do
45
+ expect(described_class.from_exception(RuntimeError.new("No LID for 919999000001"))).to eq(:recipient_unresolved)
46
+ end
47
+
48
+ it "falls back to the message for a 500 carrying a library error" do
49
+ error = WhatsAppNotifier::ServiceError.new("service request failed (500): No LID for 919999000001", status: 500)
50
+
51
+ expect(described_class.from_exception(error)).to eq(:recipient_unresolved)
52
+ end
53
+
54
+ it "leaves an opaque exception unclassified" do
55
+ expect(described_class.from_exception(RuntimeError.new("Evaluation failed: e"))).to eq(:delivery_exception)
56
+ end
57
+ end
58
+
59
+ describe ".from_message" do
60
+ {
61
+ "No LID for 919999000001" => :recipient_unresolved,
62
+ "Phone number is not registered" => :not_on_whatsapp,
63
+ "wid error: invalid wid" => :invalid_phone,
64
+ "User not authenticated" => :auth_required,
65
+ "Connection refused - connect(2) for 127.0.0.1:3001" => :service_unreachable,
66
+ "execution expired" => :timeout,
67
+ "Too Many Requests" => :rate_limited
68
+ }.each do |text, code|
69
+ it "reads #{text.inspect} as #{code}" do
70
+ expect(described_class.from_message(text)).to eq(code)
71
+ end
72
+ end
73
+
74
+ it "returns the catch-all for text it cannot place" do
75
+ expect(described_class.from_message("something new")).to eq(:delivery_exception)
76
+ expect(described_class.from_message(nil)).to eq(:delivery_exception)
77
+ end
78
+ end
79
+
80
+ describe ".normalize" do
81
+ it "folds the service's synonyms into one code" do
82
+ expect(described_class.normalize("unauthenticated")).to eq(:auth_required)
83
+ expect(described_class.normalize("NUMBER_NOT_REGISTERED")).to eq(:not_on_whatsapp)
84
+ expect(described_class.normalize(:throttled)).to eq(:rate_limited)
85
+ end
86
+
87
+ it "returns nil for a blank code so callers fall back to their own guess" do
88
+ expect(described_class.normalize(nil)).to be_nil
89
+ expect(described_class.normalize(" ")).to be_nil
90
+ end
91
+
92
+ # A newer service may introduce a code before the gem knows it — pass it
93
+ # through rather than flattening it, but only if it looks like an
94
+ # identifier.
95
+ it "passes an unknown identifier-shaped code through" do
96
+ expect(described_class.normalize("queue_full")).to eq(:queue_full)
97
+ end
98
+
99
+ it "flattens code-shaped garbage to the catch-all" do
100
+ expect(described_class.normalize("<html>502 Bad Gateway</html>")).to eq(:delivery_exception)
101
+ end
102
+ end
103
+
104
+ it "lists every code it can return" do
105
+ expect(described_class::ALL).to include(described_class::UNCLASSIFIED)
106
+ end
107
+ end
@@ -69,6 +69,68 @@ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
69
69
  end
70
70
  end
71
71
 
72
+ # Error codes are what hosts branch on to decide whether a retry could
73
+ # double-message someone, so a raised exception must survive as a code and
74
+ # not only as text.
75
+ it "classifies the raised exception into an error code" do
76
+ Dir.mktmpdir do |dir|
77
+ adapter = double
78
+ allow(adapter).to receive(:send_message)
79
+ .and_raise(WhatsAppNotifier::ServiceError.new("service request failed (401): User not authenticated", status: 401))
80
+ allow(adapter).to receive(:fetch_qr_code).and_return("qr")
81
+ allow(adapter).to receive(:connection_status).and_return({})
82
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
83
+ provider = described_class.new(configuration: config)
84
+
85
+ result = provider.deliver(to: "+1", body: "h")
86
+
87
+ expect(result.error_code).to eq(:auth_required)
88
+ # The message field is untouched — hosts still fingerprinting it work.
89
+ expect(result.error_message).to eq("service request failed (401): User not authenticated")
90
+ end
91
+ end
92
+
93
+ it "classifies a failure the adapter reported without raising" do
94
+ Dir.mktmpdir do |dir|
95
+ adapter = double
96
+ allow(adapter).to receive(:send_message)
97
+ .and_return(success: false, session: {}, error_message: "No LID for 919999000001")
98
+ allow(adapter).to receive(:fetch_qr_code).and_return("qr")
99
+ allow(adapter).to receive(:connection_status).and_return({})
100
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
101
+ provider = described_class.new(configuration: config)
102
+
103
+ expect(provider.deliver(to: "+1", body: "h").error_code).to eq(:recipient_unresolved)
104
+ end
105
+ end
106
+
107
+ it "keeps a code the adapter supplied instead of re-guessing from the text" do
108
+ Dir.mktmpdir do |dir|
109
+ adapter = double
110
+ allow(adapter).to receive(:send_message)
111
+ .and_return(success: false, session: {}, error_code: :not_on_whatsapp, error_message: "No LID for 919999000001")
112
+ allow(adapter).to receive(:fetch_qr_code).and_return("qr")
113
+ allow(adapter).to receive(:connection_status).and_return({})
114
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
115
+ provider = described_class.new(configuration: config)
116
+
117
+ expect(provider.deliver(to: "+1", body: "h").error_code).to eq(:not_on_whatsapp)
118
+ end
119
+ end
120
+
121
+ it "leaves a successful send without an error code" do
122
+ Dir.mktmpdir do |dir|
123
+ adapter = double
124
+ allow(adapter).to receive(:send_message).and_return(success: true, message_id: "w1", session: {})
125
+ allow(adapter).to receive(:fetch_qr_code).and_return("qr")
126
+ allow(adapter).to receive(:connection_status).and_return({})
127
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
128
+ provider = described_class.new(configuration: config)
129
+
130
+ expect(provider.deliver(to: "+1", body: "h").error_code).to be_nil
131
+ end
132
+ end
133
+
72
134
  it "raises for missing adapter methods in scan" do
73
135
  Dir.mktmpdir do |dir|
74
136
  adapter = Object.new
@@ -0,0 +1,114 @@
1
+ require "spec_helper"
2
+
3
+ # WhatsAppNotifier.session_ready? — the one call hosts make before sending, so
4
+ # they stop each writing their own "authenticated == true, rescue to false".
5
+ RSpec.describe "session readiness" do
6
+ def configure(adapter)
7
+ WhatsAppNotifier.configure do |config|
8
+ config.provider = :web_automation
9
+ config.web_automation_enabled = true
10
+ config.web_adapter = adapter
11
+ config.logger = Logger.new(nil)
12
+ config.warn_on_risky_provider = false
13
+ end
14
+ end
15
+
16
+ def adapter_returning(status)
17
+ adapter = double(send_message: { success: true, session: {} }, fetch_qr_code: "qr")
18
+ allow(adapter).to receive(:connection_status).and_return(status)
19
+ adapter
20
+ end
21
+
22
+ def adapter_raising(error)
23
+ adapter = double(send_message: { success: true, session: {} }, fetch_qr_code: "qr")
24
+ allow(adapter).to receive(:connection_status).and_raise(error)
25
+ adapter
26
+ end
27
+
28
+ it "is true only when the service reports an authenticated session" do
29
+ configure(adapter_returning(state: "AUTHENTICATED", authenticated: true))
30
+
31
+ expect(WhatsAppNotifier.session_ready?(user_id: 7)).to be(true)
32
+ end
33
+
34
+ it "is false when the session is not authenticated" do
35
+ configure(adapter_returning(state: "QR_REQUIRED", authenticated: false))
36
+
37
+ expect(WhatsAppNotifier.session_ready?(user_id: 7)).to be(false)
38
+ end
39
+
40
+ # Truthy-but-not-true (a "yes" string from a sloppy adapter) must not read
41
+ # as ready — the check is deliberately exact.
42
+ it "is false for a truthy non-true authenticated value" do
43
+ configure(adapter_returning(authenticated: "yes"))
44
+
45
+ expect(WhatsAppNotifier.session_ready?(user_id: 7)).to be(false)
46
+ end
47
+
48
+ it "is false when the adapter answers with something that is not a hash" do
49
+ configure(adapter_returning(nil))
50
+
51
+ expect(WhatsAppNotifier.session_ready?(user_id: 7)).to be(false)
52
+ end
53
+
54
+ # An unreachable status endpoint means the service is down, so sends can't
55
+ # succeed either — not ready, and logged rather than raised.
56
+ it "is false and logs when the status endpoint is unreachable" do
57
+ logger = double(warn: true)
58
+ configure(adapter_raising(Errno::ECONNREFUSED))
59
+ WhatsAppNotifier.configuration.logger = logger
60
+
61
+ expect(WhatsAppNotifier.session_ready?(user_id: 7)).to be(false)
62
+ expect(logger).to have_received(:warn).with(/session_ready\? failed/)
63
+ end
64
+
65
+ it "survives a nil logger" do
66
+ configure(adapter_raising(Errno::ECONNREFUSED))
67
+ WhatsAppNotifier.configuration.logger = nil
68
+
69
+ expect(WhatsAppNotifier.session_ready?(user_id: 7)).to be(false)
70
+ end
71
+
72
+ # A ConfigurationError is a boot-time mistake in the HOST. Swallowing it
73
+ # would quietly park every campaign behind a "session down" backoff.
74
+ it "does not swallow a configuration error" do
75
+ configure(adapter_returning(authenticated: true))
76
+ WhatsAppNotifier.configuration.web_automation_enabled = false
77
+
78
+ expect { WhatsAppNotifier.session_ready?(user_id: 7) }
79
+ .to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
80
+ end
81
+
82
+ it "passes the user id through as status metadata" do
83
+ adapter = adapter_returning(authenticated: true)
84
+ configure(adapter)
85
+
86
+ WhatsAppNotifier.session_ready?(user_id: 42)
87
+
88
+ expect(adapter).to have_received(:connection_status).with(metadata: { user_id: 42 })
89
+ end
90
+
91
+ it "accepts a metadata hash instead of user_id" do
92
+ adapter = adapter_returning(authenticated: true)
93
+ configure(adapter)
94
+
95
+ WhatsAppNotifier.session_ready?(metadata: { user_id: 9, tenant: "x" })
96
+
97
+ expect(adapter).to have_received(:connection_status).with(metadata: { user_id: 9, tenant: "x" })
98
+ end
99
+
100
+ it "asks for the default session when neither is given" do
101
+ adapter = adapter_returning(authenticated: true)
102
+ configure(adapter)
103
+
104
+ WhatsAppNotifier.session_ready?
105
+
106
+ expect(adapter).to have_received(:connection_status).with(metadata: {})
107
+ end
108
+
109
+ it "is not implemented by a bare provider" do
110
+ provider = WhatsAppNotifier::Providers::Base.new(configuration: WhatsAppNotifier.configuration)
111
+
112
+ expect { provider.session_ready? }.to raise_error(NotImplementedError)
113
+ end
114
+ end
@@ -104,6 +104,41 @@ RSpec.describe WhatsAppNotifier::WebAdapter do
104
104
  end.to raise_error(/service request failed/)
105
105
  end
106
106
 
107
+ # The status is what ErrorCode classifies on — re-deriving it from the
108
+ # message text would be a regex round-trip through our own string.
109
+ it "carries the HTTP status on the raised service error" do
110
+ allow(Net::HTTP).to receive(:start)
111
+ .and_return(http_failure(code: "401", body: JSON.generate({ error: "User not authenticated" })))
112
+
113
+ expect { adapter.send_message(payload: { to: "+1", body: "hi" }, session: {}) }
114
+ .to raise_error(WhatsAppNotifier::ServiceError) { |error|
115
+ expect(error.status.to_i).to eq(401)
116
+ # Pre-0.9.0 this was a bare RuntimeError; hosts rescue that class by
117
+ # name around media fetches and status polls.
118
+ expect(error).to be_a(RuntimeError)
119
+ # Wording unchanged from 0.8.x — hosts that fingerprinted it still match.
120
+ expect(error.message).to eq("service request failed (401): User not authenticated")
121
+ }
122
+ end
123
+
124
+ # Forward compatibility: a future service can name the failure itself and
125
+ # the gem stops guessing from text.
126
+ it "normalizes an error code the service supplied" do
127
+ response = http_success(body: { "success" => false, "errorCode" => "NUMBER_NOT_REGISTERED", "error" => "boom" })
128
+ allow(Net::HTTP).to receive(:start).and_return(response)
129
+
130
+ result = adapter.send_message(payload: { to: "+1", body: "hi" }, session: {})
131
+
132
+ expect(result).to include(success: false, error_code: :not_on_whatsapp, error_message: "boom")
133
+ end
134
+
135
+ it "leaves error_code nil when the service does not send one" do
136
+ response = http_success(body: { "success" => false, "error" => "boom" })
137
+ allow(Net::HTTP).to receive(:start).and_return(response)
138
+
139
+ expect(adapter.send_message(payload: { to: "+1", body: "hi" }, session: {})[:error_code]).to be_nil
140
+ end
141
+
107
142
  it "handles empty and invalid response bodies gracefully" do
108
143
  empty_body = instance_double(Net::HTTPOK, body: "", code: "200", is_a?: true)
109
144
  invalid_body = instance_double(Net::HTTPInternalServerError, body: "raw-error", code: "500", is_a?: false)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: whatsapp_notifier
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha
@@ -56,6 +56,7 @@ files:
56
56
  - app/controllers/whatsapp_notifier/sessions_controller.rb
57
57
  - bin/whatsapp_notifier
58
58
  - config/routes.rb
59
+ - docs/CONFIGURATION.md
59
60
  - docs/bulk_messaging_policy.md
60
61
  - docs/graphify.md
61
62
  - docs/rails_setup.md
@@ -72,6 +73,7 @@ files:
72
73
  - lib/whatsapp_notifier/configuration.rb
73
74
  - lib/whatsapp_notifier/doctor.rb
74
75
  - lib/whatsapp_notifier/engine.rb
76
+ - lib/whatsapp_notifier/error_code.rb
75
77
  - lib/whatsapp_notifier/errors.rb
76
78
  - lib/whatsapp_notifier/jobs/send_message_job.rb
77
79
  - lib/whatsapp_notifier/notification.rb
@@ -107,6 +109,7 @@ files:
107
109
  - spec/client_spec.rb
108
110
  - spec/configuration_spec.rb
109
111
  - spec/doctor_spec.rb
112
+ - spec/error_code_spec.rb
110
113
  - spec/generators/install_service_generator_spec.rb
111
114
  - spec/jobs/send_message_job_spec.rb
112
115
  - spec/notification_spec.rb
@@ -116,6 +119,7 @@ files:
116
119
  - spec/result_spec.rb
117
120
  - spec/session/qr_service_spec.rb
118
121
  - spec/session/store_spec.rb
122
+ - spec/session_ready_spec.rb
119
123
  - spec/spec_helper.rb
120
124
  - spec/web_adapter_spec.rb
121
125
  - spec/whatsapp_notifier_spec.rb