whatsapp_notifier 0.2.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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +7 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +126 -0
  5. data/Rakefile +6 -0
  6. data/bin/whatsapp_notifier +53 -0
  7. data/docs/bulk_messaging_policy.md +30 -0
  8. data/docs/graphify.md +108 -0
  9. data/docs/rails_setup.md +57 -0
  10. data/examples/notification_example.rb +14 -0
  11. data/lib/generators/whatsapp_notifier/install_generator.rb +60 -0
  12. data/lib/generators/whatsapp_notifier/install_service_generator.rb +33 -0
  13. data/lib/generators/whatsapp_notifier/templates/whatsapp_notifier.rb +6 -0
  14. data/lib/whatsapp_notifier/bulk/dispatcher.rb +64 -0
  15. data/lib/whatsapp_notifier/bulk/rate_limiter.rb +17 -0
  16. data/lib/whatsapp_notifier/bulk/retry_policy.rb +32 -0
  17. data/lib/whatsapp_notifier/client.rb +43 -0
  18. data/lib/whatsapp_notifier/configuration.rb +41 -0
  19. data/lib/whatsapp_notifier/doctor.rb +103 -0
  20. data/lib/whatsapp_notifier/errors.rb +5 -0
  21. data/lib/whatsapp_notifier/jobs/send_message_job.rb +20 -0
  22. data/lib/whatsapp_notifier/notification.rb +93 -0
  23. data/lib/whatsapp_notifier/providers/base.rb +24 -0
  24. data/lib/whatsapp_notifier/providers/web_automation.rb +85 -0
  25. data/lib/whatsapp_notifier/railtie.rb +14 -0
  26. data/lib/whatsapp_notifier/result.rb +23 -0
  27. data/lib/whatsapp_notifier/services/web_automation/bun.lock +452 -0
  28. data/lib/whatsapp_notifier/services/web_automation/index.ts +285 -0
  29. data/lib/whatsapp_notifier/services/web_automation/package.json +14 -0
  30. data/lib/whatsapp_notifier/session/qr_service.rb +51 -0
  31. data/lib/whatsapp_notifier/session/store.rb +22 -0
  32. data/lib/whatsapp_notifier/version.rb +4 -0
  33. data/lib/whatsapp_notifier/web_adapter.rb +72 -0
  34. data/lib/whatsapp_notifier.rb +72 -0
  35. data/spec/bulk/dispatcher_spec.rb +73 -0
  36. data/spec/bulk/rate_limiter_spec.rb +27 -0
  37. data/spec/bulk/retry_policy_spec.rb +33 -0
  38. data/spec/client_spec.rb +52 -0
  39. data/spec/configuration_spec.rb +47 -0
  40. data/spec/doctor_spec.rb +46 -0
  41. data/spec/jobs/send_message_job_spec.rb +36 -0
  42. data/spec/notification_spec.rb +60 -0
  43. data/spec/providers/base_spec.rb +17 -0
  44. data/spec/providers/web_automation_spec.rb +109 -0
  45. data/spec/railtie_spec.rb +37 -0
  46. data/spec/result_spec.rb +12 -0
  47. data/spec/session/qr_service_spec.rb +42 -0
  48. data/spec/session/store_spec.rb +21 -0
  49. data/spec/spec_helper.rb +17 -0
  50. data/spec/web_adapter_spec.rb +55 -0
  51. data/spec/whatsapp_notifier_spec.rb +102 -0
  52. metadata +126 -0
@@ -0,0 +1,52 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe WhatsAppNotifier::Client do
4
+ let(:config) { WhatsAppNotifier::Configuration.new }
5
+
6
+ it "routes to web automation provider by default" do
7
+ Dir.mktmpdir do |dir|
8
+ config.provider = :web_automation
9
+ config.web_automation_enabled = true
10
+ config.web_session_path = File.join(dir, "session.json")
11
+ config.web_adapter = double(
12
+ send_message: { success: true, session: {} },
13
+ fetch_qr_code: "qr",
14
+ connection_status: { state: "AUTHENTICATED", authenticated: true }
15
+ )
16
+ client = described_class.new(configuration: config)
17
+
18
+ result = client.deliver(to: "+1", body: "h")
19
+ expect(result.provider).to eq(:web_automation)
20
+ end
21
+ end
22
+
23
+ it "raises for unknown provider" do
24
+ client = described_class.new(configuration: config)
25
+ expect { client.deliver(to: "+1", body: "h", provider: :bad) }.to raise_error(WhatsAppNotifier::ConfigurationError, /unknown provider/)
26
+ end
27
+
28
+ it "delegates bulk and qr operations" do
29
+ Dir.mktmpdir do |dir|
30
+ config.provider = :web_automation
31
+ config.bulk_base_delay_seconds = 0
32
+ config.bulk_jitter_seconds = 0
33
+ config.web_automation_enabled = true
34
+ config.web_session_path = File.join(dir, "session.json")
35
+ config.web_adapter = double(
36
+ send_message: { success: true, session: {} },
37
+ fetch_qr_code: "qr",
38
+ connection_status: { state: "QR_REQUIRED", authenticated: false }
39
+ )
40
+ client = described_class.new(configuration: config)
41
+
42
+ summary = client.deliver_bulk([{ to: "+1", body: "a" }], sleeper: ->(_seconds) {})
43
+ expect(summary[:success]).to eq(1)
44
+
45
+ qr = client.scan_qr(provider: :web_automation)
46
+ expect(qr).to eq("qr")
47
+
48
+ status = client.connection_status(provider: :web_automation)
49
+ expect(status).to include(state: "QR_REQUIRED")
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,47 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe WhatsAppNotifier::Configuration do
4
+ it "has safe defaults" do
5
+ config = described_class.new
6
+
7
+ expect(config.provider).to eq(:web_automation)
8
+ expect(config.web_automation_enabled).to be(true)
9
+ expect(config.bulk_max_recipients).to be > 0
10
+ end
11
+
12
+ it "raises when provider is missing" do
13
+ config = described_class.new
14
+ config.provider = nil
15
+
16
+ expect { config.validate! }.to raise_error(WhatsAppNotifier::ConfigurationError, /provider is required/)
17
+ end
18
+
19
+ it "raises when bulk_max_recipients is invalid" do
20
+ config = described_class.new
21
+ config.bulk_max_recipients = 0
22
+
23
+ expect { config.validate! }.to raise_error(WhatsAppNotifier::ConfigurationError, /bulk_max_recipients/)
24
+ end
25
+
26
+ it "raises when bulk_max_attempts is invalid" do
27
+ config = described_class.new
28
+ config.bulk_max_attempts = 0
29
+
30
+ expect { config.validate! }.to raise_error(WhatsAppNotifier::ConfigurationError, /bulk_max_attempts/)
31
+ end
32
+
33
+ it "raises when provider is not web_automation" do
34
+ config = described_class.new
35
+ config.provider = :official_api
36
+ config.web_adapter = double(send_message: {}, fetch_qr_code: "qr", connection_status: {})
37
+
38
+ expect { config.validate! }.to raise_error(WhatsAppNotifier::ConfigurationError, /only :web_automation provider is supported/)
39
+ end
40
+
41
+ it "raises when web_adapter is missing required methods" do
42
+ config = described_class.new
43
+ config.web_adapter = Object.new
44
+
45
+ expect { config.validate! }.to raise_error(WhatsAppNotifier::ConfigurationError, /web_adapter must be configured/)
46
+ end
47
+ end
@@ -0,0 +1,46 @@
1
+ require "spec_helper"
2
+ require "tmpdir"
3
+
4
+ RSpec.describe WhatsAppNotifier::Doctor do
5
+ describe ".session_dir" do
6
+ it "uses provided env path when set" do
7
+ env = { "WHATSAPP_SESSION_DIR" => "/tmp/custom_sessions" }
8
+ expect(described_class.session_dir(env: env, app_root: "/app")).to eq("/tmp/custom_sessions")
9
+ end
10
+ end
11
+
12
+ describe ".run" do
13
+ it "returns true when dependencies and paths are valid" do
14
+ Dir.mktmpdir do |tmpdir|
15
+ env = {
16
+ "PUPPETEER_EXECUTABLE_PATH" => "/bin/sh",
17
+ "WHATSAPP_SESSION_DIR" => File.join(tmpdir, "sessions"),
18
+ "WHATSAPP_NOTIFIER_SERVICE_URL" => "http://127.0.0.1:3001"
19
+ }
20
+ io = StringIO.new
21
+
22
+ allow(described_class).to receive(:system).with("bun --version > /dev/null 2>&1").and_return(true)
23
+
24
+ expect(described_class.run(io: io, env: env, app_root: tmpdir)).to be(true)
25
+ expect(io.string).to include("PASS: Bun installed")
26
+ end
27
+ end
28
+
29
+ it "returns false and prints fixes when checks fail" do
30
+ Dir.mktmpdir do |tmpdir|
31
+ env = {
32
+ "PUPPETEER_EXECUTABLE_PATH" => "/missing/chrome",
33
+ "WHATSAPP_NOTIFIER_SERVICE_URL" => "not-a-url"
34
+ }
35
+ io = StringIO.new
36
+
37
+ allow(described_class).to receive(:system).with("bun --version > /dev/null 2>&1").and_return(false)
38
+ allow(FileUtils).to receive(:mkdir_p)
39
+ allow(File).to receive(:write).and_raise(Errno::EACCES)
40
+
41
+ expect(described_class.run(io: io, env: env, app_root: tmpdir)).to be(false)
42
+ expect(io.string).to include("Quick fixes:")
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,36 @@
1
+ require "spec_helper"
2
+
3
+ class JobNotification < WhatsAppNotifier::Notification
4
+ def message
5
+ "job message"
6
+ end
7
+ end
8
+
9
+ RSpec.describe WhatsAppNotifier::Jobs::SendMessageJob do
10
+ before do
11
+ WhatsAppNotifier.configure do |config|
12
+ config.provider = :web_automation
13
+ config.web_automation_enabled = true
14
+ config.web_adapter = double(
15
+ send_message: { success: true, session: {} },
16
+ fetch_qr_code: "qr",
17
+ connection_status: { state: "AUTHENTICATED", authenticated: true }
18
+ )
19
+ end
20
+ end
21
+
22
+ it "performs now by resolving class name" do
23
+ result = described_class.perform_now("JobNotification", to: "+1")
24
+ expect(result).to be_success
25
+ end
26
+
27
+ it "raises when perform_later has no active job base" do
28
+ hide_const("ActiveJob")
29
+ expect { described_class.perform_later("JobNotification", to: "+1") }.to raise_error(LoadError)
30
+ end
31
+
32
+ it "allows perform_later when active job base is present" do
33
+ stub_const("ActiveJob::Base", Class.new)
34
+ expect { described_class.perform_later("JobNotification", to: "+1") }.not_to raise_error
35
+ end
36
+ end
@@ -0,0 +1,60 @@
1
+ require "spec_helper"
2
+
3
+ class TestNotification < WhatsAppNotifier::Notification
4
+ template :hello, "Hi {{name}}"
5
+ to "+100"
6
+ provider :web_automation
7
+ end
8
+
9
+ class ExplicitMessageNotification < WhatsAppNotifier::Notification
10
+ def message
11
+ "direct-body"
12
+ end
13
+ end
14
+
15
+ RSpec.describe WhatsAppNotifier::Notification do
16
+ before do
17
+ WhatsAppNotifier.configure do |config|
18
+ config.provider = :web_automation
19
+ config.web_automation_enabled = true
20
+ config.web_adapter = double(
21
+ send_message: { success: true, metadata: {}, session: {} },
22
+ fetch_qr_code: "qr",
23
+ connection_status: { state: "AUTHENTICATED", authenticated: true }
24
+ )
25
+ end
26
+ end
27
+
28
+ it "delivers a template based notification" do
29
+ result = TestNotification.deliver_now(params: { name: "Neha" })
30
+ expect(result).to be_success
31
+ end
32
+
33
+ it "supports deliver_later when active job exists" do
34
+ stub_const("ActiveJob::Base", Class.new)
35
+ expect { TestNotification.deliver_later(params: { name: "Neha" }) }.not_to raise_error
36
+ end
37
+
38
+ it "raises for deliver_later without active job" do
39
+ hide_const("ActiveJob")
40
+ expect { TestNotification.deliver_later(params: { name: "Neha" }) }.to raise_error(LoadError)
41
+ end
42
+
43
+ it "raises when recipient is missing" do
44
+ expect { ExplicitMessageNotification.deliver_now }.to raise_error(WhatsAppNotifier::ConfigurationError, /recipient/)
45
+ end
46
+
47
+ it "raises when template not found" do
48
+ notification = TestNotification.with(template: :unknown)
49
+ expect { notification.message }.to raise_error(WhatsAppNotifier::ConfigurationError, /template not found/)
50
+ end
51
+
52
+ it "raises when neither message nor template exist" do
53
+ expect { WhatsAppNotifier::Notification.new.message }.to raise_error(NotImplementedError)
54
+ end
55
+
56
+ it "uses explicit message implementation" do
57
+ result = ExplicitMessageNotification.deliver_now(to: "+1")
58
+ expect(result).to be_success
59
+ end
60
+ end
@@ -0,0 +1,17 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe WhatsAppNotifier::Providers::Base do
4
+ let(:provider) { described_class.new(configuration: WhatsAppNotifier.configuration) }
5
+
6
+ it "requires deliver implementation" do
7
+ expect { provider.deliver({}) }.to raise_error(NotImplementedError)
8
+ end
9
+
10
+ it "does not support qr by default" do
11
+ expect { provider.scan_qr }.to raise_error(NotImplementedError)
12
+ end
13
+
14
+ it "does not support connection status by default" do
15
+ expect { provider.connection_status }.to raise_error(NotImplementedError)
16
+ end
17
+ end
@@ -0,0 +1,109 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
4
+ def build_config(path:, adapter:, enabled: true, warn: true, logger: Logger.new(nil))
5
+ config = WhatsAppNotifier::Configuration.new
6
+ config.web_session_path = path
7
+ config.web_adapter = adapter
8
+ config.web_automation_enabled = enabled
9
+ config.warn_on_risky_provider = warn
10
+ config.logger = logger
11
+ config
12
+ end
13
+
14
+ it "raises on scan when provider disabled" do
15
+ Dir.mktmpdir do |dir|
16
+ adapter = double(fetch_qr_code: "qr", connection_status: {})
17
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter, enabled: false)
18
+ provider = described_class.new(configuration: config)
19
+
20
+ expect { provider.scan_qr }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
21
+ end
22
+ end
23
+
24
+ it "scans qr when enabled" do
25
+ Dir.mktmpdir do |dir|
26
+ adapter = double(fetch_qr_code: "qr", connection_status: {})
27
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
28
+ provider = described_class.new(configuration: config)
29
+
30
+ expect(provider.scan_qr).to eq("qr")
31
+ end
32
+ end
33
+
34
+ it "delivers and persists session from adapter response" do
35
+ Dir.mktmpdir do |dir|
36
+ logger = double(warn: true)
37
+ adapter = double
38
+ allow(adapter).to receive(:send_message).and_return(
39
+ success: true,
40
+ message_id: "w1",
41
+ session: { active: true, token: "x" }
42
+ )
43
+ allow(adapter).to receive(:fetch_qr_code).and_return("qr")
44
+ allow(adapter).to receive(:connection_status).and_return(state: "AUTHENTICATED", authenticated: true)
45
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter, logger: logger)
46
+ provider = described_class.new(configuration: config)
47
+
48
+ result = provider.deliver(to: "+1", body: "h")
49
+ expect(result).to be_success
50
+ expect(result.message_id).to eq("w1")
51
+
52
+ provider.deliver(to: "+1", body: "h")
53
+ expect(logger).to have_received(:warn).once
54
+ end
55
+ end
56
+
57
+ it "returns failure on adapter error" do
58
+ Dir.mktmpdir do |dir|
59
+ adapter = double
60
+ allow(adapter).to receive(:send_message).and_raise("crash")
61
+ allow(adapter).to receive(:fetch_qr_code).and_return("qr")
62
+ allow(adapter).to receive(:connection_status).and_return({})
63
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
64
+ provider = described_class.new(configuration: config)
65
+
66
+ result = provider.deliver(to: "+1", body: "h")
67
+ expect(result).to be_failure
68
+ expect(result.error_code).to eq(:delivery_exception)
69
+ end
70
+ end
71
+
72
+ it "raises for missing adapter methods in scan" do
73
+ Dir.mktmpdir do |dir|
74
+ adapter = Object.new
75
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
76
+ provider = described_class.new(configuration: config)
77
+
78
+ expect { provider.scan_qr }.to raise_error(WhatsAppNotifier::ConfigurationError, /web_adapter/)
79
+ end
80
+ end
81
+
82
+ it "returns connection status via adapter" do
83
+ Dir.mktmpdir do |dir|
84
+ adapter = double(fetch_qr_code: "qr", connection_status: { state: "QR_REQUIRED", authenticated: false })
85
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
86
+ provider = described_class.new(configuration: config)
87
+
88
+ expect(provider.connection_status(metadata: { user_id: 1 })).to include(state: "QR_REQUIRED")
89
+ end
90
+ end
91
+
92
+ it "isolates sessions by metadata user_id" do
93
+ Dir.mktmpdir do |dir|
94
+ adapter = double(fetch_qr_code: "qr", connection_status: {})
95
+ allow(adapter).to receive(:send_message).and_return(
96
+ { success: true, session: { token: "user-a-token" } },
97
+ { success: true, session: { token: "user-b-token" } }
98
+ )
99
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
100
+ provider = described_class.new(configuration: config)
101
+
102
+ provider.deliver(to: "+1", body: "a", metadata: { user_id: "user-a" })
103
+ provider.deliver(to: "+2", body: "b", metadata: { user_id: "user-b" })
104
+
105
+ expect(adapter).to have_received(:send_message).with(payload: hash_including(metadata: { user_id: "user-a" }), session: {}).once
106
+ expect(adapter).to have_received(:send_message).with(payload: hash_including(metadata: { user_id: "user-b" }), session: {}).once
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,37 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe "WhatsAppNotifier::Railtie" do
4
+ it "applies app config values to gem configuration" do
5
+ Object.send(:remove_const, :Rails) if defined?(Rails)
6
+ WhatsAppNotifier.send(:remove_const, :Railtie) if defined?(WhatsAppNotifier::Railtie)
7
+
8
+ stub_const("Rails", Module.new)
9
+
10
+ railtie_base = Class.new do
11
+ class << self
12
+ attr_accessor :captured_initializer
13
+ end
14
+
15
+ def self.config
16
+ @config ||= Struct.new(:whatsapp_notifier).new({})
17
+ end
18
+
19
+ def self.initializer(_name, &block)
20
+ self.captured_initializer = block
21
+ end
22
+ end
23
+ stub_const("Rails::Railtie", railtie_base)
24
+
25
+ load File.expand_path("../lib/whatsapp_notifier/railtie.rb", __dir__)
26
+
27
+ app_config = {
28
+ web_automation_enabled: true,
29
+ bulk_max_attempts: 5
30
+ }
31
+ app = Struct.new(:config).new(Struct.new(:whatsapp_notifier).new(app_config))
32
+ WhatsAppNotifier::Railtie.captured_initializer.call(app)
33
+
34
+ expect(WhatsAppNotifier.configuration.web_automation_enabled).to be(true)
35
+ expect(WhatsAppNotifier.configuration.bulk_max_attempts).to eq(5)
36
+ end
37
+ end
@@ -0,0 +1,12 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe WhatsAppNotifier::Result do
4
+ it "reports success and failure predicates" do
5
+ success_result = described_class.new(success: true, provider: :web_automation)
6
+ failure_result = described_class.new(success: false, provider: :web_automation)
7
+
8
+ expect(success_result).to be_success
9
+ expect(success_result).not_to be_failure
10
+ expect(failure_result).to be_failure
11
+ end
12
+ end
@@ -0,0 +1,42 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe WhatsAppNotifier::Session::QrService do
4
+ it "fetches qr from adapter and persists it" do
5
+ Dir.mktmpdir do |dir|
6
+ store = WhatsAppNotifier::Session::Store.new(path: File.join(dir, "s.json"))
7
+ adapter = double(fetch_qr_code: "qr-1")
8
+ service = described_class.new(store: store, adapter: adapter)
9
+
10
+ expect(service.qr_code).to eq("qr-1")
11
+ expect(service.qr_code).to eq("qr-1")
12
+ end
13
+ end
14
+
15
+ it "caches qr code per user_id when metadata is provided" do
16
+ Dir.mktmpdir do |dir|
17
+ store = WhatsAppNotifier::Session::Store.new(path: File.join(dir, "s.json"))
18
+ adapter = double
19
+ allow(adapter).to receive(:fetch_qr_code).and_return("qr-user-1", "qr-user-2")
20
+ service = described_class.new(store: store, adapter: adapter)
21
+
22
+ expect(service.qr_code(metadata: { user_id: 1 })).to eq("qr-user-1")
23
+ expect(service.qr_code(metadata: { user_id: 1 })).to eq("qr-user-1")
24
+ expect(service.qr_code(metadata: { user_id: 2 })).to eq("qr-user-2")
25
+
26
+ expect(adapter).to have_received(:fetch_qr_code).twice
27
+ end
28
+ end
29
+
30
+ it "activates a session token" do
31
+ Dir.mktmpdir do |dir|
32
+ store = WhatsAppNotifier::Session::Store.new(path: File.join(dir, "s.json"))
33
+ adapter = double(fetch_qr_code: "qr-2")
34
+ service = described_class.new(store: store, adapter: adapter)
35
+ service.qr_code
36
+
37
+ service.activate!("tok")
38
+
39
+ expect(store.load).to include(active: true, token: "tok", qr_code: nil)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,21 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe WhatsAppNotifier::Session::Store do
4
+ it "returns empty hash when file is absent" do
5
+ Dir.mktmpdir do |dir|
6
+ store = described_class.new(path: File.join(dir, "missing.json"))
7
+ expect(store.load).to eq({})
8
+ end
9
+ end
10
+
11
+ it "saves and loads json data" do
12
+ Dir.mktmpdir do |dir|
13
+ path = File.join(dir, "session.json")
14
+ store = described_class.new(path: path)
15
+
16
+ store.save(active: true, token: "abc")
17
+
18
+ expect(store.load).to eq(active: true, token: "abc")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ require "simplecov"
2
+
3
+ SimpleCov.start do
4
+ add_filter "/spec/"
5
+ minimum_coverage 100
6
+ end
7
+
8
+ require "whatsapp_notifier"
9
+ require "tmpdir"
10
+
11
+ RSpec.configure do |config|
12
+ config.order = :random
13
+
14
+ config.before do
15
+ WhatsAppNotifier.reset!
16
+ end
17
+ end
@@ -0,0 +1,55 @@
1
+ require "spec_helper"
2
+ require "json"
3
+
4
+ RSpec.describe WhatsAppNotifier::WebAdapter do
5
+ let(:adapter) { described_class.new(base_url: "http://127.0.0.1:3001") }
6
+
7
+ def http_success(code: "200", body: {})
8
+ instance_double(Net::HTTPOK, body: JSON.generate(body), code: code, is_a?: true)
9
+ end
10
+
11
+ def http_failure(code: "500", body: "boom")
12
+ instance_double(Net::HTTPInternalServerError, body: body, code: code, is_a?: false)
13
+ end
14
+
15
+ it "sends a message using the service" do
16
+ response = http_success(body: { "success" => true })
17
+ allow(Net::HTTP).to receive(:start).and_return(response)
18
+
19
+ result = adapter.send_message(
20
+ payload: { to: "+1", body: "hi", metadata: { user_id: 1 }, idempotency_key: "k1" },
21
+ session: {}
22
+ )
23
+
24
+ expect(result).to include(success: true, message_id: "k1")
25
+ end
26
+
27
+ it "fetches qr and status data" do
28
+ qr_response = http_success(body: { "qr" => "data:image/png;base64,qr" })
29
+ status_response = http_success(body: { "state" => "AUTHENTICATED", "authenticated" => true, "hasQR" => false })
30
+ allow(Net::HTTP).to receive(:start).and_return(qr_response, status_response)
31
+
32
+ qr = adapter.fetch_qr_code(metadata: { user_id: "u-1" })
33
+ status = adapter.connection_status(metadata: { user_id: "u-1" })
34
+
35
+ expect(qr).to include("data:image")
36
+ expect(status).to include(state: "AUTHENTICATED", authenticated: true, has_qr: false)
37
+ end
38
+
39
+ it "raises for non-success service responses" do
40
+ allow(Net::HTTP).to receive(:start).and_return(http_failure(code: "422", body: JSON.generate({ error: "bad request" })))
41
+
42
+ expect do
43
+ adapter.fetch_qr_code(metadata: {})
44
+ end.to raise_error(/service request failed/)
45
+ end
46
+
47
+ it "handles empty and invalid response bodies gracefully" do
48
+ empty_body = instance_double(Net::HTTPOK, body: "", code: "200", is_a?: true)
49
+ invalid_body = instance_double(Net::HTTPInternalServerError, body: "raw-error", code: "500", is_a?: false)
50
+ allow(Net::HTTP).to receive(:start).and_return(empty_body, invalid_body)
51
+
52
+ expect(adapter.fetch_qr_code(metadata: {})).to be_nil
53
+ expect { adapter.connection_status(metadata: {}) }.to raise_error(/raw-error/)
54
+ end
55
+ end
@@ -0,0 +1,102 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe WhatsAppNotifier do
4
+ it "configures and validates settings" do
5
+ described_class.configure do |config|
6
+ config.provider = :web_automation
7
+ config.web_automation_enabled = true
8
+ config.web_adapter = double(
9
+ send_message: { success: true, session: {} },
10
+ fetch_qr_code: "qr",
11
+ connection_status: { state: "AUTHENTICATED", authenticated: true }
12
+ )
13
+ config.bulk_max_recipients = 10
14
+ config.bulk_max_attempts = 2
15
+ end
16
+
17
+ expect(described_class.configuration.provider).to eq(:web_automation)
18
+ end
19
+
20
+ it "delegates single delivery to client" do
21
+ adapter = double
22
+ allow(adapter).to receive(:send_message).and_return(success: true, message_id: "m1", metadata: {}, session: {})
23
+ allow(adapter).to receive(:fetch_qr_code).and_return("qr")
24
+ allow(adapter).to receive(:connection_status).and_return(state: "AUTHENTICATED", authenticated: true)
25
+ described_class.configure do |config|
26
+ config.provider = :web_automation
27
+ config.web_automation_enabled = true
28
+ config.web_adapter = adapter
29
+ end
30
+
31
+ result = described_class.deliver(to: "+91", body: "hi", metadata: { a: 1 }, idempotency_key: "k1")
32
+
33
+ expect(result).to be_success
34
+ expect(result.message_id).to eq("m1")
35
+ end
36
+
37
+ it "delegates bulk delivery to client" do
38
+ described_class.configure do |config|
39
+ config.provider = :web_automation
40
+ config.web_automation_enabled = true
41
+ config.web_adapter = double(
42
+ send_message: { success: true, session: {} },
43
+ fetch_qr_code: "qr",
44
+ connection_status: { state: "AUTHENTICATED", authenticated: true }
45
+ )
46
+ config.bulk_base_delay_seconds = 0
47
+ config.bulk_jitter_seconds = 0
48
+ end
49
+
50
+ summary = described_class.deliver_bulk([{ to: "+1", body: "a" }], sleeper: ->(_seconds) {})
51
+
52
+ expect(summary[:total]).to eq(1)
53
+ expect(summary[:success]).to eq(1)
54
+ end
55
+
56
+ it "delegates qr scan through module API" do
57
+ Dir.mktmpdir do |dir|
58
+ described_class.configure do |config|
59
+ config.provider = :web_automation
60
+ config.web_automation_enabled = true
61
+ config.web_session_path = File.join(dir, "session.json")
62
+ config.web_adapter = double(
63
+ fetch_qr_code: "qr-via-module",
64
+ send_message: { success: true, session: {} },
65
+ connection_status: { state: "QR_REQUIRED", authenticated: false }
66
+ )
67
+ end
68
+
69
+ expect(described_class.scan_qr).to eq("qr-via-module")
70
+ end
71
+ end
72
+
73
+ it "delegates status through module API" do
74
+ described_class.configure do |config|
75
+ config.provider = :web_automation
76
+ config.web_automation_enabled = true
77
+ config.web_adapter = double(
78
+ fetch_qr_code: "qr-via-module",
79
+ send_message: { success: true, session: {} },
80
+ connection_status: { state: "AUTHENTICATED", authenticated: true }
81
+ )
82
+ end
83
+
84
+ expect(described_class.connection_status).to include(state: "AUTHENTICATED", authenticated: true)
85
+ end
86
+
87
+ it "exposes bundled service path" do
88
+ expect(File.directory?(described_class.service_path)).to be(true)
89
+ end
90
+
91
+ it "delegates module helpers to client" do
92
+ fake_client = double
93
+ allow(fake_client).to receive(:deliver_bulk).and_return(total: 0, success: 0, failed: 0, results: [])
94
+ allow(fake_client).to receive(:scan_qr).and_return("qr-code")
95
+ allow(fake_client).to receive(:connection_status).and_return(state: "QR_REQUIRED")
96
+ described_class.instance_variable_set(:@client, fake_client)
97
+
98
+ expect(described_class.deliver_bulk([], provider: :web_automation)[:total]).to eq(0)
99
+ expect(described_class.scan_qr(provider: :web_automation, metadata: { user_id: 1 })).to eq("qr-code")
100
+ expect(described_class.connection_status(provider: :web_automation, metadata: { user_id: 1 })).to include(state: "QR_REQUIRED")
101
+ end
102
+ end