whatsapp_notifier 0.3.0 → 0.4.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 +4 -4
- data/README.md +21 -0
- data/app/controllers/whatsapp_notifier/sessions_controller.rb +10 -0
- data/config/routes.rb +4 -3
- data/lib/whatsapp_notifier/client.rb +4 -0
- data/lib/whatsapp_notifier/engine.rb +14 -0
- data/lib/whatsapp_notifier/jobs/send_message_job.rb +5 -0
- data/lib/whatsapp_notifier/providers/web_automation.rb +9 -0
- data/lib/whatsapp_notifier/services/web_automation/index.ts +64 -4
- data/lib/whatsapp_notifier/session/qr_service.rb +0 -22
- data/lib/whatsapp_notifier/version.rb +1 -1
- data/lib/whatsapp_notifier/web_adapter.rb +7 -0
- data/lib/whatsapp_notifier.rb +4 -0
- data/spec/client_spec.rb +17 -0
- data/spec/jobs/send_message_job_spec.rb +6 -5
- data/spec/notification_spec.rb +10 -5
- data/spec/providers/web_automation_spec.rb +30 -0
- data/spec/session/qr_service_spec.rb +3 -4
- data/spec/web_adapter_spec.rb +22 -0
- data/spec/whatsapp_notifier_spec.rb +2 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0202159fbe717a98a4bb69187abf75876258ce5f1d0bb1840deb46ed83096002'
|
|
4
|
+
data.tar.gz: df8857673fc57f8be6ba9681eb959d9aa842a051a522ceed530a19e57868d1b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a9618573ca58faba4ba1b98c69626caf7627288a1b490ea102b39053166d15552d83a30e7a2583db5702111a312cd33b6bdb7dc02c011d2f355161259532cc9a
|
|
7
|
+
data.tar.gz: 973ff970420fc87db0280f82758f2ba51cf6cc54189d0b1ea0a2ba84f43aaa993ec4938b6a606ebae0cb14bc38e314c832b6c56fb1622712433a64fb0eaa7c33
|
data/README.md
CHANGED
|
@@ -54,6 +54,20 @@ status = WhatsAppNotifier.connection_status(metadata: { user_id: current_user.id
|
|
|
54
54
|
# => { state: "...", authenticated: true/false, has_qr: true/false }
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
## Log out (disconnect + clear session)
|
|
58
|
+
|
|
59
|
+
Explicitly disconnects the user and wipes their saved WhatsApp session from the
|
|
60
|
+
service, so the next connect starts fresh with a new QR. Call this from a
|
|
61
|
+
user-initiated "Log out WhatsApp" action — NOT from your app's sign-out, which
|
|
62
|
+
should leave the WhatsApp session intact for the next login.
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
WhatsAppNotifier.logout(metadata: { user_id: current_user.id })
|
|
66
|
+
# => { success: true }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The mounted engine also exposes `DELETE /whatsapp/logout` for the same effect.
|
|
70
|
+
|
|
57
71
|
## Send a message
|
|
58
72
|
|
|
59
73
|
```ruby
|
|
@@ -120,6 +134,13 @@ This copies the service to `whatsapp_service/` and updates `.gitignore`.
|
|
|
120
134
|
|
|
121
135
|
- This gem uses WhatsApp Web automation. Use responsibly and follow WhatsApp policies.
|
|
122
136
|
- Keep Chromium available in your runtime (or set `PUPPETEER_EXECUTABLE_PATH`).
|
|
137
|
+
- Session profiles persist under `WHATSAPP_SESSION_DIR` (default `/whatsapp_data`).
|
|
138
|
+
Mount it on durable storage so logins survive restarts; the service clears stale
|
|
139
|
+
Chromium `SingletonLock` files on each launch so an unclean exit can't wedge it.
|
|
140
|
+
- Resilience knobs: `WHATSAPP_INIT_TIMEOUT_MS` (default 90000) recycles a client
|
|
141
|
+
that never reaches QR/READY; set `WWEBJS_WEB_VERSION` (e.g. `2.3000.1023204887`,
|
|
142
|
+
optionally `WWEBJS_WEB_VERSION_CACHE_URL`) to pin the WhatsApp Web build so a
|
|
143
|
+
live web.whatsapp.com change can't silently break the client.
|
|
123
144
|
|
|
124
145
|
## License
|
|
125
146
|
|
|
@@ -22,6 +22,16 @@ module WhatsAppNotifier
|
|
|
22
22
|
status: :internal_server_error
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# DELETE /logout — disconnect WhatsApp and clear the saved session for the
|
|
26
|
+
# current user. Explicit, user-initiated; app sign-out must NOT call this.
|
|
27
|
+
def destroy
|
|
28
|
+
WhatsAppNotifier.logout(metadata: metadata)
|
|
29
|
+
render json: { success: true }
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
render json: { error: "Failed to log out: #{e.message}" },
|
|
32
|
+
status: :internal_server_error
|
|
33
|
+
end
|
|
34
|
+
|
|
25
35
|
private
|
|
26
36
|
|
|
27
37
|
def metadata
|
data/config/routes.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
WhatsAppNotifier::Engine.routes.draw do
|
|
2
|
-
get
|
|
3
|
-
get
|
|
4
|
-
|
|
2
|
+
get "status", to: "sessions#show", as: :status
|
|
3
|
+
get "qr", to: "sessions#qr", as: :qr
|
|
4
|
+
delete "logout", to: "sessions#destroy", as: :logout
|
|
5
|
+
post "send", to: "messages#create", as: :send_message
|
|
5
6
|
end
|
|
@@ -27,6 +27,10 @@ module WhatsAppNotifier
|
|
|
27
27
|
provider_for(provider || @configuration.provider).connection_status(metadata: metadata)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
def logout(metadata: {}, provider: nil)
|
|
31
|
+
provider_for(provider || @configuration.provider).logout(metadata: metadata)
|
|
32
|
+
end
|
|
33
|
+
|
|
30
34
|
private
|
|
31
35
|
|
|
32
36
|
def provider_for(key)
|
|
@@ -6,6 +6,20 @@ module WhatsAppNotifier
|
|
|
6
6
|
|
|
7
7
|
config.whatsapp_notifier = {}
|
|
8
8
|
|
|
9
|
+
# The module name is WhatsAppNotifier (CapApp), but Zeitwerk's default
|
|
10
|
+
# inflector camelizes "whatsapp_notifier" -> "WhatsappNotifier" (single
|
|
11
|
+
# N). Without this override, controllers under app/controllers/
|
|
12
|
+
# whatsapp_notifier/ are indexed under the wrong constant and the
|
|
13
|
+
# host app gets "uninitialized constant WhatsAppNotifier::*" on every
|
|
14
|
+
# request to a mounted engine route. Inflect once for both autoloaders.
|
|
15
|
+
initializer "whatsapp_notifier.inflector", before: :set_autoload_paths do
|
|
16
|
+
mappings = { "whatsapp_notifier" => "WhatsAppNotifier" }
|
|
17
|
+
[Rails.autoloaders.main, Rails.autoloaders.once].each do |loader|
|
|
18
|
+
next unless loader.respond_to?(:inflector)
|
|
19
|
+
loader.inflector.inflect(mappings)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
9
23
|
initializer "whatsapp_notifier.configure" do |app|
|
|
10
24
|
WhatsAppNotifier.configure do |config|
|
|
11
25
|
app.config.whatsapp_notifier.each do |key, value|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
module WhatsAppNotifier
|
|
2
2
|
module Jobs
|
|
3
3
|
if defined?(::ActiveJob::Base)
|
|
4
|
+
# Real async path, only live in a host app that has ActiveJob loaded (any
|
|
5
|
+
# Rails app). Not exercisable in the gem's unit suite — the class is chosen
|
|
6
|
+
# at load time and ActiveJob isn't a gem dependency.
|
|
7
|
+
# :nocov:
|
|
4
8
|
class SendMessageJob < ::ActiveJob::Base
|
|
5
9
|
queue_as :default
|
|
6
10
|
|
|
@@ -9,6 +13,7 @@ module WhatsAppNotifier
|
|
|
9
13
|
klass.with(params).deliver_now
|
|
10
14
|
end
|
|
11
15
|
end
|
|
16
|
+
# :nocov:
|
|
12
17
|
else
|
|
13
18
|
class SendMessageJob
|
|
14
19
|
def self.perform_later(notification_class_name, params)
|
|
@@ -48,6 +48,15 @@ module WhatsAppNotifier
|
|
|
48
48
|
adapter.connection_status(metadata: metadata)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
def logout(metadata: {})
|
|
52
|
+
raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
|
|
53
|
+
|
|
54
|
+
adapter = configuration.web_adapter
|
|
55
|
+
raise ConfigurationError, "web_adapter must be configured for web_automation provider" unless adapter.respond_to?(:logout)
|
|
56
|
+
|
|
57
|
+
adapter.logout(metadata: metadata)
|
|
58
|
+
end
|
|
59
|
+
|
|
51
60
|
|
|
52
61
|
private
|
|
53
62
|
|
|
@@ -9,6 +9,17 @@ const port = Number(process.env.PORT || 3001);
|
|
|
9
9
|
const SESSION_BASE_DIR = process.env.WHATSAPP_SESSION_DIR || '/whatsapp_data';
|
|
10
10
|
const BROWSER_EXECUTABLE_PATH = process.env.PUPPETEER_EXECUTABLE_PATH;
|
|
11
11
|
|
|
12
|
+
// Recycle a client that boots Chromium but never reaches QR/READY (e.g. after a
|
|
13
|
+
// WhatsApp Web update breaks the injected store), instead of wedging in
|
|
14
|
+
// INITIALIZING forever with no QR.
|
|
15
|
+
const INIT_TIMEOUT_MS = Number(process.env.WHATSAPP_INIT_TIMEOUT_MS || 90000);
|
|
16
|
+
// Optionally pin the WhatsApp Web build so a live web.whatsapp.com change can't
|
|
17
|
+
// silently break the client. Set WWEBJS_WEB_VERSION to a known-good version
|
|
18
|
+
// (e.g. "2.3000.1023204887"); leave unset to use the library default.
|
|
19
|
+
const WEB_VERSION = process.env.WWEBJS_WEB_VERSION || null;
|
|
20
|
+
const WEB_VERSION_CACHE_URL = process.env.WWEBJS_WEB_VERSION_CACHE_URL ||
|
|
21
|
+
'https://raw.githubusercontent.com/wppconnect-team/wa-version/main/html/{version}.html';
|
|
22
|
+
|
|
12
23
|
// Multi-user client management
|
|
13
24
|
interface ClientData {
|
|
14
25
|
client: Client;
|
|
@@ -17,11 +28,19 @@ interface ClientData {
|
|
|
17
28
|
lastUsed: number;
|
|
18
29
|
isDestroying?: boolean;
|
|
19
30
|
ready?: boolean;
|
|
31
|
+
initTimer?: ReturnType<typeof setTimeout>;
|
|
20
32
|
}
|
|
21
33
|
|
|
22
34
|
const clients = new Map<string, ClientData>();
|
|
23
35
|
const initializingClients = new Set<string>();
|
|
24
36
|
|
|
37
|
+
function clearInitTimer(clientData: ClientData) {
|
|
38
|
+
if (clientData.initTimer) {
|
|
39
|
+
clearTimeout(clientData.initTimer);
|
|
40
|
+
clientData.initTimer = undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
function sessionDirForUser(userId: string) {
|
|
26
45
|
return join(SESSION_BASE_DIR, `session-user-${userId}`);
|
|
27
46
|
}
|
|
@@ -135,7 +154,11 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
|
|
|
135
154
|
'--disable-gpu'
|
|
136
155
|
],
|
|
137
156
|
...(BROWSER_EXECUTABLE_PATH ? { executablePath: BROWSER_EXECUTABLE_PATH } : {})
|
|
138
|
-
}
|
|
157
|
+
},
|
|
158
|
+
...(WEB_VERSION ? {
|
|
159
|
+
webVersion: WEB_VERSION,
|
|
160
|
+
webVersionCache: { type: 'remote' as const, remotePath: WEB_VERSION_CACHE_URL }
|
|
161
|
+
} : {})
|
|
139
162
|
});
|
|
140
163
|
|
|
141
164
|
const clientData: ClientData = {
|
|
@@ -148,8 +171,18 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
|
|
|
148
171
|
|
|
149
172
|
clients.set(userId, clientData);
|
|
150
173
|
|
|
174
|
+
// Watchdog: if the client never reaches QR/READY, recycle it so the next
|
|
175
|
+
// poll spins a fresh attempt instead of wedging in INITIALIZING forever.
|
|
176
|
+
clientData.initTimer = setTimeout(() => {
|
|
177
|
+
if (clientData.state === 'INITIALIZING' && !clientData.qr && !clientData.ready) {
|
|
178
|
+
console.error(`User ${userId} stuck INITIALIZING > ${INIT_TIMEOUT_MS}ms — recycling`);
|
|
179
|
+
destroyClient(userId).catch(console.error);
|
|
180
|
+
}
|
|
181
|
+
}, INIT_TIMEOUT_MS);
|
|
182
|
+
|
|
151
183
|
client.on('qr', async (qr) => {
|
|
152
184
|
clientData.state = 'QR_REQUIRED';
|
|
185
|
+
clearInitTimer(clientData); // progress made — QR is showable
|
|
153
186
|
try {
|
|
154
187
|
clientData.qr = await toDataURL(qr);
|
|
155
188
|
console.log(`QR RECEIVED and converted for User ${userId}`);
|
|
@@ -162,18 +195,21 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
|
|
|
162
195
|
clientData.state = 'AUTHENTICATED';
|
|
163
196
|
clientData.qr = null;
|
|
164
197
|
clientData.ready = true;
|
|
198
|
+
clearInitTimer(clientData);
|
|
165
199
|
console.log(`Client is READY for User ${userId}`);
|
|
166
200
|
});
|
|
167
201
|
|
|
168
202
|
client.on('authenticated', () => {
|
|
169
203
|
clientData.state = 'AUTHENTICATED';
|
|
170
204
|
clientData.ready = false;
|
|
205
|
+
clearInitTimer(clientData);
|
|
171
206
|
console.log(`AUTHENTICATED for User ${userId}`);
|
|
172
207
|
});
|
|
173
208
|
|
|
174
209
|
client.on('auth_failure', (msg) => {
|
|
175
210
|
clientData.state = 'DISCONNECTED';
|
|
176
211
|
clientData.ready = false;
|
|
212
|
+
clearInitTimer(clientData);
|
|
177
213
|
console.error(`AUTHENTICATION FAILURE for User ${userId}`, msg);
|
|
178
214
|
});
|
|
179
215
|
|
|
@@ -186,6 +222,7 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
|
|
|
186
222
|
|
|
187
223
|
client.initialize().catch(async (err) => {
|
|
188
224
|
console.error(`Initialization failed for user ${userId}:`, err.message || err);
|
|
225
|
+
clearInitTimer(clientData);
|
|
189
226
|
// Clean up the failed client
|
|
190
227
|
try { client.removeAllListeners(); } catch (_) {}
|
|
191
228
|
try { await client.destroy(); } catch (_) {}
|
|
@@ -203,11 +240,12 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
|
|
|
203
240
|
return clientData;
|
|
204
241
|
}
|
|
205
242
|
|
|
206
|
-
async function destroyClient(userId: string) {
|
|
243
|
+
async function destroyClient(userId: string, clearSession: boolean = false) {
|
|
207
244
|
const data = clients.get(userId);
|
|
208
245
|
if (data && !data.isDestroying) {
|
|
209
246
|
data.isDestroying = true;
|
|
210
|
-
|
|
247
|
+
clearInitTimer(data);
|
|
248
|
+
console.log(`Destroying WhatsApp client for User: ${userId} (clearSession: ${clearSession})`);
|
|
211
249
|
try {
|
|
212
250
|
// Unregister listeners to prevent loops or double-destroys
|
|
213
251
|
data.client.removeAllListeners();
|
|
@@ -216,7 +254,18 @@ async function destroyClient(userId: string) {
|
|
|
216
254
|
console.error(`Error destroying client for ${userId}`, e);
|
|
217
255
|
}
|
|
218
256
|
clients.delete(userId);
|
|
219
|
-
// Session data is preserved on disk for auto-reconnect
|
|
257
|
+
// Session data is preserved on disk for auto-reconnect (unless clearing below).
|
|
258
|
+
}
|
|
259
|
+
// On explicit logout, wipe the persisted Chromium profile / WhatsApp session
|
|
260
|
+
// from disk — even if there was no live client in memory.
|
|
261
|
+
if (clearSession) {
|
|
262
|
+
const dir = sessionDirForUser(userId);
|
|
263
|
+
try {
|
|
264
|
+
rmSync(dir, { recursive: true, force: true });
|
|
265
|
+
console.log(`Cleared session dir for User: ${userId}`);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
console.error(`Failed to clear session dir for ${userId}`, e);
|
|
268
|
+
}
|
|
220
269
|
}
|
|
221
270
|
}
|
|
222
271
|
|
|
@@ -250,6 +299,17 @@ app.get('/qr/:userId', async (c) => {
|
|
|
250
299
|
return c.json({ qr: data.qr });
|
|
251
300
|
});
|
|
252
301
|
|
|
302
|
+
// Explicit per-user logout: disconnect the client and wipe the saved WhatsApp
|
|
303
|
+
// session from disk so the next connect starts fresh with a new QR. Triggered by
|
|
304
|
+
// the user-settings "Log out WhatsApp" button — NOT by app sign-out.
|
|
305
|
+
app.post('/logout/:userId', async (c) => {
|
|
306
|
+
const userId = c.req.param('userId');
|
|
307
|
+
console.log(`Logout requested for User: ${userId}`);
|
|
308
|
+
await destroyClient(userId, true);
|
|
309
|
+
initializingClients.delete(userId);
|
|
310
|
+
return c.json({ success: true });
|
|
311
|
+
});
|
|
312
|
+
|
|
253
313
|
app.post('/send/:userId', async (c) => {
|
|
254
314
|
const userId = c.req.param('userId');
|
|
255
315
|
const { to, message, mediaUrl } = await c.req.json();
|
|
@@ -20,28 +20,6 @@ module WhatsAppNotifier
|
|
|
20
20
|
session = store.load
|
|
21
21
|
store.save(session.merge(active: true, token: token, qr_code: nil))
|
|
22
22
|
end
|
|
23
|
-
|
|
24
|
-
private
|
|
25
|
-
|
|
26
|
-
def cached_qr(session, user_id)
|
|
27
|
-
return session[:qr_code] unless user_id
|
|
28
|
-
|
|
29
|
-
session.fetch(:users, {}).fetch(user_key(user_id), {})[:qr_code]
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def with_cached_qr(session, user_id, qr_code)
|
|
33
|
-
return session.merge(qr_code: qr_code) unless user_id
|
|
34
|
-
|
|
35
|
-
users = session.fetch(:users, {})
|
|
36
|
-
key = user_key(user_id)
|
|
37
|
-
user_session = users.fetch(key, {})
|
|
38
|
-
users[key] = user_session.merge(qr_code: qr_code)
|
|
39
|
-
session.merge(users: users)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def user_key(user_id)
|
|
43
|
-
user_id.to_s.to_sym
|
|
44
|
-
end
|
|
45
23
|
end
|
|
46
24
|
end
|
|
47
25
|
end
|
|
@@ -52,6 +52,13 @@ module WhatsAppNotifier
|
|
|
52
52
|
}
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# Logs the user out of WhatsApp and clears their saved session on the service.
|
|
56
|
+
def logout(metadata: {})
|
|
57
|
+
user_id = user_id_from(metadata)
|
|
58
|
+
response = request(:post, "/logout/#{user_id}")
|
|
59
|
+
{ success: response.fetch("success", false) }
|
|
60
|
+
end
|
|
61
|
+
|
|
55
62
|
private
|
|
56
63
|
|
|
57
64
|
def user_id_from(metadata)
|
data/lib/whatsapp_notifier.rb
CHANGED
data/spec/client_spec.rb
CHANGED
|
@@ -49,4 +49,21 @@ RSpec.describe WhatsAppNotifier::Client do
|
|
|
49
49
|
expect(status).to include(state: "QR_REQUIRED")
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
|
+
|
|
53
|
+
it "delegates logout to the provider" do
|
|
54
|
+
Dir.mktmpdir do |dir|
|
|
55
|
+
config.provider = :web_automation
|
|
56
|
+
config.web_automation_enabled = true
|
|
57
|
+
config.web_session_path = File.join(dir, "session.json")
|
|
58
|
+
config.web_adapter = double(
|
|
59
|
+
send_message: { success: true, session: {} },
|
|
60
|
+
fetch_qr_code: "qr",
|
|
61
|
+
connection_status: { state: "AUTHENTICATED", authenticated: true },
|
|
62
|
+
logout: { success: true }
|
|
63
|
+
)
|
|
64
|
+
client = described_class.new(configuration: config)
|
|
65
|
+
|
|
66
|
+
expect(client.logout(provider: :web_automation, metadata: { user_id: 1 })).to eq(success: true)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
52
69
|
end
|
|
@@ -24,13 +24,14 @@ RSpec.describe WhatsAppNotifier::Jobs::SendMessageJob do
|
|
|
24
24
|
expect(result).to be_success
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
it "
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
it "falls back to synchronous delivery when ActiveJob is unavailable" do
|
|
28
|
+
# ActiveJob is not a gem dependency, so the sync-fallback class is the one
|
|
29
|
+
# that loads; perform_later runs perform_now rather than raising.
|
|
30
|
+
result = described_class.perform_later("JobNotification", to: "+1")
|
|
31
|
+
expect(result).to be_success
|
|
30
32
|
end
|
|
31
33
|
|
|
32
|
-
it "allows perform_later
|
|
33
|
-
stub_const("ActiveJob::Base", Class.new)
|
|
34
|
+
it "allows perform_later without raising" do
|
|
34
35
|
expect { described_class.perform_later("JobNotification", to: "+1") }.not_to raise_error
|
|
35
36
|
end
|
|
36
37
|
end
|
data/spec/notification_spec.rb
CHANGED
|
@@ -30,14 +30,19 @@ RSpec.describe WhatsAppNotifier::Notification do
|
|
|
30
30
|
expect(result).to be_success
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
it "supports deliver_later
|
|
34
|
-
stub_const("ActiveJob::Base", Class.new)
|
|
33
|
+
it "supports deliver_later" do
|
|
35
34
|
expect { TestNotification.deliver_later(params: { name: "Neha" }) }.not_to raise_error
|
|
36
35
|
end
|
|
37
36
|
|
|
38
|
-
it "
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
it "falls back to synchronous delivery for deliver_later without active job" do
|
|
38
|
+
# No ActiveJob dependency → deliver_later runs the job synchronously.
|
|
39
|
+
result = TestNotification.deliver_later(params: { name: "Neha" })
|
|
40
|
+
expect(result).to be_success
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "supports instance-level deliver_later" do
|
|
44
|
+
result = TestNotification.with(params: { name: "Neha" }).deliver_later
|
|
45
|
+
expect(result).to be_success
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
it "raises when recipient is missing" do
|
|
@@ -106,4 +106,34 @@ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
|
|
|
106
106
|
expect(adapter).to have_received(:send_message).with(payload: hash_including(metadata: { user_id: "user-b" }), session: {}).once
|
|
107
107
|
end
|
|
108
108
|
end
|
|
109
|
+
|
|
110
|
+
it "logs out via adapter when enabled" do
|
|
111
|
+
Dir.mktmpdir do |dir|
|
|
112
|
+
adapter = double(fetch_qr_code: "qr", connection_status: {}, logout: { success: true })
|
|
113
|
+
config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
|
|
114
|
+
provider = described_class.new(configuration: config)
|
|
115
|
+
|
|
116
|
+
expect(provider.logout(metadata: { user_id: 1 })).to eq(success: true)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "raises on logout when provider disabled" do
|
|
121
|
+
Dir.mktmpdir do |dir|
|
|
122
|
+
adapter = double(fetch_qr_code: "qr", connection_status: {}, logout: { success: true })
|
|
123
|
+
config = build_config(path: File.join(dir, "session.json"), adapter: adapter, enabled: false)
|
|
124
|
+
provider = described_class.new(configuration: config)
|
|
125
|
+
|
|
126
|
+
expect { provider.logout }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "raises for missing adapter logout method" do
|
|
131
|
+
Dir.mktmpdir do |dir|
|
|
132
|
+
adapter = Object.new
|
|
133
|
+
config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
|
|
134
|
+
provider = described_class.new(configuration: config)
|
|
135
|
+
|
|
136
|
+
expect { provider.logout }.to raise_error(WhatsAppNotifier::ConfigurationError, /web_adapter/)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
109
139
|
end
|
|
@@ -12,7 +12,7 @@ RSpec.describe WhatsAppNotifier::Session::QrService do
|
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
it "
|
|
15
|
+
it "fetches a fresh qr on every call (codes expire, so no caching) and passes metadata" do
|
|
16
16
|
Dir.mktmpdir do |dir|
|
|
17
17
|
store = WhatsAppNotifier::Session::Store.new(path: File.join(dir, "s.json"))
|
|
18
18
|
adapter = double
|
|
@@ -20,10 +20,9 @@ RSpec.describe WhatsAppNotifier::Session::QrService do
|
|
|
20
20
|
service = described_class.new(store: store, adapter: adapter)
|
|
21
21
|
|
|
22
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-
|
|
24
|
-
expect(service.qr_code(metadata: { user_id: 2 })).to eq("qr-user-2")
|
|
23
|
+
expect(service.qr_code(metadata: { user_id: 1 })).to eq("qr-user-2")
|
|
25
24
|
|
|
26
|
-
expect(adapter).to have_received(:fetch_qr_code).twice
|
|
25
|
+
expect(adapter).to have_received(:fetch_qr_code).with(metadata: { user_id: 1 }).twice
|
|
27
26
|
end
|
|
28
27
|
end
|
|
29
28
|
|
data/spec/web_adapter_spec.rb
CHANGED
|
@@ -52,4 +52,26 @@ RSpec.describe WhatsAppNotifier::WebAdapter do
|
|
|
52
52
|
expect(adapter.fetch_qr_code(metadata: {})).to be_nil
|
|
53
53
|
expect { adapter.connection_status(metadata: {}) }.to raise_error(/raw-error/)
|
|
54
54
|
end
|
|
55
|
+
|
|
56
|
+
it "logs out via the service" do
|
|
57
|
+
allow(Net::HTTP).to receive(:start).and_return(http_success(body: { "success" => true }))
|
|
58
|
+
|
|
59
|
+
expect(adapter.logout(metadata: { user_id: "u-1" })).to eq(success: true)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it "defaults logout success to false when the service omits it" do
|
|
63
|
+
allow(Net::HTTP).to receive(:start).and_return(http_success(body: {}))
|
|
64
|
+
|
|
65
|
+
expect(adapter.logout(metadata: {})).to eq(success: false)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "executes the request inside the Net::HTTP block" do
|
|
69
|
+
fake_http = instance_double(Net::HTTP)
|
|
70
|
+
allow(fake_http).to receive(:request).and_return(http_success(body: { "qr" => "data:image/png;base64,x" }))
|
|
71
|
+
# Invoke the block so the real request path runs (other specs stub it away).
|
|
72
|
+
allow(Net::HTTP).to receive(:start) { |*_args, &blk| blk.call(fake_http) }
|
|
73
|
+
|
|
74
|
+
expect(adapter.fetch_qr_code(metadata: { user_id: 1 })).to eq("data:image/png;base64,x")
|
|
75
|
+
expect(fake_http).to have_received(:request)
|
|
76
|
+
end
|
|
55
77
|
end
|
|
@@ -93,11 +93,13 @@ RSpec.describe WhatsAppNotifier do
|
|
|
93
93
|
allow(fake_client).to receive(:deliver_bulk).and_return(total: 0, success: 0, failed: 0, results: [])
|
|
94
94
|
allow(fake_client).to receive(:scan_qr).and_return("qr-code")
|
|
95
95
|
allow(fake_client).to receive(:connection_status).and_return(state: "QR_REQUIRED")
|
|
96
|
+
allow(fake_client).to receive(:logout).and_return(success: true)
|
|
96
97
|
described_class.instance_variable_set(:@client, fake_client)
|
|
97
98
|
|
|
98
99
|
expect(described_class.deliver_bulk([], provider: :web_automation)[:total]).to eq(0)
|
|
99
100
|
expect(described_class.scan_qr(provider: :web_automation, metadata: { user_id: 1 })).to eq("qr-code")
|
|
100
101
|
expect(described_class.connection_status(provider: :web_automation, metadata: { user_id: 1 })).to include(state: "QR_REQUIRED")
|
|
102
|
+
expect(described_class.logout(provider: :web_automation, metadata: { user_id: 1 })).to eq(success: true)
|
|
101
103
|
end
|
|
102
104
|
|
|
103
105
|
end
|