strapi-plugin-notifier 1.0.2 → 1.1.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,64 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { DEFAULT_SETTINGS, mergeWithDefaults } from '../../server/src/config';
3
+
4
+ describe('mergeWithDefaults', () => {
5
+ it('returns all default fields when called with no overrides', () => {
6
+ const result = mergeWithDefaults();
7
+ expect(result).toEqual(DEFAULT_SETTINGS);
8
+ });
9
+
10
+ it('includes default merge config', () => {
11
+ const result = mergeWithDefaults();
12
+ expect(result.merge).toEqual({
13
+ enabled: false,
14
+ windowMinutes: 60,
15
+ keyFields: ['title', 'type'],
16
+ countBadge: true,
17
+ rewriteMessage: false,
18
+ });
19
+ });
20
+
21
+ it('enables merge when overridden', () => {
22
+ const result = mergeWithDefaults({ merge: { enabled: true } });
23
+ expect(result.merge.enabled).toBe(true);
24
+ expect(result.merge.windowMinutes).toBe(60);
25
+ expect(result.merge.keyFields).toEqual(['title', 'type']);
26
+ });
27
+
28
+ it('overrides windowMinutes while preserving other merge defaults', () => {
29
+ const result = mergeWithDefaults({ merge: { windowMinutes: 30 } });
30
+ expect(result.merge.windowMinutes).toBe(30);
31
+ expect(result.merge.enabled).toBe(false);
32
+ expect(result.merge.countBadge).toBe(true);
33
+ });
34
+
35
+ it('replaces keyFields entirely when overridden', () => {
36
+ const result = mergeWithDefaults({ merge: { keyFields: ['url'] } });
37
+ expect(result.merge.keyFields).toEqual(['url']);
38
+ });
39
+
40
+ it('applies rewriteMessage override', () => {
41
+ const result = mergeWithDefaults({ merge: { rewriteMessage: true } });
42
+ expect(result.merge.rewriteMessage).toBe(true);
43
+ expect(result.merge.countBadge).toBe(true);
44
+ });
45
+
46
+ it('does not bleed merge config into other sections', () => {
47
+ const result = mergeWithDefaults({ merge: { enabled: true } });
48
+ expect(result.retention).toEqual(DEFAULT_SETTINGS.retention);
49
+ expect(result.delivery).toEqual(DEFAULT_SETTINGS.delivery);
50
+ });
51
+
52
+ it('overrides retention fields independently', () => {
53
+ const result = mergeWithDefaults({ retention: { maxDays: 30 } });
54
+ expect(result.retention.maxDays).toBe(30);
55
+ expect(result.retention.maxPerUser).toBe(DEFAULT_SETTINGS.retention.maxPerUser);
56
+ expect(result.merge).toEqual(DEFAULT_SETTINGS.merge);
57
+ });
58
+
59
+ it('deeply merges ui accent colours', () => {
60
+ const result = mergeWithDefaults({ ui: { theme: { accent: { info: '#000' } } } });
61
+ expect(result.ui.theme.accent.info).toBe('#000');
62
+ expect(result.ui.theme.accent.error).toBe(DEFAULT_SETTINGS.ui.theme.accent.error);
63
+ });
64
+ });
@@ -0,0 +1,263 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import makeNotificationService from '../../server/src/services/notification';
3
+ import { makeNotificationStrapi, makeDbQuery } from '../helpers';
4
+ import type { MergeConfig } from '../../server/src/config';
5
+
6
+ const DISABLED_MERGE: MergeConfig = {
7
+ enabled: false,
8
+ windowMinutes: 60,
9
+ keyFields: ['title', 'type'],
10
+ countBadge: true,
11
+ rewriteMessage: false,
12
+ };
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // findByRecipient — opt-out filtering
16
+ // ---------------------------------------------------------------------------
17
+
18
+ describe('notification.findByRecipient', () => {
19
+ it('returns empty array without querying DB when globalOptOut is true', async () => {
20
+ const { strapi, _db } = makeNotificationStrapi({}, { globalOptOut: true });
21
+ const svc = makeNotificationService({ strapi: strapi as any });
22
+
23
+ const result = await svc.findByRecipient(1, []);
24
+
25
+ expect(result).toEqual([]);
26
+ expect(_db.findMany).not.toHaveBeenCalled();
27
+ });
28
+
29
+ it('queries DB normally when user has no restrictions', async () => {
30
+ const rows = [{ id: 1, title: 'Hello', type: 'info' }];
31
+ const { strapi, _db } = makeNotificationStrapi(
32
+ { findMany: vi.fn().mockResolvedValue(rows) },
33
+ { globalOptOut: false, mutedTypes: [] }
34
+ );
35
+ const svc = makeNotificationService({ strapi: strapi as any });
36
+
37
+ const result = await svc.findByRecipient(1, []);
38
+
39
+ expect(result).toBe(rows);
40
+ expect(_db.findMany).toHaveBeenCalledOnce();
41
+ const call = _db.findMany.mock.calls[0][0];
42
+ expect(call.where).not.toHaveProperty('type');
43
+ });
44
+
45
+ it('adds $notIn filter for mutedTypes', async () => {
46
+ const { strapi, _db } = makeNotificationStrapi(
47
+ {},
48
+ { globalOptOut: false, mutedTypes: ['info', 'warning'] as any }
49
+ );
50
+ const svc = makeNotificationService({ strapi: strapi as any });
51
+
52
+ await svc.findByRecipient(1, []);
53
+
54
+ const where = _db.findMany.mock.calls[0][0].where;
55
+ expect(where.type).toEqual({ $notIn: ['info', 'warning'] });
56
+ });
57
+
58
+ it('respects page and pageSize', async () => {
59
+ const { strapi, _db } = makeNotificationStrapi();
60
+ const svc = makeNotificationService({ strapi: strapi as any });
61
+
62
+ await svc.findByRecipient(1, [], { page: 3, pageSize: 10 });
63
+
64
+ const call = _db.findMany.mock.calls[0][0];
65
+ expect(call.limit).toBe(10);
66
+ expect(call.offset).toBe(20);
67
+ });
68
+ });
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // countByRecipient / countUnread — opt-out
72
+ // ---------------------------------------------------------------------------
73
+
74
+ describe('notification.countByRecipient', () => {
75
+ it('returns 0 without querying DB when globalOptOut is true', async () => {
76
+ const { strapi, _db } = makeNotificationStrapi({}, { globalOptOut: true });
77
+ const svc = makeNotificationService({ strapi: strapi as any });
78
+
79
+ const count = await svc.countByRecipient(1, []);
80
+
81
+ expect(count).toBe(0);
82
+ expect(_db.count).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it('queries DB normally when not opted out', async () => {
86
+ const { strapi, _db } = makeNotificationStrapi({ count: vi.fn().mockResolvedValue(5) });
87
+ const svc = makeNotificationService({ strapi: strapi as any });
88
+
89
+ const count = await svc.countByRecipient(1, []);
90
+
91
+ expect(count).toBe(5);
92
+ expect(_db.count).toHaveBeenCalledOnce();
93
+ });
94
+ });
95
+
96
+ describe('notification.countUnread', () => {
97
+ it('returns 0 without querying DB when globalOptOut is true', async () => {
98
+ const { strapi, _db } = makeNotificationStrapi({}, { globalOptOut: true });
99
+ const svc = makeNotificationService({ strapi: strapi as any });
100
+
101
+ const count = await svc.countUnread(1, []);
102
+
103
+ expect(count).toBe(0);
104
+ expect(_db.count).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it('passes read: false filter when not opted out', async () => {
108
+ const { strapi, _db } = makeNotificationStrapi({ count: vi.fn().mockResolvedValue(3) });
109
+ const svc = makeNotificationService({ strapi: strapi as any });
110
+
111
+ await svc.countUnread(1, []);
112
+
113
+ const where = _db.count.mock.calls[0][0].where;
114
+ expect(where.read).toBe(false);
115
+ });
116
+ });
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // create
120
+ // ---------------------------------------------------------------------------
121
+
122
+ describe('notification.create', () => {
123
+ it('stores mergeKey and mergeCount: 1', async () => {
124
+ const { strapi, _db } = makeNotificationStrapi();
125
+ const svc = makeNotificationService({ strapi: strapi as any });
126
+
127
+ await svc.create({ title: 'Test', type: 'info', mergeKey: 'Test\x00info' });
128
+
129
+ const data = _db.create.mock.calls[0][0].data;
130
+ expect(data.mergeKey).toBe('Test\x00info');
131
+ expect(data.mergeCount).toBe(1);
132
+ expect(data.read).toBe(false);
133
+ });
134
+
135
+ it('defaults type to info', async () => {
136
+ const { strapi, _db } = makeNotificationStrapi();
137
+ const svc = makeNotificationService({ strapi: strapi as any });
138
+
139
+ await svc.create({ title: 'Hi' });
140
+
141
+ expect(_db.create.mock.calls[0][0].data.type).toBe('info');
142
+ });
143
+ });
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // findMergeCandidate
147
+ // ---------------------------------------------------------------------------
148
+
149
+ describe('notification.findMergeCandidate', () => {
150
+ it('queries DB with mergeKey, recipientId, and cutoff', async () => {
151
+ const candidate = { id: 5, mergeCount: 1 };
152
+ const { strapi, _db } = makeNotificationStrapi({
153
+ findOne: vi.fn().mockResolvedValue(candidate),
154
+ });
155
+ const svc = makeNotificationService({ strapi: strapi as any });
156
+
157
+ const before = Date.now();
158
+ const result = await svc.findMergeCandidate('Test\x00info', 1, null, 60 * 60 * 1000);
159
+ const after = Date.now();
160
+
161
+ expect(result).toBe(candidate);
162
+ const where = _db.findOne.mock.calls[0][0].where;
163
+ expect(where.mergeKey).toBe('Test\x00info');
164
+ expect(where.recipientId).toBe(1);
165
+ expect(where.recipientRole).toBeNull();
166
+ const cutoffMs = new Date(where.createdAt.$gt).getTime();
167
+ expect(cutoffMs).toBeGreaterThanOrEqual(before - 60 * 60 * 1000);
168
+ expect(cutoffMs).toBeLessThanOrEqual(after);
169
+ });
170
+
171
+ it('returns null when no candidate found', async () => {
172
+ const { strapi } = makeNotificationStrapi({ findOne: vi.fn().mockResolvedValue(null) });
173
+ const svc = makeNotificationService({ strapi: strapi as any });
174
+
175
+ const result = await svc.findMergeCandidate('key', null, null, 3600000);
176
+ expect(result).toBeNull();
177
+ });
178
+
179
+ it('uses recipientRole when provided', async () => {
180
+ const { strapi, _db } = makeNotificationStrapi();
181
+ const svc = makeNotificationService({ strapi: strapi as any });
182
+
183
+ await svc.findMergeCandidate('key', null, 'strapi-editor', 3600000);
184
+
185
+ const where = _db.findOne.mock.calls[0][0].where;
186
+ expect(where.recipientRole).toBe('strapi-editor');
187
+ expect(where.recipientId).toBeNull();
188
+ });
189
+ });
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // mergeInto
193
+ // ---------------------------------------------------------------------------
194
+
195
+ describe('notification.mergeInto', () => {
196
+ it('updates mergeCount without rewriting message when rewriteMessage=false', async () => {
197
+ const { strapi, _db } = makeNotificationStrapi();
198
+ const svc = makeNotificationService({ strapi: strapi as any });
199
+
200
+ await svc.mergeInto(5, 3, { ...DISABLED_MERGE, rewriteMessage: false }, 'Test');
201
+
202
+ const data = _db.update.mock.calls[0][0].data;
203
+ expect(data.mergeCount).toBe(3);
204
+ expect(data.message).toBeUndefined();
205
+ });
206
+
207
+ it('rewrites message when rewriteMessage=true', async () => {
208
+ const { strapi, _db } = makeNotificationStrapi();
209
+ const svc = makeNotificationService({ strapi: strapi as any });
210
+
211
+ await svc.mergeInto(5, 4, { ...DISABLED_MERGE, rewriteMessage: true }, 'Test');
212
+
213
+ const data = _db.update.mock.calls[0][0].data;
214
+ expect(data.mergeCount).toBe(4);
215
+ expect(data.message).toBe('4× Test');
216
+ });
217
+
218
+ it('targets the correct notification id', async () => {
219
+ const { strapi, _db } = makeNotificationStrapi();
220
+ const svc = makeNotificationService({ strapi: strapi as any });
221
+
222
+ await svc.mergeInto(99, 2, DISABLED_MERGE, 'X');
223
+
224
+ expect(_db.update.mock.calls[0][0].where).toEqual({ id: 99 });
225
+ });
226
+ });
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // createMany
230
+ // ---------------------------------------------------------------------------
231
+
232
+ describe('notification.createMany', () => {
233
+ it('calls create for each item', async () => {
234
+ const { strapi, _db } = makeNotificationStrapi();
235
+ const svc = makeNotificationService({ strapi: strapi as any });
236
+
237
+ await svc.createMany([
238
+ { title: 'A', type: 'info' },
239
+ { title: 'B', type: 'error' },
240
+ ]);
241
+
242
+ expect(_db.create).toHaveBeenCalledTimes(2);
243
+ const titles = _db.create.mock.calls.map((c: any) => c[0].data.title);
244
+ expect(titles).toContain('A');
245
+ expect(titles).toContain('B');
246
+ });
247
+
248
+ it('returns an array of created items', async () => {
249
+ const { strapi } = makeNotificationStrapi({
250
+ create: vi
251
+ .fn()
252
+ .mockResolvedValueOnce({ id: 1, title: 'A' })
253
+ .mockResolvedValueOnce({ id: 2, title: 'B' }),
254
+ });
255
+ const svc = makeNotificationService({ strapi: strapi as any });
256
+
257
+ const results = await svc.createMany([{ title: 'A' }, { title: 'B' }]);
258
+
259
+ expect(results).toHaveLength(2);
260
+ expect(results[0]).toMatchObject({ id: 1 });
261
+ expect(results[1]).toMatchObject({ id: 2 });
262
+ });
263
+ });
@@ -0,0 +1,348 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import makeNotifierService from '../../server/src/services/notifier';
3
+ import { makeNotifierStrapi } from '../helpers';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const MERGE_ENABLED = {
10
+ enabled: true,
11
+ windowMinutes: 60,
12
+ keyFields: ['title', 'type'],
13
+ countBadge: true,
14
+ rewriteMessage: false,
15
+ };
16
+
17
+ const MERGE_DISABLED = { ...MERGE_ENABLED, enabled: false };
18
+
19
+ const withMerge = (enabled: boolean, extra = {}) => ({
20
+ merge: enabled ? MERGE_ENABLED : MERGE_DISABLED,
21
+ ...extra,
22
+ });
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // send — merge disabled
26
+ // ---------------------------------------------------------------------------
27
+
28
+ describe('notifier.send — merge disabled', () => {
29
+ it('creates a notification directly', async () => {
30
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi();
31
+ _settSvc.getEffective.mockResolvedValue(withMerge(false));
32
+
33
+ const svc = makeNotifierService({ strapi: strapi as any });
34
+ await svc.send({ title: 'Hello', type: 'info' });
35
+
36
+ expect(_notifSvc.create).toHaveBeenCalledOnce();
37
+ expect(_notifSvc.findMergeCandidate).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it('passes title, message, type, url to create', async () => {
41
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi();
42
+ _settSvc.getEffective.mockResolvedValue(withMerge(false));
43
+
44
+ const svc = makeNotifierService({ strapi: strapi as any });
45
+ await svc.send({ title: 'T', message: 'M', type: 'error', url: '/path' });
46
+
47
+ const args = _notifSvc.create.mock.calls[0][0];
48
+ expect(args).toMatchObject({ title: 'T', message: 'M', type: 'error', url: '/path' });
49
+ });
50
+
51
+ it('sets recipientId when targeting a user', async () => {
52
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi();
53
+ _settSvc.getEffective.mockResolvedValue(withMerge(false));
54
+
55
+ const svc = makeNotifierService({ strapi: strapi as any });
56
+ await svc.send({ title: 'Hi', to: { userId: 42 } });
57
+
58
+ expect(_notifSvc.create.mock.calls[0][0].recipientId).toBe(42);
59
+ });
60
+
61
+ it('sets recipientRole when targeting a role', async () => {
62
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi();
63
+ _settSvc.getEffective.mockResolvedValue(withMerge(false));
64
+
65
+ const svc = makeNotifierService({ strapi: strapi as any });
66
+ await svc.send({ title: 'Hi', to: { role: 'strapi-editor' } });
67
+
68
+ expect(_notifSvc.create.mock.calls[0][0].recipientRole).toBe('strapi-editor');
69
+ });
70
+ });
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // send — opt-out
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe('notifier.send — opt-out', () => {
77
+ it('does not create when target user is opted out (globalOptOut)', async () => {
78
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi(
79
+ {},
80
+ { globalOptOut: true }
81
+ );
82
+ _settSvc.getEffective.mockResolvedValue(withMerge(false));
83
+
84
+ const svc = makeNotifierService({ strapi: strapi as any });
85
+ const result = await svc.toUser(1, { title: 'Hi' });
86
+
87
+ expect(result).toBeNull();
88
+ expect(_notifSvc.create).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it('does not create when type is muted', async () => {
92
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi(
93
+ {},
94
+ { globalOptOut: false, mutedTypes: ['info'] as any }
95
+ );
96
+ _settSvc.getEffective.mockResolvedValue(withMerge(false));
97
+
98
+ const svc = makeNotifierService({ strapi: strapi as any });
99
+ const result = await svc.toUser(1, { title: 'Hi', type: 'info' });
100
+
101
+ expect(result).toBeNull();
102
+ expect(_notifSvc.create).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it('creates when type is NOT in mutedTypes', async () => {
106
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi(
107
+ {},
108
+ { globalOptOut: false, mutedTypes: ['warning'] as any }
109
+ );
110
+ _settSvc.getEffective.mockResolvedValue(withMerge(false));
111
+
112
+ const svc = makeNotifierService({ strapi: strapi as any });
113
+ await svc.toUser(1, { title: 'Hi', type: 'info' });
114
+
115
+ expect(_notifSvc.create).toHaveBeenCalledOnce();
116
+ });
117
+
118
+ it('does NOT check opt-out for broadcast (no recipientId)', async () => {
119
+ const { strapi, _notifSvc, _prefSvc, _settSvc } = makeNotifierStrapi(
120
+ {},
121
+ { globalOptOut: true }
122
+ );
123
+ _settSvc.getEffective.mockResolvedValue(withMerge(false));
124
+
125
+ const svc = makeNotifierService({ strapi: strapi as any });
126
+ await svc.broadcast({ title: 'All' });
127
+
128
+ expect(_prefSvc.get).not.toHaveBeenCalled();
129
+ expect(_notifSvc.create).toHaveBeenCalledOnce();
130
+ });
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // send — merge enabled
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe('notifier.send — merge enabled', () => {
138
+ it('calls findMergeCandidate with computed merge key', async () => {
139
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi();
140
+ _settSvc.getEffective.mockResolvedValue(withMerge(true));
141
+ _notifSvc.findMergeCandidate.mockResolvedValue(null);
142
+
143
+ const svc = makeNotifierService({ strapi: strapi as any });
144
+ await svc.send({ title: 'Deploy', type: 'info' });
145
+
146
+ expect(_notifSvc.findMergeCandidate).toHaveBeenCalledOnce();
147
+ const [mergeKey] = _notifSvc.findMergeCandidate.mock.calls[0];
148
+ expect(mergeKey).toBe('Deploy\x00info');
149
+ });
150
+
151
+ it('calls mergeInto (not create) when a candidate is found', async () => {
152
+ const candidate = { id: 7, mergeCount: 2 };
153
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi();
154
+ _settSvc.getEffective.mockResolvedValue(withMerge(true));
155
+ _notifSvc.findMergeCandidate.mockResolvedValue(candidate);
156
+
157
+ const svc = makeNotifierService({ strapi: strapi as any });
158
+ await svc.send({ title: 'Deploy', type: 'info' });
159
+
160
+ expect(_notifSvc.mergeInto).toHaveBeenCalledWith(7, 3, MERGE_ENABLED, 'Deploy');
161
+ expect(_notifSvc.create).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it('creates a new notification when no candidate found', async () => {
165
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi();
166
+ _settSvc.getEffective.mockResolvedValue(withMerge(true));
167
+ _notifSvc.findMergeCandidate.mockResolvedValue(null);
168
+
169
+ const svc = makeNotifierService({ strapi: strapi as any });
170
+ await svc.send({ title: 'New', type: 'success' });
171
+
172
+ expect(_notifSvc.create).toHaveBeenCalledOnce();
173
+ expect(_notifSvc.mergeInto).not.toHaveBeenCalled();
174
+ });
175
+
176
+ it('includes mergeKey in the created notification', async () => {
177
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi();
178
+ _settSvc.getEffective.mockResolvedValue(withMerge(true));
179
+ _notifSvc.findMergeCandidate.mockResolvedValue(null);
180
+
181
+ const svc = makeNotifierService({ strapi: strapi as any });
182
+ await svc.send({ title: 'Test', type: 'warning' });
183
+
184
+ const args = _notifSvc.create.mock.calls[0][0];
185
+ expect(args.mergeKey).toBe('Test\x00warning');
186
+ });
187
+
188
+ it('uses configured keyFields to build merge key', async () => {
189
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi();
190
+ _settSvc.getEffective.mockResolvedValue({
191
+ merge: { ...MERGE_ENABLED, keyFields: ['title', 'url'] },
192
+ });
193
+ _notifSvc.findMergeCandidate.mockResolvedValue(null);
194
+
195
+ const svc = makeNotifierService({ strapi: strapi as any });
196
+ await svc.send({ title: 'Deploy', url: '/admin', type: 'info' });
197
+
198
+ const [mergeKey] = _notifSvc.findMergeCandidate.mock.calls[0];
199
+ expect(mergeKey).toBe('Deploy\x00/admin');
200
+ });
201
+
202
+ it('passes window in ms to findMergeCandidate', async () => {
203
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi();
204
+ _settSvc.getEffective.mockResolvedValue({
205
+ merge: { ...MERGE_ENABLED, windowMinutes: 30 },
206
+ });
207
+ _notifSvc.findMergeCandidate.mockResolvedValue(null);
208
+
209
+ const svc = makeNotifierService({ strapi: strapi as any });
210
+ await svc.send({ title: 'T', type: 'info' });
211
+
212
+ const windowMs = _notifSvc.findMergeCandidate.mock.calls[0][3];
213
+ expect(windowMs).toBe(30 * 60 * 1000);
214
+ });
215
+ });
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // sendBatch
219
+ // ---------------------------------------------------------------------------
220
+
221
+ describe('notifier.sendBatch', () => {
222
+ it('fetches settings exactly once regardless of batch size', async () => {
223
+ const { strapi, _settSvc } = makeNotifierStrapi();
224
+ _settSvc.getEffective.mockResolvedValue(withMerge(false));
225
+
226
+ const svc = makeNotifierService({ strapi: strapi as any });
227
+ await svc.sendBatch([
228
+ { title: 'A', type: 'info' },
229
+ { title: 'B', type: 'warning' },
230
+ { title: 'C', type: 'success' },
231
+ ]);
232
+
233
+ expect(_settSvc.getEffective).toHaveBeenCalledOnce();
234
+ });
235
+
236
+ it('sends all items and returns an array of results', async () => {
237
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi();
238
+ _settSvc.getEffective.mockResolvedValue(withMerge(false));
239
+ _notifSvc.create
240
+ .mockResolvedValueOnce({ id: 1 })
241
+ .mockResolvedValueOnce({ id: 2 });
242
+
243
+ const svc = makeNotifierService({ strapi: strapi as any });
244
+ const results = await svc.sendBatch([
245
+ { title: 'A', type: 'info' },
246
+ { title: 'B', type: 'error' },
247
+ ]);
248
+
249
+ expect(_notifSvc.create).toHaveBeenCalledTimes(2);
250
+ expect(results).toHaveLength(2);
251
+ });
252
+
253
+ it('skips opted-out recipients but still sends others', async () => {
254
+ const { strapi, _notifSvc, _prefSvc, _settSvc } = makeNotifierStrapi(
255
+ {},
256
+ { globalOptOut: true }
257
+ );
258
+ _settSvc.getEffective.mockResolvedValue(withMerge(false));
259
+
260
+ const svc = makeNotifierService({ strapi: strapi as any });
261
+ const results = await svc.sendBatch([
262
+ { title: 'A', to: { userId: 1 } },
263
+ { title: 'B' },
264
+ ]);
265
+
266
+ // userId:1 opted out → null; broadcast (no userId) → not checked
267
+ expect(results[0]).toBeNull();
268
+ expect(_notifSvc.create).toHaveBeenCalledOnce();
269
+ expect(results).toHaveLength(2);
270
+ });
271
+
272
+ it('returns empty array for empty input', async () => {
273
+ const { strapi, _settSvc } = makeNotifierStrapi();
274
+ _settSvc.getEffective.mockResolvedValue(withMerge(false));
275
+
276
+ const svc = makeNotifierService({ strapi: strapi as any });
277
+ const results = await svc.sendBatch([]);
278
+
279
+ expect(results).toEqual([]);
280
+ expect(_settSvc.getEffective).toHaveBeenCalledOnce();
281
+ });
282
+
283
+ it('applies merge per item in the batch', async () => {
284
+ const candidate = { id: 10, mergeCount: 1 };
285
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi();
286
+ _settSvc.getEffective.mockResolvedValue(withMerge(true));
287
+ _notifSvc.findMergeCandidate
288
+ .mockResolvedValueOnce(candidate)
289
+ .mockResolvedValueOnce(null);
290
+
291
+ const svc = makeNotifierService({ strapi: strapi as any });
292
+ await svc.sendBatch([
293
+ { title: 'Same', type: 'info' },
294
+ { title: 'New', type: 'info' },
295
+ ]);
296
+
297
+ expect(_notifSvc.mergeInto).toHaveBeenCalledOnce();
298
+ expect(_notifSvc.create).toHaveBeenCalledOnce();
299
+ });
300
+ });
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // toUser / toRole / broadcast convenience wrappers
304
+ // ---------------------------------------------------------------------------
305
+
306
+ describe('notifier convenience wrappers', () => {
307
+ it('toUser passes recipientId and skips create when opted out', async () => {
308
+ const { strapi, _notifSvc, _settSvc } = makeNotifierStrapi(
309
+ {},
310
+ { globalOptOut: true }
311
+ );
312
+ _settSvc.getEffective.mockResolvedValue(withMerge(false));
313
+
314
+ const svc = makeNotifierService({ strapi: strapi as any });
315
+ await svc.toUser(1, { title: 'Hi' });
316
+
317
+ expect(_notifSvc.create).not.toHaveBeenCalled();
318
+ });
319
+
320
+ it('toRole passes recipientRole without opt-out check', async () => {
321
+ const { strapi, _notifSvc, _prefSvc, _settSvc } = makeNotifierStrapi(
322
+ {},
323
+ { globalOptOut: true }
324
+ );
325
+ _settSvc.getEffective.mockResolvedValue(withMerge(false));
326
+
327
+ const svc = makeNotifierService({ strapi: strapi as any });
328
+ await svc.toRole('strapi-editor', { title: 'Hi' });
329
+
330
+ expect(_prefSvc.get).not.toHaveBeenCalled();
331
+ expect(_notifSvc.create).toHaveBeenCalledOnce();
332
+ expect(_notifSvc.create.mock.calls[0][0].recipientRole).toBe('strapi-editor');
333
+ });
334
+
335
+ it('broadcast creates without opt-out check', async () => {
336
+ const { strapi, _notifSvc, _prefSvc, _settSvc } = makeNotifierStrapi();
337
+ _settSvc.getEffective.mockResolvedValue(withMerge(false));
338
+
339
+ const svc = makeNotifierService({ strapi: strapi as any });
340
+ await svc.broadcast({ title: 'All', type: 'warning' });
341
+
342
+ expect(_prefSvc.get).not.toHaveBeenCalled();
343
+ expect(_notifSvc.create).toHaveBeenCalledOnce();
344
+ const args = _notifSvc.create.mock.calls[0][0];
345
+ expect(args.recipientId).toBeUndefined();
346
+ expect(args.recipientRole).toBeUndefined();
347
+ });
348
+ });