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,145 @@
1
+ import { vi } from 'vitest';
2
+ import type { NotificationType } from '../server/src/config';
3
+ import type { UserPreference } from '../server/src/services/preference';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // DB query mock
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export const makeDbQuery = (overrides: Record<string, unknown> = {}) => ({
10
+ findOne: vi.fn().mockResolvedValue(null),
11
+ findMany: vi.fn().mockResolvedValue([]),
12
+ create: vi.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) =>
13
+ Promise.resolve({ id: 1, ...data })
14
+ ),
15
+ update: vi.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) =>
16
+ Promise.resolve({ id: 1, ...data })
17
+ ),
18
+ updateMany: vi.fn().mockResolvedValue({ count: 0 }),
19
+ delete: vi.fn().mockResolvedValue({ id: 1 }),
20
+ deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
21
+ count: vi.fn().mockResolvedValue(0),
22
+ ...overrides,
23
+ });
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Preference helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export const makeDefaultPref = (overrides: Partial<UserPreference> = {}): UserPreference => ({
30
+ userId: 1,
31
+ globalOptOut: false,
32
+ mutedTypes: [],
33
+ ...overrides,
34
+ });
35
+
36
+ export const makePrefService = (prefOverrides: Partial<UserPreference> = {}) => {
37
+ const pref = makeDefaultPref(prefOverrides);
38
+ return {
39
+ get: vi.fn().mockResolvedValue(pref),
40
+ isOptedOut: (p: UserPreference, type: NotificationType) =>
41
+ p.globalOptOut || p.mutedTypes.includes(type),
42
+ upsert: vi.fn().mockResolvedValue(pref),
43
+ };
44
+ };
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Notification service mock (for notifier tests)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export const makeNotificationService = (overrides: Record<string, unknown> = {}) => ({
51
+ create: vi.fn().mockResolvedValue({ id: 1, mergeCount: 1 }),
52
+ createMany: vi.fn().mockResolvedValue([{ id: 1 }]),
53
+ findMergeCandidate: vi.fn().mockResolvedValue(null),
54
+ mergeInto: vi.fn().mockResolvedValue({ id: 1, mergeCount: 2 }),
55
+ findByRecipient: vi.fn().mockResolvedValue([]),
56
+ countByRecipient: vi.fn().mockResolvedValue(0),
57
+ countUnread: vi.fn().mockResolvedValue(0),
58
+ markAsRead: vi.fn().mockResolvedValue({ id: 1, read: true }),
59
+ markAllAsRead: vi.fn().mockResolvedValue({}),
60
+ delete: vi.fn().mockResolvedValue({ id: 1 }),
61
+ clearAll: vi.fn().mockResolvedValue({}),
62
+ ...overrides,
63
+ });
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Settings mock
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export const makeSettingsService = (overrides: Record<string, unknown> = {}) => ({
70
+ getEffective: vi.fn().mockResolvedValue({
71
+ retention: { maxDays: 90, maxPerUser: 500 },
72
+ delivery: { defaultRecipient: 'broadcast', allowedRoles: [] },
73
+ merge: {
74
+ enabled: false,
75
+ windowMinutes: 60,
76
+ keyFields: ['title', 'type'],
77
+ countBadge: true,
78
+ rewriteMessage: false,
79
+ },
80
+ ui: {
81
+ pollInterval: 30000,
82
+ pageSize: 20,
83
+ defaultFilter: 'all',
84
+ badge: { enabled: true, color: '#ee5e52' },
85
+ theme: {
86
+ bellSize: '1.5em',
87
+ accent: { info: '#4945ff', success: '#328048', warning: '#d97706', error: '#d02b20' },
88
+ },
89
+ },
90
+ ...overrides,
91
+ }),
92
+ get: vi.fn(),
93
+ update: vi.fn(),
94
+ reset: vi.fn(),
95
+ });
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Strapi instance mocks
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /** Minimal strapi mock for notification service tests. */
102
+ export const makeNotificationStrapi = (
103
+ dbOverrides: Record<string, unknown> = {},
104
+ prefOverrides: Partial<UserPreference> = {}
105
+ ) => {
106
+ const db = makeDbQuery(dbOverrides);
107
+ const prefSvc = makePrefService(prefOverrides);
108
+ const strapi = {
109
+ db: { query: vi.fn().mockReturnValue(db) },
110
+ plugin: vi.fn().mockReturnValue({ service: vi.fn().mockReturnValue(prefSvc) }),
111
+ };
112
+ return { strapi, _db: db, _prefSvc: prefSvc };
113
+ };
114
+
115
+ /** Minimal strapi mock for preference service tests. */
116
+ export const makePreferenceStrapi = (dbOverrides: Record<string, unknown> = {}) => {
117
+ const db = makeDbQuery(dbOverrides);
118
+ const strapi = { db: { query: vi.fn().mockReturnValue(db) } };
119
+ return { strapi, _db: db };
120
+ };
121
+
122
+ /** Minimal strapi mock for notifier service tests. */
123
+ export const makeNotifierStrapi = (
124
+ notifOverrides: Record<string, unknown> = {},
125
+ prefOverrides: Partial<UserPreference> = {},
126
+ settingsOverrides: Record<string, unknown> = {}
127
+ ) => {
128
+ const notifSvc = makeNotificationService(notifOverrides);
129
+ const prefSvc = makePrefService(prefOverrides);
130
+ const settSvc = makeSettingsService(settingsOverrides);
131
+
132
+ const serviceMap: Record<string, unknown> = {
133
+ notification: notifSvc,
134
+ preference: prefSvc,
135
+ settings: settSvc,
136
+ };
137
+
138
+ const strapi = {
139
+ plugin: vi.fn().mockReturnValue({
140
+ service: vi.fn().mockImplementation((name: string) => serviceMap[name]),
141
+ }),
142
+ };
143
+
144
+ return { strapi, _notifSvc: notifSvc, _prefSvc: prefSvc, _settSvc: settSvc };
145
+ };
@@ -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
+ });