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.
- checksums.yaml +7 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +126 -0
- data/Rakefile +6 -0
- data/bin/whatsapp_notifier +53 -0
- data/docs/bulk_messaging_policy.md +30 -0
- data/docs/graphify.md +108 -0
- data/docs/rails_setup.md +57 -0
- data/examples/notification_example.rb +14 -0
- data/lib/generators/whatsapp_notifier/install_generator.rb +60 -0
- data/lib/generators/whatsapp_notifier/install_service_generator.rb +33 -0
- data/lib/generators/whatsapp_notifier/templates/whatsapp_notifier.rb +6 -0
- data/lib/whatsapp_notifier/bulk/dispatcher.rb +64 -0
- data/lib/whatsapp_notifier/bulk/rate_limiter.rb +17 -0
- data/lib/whatsapp_notifier/bulk/retry_policy.rb +32 -0
- data/lib/whatsapp_notifier/client.rb +43 -0
- data/lib/whatsapp_notifier/configuration.rb +41 -0
- data/lib/whatsapp_notifier/doctor.rb +103 -0
- data/lib/whatsapp_notifier/errors.rb +5 -0
- data/lib/whatsapp_notifier/jobs/send_message_job.rb +20 -0
- data/lib/whatsapp_notifier/notification.rb +93 -0
- data/lib/whatsapp_notifier/providers/base.rb +24 -0
- data/lib/whatsapp_notifier/providers/web_automation.rb +85 -0
- data/lib/whatsapp_notifier/railtie.rb +14 -0
- data/lib/whatsapp_notifier/result.rb +23 -0
- data/lib/whatsapp_notifier/services/web_automation/bun.lock +452 -0
- data/lib/whatsapp_notifier/services/web_automation/index.ts +285 -0
- data/lib/whatsapp_notifier/services/web_automation/package.json +14 -0
- data/lib/whatsapp_notifier/session/qr_service.rb +51 -0
- data/lib/whatsapp_notifier/session/store.rb +22 -0
- data/lib/whatsapp_notifier/version.rb +4 -0
- data/lib/whatsapp_notifier/web_adapter.rb +72 -0
- data/lib/whatsapp_notifier.rb +72 -0
- data/spec/bulk/dispatcher_spec.rb +73 -0
- data/spec/bulk/rate_limiter_spec.rb +27 -0
- data/spec/bulk/retry_policy_spec.rb +33 -0
- data/spec/client_spec.rb +52 -0
- data/spec/configuration_spec.rb +47 -0
- data/spec/doctor_spec.rb +46 -0
- data/spec/jobs/send_message_job_spec.rb +36 -0
- data/spec/notification_spec.rb +60 -0
- data/spec/providers/base_spec.rb +17 -0
- data/spec/providers/web_automation_spec.rb +109 -0
- data/spec/railtie_spec.rb +37 -0
- data/spec/result_spec.rb +12 -0
- data/spec/session/qr_service_spec.rb +42 -0
- data/spec/session/store_spec.rb +21 -0
- data/spec/spec_helper.rb +17 -0
- data/spec/web_adapter_spec.rb +55 -0
- data/spec/whatsapp_notifier_spec.rb +102 -0
- metadata +126 -0
data/spec/client_spec.rb
ADDED
|
@@ -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
|
data/spec/doctor_spec.rb
ADDED
|
@@ -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
|
data/spec/result_spec.rb
ADDED
|
@@ -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
|
data/spec/spec_helper.rb
ADDED
|
@@ -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
|