@checkstack/notification-backend 0.0.2

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,478 @@
1
+ import { describe, it, expect, beforeEach, mock } from "bun:test";
2
+ import {
3
+ createStrategyService,
4
+ type StrategyService,
5
+ } from "./strategy-service";
6
+ import type {
7
+ ConfigService,
8
+ NotificationStrategyRegistry,
9
+ RegisteredNotificationStrategy,
10
+ } from "@checkstack/backend-api";
11
+ import { Versioned } from "@checkstack/backend-api";
12
+ import { z } from "zod";
13
+
14
+ /**
15
+ * Unit tests for StrategyService.
16
+ *
17
+ * Tests cover:
18
+ * - Admin strategy meta-config (enabled state)
19
+ * - Admin strategy config management
20
+ * - User preference management (with redacted methods)
21
+ * - OAuth token storage and retrieval
22
+ */
23
+
24
+ // Mock ConfigService implementation (using unknown casts for test flexibility)
25
+ function createMockConfigService(): ConfigService & {
26
+ storage: Map<string, { data: unknown; version: number }>;
27
+ } {
28
+ const storage = new Map<string, { data: unknown; version: number }>();
29
+
30
+ return {
31
+ storage,
32
+ async set(_configId, _schema, version, data) {
33
+ storage.set(_configId, { data, version });
34
+ },
35
+ get: (async (configId: string) => {
36
+ const stored = storage.get(configId);
37
+ return stored?.data;
38
+ }) as ConfigService["get"],
39
+ getRedacted: (async (configId: string) => {
40
+ const stored = storage.get(configId);
41
+ if (!stored) return undefined;
42
+
43
+ // Strip fields branded as secret (accessToken, refreshToken)
44
+ const data = stored.data as Record<string, unknown>;
45
+ const redacted = { ...data };
46
+ delete redacted.accessToken;
47
+ delete redacted.refreshToken;
48
+ return redacted;
49
+ }) as ConfigService["getRedacted"],
50
+ async delete(configId) {
51
+ storage.delete(configId);
52
+ },
53
+ async list() {
54
+ return [...storage.keys()];
55
+ },
56
+ };
57
+ }
58
+
59
+ // Mock strategy for testing
60
+ const testStrategyConfig = new Versioned({
61
+ version: 1,
62
+ schema: z.object({
63
+ smtpHost: z.string(),
64
+ smtpPort: z.number(),
65
+ }),
66
+ });
67
+
68
+ const testUserConfig = new Versioned({
69
+ version: 1,
70
+ schema: z.object({
71
+ phoneNumber: z.string(),
72
+ }),
73
+ });
74
+
75
+ function createMockRegistry(): NotificationStrategyRegistry & {
76
+ strategies: Map<string, RegisteredNotificationStrategy<unknown, unknown>>;
77
+ } {
78
+ const strategies = new Map<
79
+ string,
80
+ RegisteredNotificationStrategy<unknown, unknown>
81
+ >();
82
+
83
+ // Add test strategy
84
+ strategies.set("test-plugin.smtp", {
85
+ id: "smtp",
86
+ qualifiedId: "test-plugin.smtp",
87
+ ownerPluginId: "test-plugin",
88
+ permissionId: "test-plugin.strategy.smtp.use",
89
+ displayName: "SMTP Email",
90
+ description: "Send emails via SMTP",
91
+ contactResolution: { type: "auth-email" },
92
+ config: testStrategyConfig,
93
+ send: async () => ({ success: true }),
94
+ } as RegisteredNotificationStrategy<unknown, unknown>);
95
+
96
+ strategies.set("test-plugin.sms", {
97
+ id: "sms",
98
+ qualifiedId: "test-plugin.sms",
99
+ ownerPluginId: "test-plugin",
100
+ permissionId: "test-plugin.strategy.sms.use",
101
+ displayName: "SMS",
102
+ description: "Send SMS messages",
103
+ contactResolution: { type: "user-config", field: "phoneNumber" },
104
+ config: testStrategyConfig,
105
+ userConfig: testUserConfig,
106
+ send: async () => ({ success: true }),
107
+ } as RegisteredNotificationStrategy<unknown, unknown>);
108
+
109
+ return {
110
+ strategies,
111
+ register: mock(() => {}),
112
+ getStrategy: (id) => strategies.get(id),
113
+ getStrategies: () => [...strategies.values()],
114
+ getStrategiesForUser: () => [...strategies.values()],
115
+ };
116
+ }
117
+
118
+ // Mock database (not used since we're using ConfigService)
119
+ const mockDb = {} as Parameters<typeof createStrategyService>[0]["db"];
120
+
121
+ describe("StrategyService", () => {
122
+ let configService: ReturnType<typeof createMockConfigService>;
123
+ let registry: ReturnType<typeof createMockRegistry>;
124
+ let strategyService: StrategyService;
125
+
126
+ beforeEach(() => {
127
+ configService = createMockConfigService();
128
+ registry = createMockRegistry();
129
+ strategyService = createStrategyService({
130
+ db: mockDb,
131
+ configService,
132
+ strategyRegistry: registry,
133
+ });
134
+ });
135
+
136
+ // ─────────────────────────────────────────────────────────────────────────
137
+ // Strategy Meta-Config (Admin)
138
+ // ─────────────────────────────────────────────────────────────────────────
139
+
140
+ describe("getStrategyMeta", () => {
141
+ it("returns default disabled state when no config exists", async () => {
142
+ const meta = await strategyService.getStrategyMeta("test-plugin.smtp");
143
+ expect(meta.enabled).toBe(false);
144
+ });
145
+
146
+ it("returns stored enabled state", async () => {
147
+ await strategyService.setStrategyMeta("test-plugin.smtp", {
148
+ enabled: true,
149
+ });
150
+
151
+ const meta = await strategyService.getStrategyMeta("test-plugin.smtp");
152
+ expect(meta.enabled).toBe(true);
153
+ });
154
+ });
155
+
156
+ describe("setStrategyMeta", () => {
157
+ it("stores enabled state", async () => {
158
+ await strategyService.setStrategyMeta("test-plugin.smtp", {
159
+ enabled: true,
160
+ });
161
+
162
+ const stored = configService.storage.get(
163
+ "strategy.test-plugin.smtp.meta"
164
+ );
165
+ expect(stored?.data).toEqual({ enabled: true });
166
+ });
167
+
168
+ it("can toggle enabled state", async () => {
169
+ await strategyService.setStrategyMeta("test-plugin.smtp", {
170
+ enabled: true,
171
+ });
172
+ await strategyService.setStrategyMeta("test-plugin.smtp", {
173
+ enabled: false,
174
+ });
175
+
176
+ const meta = await strategyService.getStrategyMeta("test-plugin.smtp");
177
+ expect(meta.enabled).toBe(false);
178
+ });
179
+ });
180
+
181
+ // ─────────────────────────────────────────────────────────────────────────
182
+ // User Preferences
183
+ // ─────────────────────────────────────────────────────────────────────────
184
+
185
+ describe("getUserPreference", () => {
186
+ it("returns undefined when no preference exists", async () => {
187
+ const pref = await strategyService.getUserPreference(
188
+ "user-123",
189
+ "test-plugin.smtp"
190
+ );
191
+ expect(pref).toBeUndefined();
192
+ });
193
+
194
+ it("returns stored preference", async () => {
195
+ await strategyService.setUserPreference("user-123", "test-plugin.smtp", {
196
+ enabled: true,
197
+ });
198
+
199
+ const pref = await strategyService.getUserPreference(
200
+ "user-123",
201
+ "test-plugin.smtp"
202
+ );
203
+ expect(pref?.enabled).toBe(true);
204
+ });
205
+ });
206
+
207
+ describe("setUserPreference", () => {
208
+ it("stores user preference", async () => {
209
+ await strategyService.setUserPreference("user-123", "test-plugin.sms", {
210
+ enabled: true,
211
+ userConfig: { phoneNumber: "+1234567890" },
212
+ });
213
+
214
+ const pref = await strategyService.getUserPreference(
215
+ "user-123",
216
+ "test-plugin.sms"
217
+ );
218
+ expect(pref?.userConfig).toEqual({ phoneNumber: "+1234567890" });
219
+ });
220
+
221
+ it("merges with existing preference", async () => {
222
+ await strategyService.setUserPreference("user-123", "test-plugin.smtp", {
223
+ enabled: true,
224
+ });
225
+ await strategyService.setUserPreference("user-123", "test-plugin.smtp", {
226
+ userConfig: { setting: "value" },
227
+ });
228
+
229
+ const pref = await strategyService.getUserPreference(
230
+ "user-123",
231
+ "test-plugin.smtp"
232
+ );
233
+ expect(pref?.enabled).toBe(true);
234
+ expect(pref?.userConfig).toEqual({ setting: "value" });
235
+ });
236
+ });
237
+
238
+ describe("getUserPreferenceRedacted", () => {
239
+ it("returns preference without secret fields", async () => {
240
+ // Store preference with tokens
241
+ configService.storage.set("user-pref.user-123.test-plugin.smtp", {
242
+ data: {
243
+ enabled: true,
244
+ accessToken: "secret-token",
245
+ refreshToken: "secret-refresh",
246
+ externalId: "ext-123",
247
+ },
248
+ version: 1,
249
+ });
250
+
251
+ const pref = await strategyService.getUserPreferenceRedacted(
252
+ "user-123",
253
+ "test-plugin.smtp"
254
+ );
255
+
256
+ expect(pref?.enabled).toBe(true);
257
+ expect(pref?.externalId).toBe("ext-123");
258
+ expect(pref?.accessToken).toBeUndefined();
259
+ expect(pref?.refreshToken).toBeUndefined();
260
+ });
261
+ });
262
+
263
+ describe("getAllUserPreferences", () => {
264
+ it("returns all preferences for a user", async () => {
265
+ await strategyService.setUserPreference("user-123", "test-plugin.smtp", {
266
+ enabled: true,
267
+ });
268
+ await strategyService.setUserPreference("user-123", "test-plugin.sms", {
269
+ enabled: false,
270
+ });
271
+
272
+ const prefs = await strategyService.getAllUserPreferences("user-123");
273
+
274
+ expect(prefs.length).toBe(2);
275
+ expect(prefs.map((p) => p.strategyId).sort()).toEqual([
276
+ "test-plugin.sms",
277
+ "test-plugin.smtp",
278
+ ]);
279
+ });
280
+ });
281
+
282
+ describe("deleteUserPreferences", () => {
283
+ it("deletes all preferences for a user", async () => {
284
+ await strategyService.setUserPreference("user-123", "test-plugin.smtp", {
285
+ enabled: true,
286
+ });
287
+ await strategyService.setUserPreference("user-123", "test-plugin.sms", {
288
+ enabled: true,
289
+ });
290
+
291
+ await strategyService.deleteUserPreferences("user-123");
292
+
293
+ const prefs = await strategyService.getAllUserPreferences("user-123");
294
+ expect(prefs.length).toBe(0);
295
+ });
296
+ });
297
+
298
+ // ─────────────────────────────────────────────────────────────────────────
299
+ // OAuth Token Storage
300
+ // ─────────────────────────────────────────────────────────────────────────
301
+
302
+ describe("storeOAuthTokens", () => {
303
+ it("stores OAuth tokens with preference", async () => {
304
+ const expiresAt = new Date("2025-01-01T00:00:00Z");
305
+
306
+ await strategyService.storeOAuthTokens({
307
+ userId: "user-123",
308
+ strategyId: "test-plugin.slack",
309
+ externalId: "U123ABC",
310
+ accessToken: "xoxb-access-token",
311
+ refreshToken: "xoxb-refresh-token",
312
+ expiresAt,
313
+ });
314
+
315
+ const pref = await strategyService.getUserPreference(
316
+ "user-123",
317
+ "test-plugin.slack"
318
+ );
319
+
320
+ expect(pref?.enabled).toBe(true);
321
+ expect(pref?.externalId).toBe("U123ABC");
322
+ // Cast to string to bypass secret branding in test assertions
323
+ expect(String(pref?.accessToken)).toBe("xoxb-access-token");
324
+ expect(String(pref?.refreshToken)).toBe("xoxb-refresh-token");
325
+ expect(pref?.linkedAt).toBeDefined();
326
+ });
327
+ });
328
+
329
+ describe("getOAuthTokens", () => {
330
+ it("returns decrypted tokens for internal use", async () => {
331
+ await strategyService.storeOAuthTokens({
332
+ userId: "user-123",
333
+ strategyId: "test-plugin.slack",
334
+ externalId: "U123ABC",
335
+ accessToken: "access-token",
336
+ refreshToken: "refresh-token",
337
+ expiresAt: new Date("2025-01-01T00:00:00Z"),
338
+ });
339
+
340
+ const tokens = await strategyService.getOAuthTokens(
341
+ "user-123",
342
+ "test-plugin.slack"
343
+ );
344
+
345
+ expect(tokens?.externalId).toBe("U123ABC");
346
+ expect(tokens?.accessToken).toBe("access-token");
347
+ expect(tokens?.refreshToken).toBe("refresh-token");
348
+ expect(tokens?.expiresAt).toBeInstanceOf(Date);
349
+ });
350
+
351
+ it("returns undefined when no tokens exist", async () => {
352
+ const tokens = await strategyService.getOAuthTokens(
353
+ "user-123",
354
+ "test-plugin.slack"
355
+ );
356
+ expect(tokens).toBeUndefined();
357
+ });
358
+ });
359
+
360
+ describe("clearOAuthTokens", () => {
361
+ it("clears OAuth tokens while preserving other preference data", async () => {
362
+ await strategyService.storeOAuthTokens({
363
+ userId: "user-123",
364
+ strategyId: "test-plugin.slack",
365
+ externalId: "U123ABC",
366
+ accessToken: "access-token",
367
+ });
368
+
369
+ await strategyService.clearOAuthTokens("user-123", "test-plugin.slack");
370
+
371
+ const tokens = await strategyService.getOAuthTokens(
372
+ "user-123",
373
+ "test-plugin.slack"
374
+ );
375
+ expect(tokens).toBeUndefined();
376
+
377
+ // Preference should still exist but without tokens
378
+ const pref = await strategyService.getUserPreference(
379
+ "user-123",
380
+ "test-plugin.slack"
381
+ );
382
+ expect(pref?.enabled).toBe(true);
383
+ expect(pref?.accessToken).toBeUndefined();
384
+ });
385
+ });
386
+
387
+ // ─────────────────────────────────────────────────────────────────────────
388
+ // Strategy Cleanup
389
+ // ─────────────────────────────────────────────────────────────────────────
390
+
391
+ describe("deleteStrategyConfig", () => {
392
+ it("deletes admin config for a strategy", async () => {
393
+ // Set config first
394
+ await strategyService.setStrategyConfig("test-plugin.smtp", {
395
+ smtpHost: "mail.example.com",
396
+ smtpPort: 587,
397
+ });
398
+
399
+ await strategyService.deleteStrategyConfig("test-plugin.smtp");
400
+
401
+ const config = await strategyService.getStrategyConfig(
402
+ "test-plugin.smtp"
403
+ );
404
+ expect(config).toBeUndefined();
405
+ });
406
+ });
407
+
408
+ describe("deleteAllStrategyConfigs", () => {
409
+ it("deletes both meta and config for a strategy", async () => {
410
+ // Set meta and config
411
+ await strategyService.setStrategyMeta("test-plugin.smtp", {
412
+ enabled: true,
413
+ });
414
+ await strategyService.setStrategyConfig("test-plugin.smtp", {
415
+ smtpHost: "mail.example.com",
416
+ smtpPort: 587,
417
+ });
418
+
419
+ await strategyService.deleteAllStrategyConfigs("test-plugin.smtp");
420
+
421
+ const meta = await strategyService.getStrategyMeta("test-plugin.smtp");
422
+ const config = await strategyService.getStrategyConfig(
423
+ "test-plugin.smtp"
424
+ );
425
+
426
+ expect(meta.enabled).toBe(false); // Default when not found
427
+ expect(config).toBeUndefined();
428
+ });
429
+ });
430
+
431
+ describe("deleteAllUserPreferencesForStrategy", () => {
432
+ it("deletes all user preferences for a specific strategy", async () => {
433
+ // Set preferences for multiple users for the same strategy
434
+ await strategyService.setUserPreference("user-1", "test-plugin.smtp", {
435
+ enabled: true,
436
+ });
437
+ await strategyService.setUserPreference("user-2", "test-plugin.smtp", {
438
+ enabled: false,
439
+ });
440
+ await strategyService.setUserPreference("user-3", "test-plugin.smtp", {
441
+ enabled: true,
442
+ });
443
+
444
+ // Also set a preference for a different strategy (should not be deleted)
445
+ await strategyService.setUserPreference("user-1", "test-plugin.sms", {
446
+ enabled: true,
447
+ });
448
+
449
+ await strategyService.deleteAllUserPreferencesForStrategy(
450
+ "test-plugin.smtp"
451
+ );
452
+
453
+ // All smtp preferences should be deleted
454
+ const pref1 = await strategyService.getUserPreference(
455
+ "user-1",
456
+ "test-plugin.smtp"
457
+ );
458
+ const pref2 = await strategyService.getUserPreference(
459
+ "user-2",
460
+ "test-plugin.smtp"
461
+ );
462
+ const pref3 = await strategyService.getUserPreference(
463
+ "user-3",
464
+ "test-plugin.smtp"
465
+ );
466
+ expect(pref1).toBeUndefined();
467
+ expect(pref2).toBeUndefined();
468
+ expect(pref3).toBeUndefined();
469
+
470
+ // SMS preference should still exist
471
+ const smsPref = await strategyService.getUserPreference(
472
+ "user-1",
473
+ "test-plugin.sms"
474
+ );
475
+ expect(smsPref?.enabled).toBe(true);
476
+ });
477
+ });
478
+ });