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.
- package/CHANGELOG.md +36 -0
- package/README.md +130 -20
- package/dist/_chunks/{Index-DOQrGurB.mjs → Index-1ChU1M3b.mjs} +22 -4
- package/dist/_chunks/{Index-C5mgbISF.js → Index-cFnGw4rg.js} +22 -4
- package/dist/_chunks/{SettingsPage-Cft7agRa.js → SettingsPage-DKTIHVrI.js} +103 -2
- package/dist/_chunks/{SettingsPage-CRsuB4cw.mjs → SettingsPage-DVaHqffU.mjs} +105 -4
- package/dist/_chunks/{index-DrwLcZBZ.js → index-BMiYJk-5.js} +2 -2
- package/dist/_chunks/{index-7hrPEwa_.mjs → index-BauNAWBC.mjs} +2 -2
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/server/index.js +433 -106
- package/dist/server/index.mjs +433 -106
- package/package.json +5 -2
- package/tests/helpers.ts +145 -0
- package/tests/unit/config.test.ts +64 -0
- package/tests/unit/notification.test.ts +263 -0
- package/tests/unit/notifier.test.ts +348 -0
- package/tests/unit/preference.test.ts +122 -0
- package/tests/unit/rules.test.ts +470 -0
- package/vitest.config.ts +9 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import makePreferenceService from '../../server/src/services/preference';
|
|
3
|
+
import { makePreferenceStrapi } from '../helpers';
|
|
4
|
+
|
|
5
|
+
describe('preference.isOptedOut (pure logic)', () => {
|
|
6
|
+
const { isOptedOut } = makePreferenceService({ strapi: {} as any });
|
|
7
|
+
|
|
8
|
+
it('returns true when globalOptOut is set', () => {
|
|
9
|
+
expect(isOptedOut({ userId: 1, globalOptOut: true, mutedTypes: [] }, 'info')).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('returns true when type is in mutedTypes', () => {
|
|
13
|
+
expect(isOptedOut({ userId: 1, globalOptOut: false, mutedTypes: ['info'] }, 'info')).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns false when type is not muted and globalOptOut is false', () => {
|
|
17
|
+
expect(isOptedOut({ userId: 1, globalOptOut: false, mutedTypes: ['warning'] }, 'info')).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns false with empty mutedTypes and globalOptOut false', () => {
|
|
21
|
+
expect(isOptedOut({ userId: 1, globalOptOut: false, mutedTypes: [] }, 'error')).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('globalOptOut overrides even when mutedTypes is empty', () => {
|
|
25
|
+
expect(isOptedOut({ userId: 1, globalOptOut: true, mutedTypes: [] }, 'success')).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('mutes multiple types independently', () => {
|
|
29
|
+
const pref = { userId: 1, globalOptOut: false, mutedTypes: ['info', 'warning'] as any };
|
|
30
|
+
expect(isOptedOut(pref, 'info')).toBe(true);
|
|
31
|
+
expect(isOptedOut(pref, 'warning')).toBe(true);
|
|
32
|
+
expect(isOptedOut(pref, 'success')).toBe(false);
|
|
33
|
+
expect(isOptedOut(pref, 'error')).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('preference.get', () => {
|
|
38
|
+
it('returns default preference when no DB row exists', async () => {
|
|
39
|
+
const { strapi, _db } = makePreferenceStrapi({ findOne: vi.fn().mockResolvedValue(null) });
|
|
40
|
+
const svc = makePreferenceService({ strapi: strapi as any });
|
|
41
|
+
|
|
42
|
+
const pref = await svc.get(5);
|
|
43
|
+
|
|
44
|
+
expect(pref).toEqual({ userId: 5, globalOptOut: false, mutedTypes: [] });
|
|
45
|
+
expect(_db.findOne).toHaveBeenCalledWith({ where: { userId: 5 } });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns stored preference when DB row exists', async () => {
|
|
49
|
+
const stored = { userId: 5, globalOptOut: true, mutedTypes: ['info', 'warning'] };
|
|
50
|
+
const { strapi } = makePreferenceStrapi({ findOne: vi.fn().mockResolvedValue(stored) });
|
|
51
|
+
const svc = makePreferenceService({ strapi: strapi as any });
|
|
52
|
+
|
|
53
|
+
const pref = await svc.get(5);
|
|
54
|
+
|
|
55
|
+
expect(pref.globalOptOut).toBe(true);
|
|
56
|
+
expect(pref.mutedTypes).toEqual(['info', 'warning']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('normalises null mutedTypes to empty array', async () => {
|
|
60
|
+
const stored = { userId: 5, globalOptOut: false, mutedTypes: null };
|
|
61
|
+
const { strapi } = makePreferenceStrapi({ findOne: vi.fn().mockResolvedValue(stored) });
|
|
62
|
+
const svc = makePreferenceService({ strapi: strapi as any });
|
|
63
|
+
|
|
64
|
+
const pref = await svc.get(5);
|
|
65
|
+
expect(pref.mutedTypes).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('preference.upsert', () => {
|
|
70
|
+
it('creates a new row when none exists', async () => {
|
|
71
|
+
const { strapi, _db } = makePreferenceStrapi({
|
|
72
|
+
findOne: vi.fn().mockResolvedValue(null),
|
|
73
|
+
create: vi.fn().mockResolvedValue({
|
|
74
|
+
userId: 3,
|
|
75
|
+
globalOptOut: true,
|
|
76
|
+
mutedTypes: [],
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
const svc = makePreferenceService({ strapi: strapi as any });
|
|
80
|
+
|
|
81
|
+
const pref = await svc.upsert(3, { globalOptOut: true });
|
|
82
|
+
|
|
83
|
+
expect(_db.create).toHaveBeenCalledWith({
|
|
84
|
+
data: expect.objectContaining({ userId: 3, globalOptOut: true }),
|
|
85
|
+
});
|
|
86
|
+
expect(_db.update).not.toHaveBeenCalled();
|
|
87
|
+
expect(pref.globalOptOut).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('updates existing row when one exists', async () => {
|
|
91
|
+
const existing = { id: 10, userId: 3, globalOptOut: false, mutedTypes: [] };
|
|
92
|
+
const { strapi, _db } = makePreferenceStrapi({
|
|
93
|
+
findOne: vi.fn().mockResolvedValue(existing),
|
|
94
|
+
update: vi.fn().mockResolvedValue({ ...existing, mutedTypes: ['info'] }),
|
|
95
|
+
});
|
|
96
|
+
const svc = makePreferenceService({ strapi: strapi as any });
|
|
97
|
+
|
|
98
|
+
const pref = await svc.upsert(3, { mutedTypes: ['info'] as any });
|
|
99
|
+
|
|
100
|
+
expect(_db.update).toHaveBeenCalledWith({
|
|
101
|
+
where: { id: 10 },
|
|
102
|
+
data: { mutedTypes: ['info'] },
|
|
103
|
+
});
|
|
104
|
+
expect(_db.create).not.toHaveBeenCalled();
|
|
105
|
+
expect(pref.mutedTypes).toEqual(['info']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('persists globalOptOut=false to re-enable notifications', async () => {
|
|
109
|
+
const existing = { id: 7, userId: 2, globalOptOut: true, mutedTypes: [] };
|
|
110
|
+
const { strapi, _db } = makePreferenceStrapi({
|
|
111
|
+
findOne: vi.fn().mockResolvedValue(existing),
|
|
112
|
+
update: vi.fn().mockResolvedValue({ ...existing, globalOptOut: false }),
|
|
113
|
+
});
|
|
114
|
+
const svc = makePreferenceService({ strapi: strapi as any });
|
|
115
|
+
|
|
116
|
+
await svc.upsert(2, { globalOptOut: false });
|
|
117
|
+
|
|
118
|
+
expect(_db.update).toHaveBeenCalledWith(
|
|
119
|
+
expect.objectContaining({ data: { globalOptOut: false } })
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
});
|