strapi-plugin-notifier 1.0.3 → 1.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.
@@ -0,0 +1,470 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import makeRulesService, { interpolate, getByPath } from '../../server/src/services/rules';
3
+ import type { NotifierRule } from '../../server/src/config';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const makeSend = () => vi.fn().mockResolvedValue({ id: 1 });
10
+
11
+ /** Builds a minimal strapi mock and returns it alongside a reference to `send`. */
12
+ const makeStrapi = (send = makeSend()) => {
13
+ let lifecycleCallback: ((event: any) => Promise<void>) | undefined;
14
+ let eventCallback: ((data: any) => Promise<void>) | undefined;
15
+ let cronTask: ((ctx: { strapi: any }) => Promise<void>) | undefined;
16
+
17
+ const strapi = {
18
+ db: {
19
+ lifecycles: {
20
+ subscribe: vi.fn((opts: any) => {
21
+ // Capture whichever action key is present (afterCreate, afterUpdate, …)
22
+ const actionKey = Object.keys(opts).find((k) => k !== 'models');
23
+ if (actionKey) lifecycleCallback = opts[actionKey];
24
+ }),
25
+ },
26
+ },
27
+ eventHub: {
28
+ on: vi.fn((_event: string, cb: any) => { eventCallback = cb; }),
29
+ },
30
+ cron: {
31
+ add: vi.fn((tasks: any) => {
32
+ const key = Object.keys(tasks)[0];
33
+ cronTask = tasks[key].task;
34
+ }),
35
+ },
36
+ plugin: vi.fn().mockReturnValue({
37
+ service: vi.fn().mockReturnValue({ send }),
38
+ }),
39
+ log: { error: vi.fn() },
40
+ };
41
+
42
+ return { strapi, send, getLifecycleCallback: () => lifecycleCallback, getEventCallback: () => eventCallback, getCronTask: () => cronTask };
43
+ };
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Unit: interpolate / getByPath
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe('getByPath', () => {
50
+ it('resolves shallow key', () => expect(getByPath({ a: 1 }, 'a')).toBe(1));
51
+ it('resolves nested key', () => expect(getByPath({ a: { b: 2 } }, 'a.b')).toBe(2));
52
+ it('returns undefined for missing path', () => expect(getByPath({ a: 1 }, 'a.b.c')).toBeUndefined());
53
+ it('returns undefined for null intermediate', () => expect(getByPath({ a: null }, 'a.b')).toBeUndefined());
54
+ });
55
+
56
+ describe('interpolate', () => {
57
+ it('replaces a simple token', () => {
58
+ expect(interpolate('Hello {{name}}', { name: 'World' })).toBe('Hello World');
59
+ });
60
+
61
+ it('replaces a nested token', () => {
62
+ expect(interpolate('Title: {{entry.title}}', { entry: { title: 'My Post' } })).toBe('Title: My Post');
63
+ });
64
+
65
+ it('replaces multiple tokens', () => {
66
+ expect(interpolate('{{a}} and {{b}}', { a: 'foo', b: 'bar' })).toBe('foo and bar');
67
+ });
68
+
69
+ it('replaces missing token with empty string', () => {
70
+ expect(interpolate('{{missing}}', {})).toBe('');
71
+ });
72
+
73
+ it('leaves non-token text unchanged', () => {
74
+ expect(interpolate('plain text', {})).toBe('plain text');
75
+ });
76
+ });
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Lifecycle rules
80
+ // ---------------------------------------------------------------------------
81
+
82
+ describe('lifecycle rule', () => {
83
+ it('subscribes to the correct model and action', () => {
84
+ const { strapi } = makeStrapi();
85
+ const svc = makeRulesService({ strapi: strapi as any });
86
+
87
+ svc.setup([{
88
+ on: 'lifecycle',
89
+ model: 'api::article.article',
90
+ action: 'afterCreate',
91
+ notification: { title: 'Created', type: 'info' },
92
+ }]);
93
+
94
+ expect(strapi.db.lifecycles.subscribe).toHaveBeenCalledOnce();
95
+ const opts = strapi.db.lifecycles.subscribe.mock.calls[0][0];
96
+ expect(opts.models).toEqual(['api::article.article']);
97
+ expect(typeof opts.afterCreate).toBe('function');
98
+ });
99
+
100
+ it('fires send with interpolated title', async () => {
101
+ const { strapi, send, getLifecycleCallback } = makeStrapi();
102
+ const svc = makeRulesService({ strapi: strapi as any });
103
+
104
+ svc.setup([{
105
+ on: 'lifecycle',
106
+ model: 'api::article.article',
107
+ action: 'afterCreate',
108
+ notification: { title: 'New: {{entry.title}}', type: 'success' },
109
+ }]);
110
+
111
+ await getLifecycleCallback()!({ result: { title: 'Hello World' } });
112
+
113
+ expect(send).toHaveBeenCalledWith(expect.objectContaining({ title: 'New: Hello World', type: 'success' }));
114
+ });
115
+
116
+ it('fires send with function title', async () => {
117
+ const { strapi, send, getLifecycleCallback } = makeStrapi();
118
+ const svc = makeRulesService({ strapi: strapi as any });
119
+
120
+ svc.setup([{
121
+ on: 'lifecycle',
122
+ model: 'api::article.article',
123
+ action: 'afterCreate',
124
+ notification: { title: (ctx) => `Entry #${ctx.entry.id}`, type: 'info' },
125
+ }]);
126
+
127
+ await getLifecycleCallback()!({ result: { id: 42 } });
128
+ expect(send).toHaveBeenCalledWith(expect.objectContaining({ title: 'Entry #42' }));
129
+ });
130
+
131
+ it('skips send when when() returns false', async () => {
132
+ const { strapi, send, getLifecycleCallback } = makeStrapi();
133
+ const svc = makeRulesService({ strapi: strapi as any });
134
+
135
+ svc.setup([{
136
+ on: 'lifecycle',
137
+ model: 'api::article.article',
138
+ action: 'afterCreate',
139
+ when: (ctx) => ctx.entry.published === true,
140
+ notification: { title: 'Published', type: 'info' },
141
+ }]);
142
+
143
+ await getLifecycleCallback()!({ result: { published: false } });
144
+ expect(send).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it('fires send when when() returns true', async () => {
148
+ const { strapi, send, getLifecycleCallback } = makeStrapi();
149
+ const svc = makeRulesService({ strapi: strapi as any });
150
+
151
+ svc.setup([{
152
+ on: 'lifecycle',
153
+ model: 'api::article.article',
154
+ action: 'afterCreate',
155
+ when: (ctx) => ctx.entry.status === 'published',
156
+ notification: { title: 'Published', type: 'success' },
157
+ }]);
158
+
159
+ await getLifecycleCallback()!({ result: { status: 'published' } });
160
+ expect(send).toHaveBeenCalledOnce();
161
+ });
162
+
163
+ it('resolves userIdFrom target from entry', async () => {
164
+ const { strapi, send, getLifecycleCallback } = makeStrapi();
165
+ const svc = makeRulesService({ strapi: strapi as any });
166
+
167
+ svc.setup([{
168
+ on: 'lifecycle',
169
+ model: 'api::article.article',
170
+ action: 'afterCreate',
171
+ notification: {
172
+ title: 'Your article was created',
173
+ to: { userIdFrom: 'entry.createdBy.id' },
174
+ },
175
+ }]);
176
+
177
+ await getLifecycleCallback()!({ result: { createdBy: { id: 7 } } });
178
+ expect(send).toHaveBeenCalledWith(expect.objectContaining({ to: { userId: 7 } }));
179
+ });
180
+
181
+ it('uses params.data as entry fallback for before-hooks', async () => {
182
+ const { strapi, send, getLifecycleCallback } = makeStrapi();
183
+ const svc = makeRulesService({ strapi: strapi as any });
184
+
185
+ svc.setup([{
186
+ on: 'lifecycle',
187
+ model: 'api::article.article',
188
+ action: 'beforeCreate',
189
+ notification: { title: '{{entry.title}}' },
190
+ }]);
191
+
192
+ // beforeCreate has no result, only params.data
193
+ await getLifecycleCallback()!({ params: { data: { title: 'Draft' } } });
194
+ expect(send).toHaveBeenCalledWith(expect.objectContaining({ title: 'Draft' }));
195
+ });
196
+
197
+ it('catches errors and logs them without throwing', async () => {
198
+ const send = vi.fn().mockRejectedValue(new Error('boom'));
199
+ const { strapi, getLifecycleCallback } = makeStrapi(send);
200
+ const svc = makeRulesService({ strapi: strapi as any });
201
+
202
+ svc.setup([{
203
+ on: 'lifecycle',
204
+ model: 'api::article.article',
205
+ action: 'afterCreate',
206
+ notification: { title: 'Test' },
207
+ }]);
208
+
209
+ await expect(getLifecycleCallback()!({ result: {} })).resolves.toBeUndefined();
210
+ expect(strapi.log.error).toHaveBeenCalledOnce();
211
+ });
212
+ });
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Event rules
216
+ // ---------------------------------------------------------------------------
217
+
218
+ describe('event rule', () => {
219
+ it('subscribes to the event hub with the given event name', () => {
220
+ const { strapi } = makeStrapi();
221
+ const svc = makeRulesService({ strapi: strapi as any });
222
+
223
+ svc.setup([{
224
+ on: 'event',
225
+ event: 'media.upload',
226
+ notification: { title: 'Upload' },
227
+ }]);
228
+
229
+ expect(strapi.eventHub.on).toHaveBeenCalledWith('media.upload', expect.any(Function));
230
+ });
231
+
232
+ it('fires send with interpolated title from event payload', async () => {
233
+ const { strapi, send, getEventCallback } = makeStrapi();
234
+ const svc = makeRulesService({ strapi: strapi as any });
235
+
236
+ svc.setup([{
237
+ on: 'event',
238
+ event: 'entry.create',
239
+ notification: { title: 'New entry: {{entry.title}}', type: 'info' },
240
+ }]);
241
+
242
+ await getEventCallback()!({ entry: { title: 'My Post' } });
243
+ expect(send).toHaveBeenCalledWith(expect.objectContaining({ title: 'New entry: My Post' }));
244
+ });
245
+
246
+ it('applies filter — skips when payload does not match', async () => {
247
+ const { strapi, send, getEventCallback } = makeStrapi();
248
+ const svc = makeRulesService({ strapi: strapi as any });
249
+
250
+ svc.setup([{
251
+ on: 'event',
252
+ event: 'entry.create',
253
+ filter: { uid: 'api::article.article' },
254
+ notification: { title: 'Article created' },
255
+ }]);
256
+
257
+ await getEventCallback()!({ uid: 'api::comment.comment' });
258
+ expect(send).not.toHaveBeenCalled();
259
+ });
260
+
261
+ it('applies filter — fires when payload matches', async () => {
262
+ const { strapi, send, getEventCallback } = makeStrapi();
263
+ const svc = makeRulesService({ strapi: strapi as any });
264
+
265
+ svc.setup([{
266
+ on: 'event',
267
+ event: 'entry.create',
268
+ filter: { uid: 'api::article.article' },
269
+ notification: { title: 'Article created' },
270
+ }]);
271
+
272
+ await getEventCallback()!({ uid: 'api::article.article', entry: { title: 'Hello' } });
273
+ expect(send).toHaveBeenCalledOnce();
274
+ });
275
+
276
+ it('respects when() guard', async () => {
277
+ const { strapi, send, getEventCallback } = makeStrapi();
278
+ const svc = makeRulesService({ strapi: strapi as any });
279
+
280
+ svc.setup([{
281
+ on: 'event',
282
+ event: 'entry.update',
283
+ when: (ctx) => (ctx.entry as any)?.status === 'published',
284
+ notification: { title: 'Published' },
285
+ }]);
286
+
287
+ await getEventCallback()!({ entry: { status: 'draft' } });
288
+ expect(send).not.toHaveBeenCalled();
289
+
290
+ await getEventCallback()!({ entry: { status: 'published' } });
291
+ expect(send).toHaveBeenCalledOnce();
292
+ });
293
+
294
+ it('catches errors and logs them without throwing', async () => {
295
+ const send = vi.fn().mockRejectedValue(new Error('net'));
296
+ const { strapi, getEventCallback } = makeStrapi(send);
297
+ const svc = makeRulesService({ strapi: strapi as any });
298
+
299
+ svc.setup([{ on: 'event', event: 'media.upload', notification: { title: 'Upload' } }]);
300
+
301
+ await expect(getEventCallback()!({})).resolves.toBeUndefined();
302
+ expect(strapi.log.error).toHaveBeenCalledOnce();
303
+ });
304
+ });
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Cron rules
308
+ // ---------------------------------------------------------------------------
309
+
310
+ describe('cron rule', () => {
311
+ it('registers a cron task with the given schedule', () => {
312
+ const { strapi } = makeStrapi();
313
+ const svc = makeRulesService({ strapi: strapi as any });
314
+
315
+ svc.setup([{ on: 'cron', schedule: '0 9 * * 1', notification: { title: 'Weekly' } }]);
316
+
317
+ expect(strapi.cron.add).toHaveBeenCalledOnce();
318
+ const tasks = strapi.cron.add.mock.calls[0][0];
319
+ const key = Object.keys(tasks)[0];
320
+ expect(tasks[key].options.rule).toBe('0 9 * * 1');
321
+ });
322
+
323
+ it('fires send when the task runs', async () => {
324
+ const { strapi, send, getCronTask } = makeStrapi();
325
+ const svc = makeRulesService({ strapi: strapi as any });
326
+
327
+ svc.setup([{
328
+ on: 'cron',
329
+ schedule: '0 9 * * 1',
330
+ notification: { title: 'Weekly reminder', type: 'warning', to: 'broadcast' },
331
+ }]);
332
+
333
+ await getCronTask()!({ strapi: strapi as any });
334
+ expect(send).toHaveBeenCalledWith(expect.objectContaining({ title: 'Weekly reminder', type: 'warning' }));
335
+ });
336
+
337
+ it('uses distinct keys for multiple cron rules', () => {
338
+ const { strapi } = makeStrapi();
339
+ const svc = makeRulesService({ strapi: strapi as any });
340
+
341
+ const rules: NotifierRule[] = [
342
+ { on: 'cron', schedule: '0 9 * * 1', notification: { title: 'A' } },
343
+ { on: 'cron', schedule: '0 17 * * 5', notification: { title: 'B' } },
344
+ ];
345
+ svc.setup(rules);
346
+
347
+ const keys = strapi.cron.add.mock.calls.map((c: any) => Object.keys(c[0])[0]);
348
+ expect(new Set(keys).size).toBe(2);
349
+ });
350
+
351
+ it('catches errors and logs them without throwing', async () => {
352
+ const send = vi.fn().mockRejectedValue(new Error('oops'));
353
+ const { strapi, getCronTask } = makeStrapi(send);
354
+ const svc = makeRulesService({ strapi: strapi as any });
355
+
356
+ svc.setup([{ on: 'cron', schedule: '* * * * *', notification: { title: 'Fail' } }]);
357
+
358
+ await expect(getCronTask()!({ strapi: strapi as any })).resolves.toBeUndefined();
359
+ expect(strapi.log.error).toHaveBeenCalledOnce();
360
+ });
361
+ });
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Targeting
365
+ // ---------------------------------------------------------------------------
366
+
367
+ describe('rule targeting', () => {
368
+ it('broadcast: sends with no `to` key', async () => {
369
+ const { strapi, send, getLifecycleCallback } = makeStrapi();
370
+ const svc = makeRulesService({ strapi: strapi as any });
371
+
372
+ svc.setup([{
373
+ on: 'lifecycle', model: 'api::x.x', action: 'afterCreate',
374
+ notification: { title: 'T', to: 'broadcast' },
375
+ }]);
376
+
377
+ await getLifecycleCallback()!({ result: {} });
378
+ const args = send.mock.calls[0][0];
379
+ expect(args.to).toBeUndefined();
380
+ });
381
+
382
+ it('role: sends with to.role', async () => {
383
+ const { strapi, send, getLifecycleCallback } = makeStrapi();
384
+ const svc = makeRulesService({ strapi: strapi as any });
385
+
386
+ svc.setup([{
387
+ on: 'lifecycle', model: 'api::x.x', action: 'afterCreate',
388
+ notification: { title: 'T', to: { role: 'strapi-editor' } },
389
+ }]);
390
+
391
+ await getLifecycleCallback()!({ result: {} });
392
+ expect(send).toHaveBeenCalledWith(expect.objectContaining({ to: { role: 'strapi-editor' } }));
393
+ });
394
+
395
+ it('userId: sends with to.userId', async () => {
396
+ const { strapi, send, getLifecycleCallback } = makeStrapi();
397
+ const svc = makeRulesService({ strapi: strapi as any });
398
+
399
+ svc.setup([{
400
+ on: 'lifecycle', model: 'api::x.x', action: 'afterCreate',
401
+ notification: { title: 'T', to: { userId: 3 } },
402
+ }]);
403
+
404
+ await getLifecycleCallback()!({ result: {} });
405
+ expect(send).toHaveBeenCalledWith(expect.objectContaining({ to: { userId: 3 } }));
406
+ });
407
+
408
+ it('userIdFrom missing path: sends with no to key (falls back to broadcast)', async () => {
409
+ const { strapi, send, getLifecycleCallback } = makeStrapi();
410
+ const svc = makeRulesService({ strapi: strapi as any });
411
+
412
+ svc.setup([{
413
+ on: 'lifecycle', model: 'api::x.x', action: 'afterCreate',
414
+ notification: { title: 'T', to: { userIdFrom: 'entry.author.id' } },
415
+ }]);
416
+
417
+ await getLifecycleCallback()!({ result: {} });
418
+ const args = send.mock.calls[0][0];
419
+ expect(args.to).toBeUndefined();
420
+ });
421
+
422
+ it('function to: resolves dynamically', async () => {
423
+ const { strapi, send, getLifecycleCallback } = makeStrapi();
424
+ const svc = makeRulesService({ strapi: strapi as any });
425
+
426
+ svc.setup([{
427
+ on: 'lifecycle', model: 'api::x.x', action: 'afterCreate',
428
+ notification: {
429
+ title: 'T',
430
+ to: (ctx) => (ctx as any).entry.vip ? { role: 'strapi-super-admin' } : 'broadcast',
431
+ },
432
+ }]);
433
+
434
+ await getLifecycleCallback()!({ result: { vip: true } });
435
+ expect(send).toHaveBeenCalledWith(expect.objectContaining({ to: { role: 'strapi-super-admin' } }));
436
+ });
437
+ });
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // Mixed rule sets
441
+ // ---------------------------------------------------------------------------
442
+
443
+ describe('setup with mixed rule types', () => {
444
+ it('wires all three trigger types independently', () => {
445
+ const { strapi } = makeStrapi();
446
+ const svc = makeRulesService({ strapi: strapi as any });
447
+
448
+ const rules: NotifierRule[] = [
449
+ { on: 'lifecycle', model: 'api::a.a', action: 'afterCreate', notification: { title: 'L' } },
450
+ { on: 'event', event: 'media.upload', notification: { title: 'E' } },
451
+ { on: 'cron', schedule: '0 3 * * *', notification: { title: 'C' } },
452
+ ];
453
+ svc.setup(rules);
454
+
455
+ expect(strapi.db.lifecycles.subscribe).toHaveBeenCalledOnce();
456
+ expect(strapi.eventHub.on).toHaveBeenCalledOnce();
457
+ expect(strapi.cron.add).toHaveBeenCalledOnce();
458
+ });
459
+
460
+ it('returns immediately without registering anything for empty rules array', () => {
461
+ const { strapi } = makeStrapi();
462
+ const svc = makeRulesService({ strapi: strapi as any });
463
+
464
+ svc.setup([]);
465
+
466
+ expect(strapi.db.lifecycles.subscribe).not.toHaveBeenCalled();
467
+ expect(strapi.eventHub.on).not.toHaveBeenCalled();
468
+ expect(strapi.cron.add).not.toHaveBeenCalled();
469
+ });
470
+ });
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['tests/**/*.test.ts'],
8
+ },
9
+ });