@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.
- package/README.md +2 -2
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +86 -2
- package/package.json +1 -1
- package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
- package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
- package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
- package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
- package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
- package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
- package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
- package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
- package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
- package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
- package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
- package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
- package/templates/full/dashboard/src/lib/search/api.ts +258 -0
- package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
- package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
- package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
- package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
- package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
- package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
- package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
- package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
- package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
- package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
- package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
- package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
- package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
- package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
- package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
- package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
- package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
- package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
- package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
- package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
- package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
- package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
- package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
- package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
- package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
- package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
- package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
- package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
- package/templates/shared/tests/helpers/mock-storage.ts +166 -0
- package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
- package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
- package/templates/shared/tests/unit/billing.test.ts +331 -0
- package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
- package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
- package/templates/shared/tests/unit/control.test.ts +226 -0
- package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
- package/templates/shared/tests/unit/economics.test.ts +365 -0
- package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
- package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
- package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
- package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
- package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
- package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
- package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
- package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
- package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
- package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
- package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
- package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
- package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
- package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
- package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
- package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
- package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
- package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
- package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
- package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit Tests for Alerting Service
|
|
3
|
+
*
|
|
4
|
+
* Tests Slack/Email alert generation, severity formatting, and rate limiting logic.
|
|
5
|
+
*
|
|
6
|
+
* @module tests/unit/cloudflare/alerting
|
|
7
|
+
* @created 2026-01-05
|
|
8
|
+
* @task task-17.25 - Unit tests for alerting service
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import {
|
|
13
|
+
getSeverityColour,
|
|
14
|
+
getSeverityEmoji,
|
|
15
|
+
formatPercentage,
|
|
16
|
+
generateAlertKey,
|
|
17
|
+
buildSlackMessage,
|
|
18
|
+
buildSummarySlackMessage,
|
|
19
|
+
evaluateWarning,
|
|
20
|
+
buildEmailHtml,
|
|
21
|
+
buildEmailText,
|
|
22
|
+
type CostSpikeAlert,
|
|
23
|
+
} from '../../../dashboard/src/lib/cloudflare/alerting';
|
|
24
|
+
import type { ThresholdWarning, CostBreakdown } from '../../../dashboard/src/lib/cloudflare/costs';
|
|
25
|
+
|
|
26
|
+
describe('Alerting Service', () => {
|
|
27
|
+
describe('getSeverityColour', () => {
|
|
28
|
+
it('returns red for critical level', () => {
|
|
29
|
+
expect(getSeverityColour('critical')).toBe('#dc3545');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns orange for high level', () => {
|
|
33
|
+
expect(getSeverityColour('high')).toBe('#fd7e14');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns yellow for warning level', () => {
|
|
37
|
+
expect(getSeverityColour('warning')).toBe('#ffc107');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns green for normal level', () => {
|
|
41
|
+
expect(getSeverityColour('normal')).toBe('#28a745');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('getSeverityEmoji', () => {
|
|
46
|
+
it('returns siren emoji for critical level', () => {
|
|
47
|
+
expect(getSeverityEmoji('critical')).toBe(':rotating_light:');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns warning emoji for high level', () => {
|
|
51
|
+
expect(getSeverityEmoji('high')).toBe(':warning:');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns yellow circle for warning level', () => {
|
|
55
|
+
expect(getSeverityEmoji('warning')).toBe(':yellow_circle:');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns checkmark for normal level', () => {
|
|
59
|
+
expect(getSeverityEmoji('normal')).toBe(':white_check_mark:');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('formatPercentage', () => {
|
|
64
|
+
it('formats positive percentages with plus sign', () => {
|
|
65
|
+
expect(formatPercentage(50)).toBe('+50.0%');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('formats negative percentages with minus sign', () => {
|
|
69
|
+
expect(formatPercentage(-25.5)).toBe('-25.5%');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('formats zero with plus sign', () => {
|
|
73
|
+
expect(formatPercentage(0)).toBe('+0.0%');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('formats decimal values correctly', () => {
|
|
77
|
+
expect(formatPercentage(33.333)).toBe('+33.3%');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('generateAlertKey', () => {
|
|
82
|
+
it('generates correct deduplication key', () => {
|
|
83
|
+
const alert: CostSpikeAlert = {
|
|
84
|
+
id: 'test-123',
|
|
85
|
+
serviceType: 'Workers',
|
|
86
|
+
resourceName: 'platform-api',
|
|
87
|
+
currentCost: 5.0,
|
|
88
|
+
previousCost: 2.5,
|
|
89
|
+
costDeltaPct: 100,
|
|
90
|
+
thresholdLevel: 'high',
|
|
91
|
+
absoluteMax: 10.0,
|
|
92
|
+
timestamp: '2026-01-05T12:00:00Z',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
expect(generateAlertKey(alert)).toBe('cost-spike:Workers:platform-api');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('handles special characters in resource name', () => {
|
|
99
|
+
const alert: CostSpikeAlert = {
|
|
100
|
+
id: 'test-123',
|
|
101
|
+
serviceType: 'D1',
|
|
102
|
+
resourceName: 'my-project-db',
|
|
103
|
+
currentCost: 10.0,
|
|
104
|
+
previousCost: 5.0,
|
|
105
|
+
costDeltaPct: 100,
|
|
106
|
+
thresholdLevel: 'critical',
|
|
107
|
+
absoluteMax: 15.0,
|
|
108
|
+
timestamp: '2026-01-05T12:00:00Z',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
expect(generateAlertKey(alert)).toBe('cost-spike:D1:my-project-db');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('buildSlackMessage', () => {
|
|
116
|
+
const mockAlert: CostSpikeAlert = {
|
|
117
|
+
id: 'alert-456',
|
|
118
|
+
serviceType: 'Workers',
|
|
119
|
+
resourceName: 'requests',
|
|
120
|
+
currentCost: 7.5,
|
|
121
|
+
previousCost: 3.0,
|
|
122
|
+
costDeltaPct: 150,
|
|
123
|
+
thresholdLevel: 'critical',
|
|
124
|
+
absoluteMax: 5.0,
|
|
125
|
+
timestamp: '2026-01-05T14:30:00Z',
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
it('includes fallback text with cost info', () => {
|
|
129
|
+
const message = buildSlackMessage(mockAlert);
|
|
130
|
+
|
|
131
|
+
expect(message.text).toContain('[CRITICAL]');
|
|
132
|
+
expect(message.text).toContain('Workers');
|
|
133
|
+
expect(message.text).toContain('$7.50');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('includes header block with severity emoji', () => {
|
|
137
|
+
const message = buildSlackMessage(mockAlert);
|
|
138
|
+
const headerBlock = message.blocks.find((b) => b.type === 'header');
|
|
139
|
+
|
|
140
|
+
expect(headerBlock).toBeDefined();
|
|
141
|
+
expect(headerBlock?.text?.text).toContain(':rotating_light:');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('includes section block with service fields', () => {
|
|
145
|
+
const message = buildSlackMessage(mockAlert);
|
|
146
|
+
const sectionBlock = message.blocks.find((b) => b.type === 'section');
|
|
147
|
+
|
|
148
|
+
expect(sectionBlock).toBeDefined();
|
|
149
|
+
expect(sectionBlock?.fields).toHaveLength(6);
|
|
150
|
+
|
|
151
|
+
const fieldTexts = sectionBlock?.fields?.map((f) => f.text) ?? [];
|
|
152
|
+
expect(fieldTexts.some((t) => t.includes('Workers'))).toBe(true);
|
|
153
|
+
expect(fieldTexts.some((t) => t.includes('$7.50'))).toBe(true);
|
|
154
|
+
expect(fieldTexts.some((t) => t.includes('+150.0%'))).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('includes context block with alert ID', () => {
|
|
158
|
+
const message = buildSlackMessage(mockAlert);
|
|
159
|
+
const contextBlock = message.blocks.find((b) => b.type === 'context');
|
|
160
|
+
|
|
161
|
+
expect(contextBlock).toBeDefined();
|
|
162
|
+
expect(contextBlock?.text?.text).toContain('alert-456');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('includes attachment with severity colour', () => {
|
|
166
|
+
const message = buildSlackMessage(mockAlert);
|
|
167
|
+
|
|
168
|
+
expect(message.attachments).toHaveLength(1);
|
|
169
|
+
expect(message.attachments?.[0].color).toBe('#dc3545'); // Critical = red
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('buildSummarySlackMessage', () => {
|
|
174
|
+
const mockAlerts: CostSpikeAlert[] = [
|
|
175
|
+
{
|
|
176
|
+
id: 'alert-1',
|
|
177
|
+
serviceType: 'Workers',
|
|
178
|
+
resourceName: 'requests',
|
|
179
|
+
currentCost: 7.5,
|
|
180
|
+
previousCost: 3.0,
|
|
181
|
+
costDeltaPct: 150,
|
|
182
|
+
thresholdLevel: 'critical',
|
|
183
|
+
absoluteMax: 5.0,
|
|
184
|
+
timestamp: '2026-01-05T14:30:00Z',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: 'alert-2',
|
|
188
|
+
serviceType: 'D1',
|
|
189
|
+
resourceName: 'rowsRead',
|
|
190
|
+
currentCost: 4.0,
|
|
191
|
+
previousCost: 2.0,
|
|
192
|
+
costDeltaPct: 100,
|
|
193
|
+
thresholdLevel: 'high',
|
|
194
|
+
absoluteMax: 5.0,
|
|
195
|
+
timestamp: '2026-01-05T14:30:00Z',
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: 'alert-3',
|
|
199
|
+
serviceType: 'KV',
|
|
200
|
+
resourceName: 'reads',
|
|
201
|
+
currentCost: 2.0,
|
|
202
|
+
previousCost: 1.5,
|
|
203
|
+
costDeltaPct: 33.3,
|
|
204
|
+
thresholdLevel: 'warning',
|
|
205
|
+
absoluteMax: 5.0,
|
|
206
|
+
timestamp: '2026-01-05T14:30:00Z',
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
it('counts alerts by severity', () => {
|
|
211
|
+
const message = buildSummarySlackMessage(mockAlerts);
|
|
212
|
+
const sectionBlock = message.blocks.find((b) => b.type === 'section');
|
|
213
|
+
const fieldTexts = sectionBlock?.fields?.map((f) => f.text) ?? [];
|
|
214
|
+
|
|
215
|
+
expect(fieldTexts.some((t) => t.includes('*Critical:* 1'))).toBe(true);
|
|
216
|
+
expect(fieldTexts.some((t) => t.includes('*High:* 1'))).toBe(true);
|
|
217
|
+
expect(fieldTexts.some((t) => t.includes('*Warning:* 1'))).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('calculates total cost across alerts', () => {
|
|
221
|
+
const message = buildSummarySlackMessage(mockAlerts);
|
|
222
|
+
const sectionBlock = message.blocks.find((b) => b.type === 'section');
|
|
223
|
+
const fieldTexts = sectionBlock?.fields?.map((f) => f.text) ?? [];
|
|
224
|
+
|
|
225
|
+
// Total: 7.5 + 4.0 + 2.0 = 13.5
|
|
226
|
+
expect(fieldTexts.some((t) => t.includes('$13.50'))).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('uses critical emoji when critical alerts exist', () => {
|
|
230
|
+
const message = buildSummarySlackMessage(mockAlerts);
|
|
231
|
+
const headerBlock = message.blocks.find((b) => b.type === 'header');
|
|
232
|
+
|
|
233
|
+
expect(headerBlock?.text?.text).toContain(':rotating_light:');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('lists up to 5 alerts in summary', () => {
|
|
237
|
+
const message = buildSummarySlackMessage(mockAlerts);
|
|
238
|
+
const alertBlocks = message.blocks.filter(
|
|
239
|
+
(b) => b.type === 'section' && b.text?.text?.includes('$')
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(alertBlocks.length).toBeLessThanOrEqual(5);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('evaluateWarning', () => {
|
|
247
|
+
const baseCosts: CostBreakdown = {
|
|
248
|
+
workers: 5.0,
|
|
249
|
+
d1: 3.0,
|
|
250
|
+
kv: 1.0,
|
|
251
|
+
r2: 2.0,
|
|
252
|
+
durableObjects: 0.5,
|
|
253
|
+
vectorize: 0.0,
|
|
254
|
+
aiGateway: 1.5,
|
|
255
|
+
workersAI: 0.0,
|
|
256
|
+
pages: 0.0,
|
|
257
|
+
queues: 0.0,
|
|
258
|
+
workflows: 0.0,
|
|
259
|
+
total: 13.0,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const previousCosts: CostBreakdown = {
|
|
263
|
+
workers: 2.0,
|
|
264
|
+
d1: 2.5,
|
|
265
|
+
kv: 0.8,
|
|
266
|
+
r2: 1.5,
|
|
267
|
+
durableObjects: 0.4,
|
|
268
|
+
vectorize: 0.0,
|
|
269
|
+
aiGateway: 1.0,
|
|
270
|
+
workersAI: 0.0,
|
|
271
|
+
pages: 0.0,
|
|
272
|
+
queues: 0.0,
|
|
273
|
+
workflows: 0.0,
|
|
274
|
+
total: 8.2,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
it('returns alert for critical threshold level', () => {
|
|
278
|
+
const warning: ThresholdWarning = {
|
|
279
|
+
resource: 'Workers',
|
|
280
|
+
metric: 'requests',
|
|
281
|
+
current: 1000000,
|
|
282
|
+
limit: 100000,
|
|
283
|
+
percentage: 1000,
|
|
284
|
+
level: 'critical',
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const result = evaluateWarning(warning, baseCosts, previousCosts, 5.0);
|
|
288
|
+
|
|
289
|
+
expect(result).not.toBeNull();
|
|
290
|
+
expect(result?.thresholdLevel).toBe('critical');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('returns alert when cost delta exceeds 50%', () => {
|
|
294
|
+
const warning: ThresholdWarning = {
|
|
295
|
+
resource: 'Workers',
|
|
296
|
+
metric: 'requests',
|
|
297
|
+
current: 100000,
|
|
298
|
+
limit: 100000,
|
|
299
|
+
percentage: 100,
|
|
300
|
+
level: 'high', // Not critical, but delta > 50%
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const result = evaluateWarning(warning, baseCosts, previousCosts, 10.0);
|
|
304
|
+
|
|
305
|
+
expect(result).not.toBeNull();
|
|
306
|
+
// Workers: current 5.0, previous 2.0 = 150% delta
|
|
307
|
+
expect(result?.costDeltaPct).toBeCloseTo(150);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('returns alert when cost exceeds absolute max', () => {
|
|
311
|
+
const warning: ThresholdWarning = {
|
|
312
|
+
resource: 'Workers',
|
|
313
|
+
metric: 'requests',
|
|
314
|
+
current: 100000,
|
|
315
|
+
limit: 100000,
|
|
316
|
+
percentage: 100,
|
|
317
|
+
level: 'warning', // Low level
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// Set absolute max below current cost
|
|
321
|
+
const result = evaluateWarning(warning, baseCosts, previousCosts, 3.0);
|
|
322
|
+
|
|
323
|
+
expect(result).not.toBeNull();
|
|
324
|
+
expect(result?.currentCost).toBe(5.0);
|
|
325
|
+
expect(result?.absoluteMax).toBe(3.0);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('returns null for warning level below thresholds', () => {
|
|
329
|
+
const warning: ThresholdWarning = {
|
|
330
|
+
resource: 'KV',
|
|
331
|
+
metric: 'reads',
|
|
332
|
+
current: 10000,
|
|
333
|
+
limit: 100000,
|
|
334
|
+
percentage: 10,
|
|
335
|
+
level: 'warning',
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// KV: current 1.0, previous 0.8 = 25% delta (below 50%)
|
|
339
|
+
// Cost 1.0 is below absoluteMax 10.0
|
|
340
|
+
// Level is not critical
|
|
341
|
+
const result = evaluateWarning(warning, baseCosts, previousCosts, 10.0);
|
|
342
|
+
|
|
343
|
+
expect(result).toBeNull();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('returns null for unknown service type', () => {
|
|
347
|
+
const warning: ThresholdWarning = {
|
|
348
|
+
resource: 'UnknownService',
|
|
349
|
+
metric: 'something',
|
|
350
|
+
current: 1000,
|
|
351
|
+
limit: 100,
|
|
352
|
+
percentage: 1000,
|
|
353
|
+
level: 'critical',
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const result = evaluateWarning(warning, baseCosts, previousCosts, 5.0);
|
|
357
|
+
|
|
358
|
+
expect(result).toBeNull();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('handles null previous costs', () => {
|
|
362
|
+
const warning: ThresholdWarning = {
|
|
363
|
+
resource: 'Workers',
|
|
364
|
+
metric: 'requests',
|
|
365
|
+
current: 100000,
|
|
366
|
+
limit: 100000,
|
|
367
|
+
percentage: 100,
|
|
368
|
+
level: 'critical',
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const result = evaluateWarning(warning, baseCosts, null, 5.0);
|
|
372
|
+
|
|
373
|
+
expect(result).not.toBeNull();
|
|
374
|
+
expect(result?.previousCost).toBe(0);
|
|
375
|
+
expect(result?.costDeltaPct).toBe(0);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe('buildEmailHtml', () => {
|
|
380
|
+
const mockAlert: CostSpikeAlert = {
|
|
381
|
+
id: 'email-test-123',
|
|
382
|
+
serviceType: 'D1',
|
|
383
|
+
resourceName: 'rowsRead',
|
|
384
|
+
currentCost: 12.0,
|
|
385
|
+
previousCost: 5.0,
|
|
386
|
+
costDeltaPct: 140,
|
|
387
|
+
thresholdLevel: 'high',
|
|
388
|
+
absoluteMax: 10.0,
|
|
389
|
+
timestamp: '2026-01-05T16:00:00Z',
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
it('includes HTML doctype and structure', () => {
|
|
393
|
+
const html = buildEmailHtml(mockAlert);
|
|
394
|
+
|
|
395
|
+
expect(html).toContain('<!DOCTYPE html>');
|
|
396
|
+
expect(html).toContain('<html');
|
|
397
|
+
expect(html).toContain('</html>');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('includes severity level in header', () => {
|
|
401
|
+
const html = buildEmailHtml(mockAlert);
|
|
402
|
+
|
|
403
|
+
expect(html).toContain('[HIGH]');
|
|
404
|
+
expect(html).toContain('D1');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('includes cost details table', () => {
|
|
408
|
+
const html = buildEmailHtml(mockAlert);
|
|
409
|
+
|
|
410
|
+
expect(html).toContain('$12.00');
|
|
411
|
+
expect(html).toContain('$5.00');
|
|
412
|
+
expect(html).toContain('+140.0%');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('includes severity colour in styling', () => {
|
|
416
|
+
const html = buildEmailHtml(mockAlert);
|
|
417
|
+
|
|
418
|
+
expect(html).toContain('#fd7e14'); // Orange for high
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('includes alert ID in footer', () => {
|
|
422
|
+
const html = buildEmailHtml(mockAlert);
|
|
423
|
+
|
|
424
|
+
expect(html).toContain('email-test-123');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('includes recommended action for high level', () => {
|
|
428
|
+
const html = buildEmailHtml(mockAlert);
|
|
429
|
+
|
|
430
|
+
expect(html).toContain('Review usage patterns');
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
describe('buildEmailText', () => {
|
|
435
|
+
const mockAlert: CostSpikeAlert = {
|
|
436
|
+
id: 'text-test-456',
|
|
437
|
+
serviceType: 'R2',
|
|
438
|
+
resourceName: 'storageBytes',
|
|
439
|
+
currentCost: 25.0,
|
|
440
|
+
previousCost: 10.0,
|
|
441
|
+
costDeltaPct: 150,
|
|
442
|
+
thresholdLevel: 'critical',
|
|
443
|
+
absoluteMax: 20.0,
|
|
444
|
+
timestamp: '2026-01-05T17:00:00Z',
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
it('includes severity level', () => {
|
|
448
|
+
const text = buildEmailText(mockAlert);
|
|
449
|
+
|
|
450
|
+
expect(text).toContain('[CRITICAL]');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('includes service and resource info', () => {
|
|
454
|
+
const text = buildEmailText(mockAlert);
|
|
455
|
+
|
|
456
|
+
expect(text).toContain('Service: R2');
|
|
457
|
+
expect(text).toContain('Resource: storageBytes');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('includes cost details', () => {
|
|
461
|
+
const text = buildEmailText(mockAlert);
|
|
462
|
+
|
|
463
|
+
expect(text).toContain('Current Cost: $25.00');
|
|
464
|
+
expect(text).toContain('Previous Cost: $10.00');
|
|
465
|
+
expect(text).toContain('Change: +150.0%');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('includes recommended action for critical level', () => {
|
|
469
|
+
const text = buildEmailText(mockAlert);
|
|
470
|
+
|
|
471
|
+
expect(text).toContain('Investigate immediately');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('includes alert ID', () => {
|
|
475
|
+
const text = buildEmailText(mockAlert);
|
|
476
|
+
|
|
477
|
+
expect(text).toContain('Alert ID: text-test-456');
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
});
|