@wopr-network/platform-core 1.35.0 → 1.35.1

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,164 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createAdminFleetUpdateRouter } from "./admin-fleet-update-router.js";
3
+ import { createCallerFactory, router, setTrpcOrgMemberRepo } from "./init.js";
4
+ vi.mock("../config/logger.js", () => ({
5
+ logger: {
6
+ info: vi.fn(),
7
+ error: vi.fn(),
8
+ warn: vi.fn(),
9
+ debug: vi.fn(),
10
+ },
11
+ }));
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+ function makeMockOrgRepo() {
16
+ return {
17
+ listMembers: vi.fn().mockResolvedValue([]),
18
+ addMember: vi.fn().mockResolvedValue(undefined),
19
+ updateMemberRole: vi.fn().mockResolvedValue(undefined),
20
+ removeMember: vi.fn().mockResolvedValue(undefined),
21
+ findMember: vi.fn().mockResolvedValue(null),
22
+ countAdminsAndOwners: vi.fn().mockResolvedValue(0),
23
+ listInvites: vi.fn().mockResolvedValue([]),
24
+ createInvite: vi.fn().mockResolvedValue(undefined),
25
+ findInviteById: vi.fn().mockResolvedValue(null),
26
+ findInviteByToken: vi.fn().mockResolvedValue(null),
27
+ deleteInvite: vi.fn().mockResolvedValue(undefined),
28
+ deleteAllMembers: vi.fn().mockResolvedValue(undefined),
29
+ deleteAllInvites: vi.fn().mockResolvedValue(undefined),
30
+ listOrgsByUser: vi.fn().mockResolvedValue([]),
31
+ markInviteAccepted: vi.fn().mockResolvedValue(undefined),
32
+ };
33
+ }
34
+ function makeOrchestrator(overrides = {}) {
35
+ return {
36
+ isRolling: false,
37
+ rollout: vi.fn().mockResolvedValue({
38
+ totalBots: 0,
39
+ succeeded: 0,
40
+ failed: 0,
41
+ skipped: 0,
42
+ aborted: false,
43
+ alreadyRunning: false,
44
+ results: [],
45
+ }),
46
+ ...overrides,
47
+ };
48
+ }
49
+ function makeConfigRepo(configs = {}) {
50
+ return {
51
+ get: vi.fn().mockImplementation((tenantId) => Promise.resolve(configs[tenantId] ?? null)),
52
+ upsert: vi.fn().mockResolvedValue(undefined),
53
+ listAutoEnabled: vi.fn().mockResolvedValue(Object.values(configs)),
54
+ };
55
+ }
56
+ const adminCtx = {
57
+ user: { id: "admin-1", roles: ["platform_admin"] },
58
+ tenantId: undefined,
59
+ };
60
+ // ---------------------------------------------------------------------------
61
+ // Tests
62
+ // ---------------------------------------------------------------------------
63
+ describe("createAdminFleetUpdateRouter", () => {
64
+ let orchestrator;
65
+ let configRepo;
66
+ beforeEach(() => {
67
+ setTrpcOrgMemberRepo(makeMockOrgRepo());
68
+ orchestrator = makeOrchestrator();
69
+ configRepo = makeConfigRepo();
70
+ });
71
+ function makeCaller() {
72
+ const fleetRouter = createAdminFleetUpdateRouter(() => orchestrator, () => configRepo);
73
+ const appRouter = router({ fleet: fleetRouter });
74
+ const createCaller = createCallerFactory(appRouter);
75
+ return createCaller(adminCtx);
76
+ }
77
+ describe("rolloutStatus", () => {
78
+ it("returns isRolling from orchestrator (false)", async () => {
79
+ const caller = makeCaller();
80
+ const status = await caller.fleet.rolloutStatus();
81
+ expect(status).toEqual({ isRolling: false });
82
+ });
83
+ it("returns isRolling from orchestrator (true)", async () => {
84
+ orchestrator = makeOrchestrator({ isRolling: true });
85
+ const caller = makeCaller();
86
+ const status = await caller.fleet.rolloutStatus();
87
+ expect(status).toEqual({ isRolling: true });
88
+ });
89
+ });
90
+ describe("forceRollout", () => {
91
+ it("calls orchestrator.rollout()", async () => {
92
+ const caller = makeCaller();
93
+ const result = await caller.fleet.forceRollout();
94
+ expect(result).toEqual({ triggered: true });
95
+ expect(orchestrator.rollout).toHaveBeenCalledOnce();
96
+ });
97
+ });
98
+ describe("listTenantConfigs", () => {
99
+ it("delegates to repo.listAutoEnabled()", async () => {
100
+ const configs = [
101
+ { tenantId: "t1", mode: "auto", preferredHourUtc: 3, updatedAt: Date.now() },
102
+ { tenantId: "t2", mode: "auto", preferredHourUtc: 12, updatedAt: Date.now() },
103
+ ];
104
+ vi.mocked(configRepo.listAutoEnabled).mockResolvedValue(configs);
105
+ const caller = makeCaller();
106
+ const result = await caller.fleet.listTenantConfigs();
107
+ expect(configRepo.listAutoEnabled).toHaveBeenCalledOnce();
108
+ expect(result).toEqual(configs);
109
+ });
110
+ });
111
+ describe("setTenantConfig", () => {
112
+ it("preserves existing preferredHourUtc when not provided", async () => {
113
+ const existing = {
114
+ tenantId: "t1",
115
+ mode: "auto",
116
+ preferredHourUtc: 17,
117
+ updatedAt: Date.now(),
118
+ };
119
+ vi.mocked(configRepo.get).mockResolvedValue(existing);
120
+ const caller = makeCaller();
121
+ await caller.fleet.setTenantConfig({ tenantId: "t1", mode: "manual" });
122
+ expect(configRepo.get).toHaveBeenCalledWith("t1");
123
+ expect(configRepo.upsert).toHaveBeenCalledWith("t1", {
124
+ mode: "manual",
125
+ preferredHourUtc: 17, // preserved from existing
126
+ });
127
+ });
128
+ it("uses provided preferredHourUtc when given", async () => {
129
+ const existing = {
130
+ tenantId: "t1",
131
+ mode: "auto",
132
+ preferredHourUtc: 17,
133
+ updatedAt: Date.now(),
134
+ };
135
+ vi.mocked(configRepo.get).mockResolvedValue(existing);
136
+ const caller = makeCaller();
137
+ await caller.fleet.setTenantConfig({
138
+ tenantId: "t1",
139
+ mode: "auto",
140
+ preferredHourUtc: 9,
141
+ });
142
+ expect(configRepo.upsert).toHaveBeenCalledWith("t1", {
143
+ mode: "auto",
144
+ preferredHourUtc: 9,
145
+ });
146
+ });
147
+ it("defaults to 3 when no existing config and preferredHourUtc not provided", async () => {
148
+ vi.mocked(configRepo.get).mockResolvedValue(null);
149
+ const caller = makeCaller();
150
+ await caller.fleet.setTenantConfig({ tenantId: "t-new", mode: "auto" });
151
+ expect(configRepo.upsert).toHaveBeenCalledWith("t-new", {
152
+ mode: "auto",
153
+ preferredHourUtc: 3, // default
154
+ });
155
+ });
156
+ });
157
+ it("rejects non-admin users", async () => {
158
+ const fleetRouter = createAdminFleetUpdateRouter(() => orchestrator, () => configRepo);
159
+ const appRouter = router({ fleet: fleetRouter });
160
+ const createCaller = createCallerFactory(appRouter);
161
+ const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
162
+ await expect(caller.fleet.rolloutStatus()).rejects.toThrow("Platform admin role required");
163
+ });
164
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.35.0",
3
+ "version": "1.35.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,209 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { HandlebarsRenderer } from "./handlebars-renderer.js";
3
+ import type { INotificationTemplateRepository, NotificationTemplateRow } from "./notification-repository-types.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function makeTemplate(overrides: Partial<NotificationTemplateRow> = {}): NotificationTemplateRow {
10
+ return {
11
+ id: "tpl-1",
12
+ name: "test-template",
13
+ description: "A test template",
14
+ subject: "Hello {{name}}",
15
+ htmlBody: "<h1>Hello {{name}}</h1>",
16
+ textBody: "Hello {{name}}",
17
+ active: true,
18
+ createdAt: Date.now(),
19
+ updatedAt: Date.now(),
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ function makeRepo(templates: Record<string, NotificationTemplateRow | null> = {}): INotificationTemplateRepository {
25
+ return {
26
+ getByName: vi.fn().mockImplementation((name: string) => Promise.resolve(templates[name] ?? null)),
27
+ list: vi.fn().mockResolvedValue(Object.values(templates).filter(Boolean)),
28
+ upsert: vi.fn().mockResolvedValue(undefined),
29
+ } as unknown as INotificationTemplateRepository;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Tests
34
+ // ---------------------------------------------------------------------------
35
+
36
+ describe("HandlebarsRenderer", () => {
37
+ it("returns null for unknown template", async () => {
38
+ const repo = makeRepo({});
39
+ const renderer = new HandlebarsRenderer(repo);
40
+
41
+ const result = await renderer.render("nonexistent", { name: "World" });
42
+
43
+ expect(result).toBeNull();
44
+ expect(repo.getByName).toHaveBeenCalledWith("nonexistent");
45
+ });
46
+
47
+ it("returns null for inactive template", async () => {
48
+ const repo = makeRepo({
49
+ "inactive-tpl": makeTemplate({ name: "inactive-tpl", active: false }),
50
+ });
51
+ const renderer = new HandlebarsRenderer(repo);
52
+
53
+ const result = await renderer.render("inactive-tpl", { name: "World" });
54
+
55
+ expect(result).toBeNull();
56
+ });
57
+
58
+ it("compiles Handlebars and returns subject/html/text", async () => {
59
+ const repo = makeRepo({
60
+ greeting: makeTemplate({
61
+ name: "greeting",
62
+ subject: "Hi {{name}}!",
63
+ htmlBody: "<p>Welcome, {{name}}!</p>",
64
+ textBody: "Welcome, {{name}}!",
65
+ }),
66
+ });
67
+ const renderer = new HandlebarsRenderer(repo);
68
+
69
+ const result = await renderer.render("greeting", { name: "Alice" });
70
+
71
+ expect(result).not.toBeNull();
72
+ expect(result?.subject).toBe("Hi Alice!");
73
+ expect(result?.html).toBe("<p>Welcome, Alice!</p>");
74
+ expect(result?.text).toBe("Welcome, Alice!");
75
+ });
76
+
77
+ it("injects currentYear automatically", async () => {
78
+ const repo = makeRepo({
79
+ footer: makeTemplate({
80
+ name: "footer",
81
+ subject: "Year: {{currentYear}}",
82
+ htmlBody: "<p>&copy; {{currentYear}}</p>",
83
+ textBody: "(c) {{currentYear}}",
84
+ }),
85
+ });
86
+ const renderer = new HandlebarsRenderer(repo);
87
+
88
+ const result = await renderer.render("footer", {});
89
+
90
+ const year = new Date().getFullYear();
91
+ expect(result?.subject).toBe(`Year: ${year}`);
92
+ expect(result?.html).toBe(`<p>&copy; ${year}</p>`);
93
+ expect(result?.text).toBe(`(c) ${year}`);
94
+ });
95
+
96
+ it("does not override explicit currentYear from data", async () => {
97
+ const repo = makeRepo({
98
+ footer: makeTemplate({
99
+ name: "footer",
100
+ subject: "Year: {{currentYear}}",
101
+ htmlBody: "<p>{{currentYear}}</p>",
102
+ textBody: "{{currentYear}}",
103
+ }),
104
+ });
105
+ const renderer = new HandlebarsRenderer(repo);
106
+
107
+ const result = await renderer.render("footer", { currentYear: 2099 });
108
+
109
+ expect(result?.subject).toBe("Year: 2099");
110
+ });
111
+
112
+ describe("helpers", () => {
113
+ it("eq helper returns true for equal values", async () => {
114
+ const repo = makeRepo({
115
+ cond: makeTemplate({
116
+ name: "cond",
117
+ subject: '{{#if (eq status "active")}}Active{{else}}Inactive{{/if}}',
118
+ htmlBody: "ok",
119
+ textBody: "ok",
120
+ }),
121
+ });
122
+ const renderer = new HandlebarsRenderer(repo);
123
+
124
+ const active = await renderer.render("cond", { status: "active" });
125
+ expect(active?.subject).toBe("Active");
126
+
127
+ const inactive = await renderer.render("cond", { status: "disabled" });
128
+ expect(inactive?.subject).toBe("Inactive");
129
+ });
130
+
131
+ it("gt helper compares numbers", async () => {
132
+ const repo = makeRepo({
133
+ gt: makeTemplate({
134
+ name: "gt",
135
+ subject: "{{#if (gt count 5)}}Many{{else}}Few{{/if}}",
136
+ htmlBody: "ok",
137
+ textBody: "ok",
138
+ }),
139
+ });
140
+ const renderer = new HandlebarsRenderer(repo);
141
+
142
+ const many = await renderer.render("gt", { count: 10 });
143
+ expect(many?.subject).toBe("Many");
144
+
145
+ const few = await renderer.render("gt", { count: 3 });
146
+ expect(few?.subject).toBe("Few");
147
+ });
148
+
149
+ it("formatDate helper formats timestamps", async () => {
150
+ const repo = makeRepo({
151
+ dated: makeTemplate({
152
+ name: "dated",
153
+ subject: "Date: {{formatDate ts}}",
154
+ htmlBody: "ok",
155
+ textBody: "ok",
156
+ }),
157
+ });
158
+ const renderer = new HandlebarsRenderer(repo);
159
+
160
+ // Use noon UTC to avoid timezone date-boundary shifts
161
+ const ts = new Date("2025-01-15T12:00:00Z").getTime();
162
+ const result = await renderer.render("dated", { ts });
163
+
164
+ // The formatted string uses en-US locale with year/month/day
165
+ const expected = new Date(ts).toLocaleDateString("en-US", {
166
+ year: "numeric",
167
+ month: "long",
168
+ day: "numeric",
169
+ });
170
+ expect(result?.subject).toBe(`Date: ${expected}`);
171
+ });
172
+
173
+ it("formatDate helper passes through non-numeric values", async () => {
174
+ const repo = makeRepo({
175
+ dated: makeTemplate({
176
+ name: "dated",
177
+ subject: "Date: {{formatDate ts}}",
178
+ htmlBody: "ok",
179
+ textBody: "ok",
180
+ }),
181
+ });
182
+ const renderer = new HandlebarsRenderer(repo);
183
+
184
+ const result = await renderer.render("dated", { ts: "not-a-number" });
185
+
186
+ expect(result?.subject).toBe("Date: not-a-number");
187
+ });
188
+
189
+ it("escapeHtml helper escapes special characters", async () => {
190
+ const repo = makeRepo({
191
+ esc: makeTemplate({
192
+ name: "esc",
193
+ subject: "Safe: {{escapeHtml input}}",
194
+ htmlBody: "ok",
195
+ textBody: "ok",
196
+ }),
197
+ });
198
+ const renderer = new HandlebarsRenderer(repo);
199
+
200
+ const result = await renderer.render("esc", {
201
+ input: '<script>alert("xss")</script>',
202
+ });
203
+
204
+ expect(result?.subject).toContain("&lt;script&gt;");
205
+ expect(result?.subject).toContain("&quot;xss&quot;");
206
+ expect(result?.subject).not.toContain("<script>");
207
+ });
208
+ });
209
+ });
@@ -0,0 +1,322 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { INotificationPreferencesRepository, NotificationPrefs } from "../email/notification-repository-types.js";
3
+ import type { NotificationService } from "../email/notification-service.js";
4
+ import type { BotFleetEvent } from "./fleet-event-emitter.js";
5
+ import { FleetEventEmitter } from "./fleet-event-emitter.js";
6
+ import { initFleetNotificationListener } from "./fleet-notification-listener.js";
7
+
8
+ vi.mock("../config/logger.js", () => ({
9
+ logger: {
10
+ info: vi.fn(),
11
+ error: vi.fn(),
12
+ warn: vi.fn(),
13
+ debug: vi.fn(),
14
+ },
15
+ }));
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const DEBOUNCE_MS = 100;
22
+
23
+ function makePrefs(overrides: Partial<NotificationPrefs> = {}): NotificationPrefs {
24
+ return {
25
+ billing_low_balance: true,
26
+ billing_receipts: true,
27
+ billing_auto_topup: true,
28
+ agent_channel_disconnect: true,
29
+ agent_status_changes: false,
30
+ account_role_changes: true,
31
+ account_team_invites: true,
32
+ fleet_updates: true,
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ function makePrefsRepo(prefs: NotificationPrefs = makePrefs()): INotificationPreferencesRepository {
38
+ return {
39
+ get: vi.fn().mockResolvedValue(prefs),
40
+ update: vi.fn().mockResolvedValue(undefined),
41
+ } as unknown as INotificationPreferencesRepository;
42
+ }
43
+
44
+ function makeNotificationService(): NotificationService {
45
+ return {
46
+ notifyFleetUpdateComplete: vi.fn(),
47
+ notifyFleetUpdateAvailable: vi.fn(),
48
+ notifyLowBalance: vi.fn(),
49
+ } as unknown as NotificationService;
50
+ }
51
+
52
+ function botUpdated(tenantId: string, version = "v1.2.0"): BotFleetEvent {
53
+ return {
54
+ type: "bot.updated",
55
+ botId: `bot-${Math.random().toString(36).slice(2, 6)}`,
56
+ tenantId,
57
+ timestamp: new Date().toISOString(),
58
+ version,
59
+ };
60
+ }
61
+
62
+ function botUpdateFailed(tenantId: string, version = "v1.2.0"): BotFleetEvent {
63
+ return {
64
+ type: "bot.update_failed",
65
+ botId: `bot-${Math.random().toString(36).slice(2, 6)}`,
66
+ tenantId,
67
+ timestamp: new Date().toISOString(),
68
+ version,
69
+ };
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Tests
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe("initFleetNotificationListener", () => {
77
+ let emitter: FleetEventEmitter;
78
+
79
+ beforeEach(() => {
80
+ vi.useFakeTimers();
81
+ emitter = new FleetEventEmitter();
82
+ });
83
+
84
+ afterEach(() => {
85
+ vi.useRealTimers();
86
+ });
87
+
88
+ it("ignores non-bot events (node events)", async () => {
89
+ const notificationService = makeNotificationService();
90
+ const preferences = makePrefsRepo();
91
+
92
+ initFleetNotificationListener({
93
+ eventEmitter: emitter,
94
+ notificationService,
95
+ preferences,
96
+ resolveEmail: vi.fn().mockResolvedValue("user@example.com"),
97
+ debounceMs: DEBOUNCE_MS,
98
+ });
99
+
100
+ emitter.emit({
101
+ type: "node.provisioned",
102
+ nodeId: "node-1",
103
+ timestamp: new Date().toISOString(),
104
+ });
105
+
106
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
107
+
108
+ expect(notificationService.notifyFleetUpdateComplete).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it("ignores non-update bot events (bot.started, bot.stopped)", async () => {
112
+ const notificationService = makeNotificationService();
113
+ const preferences = makePrefsRepo();
114
+
115
+ initFleetNotificationListener({
116
+ eventEmitter: emitter,
117
+ notificationService,
118
+ preferences,
119
+ resolveEmail: vi.fn().mockResolvedValue("user@example.com"),
120
+ debounceMs: DEBOUNCE_MS,
121
+ });
122
+
123
+ emitter.emit({
124
+ type: "bot.started",
125
+ botId: "bot-1",
126
+ tenantId: "t1",
127
+ timestamp: new Date().toISOString(),
128
+ });
129
+
130
+ emitter.emit({
131
+ type: "bot.stopped",
132
+ botId: "bot-2",
133
+ tenantId: "t1",
134
+ timestamp: new Date().toISOString(),
135
+ });
136
+
137
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
138
+
139
+ expect(notificationService.notifyFleetUpdateComplete).not.toHaveBeenCalled();
140
+ });
141
+
142
+ it("aggregates multiple bot.updated events per tenant into one notification", async () => {
143
+ const notificationService = makeNotificationService();
144
+ const preferences = makePrefsRepo();
145
+ const resolveEmail = vi.fn().mockResolvedValue("owner@example.com");
146
+
147
+ initFleetNotificationListener({
148
+ eventEmitter: emitter,
149
+ notificationService,
150
+ preferences,
151
+ resolveEmail,
152
+ debounceMs: DEBOUNCE_MS,
153
+ });
154
+
155
+ // Emit 3 successful updates for the same tenant
156
+ emitter.emit(botUpdated("t1", "v2.0.0"));
157
+ emitter.emit(botUpdated("t1", "v2.0.0"));
158
+ emitter.emit(botUpdated("t1", "v2.0.0"));
159
+
160
+ // No notification yet — debounce hasn't fired
161
+ expect(notificationService.notifyFleetUpdateComplete).not.toHaveBeenCalled();
162
+
163
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
164
+
165
+ expect(notificationService.notifyFleetUpdateComplete).toHaveBeenCalledOnce();
166
+ expect(notificationService.notifyFleetUpdateComplete).toHaveBeenCalledWith(
167
+ "t1",
168
+ "owner@example.com",
169
+ "v2.0.0",
170
+ 3, // succeeded
171
+ 0, // failed
172
+ );
173
+ });
174
+
175
+ it("sends summary with correct succeeded/failed counts after debounce", async () => {
176
+ const notificationService = makeNotificationService();
177
+ const preferences = makePrefsRepo();
178
+ const resolveEmail = vi.fn().mockResolvedValue("owner@example.com");
179
+
180
+ initFleetNotificationListener({
181
+ eventEmitter: emitter,
182
+ notificationService,
183
+ preferences,
184
+ resolveEmail,
185
+ debounceMs: DEBOUNCE_MS,
186
+ });
187
+
188
+ emitter.emit(botUpdated("t1", "v3.0.0"));
189
+ emitter.emit(botUpdated("t1", "v3.0.0"));
190
+ emitter.emit(botUpdateFailed("t1", "v3.0.0"));
191
+
192
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
193
+
194
+ expect(notificationService.notifyFleetUpdateComplete).toHaveBeenCalledWith(
195
+ "t1",
196
+ "owner@example.com",
197
+ "v3.0.0",
198
+ 2, // succeeded
199
+ 1, // failed
200
+ );
201
+ });
202
+
203
+ it("updates version from subsequent events", async () => {
204
+ const notificationService = makeNotificationService();
205
+ const preferences = makePrefsRepo();
206
+ const resolveEmail = vi.fn().mockResolvedValue("owner@example.com");
207
+
208
+ initFleetNotificationListener({
209
+ eventEmitter: emitter,
210
+ notificationService,
211
+ preferences,
212
+ resolveEmail,
213
+ debounceMs: DEBOUNCE_MS,
214
+ });
215
+
216
+ // First event has v1.0.0, subsequent event changes to v1.1.0
217
+ emitter.emit(botUpdated("t1", "v1.0.0"));
218
+ emitter.emit(botUpdated("t1", "v1.1.0"));
219
+
220
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
221
+
222
+ expect(notificationService.notifyFleetUpdateComplete).toHaveBeenCalledWith(
223
+ "t1",
224
+ "owner@example.com",
225
+ "v1.1.0", // updated to latest version
226
+ 2,
227
+ 0,
228
+ );
229
+ });
230
+
231
+ it("checks fleet_updates preference — skips if disabled", async () => {
232
+ const notificationService = makeNotificationService();
233
+ const preferences = makePrefsRepo(makePrefs({ fleet_updates: false }));
234
+ const resolveEmail = vi.fn().mockResolvedValue("owner@example.com");
235
+
236
+ initFleetNotificationListener({
237
+ eventEmitter: emitter,
238
+ notificationService,
239
+ preferences,
240
+ resolveEmail,
241
+ debounceMs: DEBOUNCE_MS,
242
+ });
243
+
244
+ emitter.emit(botUpdated("t1"));
245
+
246
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
247
+
248
+ expect(preferences.get).toHaveBeenCalledWith("t1");
249
+ expect(notificationService.notifyFleetUpdateComplete).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it("skips if email resolver returns null", async () => {
253
+ const notificationService = makeNotificationService();
254
+ const preferences = makePrefsRepo();
255
+ const resolveEmail = vi.fn().mockResolvedValue(null);
256
+
257
+ initFleetNotificationListener({
258
+ eventEmitter: emitter,
259
+ notificationService,
260
+ preferences,
261
+ resolveEmail,
262
+ debounceMs: DEBOUNCE_MS,
263
+ });
264
+
265
+ emitter.emit(botUpdated("t1"));
266
+
267
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
268
+
269
+ expect(resolveEmail).toHaveBeenCalledWith("t1");
270
+ expect(notificationService.notifyFleetUpdateComplete).not.toHaveBeenCalled();
271
+ });
272
+
273
+ it("async shutdown flushes pending notifications", async () => {
274
+ const notificationService = makeNotificationService();
275
+ const preferences = makePrefsRepo();
276
+ const resolveEmail = vi.fn().mockResolvedValue("owner@example.com");
277
+
278
+ const shutdown = initFleetNotificationListener({
279
+ eventEmitter: emitter,
280
+ notificationService,
281
+ preferences,
282
+ resolveEmail,
283
+ debounceMs: DEBOUNCE_MS,
284
+ });
285
+
286
+ emitter.emit(botUpdated("t1", "v5.0.0"));
287
+ emitter.emit(botUpdated("t2", "v5.0.0"));
288
+
289
+ // Don't advance timers — flush via shutdown instead
290
+ await shutdown();
291
+
292
+ // Both tenants should have been flushed
293
+ expect(notificationService.notifyFleetUpdateComplete).toHaveBeenCalledTimes(2);
294
+
295
+ const calls = vi.mocked(notificationService.notifyFleetUpdateComplete).mock.calls;
296
+ const tenantIds = calls.map((c) => c[0]);
297
+ expect(tenantIds).toContain("t1");
298
+ expect(tenantIds).toContain("t2");
299
+ });
300
+
301
+ it("no further events after shutdown", async () => {
302
+ const notificationService = makeNotificationService();
303
+ const preferences = makePrefsRepo();
304
+ const resolveEmail = vi.fn().mockResolvedValue("owner@example.com");
305
+
306
+ const shutdown = initFleetNotificationListener({
307
+ eventEmitter: emitter,
308
+ notificationService,
309
+ preferences,
310
+ resolveEmail,
311
+ debounceMs: DEBOUNCE_MS,
312
+ });
313
+
314
+ await shutdown();
315
+
316
+ // Events after shutdown should not trigger anything
317
+ emitter.emit(botUpdated("t1"));
318
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
319
+
320
+ expect(notificationService.notifyFleetUpdateComplete).not.toHaveBeenCalled();
321
+ });
322
+ });