@littlebearapps/platform-admin-sdk 2.0.0 → 2.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.
Files changed (185) hide show
  1. package/README.md +4 -7
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +206 -4
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
  6. package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
  7. package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
  8. package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
  9. package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
  10. package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
  11. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  12. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  13. package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
  14. package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
  15. package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
  16. package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
  17. package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
  18. package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
  19. package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
  20. package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
  21. package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
  22. package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
  23. package/templates/full/dashboard/src/lib/search/api.ts +258 -0
  24. package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
  25. package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
  26. package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
  27. package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
  28. package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
  29. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  30. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  31. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  32. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  33. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  34. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  35. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  36. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  37. package/templates/full/dashboard/src/pages/map.astro +561 -0
  38. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  39. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  40. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  41. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  42. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  43. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  44. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  45. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  46. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  47. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  48. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  49. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  50. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  51. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  52. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  53. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  54. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  55. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  56. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  57. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  58. package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
  59. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  60. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  61. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  62. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  63. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  64. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  65. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  66. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  67. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  68. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  69. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  70. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  71. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  72. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  73. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  74. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  75. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  76. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  77. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  78. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  79. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  80. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  81. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  82. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  83. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  84. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  85. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  86. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  87. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  88. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  89. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  90. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  91. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  92. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  93. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  94. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  95. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  96. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  97. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  98. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  99. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  100. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  101. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  102. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  103. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  104. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  105. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  106. package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
  107. package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
  108. package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
  109. package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
  110. package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
  111. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  112. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  113. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  114. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  115. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  116. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  117. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  118. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  119. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  120. package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
  121. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  122. package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
  123. package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
  124. package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
  125. package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
  126. package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
  127. package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
  128. package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
  129. package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
  130. package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
  131. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  132. package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
  133. package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
  134. package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
  135. package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
  136. package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
  137. package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
  138. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  139. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  140. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  141. package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
  142. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  143. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  144. package/templates/shared/tests/helpers/mock-storage.ts +166 -0
  145. package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
  146. package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
  147. package/templates/shared/tests/unit/billing.test.ts +331 -0
  148. package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
  149. package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
  150. package/templates/shared/tests/unit/control.test.ts +226 -0
  151. package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
  152. package/templates/shared/tests/unit/economics.test.ts +365 -0
  153. package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
  154. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  155. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  156. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  157. package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
  158. package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
  159. package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
  160. package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
  161. package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
  162. package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
  163. package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
  164. package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
  165. package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
  166. package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
  167. package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
  168. package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
  169. package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
  170. package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
  171. package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
  172. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  173. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  174. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  175. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  176. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  177. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  178. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  179. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  180. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  181. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
  182. package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
  183. package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
  184. package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
  185. 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
+ });