@littlebearapps/platform-admin-sdk 2.0.0 → 2.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.
Files changed (74) hide show
  1. package/README.md +2 -2
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +86 -2
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
  6. package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
  7. package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
  8. package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
  9. package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
  10. package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
  11. package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
  12. package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
  13. package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
  14. package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
  15. package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
  16. package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
  17. package/templates/full/dashboard/src/lib/search/api.ts +258 -0
  18. package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
  19. package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
  20. package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
  21. package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
  22. package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
  23. package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
  24. package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
  25. package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
  26. package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
  27. package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
  28. package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
  29. package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
  30. package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
  31. package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
  32. package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
  33. package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
  34. package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
  35. package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
  36. package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
  37. package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
  38. package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
  39. package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
  40. package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
  41. package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
  42. package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
  43. package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
  44. package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
  45. package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
  46. package/templates/shared/tests/helpers/mock-storage.ts +166 -0
  47. package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
  48. package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
  49. package/templates/shared/tests/unit/billing.test.ts +331 -0
  50. package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
  51. package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
  52. package/templates/shared/tests/unit/control.test.ts +226 -0
  53. package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
  54. package/templates/shared/tests/unit/economics.test.ts +365 -0
  55. package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
  56. package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
  57. package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
  58. package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
  59. package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
  60. package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
  61. package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
  62. package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
  63. package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
  64. package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
  65. package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
  66. package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
  67. package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
  68. package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
  69. package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
  70. package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
  71. package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
  72. package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
  73. package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
  74. package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Integration Tests for Platform Sentinel Worker
3
+ *
4
+ * Tests the cron-triggered alerting workflow including:
5
+ * - Threshold evaluation logic
6
+ * - Rate limiting via KV
7
+ * - Alert generation and filtering
8
+ *
9
+ * @module tests/integration/platform-sentinel
10
+ * @created 2026-01-05
11
+ * @renamed 2026-01-23 (from cost-spike-alerter)
12
+ * @task task-17.28 - Integration test for alert cron trigger
13
+ */
14
+
15
+ import { describe, expect, it, vi } from 'vitest';
16
+
17
+ // Import the worker module for testing internal functions
18
+ // Note: We test via the exported default handlers
19
+
20
+ /**
21
+ * Mock KV namespace for testing
22
+ */
23
+ function createMockKV(initialData: Record<string, string> = {}) {
24
+ const store = { ...initialData };
25
+
26
+ return {
27
+ _store: store,
28
+ get: vi.fn().mockImplementation((key: string) => Promise.resolve(store[key] || null)),
29
+ put: vi.fn().mockImplementation((key: string, value: string) => {
30
+ store[key] = value;
31
+ return Promise.resolve();
32
+ }),
33
+ delete: vi.fn().mockImplementation((key: string) => {
34
+ delete store[key];
35
+ return Promise.resolve();
36
+ }),
37
+ };
38
+ }
39
+
40
+ describe('Platform Sentinel - Alert Evaluation', () => {
41
+ const DEFAULT_THRESHOLDS = {
42
+ workers: { warningPct: 50, highPct: 75, criticalPct: 90, absoluteMax: 5, enabled: true },
43
+ d1: { warningPct: 40, highPct: 60, criticalPct: 80, absoluteMax: 20, enabled: true },
44
+ kv: { warningPct: 50, highPct: 75, criticalPct: 90, absoluteMax: 5, enabled: true },
45
+ r2: { warningPct: 50, highPct: 75, criticalPct: 90, absoluteMax: 20, enabled: true },
46
+ vectorize: { warningPct: 50, highPct: 75, criticalPct: 90, absoluteMax: 5, enabled: false },
47
+ };
48
+
49
+ describe('Threshold Levels', () => {
50
+ it('marks cost as warning when >= 50% of max', () => {
51
+ // Workers: max $5, 50% = $2.50
52
+ const currentCost = 2.5;
53
+ const max = 5;
54
+ const pct = (currentCost / max) * 100;
55
+
56
+ expect(pct).toBeGreaterThanOrEqual(50);
57
+ expect(pct).toBeLessThan(75);
58
+ });
59
+
60
+ it('marks cost as high when >= 75% of max', () => {
61
+ // Workers: max $5, 75% = $3.75
62
+ const currentCost = 3.75;
63
+ const max = 5;
64
+ const pct = (currentCost / max) * 100;
65
+
66
+ expect(pct).toBeGreaterThanOrEqual(75);
67
+ expect(pct).toBeLessThan(90);
68
+ });
69
+
70
+ it('marks cost as critical when >= 90% of max', () => {
71
+ // Workers: max $5, 90% = $4.50
72
+ const currentCost = 4.5;
73
+ const max = 5;
74
+ const pct = (currentCost / max) * 100;
75
+
76
+ expect(pct).toBeGreaterThanOrEqual(90);
77
+ });
78
+
79
+ it('escalates to critical when cost exceeds absolute max', () => {
80
+ // Workers: max $5, current $6
81
+ const currentCost = 6;
82
+ const max = 5;
83
+
84
+ expect(currentCost).toBeGreaterThan(max);
85
+ });
86
+ });
87
+
88
+ describe('Cost Delta Detection', () => {
89
+ it('triggers alert when delta > 50%', () => {
90
+ const previousCost = 2.0;
91
+ const currentCost = 3.5;
92
+ const deltaPct = ((currentCost - previousCost) / previousCost) * 100;
93
+
94
+ expect(deltaPct).toBeGreaterThan(50); // 75% increase
95
+ });
96
+
97
+ it('does not trigger on small changes', () => {
98
+ const previousCost = 2.0;
99
+ const currentCost = 2.2;
100
+ const deltaPct = ((currentCost - previousCost) / previousCost) * 100;
101
+
102
+ expect(deltaPct).toBeLessThan(50); // 10% increase
103
+ });
104
+
105
+ it('handles zero previous cost', () => {
106
+ const previousCost = 0;
107
+ const currentCost = 1.0;
108
+ const deltaPct = previousCost > 0 ? ((currentCost - previousCost) / previousCost) * 100 : 0;
109
+
110
+ expect(deltaPct).toBe(0); // No delta calculation when previous is 0
111
+ });
112
+ });
113
+
114
+ describe('Service Enable/Disable', () => {
115
+ it('respects enabled flag on thresholds', () => {
116
+ const workers = DEFAULT_THRESHOLDS.workers;
117
+ const vectorize = DEFAULT_THRESHOLDS.vectorize;
118
+
119
+ expect(workers.enabled).toBe(true);
120
+ expect(vectorize.enabled).toBe(false);
121
+ });
122
+ });
123
+ });
124
+
125
+ describe('Platform Sentinel - Rate Limiting', () => {
126
+ describe('Slack Rate Limiting', () => {
127
+ it('allows first alert for a resource', async () => {
128
+ const kv = createMockKV();
129
+ const key = 'slack:cost-spike:workers';
130
+
131
+ // No existing entry
132
+ const existing = await kv.get(key);
133
+ expect(existing).toBeNull();
134
+ });
135
+
136
+ it('blocks second alert within 1 hour', async () => {
137
+ const kv = createMockKV({
138
+ 'slack:cost-spike:workers': new Date().toISOString(),
139
+ });
140
+
141
+ // Entry exists
142
+ const existing = await kv.get('slack:cost-spike:workers');
143
+ expect(existing).not.toBeNull();
144
+ });
145
+
146
+ it('stores rate limit key with correct TTL pattern', async () => {
147
+ const kv = createMockKV();
148
+
149
+ await kv.put('slack:cost-spike:workers', new Date().toISOString());
150
+
151
+ expect(kv.put).toHaveBeenCalledWith('slack:cost-spike:workers', expect.any(String));
152
+ });
153
+ });
154
+
155
+ describe('Email Rate Limiting', () => {
156
+ it('only sends email for high/critical alerts', () => {
157
+ const thresholdLevels = ['normal', 'warning', 'high', 'critical'];
158
+ const emailEnabled = thresholdLevels.filter(
159
+ (level) => level === 'high' || level === 'critical'
160
+ );
161
+
162
+ expect(emailEnabled).toEqual(['high', 'critical']);
163
+ });
164
+
165
+ it('uses longer rate limit (4 hours) than Slack', () => {
166
+ const SLACK_RATE_LIMIT_TTL = 3600;
167
+ const EMAIL_RATE_LIMIT_TTL = 14400;
168
+
169
+ expect(EMAIL_RATE_LIMIT_TTL).toBe(SLACK_RATE_LIMIT_TTL * 4);
170
+ });
171
+ });
172
+ });
173
+
174
+ describe('Platform Sentinel - KV Data Flow', () => {
175
+ describe('Threshold Loading', () => {
176
+ it('merges stored thresholds with defaults', () => {
177
+ const defaults = {
178
+ workers: { warningPct: 50, highPct: 75, criticalPct: 90, absoluteMax: 5, enabled: true },
179
+ d1: { warningPct: 40, highPct: 60, criticalPct: 80, absoluteMax: 20, enabled: true },
180
+ };
181
+
182
+ const stored = {
183
+ workers: { warningPct: 60, highPct: 80, criticalPct: 95, absoluteMax: 10, enabled: true },
184
+ };
185
+
186
+ const merged = { ...defaults, ...stored };
187
+
188
+ // Workers should use stored values
189
+ expect(merged.workers.absoluteMax).toBe(10);
190
+ // D1 should use defaults (20, as defined above)
191
+ expect(merged.d1.absoluteMax).toBe(20);
192
+ });
193
+ });
194
+
195
+ describe('Cost Storage', () => {
196
+ it('stores costs with 7-day expiration', async () => {
197
+ const kv = createMockKV();
198
+ const costs = {
199
+ workers: 5.0,
200
+ d1: 2.0,
201
+ total: 7.0,
202
+ };
203
+
204
+ await kv.put('platform-sentinel:previous-costs', JSON.stringify(costs));
205
+
206
+ const stored = await kv.get('platform-sentinel:previous-costs');
207
+ expect(stored).toBe(JSON.stringify(costs));
208
+ });
209
+
210
+ it('retrieves previous costs for comparison', async () => {
211
+ const previousCosts = {
212
+ workers: 4.0,
213
+ d1: 1.5,
214
+ total: 5.5,
215
+ };
216
+
217
+ const kv = createMockKV({
218
+ 'platform-sentinel:previous-costs': JSON.stringify(previousCosts),
219
+ });
220
+
221
+ const stored = await kv.get('platform-sentinel:previous-costs');
222
+ const parsed = JSON.parse(stored!);
223
+
224
+ expect(parsed.workers).toBe(4.0);
225
+ });
226
+ });
227
+
228
+ describe('Usage Data Loading', () => {
229
+ it('reads cached usage data from KV', async () => {
230
+ const usageData = {
231
+ costs: {
232
+ workers: 5.0,
233
+ d1: 2.0,
234
+ kv: 1.0,
235
+ r2: 3.0,
236
+ total: 11.0,
237
+ },
238
+ };
239
+
240
+ const kv = createMockKV({
241
+ 'usage:30d:all': JSON.stringify(usageData),
242
+ });
243
+
244
+ const stored = await kv.get('usage:30d:all');
245
+ const parsed = JSON.parse(stored!);
246
+
247
+ expect(parsed.costs.total).toBe(11.0);
248
+ });
249
+
250
+ it('handles missing usage data gracefully', async () => {
251
+ const kv = createMockKV();
252
+ const stored = await kv.get('usage:30d:all');
253
+
254
+ expect(stored).toBeNull();
255
+ });
256
+ });
257
+ });
258
+
259
+ describe('Platform Sentinel - HTTP Endpoints', () => {
260
+ describe('Health Check', () => {
261
+ it('returns health status JSON', () => {
262
+ const response = {
263
+ status: 'ok',
264
+ service: 'platform-sentinel',
265
+ timestamp: expect.any(String),
266
+ };
267
+
268
+ expect(response.status).toBe('ok');
269
+ expect(response.service).toBe('platform-sentinel');
270
+ });
271
+ });
272
+
273
+ describe('Manual Trigger', () => {
274
+ it('accepts POST to /trigger endpoint', () => {
275
+ const validMethod = 'POST';
276
+ const validPath = '/trigger';
277
+
278
+ expect(validMethod).toBe('POST');
279
+ expect(validPath).toBe('/trigger');
280
+ });
281
+ });
282
+
283
+ describe('Root Endpoint', () => {
284
+ it('returns service info and available endpoints', () => {
285
+ const response = {
286
+ service: 'platform-sentinel',
287
+ endpoints: ['/health', '/trigger (POST)'],
288
+ };
289
+
290
+ expect(response.endpoints).toContain('/health');
291
+ expect(response.endpoints).toContain('/trigger (POST)');
292
+ });
293
+ });
294
+ });
295
+
296
+ describe('Platform Sentinel - Alert Message Formatting', () => {
297
+ describe('Service Name Formatting', () => {
298
+ const formatServiceName = (service: string): string => {
299
+ const names: Record<string, string> = {
300
+ workers: 'Workers',
301
+ d1: 'D1 Database',
302
+ kv: 'KV Storage',
303
+ r2: 'R2 Storage',
304
+ durableObjects: 'Durable Objects',
305
+ vectorize: 'Vectorize',
306
+ aiGateway: 'AI Gateway',
307
+ pages: 'Pages',
308
+ queues: 'Queues',
309
+ workflows: 'Workflows',
310
+ };
311
+ return names[service] || service;
312
+ };
313
+
314
+ it('formats workers correctly', () => {
315
+ expect(formatServiceName('workers')).toBe('Workers');
316
+ });
317
+
318
+ it('formats d1 correctly', () => {
319
+ expect(formatServiceName('d1')).toBe('D1 Database');
320
+ });
321
+
322
+ it('formats durableObjects correctly', () => {
323
+ expect(formatServiceName('durableObjects')).toBe('Durable Objects');
324
+ });
325
+
326
+ it('returns raw name for unknown services', () => {
327
+ expect(formatServiceName('unknown')).toBe('unknown');
328
+ });
329
+ });
330
+
331
+ describe('Currency Formatting', () => {
332
+ const formatCurrency = (amount: number): string => `$${amount.toFixed(2)}`;
333
+
334
+ it('formats whole numbers with cents', () => {
335
+ expect(formatCurrency(5)).toBe('$5.00');
336
+ });
337
+
338
+ it('formats decimals correctly', () => {
339
+ expect(formatCurrency(5.5)).toBe('$5.50');
340
+ });
341
+
342
+ it('rounds to 2 decimal places', () => {
343
+ // Note: JavaScript toFixed uses banker's rounding
344
+ expect(formatCurrency(5.556)).toBe('$5.56');
345
+ });
346
+ });
347
+
348
+ describe('Percentage Formatting', () => {
349
+ const formatPercentage = (pct: number): string => {
350
+ const sign = pct >= 0 ? '+' : '';
351
+ return `${sign}${pct.toFixed(1)}%`;
352
+ };
353
+
354
+ it('adds plus sign to positive values', () => {
355
+ expect(formatPercentage(50)).toBe('+50.0%');
356
+ });
357
+
358
+ it('preserves minus sign on negative values', () => {
359
+ expect(formatPercentage(-25)).toBe('-25.0%');
360
+ });
361
+
362
+ it('formats zero with plus sign', () => {
363
+ expect(formatPercentage(0)).toBe('+0.0%');
364
+ });
365
+ });
366
+
367
+ describe('Severity Colours', () => {
368
+ const getColour = (level: string): string => {
369
+ const colours: Record<string, string> = {
370
+ critical: '#dc3545',
371
+ high: '#fd7e14',
372
+ warning: '#ffc107',
373
+ normal: '#28a745',
374
+ };
375
+ return colours[level] || '#17a2b8';
376
+ };
377
+
378
+ it('returns red for critical', () => {
379
+ expect(getColour('critical')).toBe('#dc3545');
380
+ });
381
+
382
+ it('returns orange for high', () => {
383
+ expect(getColour('high')).toBe('#fd7e14');
384
+ });
385
+
386
+ it('returns yellow for warning', () => {
387
+ expect(getColour('warning')).toBe('#ffc107');
388
+ });
389
+
390
+ it('returns default blue for unknown', () => {
391
+ expect(getColour('unknown')).toBe('#17a2b8');
392
+ });
393
+ });
394
+
395
+ describe('Severity Emojis', () => {
396
+ const getEmoji = (level: string): string => {
397
+ const emojis: Record<string, string> = {
398
+ critical: ':rotating_light:',
399
+ high: ':warning:',
400
+ warning: ':yellow_circle:',
401
+ normal: ':white_check_mark:',
402
+ };
403
+ return emojis[level] || ':bell:';
404
+ };
405
+
406
+ it('returns siren for critical', () => {
407
+ expect(getEmoji('critical')).toBe(':rotating_light:');
408
+ });
409
+
410
+ it('returns warning for high', () => {
411
+ expect(getEmoji('high')).toBe(':warning:');
412
+ });
413
+
414
+ it('returns default bell for unknown', () => {
415
+ expect(getEmoji('unknown')).toBe(':bell:');
416
+ });
417
+ });
418
+
419
+ describe('Action Text', () => {
420
+ const getActionText = (level: string): string => {
421
+ switch (level) {
422
+ case 'critical':
423
+ return 'Investigate immediately - usage significantly exceeds budget';
424
+ case 'high':
425
+ return 'Review usage patterns and consider optimisation';
426
+ case 'warning':
427
+ return 'Monitor closely - approaching threshold';
428
+ default:
429
+ return 'No action required';
430
+ }
431
+ };
432
+
433
+ it('returns urgent text for critical', () => {
434
+ expect(getActionText('critical')).toContain('immediately');
435
+ });
436
+
437
+ it('returns review text for high', () => {
438
+ expect(getActionText('high')).toContain('Review');
439
+ });
440
+
441
+ it('returns monitor text for warning', () => {
442
+ expect(getActionText('warning')).toContain('Monitor');
443
+ });
444
+
445
+ it('returns no action for normal', () => {
446
+ expect(getActionText('normal')).toContain('No action');
447
+ });
448
+ });
449
+ });
450
+
451
+ describe('Platform Sentinel - Complete Workflow', () => {
452
+ it('follows correct execution order', () => {
453
+ const steps = [
454
+ '1. Load thresholds from KV',
455
+ '2. Fetch current costs from Usage API',
456
+ '3. Load previous costs from KV',
457
+ '4. Evaluate alerts',
458
+ '5. Send alerts (with rate limiting)',
459
+ '6. Store current costs for next comparison',
460
+ ];
461
+
462
+ expect(steps).toHaveLength(6);
463
+ expect(steps[0]).toContain('thresholds');
464
+ expect(steps[1]).toContain('current costs');
465
+ expect(steps[2]).toContain('previous costs');
466
+ expect(steps[3]).toContain('Evaluate');
467
+ expect(steps[4]).toContain('Send');
468
+ expect(steps[5]).toContain('Store');
469
+ });
470
+
471
+ it('handles missing current costs gracefully', async () => {
472
+ const kv = createMockKV(); // No usage data
473
+
474
+ const usageData = await kv.get('usage:30d:all');
475
+
476
+ // Should return early without errors
477
+ expect(usageData).toBeNull();
478
+ });
479
+
480
+ it('skips sending if no alerts generated', () => {
481
+ const costs = {
482
+ workers: 1.0, // Well under $5 max
483
+ d1: 2.0, // Well under $10 max
484
+ total: 3.0,
485
+ };
486
+
487
+ const previousCosts = {
488
+ workers: 0.9, // Small 11% increase
489
+ d1: 1.9, // Small 5% increase
490
+ total: 2.8,
491
+ };
492
+
493
+ // Check that delta is below threshold
494
+ const workersDelta = ((costs.workers - previousCosts.workers) / previousCosts.workers) * 100;
495
+ expect(workersDelta).toBeLessThan(50);
496
+ });
497
+ });