@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,909 @@
1
+ /**
2
+ * Usage API - End-to-End Tests
3
+ *
4
+ * Tests the full request/response cycle for usage API endpoints:
5
+ * - GET /usage - Get usage metrics with filtering
6
+ * - GET /usage/costs - Get cost breakdown
7
+ * - GET /usage/thresholds - Get threshold warnings
8
+ * - GET /usage/compare - Period comparison
9
+ * - GET /usage/settings - Get alert settings
10
+ * - PUT /usage/settings - Update alert settings
11
+ *
12
+ * Uses mocked CloudflareGraphQL and KV to test:
13
+ * - Query parameter parsing
14
+ * - Response formatting
15
+ * - Error handling
16
+ * - Caching behaviour
17
+ *
18
+ * @module tests/e2e/usage-api
19
+ * @created 2026-01-05
20
+ * @task task-17.23 - E2E tests for filtering/sorting
21
+ */
22
+
23
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
24
+ import type { KVNamespace, D1Database } from '@cloudflare/workers-types';
25
+ import { MockKVNamespace, MockD1Database } from '../helpers/mock-storage';
26
+ import { clearRegistryCache } from '../../dashboard/src/lib/cloudflare';
27
+
28
+ // Mock the CloudflareGraphQL class since it makes external API calls
29
+ vi.mock('../../dashboard/src/lib/cloudflare', async (importOriginal) => {
30
+ const actual = (await importOriginal()) as Record<string, unknown>;
31
+
32
+ // Create a mock class with static methods using Object.assign for proper typing
33
+ // Note: Vitest 4 requires regular functions (not arrow functions) for constructor mocks
34
+ const MockCloudflareGraphQL = Object.assign(
35
+ vi.fn().mockImplementation(function () {
36
+ return {
37
+ getAllMetrics: vi.fn().mockResolvedValue(MOCK_USAGE_DATA),
38
+ getAllEnhancedMetrics: vi.fn().mockResolvedValue({
39
+ ...MOCK_USAGE_DATA,
40
+ sparklines: {
41
+ workersRequests: { points: [], trend: 'stable' },
42
+ workersErrors: { points: [], trend: 'stable' },
43
+ d1RowsRead: { points: [], trend: 'stable' },
44
+ kvReads: { points: [], trend: 'stable' },
45
+ },
46
+ errorBreakdown: [],
47
+ queues: [],
48
+ cache: { hits: 0, misses: 0, hitRate: 0 },
49
+ comparison: {
50
+ workersRequests: { current: 100, previous: 80, trend: 'up', percentChange: 25 },
51
+ workersErrors: { current: 5, previous: 5, trend: 'stable', percentChange: 0 },
52
+ d1RowsRead: { current: 1000, previous: 900, trend: 'up', percentChange: 11.1 },
53
+ totalCost: { current: 5, previous: 4, trend: 'up', percentChange: 25 },
54
+ },
55
+ }),
56
+ getMetricsForDateRange: vi.fn().mockResolvedValue(MOCK_USAGE_DATA),
57
+ };
58
+ }),
59
+ {
60
+ // Static methods
61
+ getSamePeriodLastMonth: vi.fn((startDate: string, endDate: string) => {
62
+ // Calculate 1 month prior
63
+ const start = new Date(startDate);
64
+ const end = new Date(endDate);
65
+ start.setMonth(start.getMonth() - 1);
66
+ end.setMonth(end.getMonth() - 1);
67
+ return {
68
+ startDate: start.toISOString().split('T')[0],
69
+ endDate: end.toISOString().split('T')[0],
70
+ };
71
+ }),
72
+ validateCustomDateRange: vi.fn(
73
+ (params: {
74
+ startDate: string;
75
+ endDate: string;
76
+ priorStartDate?: string;
77
+ priorEndDate?: string;
78
+ }) => {
79
+ const { startDate, endDate, priorStartDate, priorEndDate } = params;
80
+ // Simple validation for tests
81
+ if (!startDate || !endDate) {
82
+ return { error: 'Missing date parameters' };
83
+ }
84
+ const start = new Date(startDate);
85
+ const end = new Date(endDate);
86
+ if (isNaN(start.getTime()) || isNaN(end.getTime())) {
87
+ return { error: 'Invalid date format' };
88
+ }
89
+ if (end < start) {
90
+ return { error: 'End date must be on or after start date' };
91
+ }
92
+ // Calculate prior period (same duration ending day before start)
93
+ const duration = end.getTime() - start.getTime();
94
+ const priorEnd = new Date(start.getTime() - 24 * 60 * 60 * 1000);
95
+ const priorStart = new Date(priorEnd.getTime() - duration);
96
+ return {
97
+ current: { startDate, endDate },
98
+ prior: {
99
+ startDate: priorStartDate || priorStart.toISOString().split('T')[0],
100
+ endDate: priorEndDate || priorEnd.toISOString().split('T')[0],
101
+ },
102
+ };
103
+ }
104
+ ),
105
+ }
106
+ );
107
+
108
+ return {
109
+ ...actual,
110
+ CloudflareGraphQL: MockCloudflareGraphQL,
111
+ };
112
+ });
113
+
114
+ // Import the worker after mocking
115
+ import UsageWorker from '../../workers/platform-usage';
116
+
117
+ // Mock usage data fixture - matches AccountUsage interface from graphql.ts
118
+ const MOCK_USAGE_DATA = {
119
+ period: '30d' as const,
120
+ workers: [
121
+ {
122
+ scriptName: 'my-project-api',
123
+ requests: 100000,
124
+ cpuTimeMs: 5000,
125
+ duration50thMs: 10,
126
+ duration99thMs: 100,
127
+ errors: 50,
128
+ },
129
+ {
130
+ scriptName: 'platform-dashboard',
131
+ requests: 50000,
132
+ cpuTimeMs: 2000,
133
+ duration50thMs: 5,
134
+ duration99thMs: 50,
135
+ errors: 10,
136
+ },
137
+ {
138
+ scriptName: 'test-project-api',
139
+ requests: 25000,
140
+ cpuTimeMs: 1000,
141
+ duration50thMs: 3,
142
+ duration99thMs: 30,
143
+ errors: 5,
144
+ },
145
+ ],
146
+ d1: [
147
+ {
148
+ databaseId: 'db-001',
149
+ databaseName: 'my-project-db',
150
+ rowsRead: 500000,
151
+ rowsWritten: 10000,
152
+ queryCount: 50000,
153
+ },
154
+ {
155
+ databaseId: 'db-002',
156
+ databaseName: 'platform-metrics',
157
+ rowsRead: 100000,
158
+ rowsWritten: 5000,
159
+ queryCount: 10000,
160
+ },
161
+ ],
162
+ kv: [
163
+ {
164
+ namespaceId: 'kv-001',
165
+ namespaceName: 'my-project-cache',
166
+ reads: 50000,
167
+ writes: 1000,
168
+ deletes: 100,
169
+ lists: 50,
170
+ },
171
+ ],
172
+ r2: [
173
+ {
174
+ bucketName: 'my-project-ai-logs',
175
+ storageBytes: 1073741824, // 1 GB
176
+ objectCount: 10000,
177
+ classAOperations: 1000,
178
+ classBOperations: 5000,
179
+ egressBytes: 10737418240, // 10 GB
180
+ },
181
+ ],
182
+ durableObjects: {
183
+ requests: 1000,
184
+ storageBytes: 1024 * 1024,
185
+ gbSeconds: 100,
186
+ storageReadUnits: 500,
187
+ storageWriteUnits: 100,
188
+ storageDeleteUnits: 10,
189
+ },
190
+ vectorize: [
191
+ {
192
+ name: 'my-project-content',
193
+ vectorCount: 2199,
194
+ dimensions: 1536,
195
+ queries: 500,
196
+ },
197
+ ],
198
+ aiGateway: [
199
+ {
200
+ gatewayId: 'my-project',
201
+ requests: 5000,
202
+ tokens: 1000000,
203
+ cacheHits: 500,
204
+ },
205
+ ],
206
+ pages: [
207
+ {
208
+ projectName: 'platform-dashboard',
209
+ subdomain: 'platform-dashboard',
210
+ productionDeployments: 8,
211
+ previewDeployments: 2,
212
+ totalBuilds: 10,
213
+ lastDeployedAt: '2026-01-05T10:00:00Z',
214
+ },
215
+ ],
216
+ };
217
+
218
+ // Env type matching the worker's Env interface
219
+ interface TestEnv {
220
+ PLATFORM_CACHE: KVNamespace;
221
+ PLATFORM_DB: D1Database;
222
+ CLOUDFLARE_ACCOUNT_ID: string;
223
+ CLOUDFLARE_API_TOKEN: string;
224
+ SLACK_WEBHOOK_URL?: string;
225
+ GITHUB_TOKEN?: string;
226
+ // Platform SDK bindings (mocked)
227
+ PLATFORM_TELEMETRY: { send: () => Promise<void>; sendBatch: () => Promise<void> };
228
+ PLATFORM_DLQ: { send: () => Promise<void>; sendBatch: () => Promise<void> };
229
+ PLATFORM_ANALYTICS: { writeDataPoint: () => void };
230
+ CIRCUIT_BREAKER: KVNamespace;
231
+ }
232
+
233
+ // Response type interfaces for type-safe JSON parsing
234
+ interface CostFormatted {
235
+ workers: string;
236
+ d1: string;
237
+ kv?: string;
238
+ r2?: string;
239
+ total: string;
240
+ }
241
+
242
+ interface ThresholdConfig {
243
+ warningPct?: number;
244
+ highPct?: number;
245
+ criticalPct?: number;
246
+ absoluteMax?: number;
247
+ enabled?: boolean;
248
+ }
249
+
250
+ interface UsageResponse {
251
+ success: boolean;
252
+ period?: string;
253
+ project?: string;
254
+ data?: {
255
+ summary?: {
256
+ totalWorkers: number;
257
+ totalD1Databases: number;
258
+ totalRequests: number;
259
+ };
260
+ };
261
+ costs?: {
262
+ formatted?: CostFormatted;
263
+ };
264
+ thresholds?: Record<string, ThresholdConfig>;
265
+ responseTimeMs?: number;
266
+ error?: string;
267
+ cached?: boolean;
268
+ projectCosts?: unknown;
269
+ sparklines?: unknown;
270
+ comparison?: {
271
+ workersRequests?: unknown;
272
+ totalCost?: unknown;
273
+ };
274
+ compareMode?: string;
275
+ current?: unknown;
276
+ prior?: unknown;
277
+ }
278
+
279
+ // Create mock environment - cast to TestEnv for worker compatibility
280
+ function createMockEnv(kvData: Record<string, string> = {}): TestEnv & {
281
+ PLATFORM_CACHE: MockKVNamespace & { store: Map<string, string> };
282
+ PLATFORM_DB: MockD1Database;
283
+ } {
284
+ const kv = new MockKVNamespace();
285
+ // Override get to actually return values from the store, handling JSON type
286
+ kv.get = vi.fn((key: string, type?: string) => {
287
+ const value = kv.store.get(key) ?? null;
288
+ if (value && type === 'json') {
289
+ return Promise.resolve(JSON.parse(value));
290
+ }
291
+ return Promise.resolve(value);
292
+ }) as typeof kv.get;
293
+
294
+ // Pre-populate with initial data
295
+ Object.entries(kvData).forEach(([key, value]) => {
296
+ kv.store.set(key, value);
297
+ });
298
+
299
+ // Create mock D1 database
300
+ const db = new MockD1Database();
301
+
302
+ // Create mock circuit breaker KV (same as PLATFORM_CACHE for simplicity)
303
+ const circuitBreakerKv = new MockKVNamespace();
304
+
305
+ return {
306
+ PLATFORM_CACHE: kv as unknown as KVNamespace & MockKVNamespace & { store: Map<string, string> },
307
+ PLATFORM_DB: db as unknown as D1Database & MockD1Database,
308
+ CLOUDFLARE_ACCOUNT_ID: 'test-account-id',
309
+ CLOUDFLARE_API_TOKEN: 'test-api-token',
310
+ // Platform SDK mock bindings
311
+ PLATFORM_TELEMETRY: {
312
+ send: vi.fn().mockResolvedValue(undefined),
313
+ sendBatch: vi.fn().mockResolvedValue(undefined),
314
+ },
315
+ PLATFORM_DLQ: {
316
+ send: vi.fn().mockResolvedValue(undefined),
317
+ sendBatch: vi.fn().mockResolvedValue(undefined),
318
+ },
319
+ PLATFORM_ANALYTICS: {
320
+ writeDataPoint: vi.fn(),
321
+ },
322
+ CIRCUIT_BREAKER: circuitBreakerKv as unknown as KVNamespace,
323
+ } as TestEnv & {
324
+ PLATFORM_CACHE: MockKVNamespace & { store: Map<string, string> };
325
+ PLATFORM_DB: MockD1Database;
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Set up mock D1 with project registry data for project filtering tests.
331
+ * The worker calls getProjects() which queries D1 for the project registry.
332
+ * Without this setup, all project filters fall back to 'all'.
333
+ */
334
+ function setupProjectRegistry(db: MockD1Database): void {
335
+ // Queue project registry results (queried first by loadRegistry)
336
+ db.queueAllResult({
337
+ results: [
338
+ {
339
+ project_id: 'my-project',
340
+ display_name: 'Brand Copilot',
341
+ description: 'AI-powered content generation',
342
+ color: '#4F46E5',
343
+ icon: null,
344
+ owner: null,
345
+ repo_path: null,
346
+ status: 'active',
347
+ primary_resource: null,
348
+ custom_limit: null,
349
+ },
350
+ {
351
+ project_id: 'platform',
352
+ display_name: 'Platform',
353
+ description: 'Infrastructure monitoring',
354
+ color: '#059669',
355
+ icon: null,
356
+ owner: null,
357
+ repo_path: null,
358
+ status: 'active',
359
+ primary_resource: null,
360
+ custom_limit: null,
361
+ },
362
+ {
363
+ project_id: 'test-project',
364
+ display_name: 'Test Project',
365
+ description: 'Opportunity discovery',
366
+ color: '#D97706',
367
+ icon: null,
368
+ owner: null,
369
+ repo_path: null,
370
+ status: 'active',
371
+ primary_resource: null,
372
+ custom_limit: null,
373
+ },
374
+ ],
375
+ });
376
+
377
+ // Queue resource mapping results (queried second by loadRegistry)
378
+ db.queueAllResult({
379
+ results: [
380
+ { resource_type: 'worker', resource_id: 'my-project-api', project_id: 'my-project' },
381
+ { resource_type: 'worker', resource_id: 'platform-usage', project_id: 'platform' },
382
+ { resource_type: 'worker', resource_id: 'test-project', project_id: 'test-project' },
383
+ ],
384
+ });
385
+ }
386
+
387
+ // Create mock request
388
+ function createRequest(path: string, method = 'GET', body?: unknown): Request {
389
+ const url = new URL(path, 'https://usage-api.example.com');
390
+ const init: RequestInit = {
391
+ method,
392
+ headers: { 'Content-Type': 'application/json' },
393
+ };
394
+ if (body) {
395
+ init.body = JSON.stringify(body);
396
+ }
397
+ return new Request(url.toString(), init);
398
+ }
399
+
400
+ // Mock execution context
401
+ const mockCtx = {
402
+ waitUntil: vi.fn(),
403
+ passThroughOnException: vi.fn(),
404
+ } as unknown as ExecutionContext;
405
+
406
+ describe('Usage API - End-to-End Tests', () => {
407
+ let env: ReturnType<typeof createMockEnv>;
408
+
409
+ beforeEach(() => {
410
+ env = createMockEnv();
411
+ vi.clearAllMocks();
412
+ // Clear the project registry cache so each test starts fresh
413
+ clearRegistryCache();
414
+ });
415
+
416
+ describe('GET /usage', () => {
417
+ it('returns usage metrics with default parameters', async () => {
418
+ const request = createRequest('/usage');
419
+ const response = await UsageWorker.fetch(request, env, mockCtx);
420
+
421
+ expect(response.status).toBe(200);
422
+ expect(response.headers.get('Content-Type')).toBe('application/json');
423
+
424
+ const body = (await response.json()) as UsageResponse;
425
+
426
+ expect(body.success).toBe(true);
427
+ expect(body.period).toBe('30d');
428
+ expect(body.project).toBe('all');
429
+ expect(body.data).toBeDefined();
430
+ expect(body.costs).toBeDefined();
431
+ expect(body.thresholds).toBeDefined();
432
+ });
433
+
434
+ it('filters by period (24h)', async () => {
435
+ const request = createRequest('/usage?period=24h');
436
+ const response = await UsageWorker.fetch(request, env, mockCtx);
437
+
438
+ expect(response.status).toBe(200);
439
+
440
+ const body = (await response.json()) as UsageResponse;
441
+ expect(body.period).toBe('24h');
442
+ });
443
+
444
+ it('filters by period (7d)', async () => {
445
+ const request = createRequest('/usage?period=7d');
446
+ const response = await UsageWorker.fetch(request, env, mockCtx);
447
+
448
+ expect(response.status).toBe(200);
449
+
450
+ const body = (await response.json()) as UsageResponse;
451
+ expect(body.period).toBe('7d');
452
+ });
453
+
454
+ it('filters by project (my-project)', async () => {
455
+ // Set up project registry so 'my-project' is recognized as valid
456
+ setupProjectRegistry(env.PLATFORM_DB);
457
+
458
+ const request = createRequest('/usage?project=my-project');
459
+ const response = await UsageWorker.fetch(request, env, mockCtx);
460
+
461
+ expect(response.status).toBe(200);
462
+
463
+ const body = (await response.json()) as UsageResponse;
464
+ expect(body.project).toBe('my-project');
465
+ });
466
+
467
+ it('filters by project (platform)', async () => {
468
+ // Set up project registry so 'platform' is recognized as valid
469
+ setupProjectRegistry(env.PLATFORM_DB);
470
+
471
+ const request = createRequest('/usage?project=platform');
472
+ const response = await UsageWorker.fetch(request, env, mockCtx);
473
+
474
+ expect(response.status).toBe(200);
475
+
476
+ const body = (await response.json()) as UsageResponse;
477
+ expect(body.project).toBe('platform');
478
+ });
479
+
480
+ it('filters by project (test-project)', async () => {
481
+ // Set up project registry so 'test-project' is recognized as valid
482
+ setupProjectRegistry(env.PLATFORM_DB);
483
+
484
+ const request = createRequest('/usage?project=test-project');
485
+ const response = await UsageWorker.fetch(request, env, mockCtx);
486
+
487
+ expect(response.status).toBe(200);
488
+
489
+ const body = (await response.json()) as UsageResponse;
490
+ expect(body.project).toBe('test-project');
491
+ });
492
+
493
+ it('combines period and project filters', async () => {
494
+ // Set up project registry so 'my-project' is recognized as valid
495
+ setupProjectRegistry(env.PLATFORM_DB);
496
+
497
+ const request = createRequest('/usage?period=7d&project=my-project');
498
+ const response = await UsageWorker.fetch(request, env, mockCtx);
499
+
500
+ expect(response.status).toBe(200);
501
+
502
+ const body = (await response.json()) as UsageResponse;
503
+ expect(body.period).toBe('7d');
504
+ expect(body.project).toBe('my-project');
505
+ });
506
+
507
+ it('defaults invalid period to 30d', async () => {
508
+ const request = createRequest('/usage?period=invalid');
509
+ const response = await UsageWorker.fetch(request, env, mockCtx);
510
+
511
+ expect(response.status).toBe(200);
512
+
513
+ const body = (await response.json()) as UsageResponse;
514
+ expect(body.period).toBe('30d');
515
+ });
516
+
517
+ it('defaults invalid project to all', async () => {
518
+ // Set up project registry so we can test that 'invalid' falls back to 'all'
519
+ setupProjectRegistry(env.PLATFORM_DB);
520
+
521
+ const request = createRequest('/usage?project=invalid');
522
+ const response = await UsageWorker.fetch(request, env, mockCtx);
523
+
524
+ expect(response.status).toBe(200);
525
+
526
+ const body = (await response.json()) as UsageResponse;
527
+ expect(body.project).toBe('all');
528
+ });
529
+
530
+ it('includes formatted cost breakdown', async () => {
531
+ const request = createRequest('/usage');
532
+ const response = await UsageWorker.fetch(request, env, mockCtx);
533
+
534
+ const body = (await response.json()) as UsageResponse;
535
+
536
+ expect(body.costs?.formatted).toBeDefined();
537
+ expect(body.costs?.formatted?.workers).toMatch(/^\$[\d.]+$/);
538
+ expect(body.costs?.formatted?.d1).toMatch(/^\$[\d.]+$/);
539
+ expect(body.costs?.formatted?.total).toMatch(/^\$[\d.]+$/);
540
+ });
541
+
542
+ it('includes summary statistics', async () => {
543
+ const request = createRequest('/usage');
544
+ const response = await UsageWorker.fetch(request, env, mockCtx);
545
+
546
+ const body = (await response.json()) as UsageResponse;
547
+
548
+ expect(body.data?.summary).toBeDefined();
549
+ expect(body.data?.summary?.totalWorkers).toBeGreaterThanOrEqual(0);
550
+ expect(body.data?.summary?.totalD1Databases).toBeGreaterThanOrEqual(0);
551
+ expect(body.data?.summary?.totalRequests).toBeGreaterThanOrEqual(0);
552
+ });
553
+
554
+ it('includes CORS headers', async () => {
555
+ const request = createRequest('/usage');
556
+ const response = await UsageWorker.fetch(request, env, mockCtx);
557
+
558
+ expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
559
+ });
560
+
561
+ it('returns 500 when API credentials missing', async () => {
562
+ const badEnv = {
563
+ ...env,
564
+ CLOUDFLARE_ACCOUNT_ID: '',
565
+ CLOUDFLARE_API_TOKEN: '',
566
+ };
567
+
568
+ const request = createRequest('/usage');
569
+ const response = await UsageWorker.fetch(request, badEnv, mockCtx);
570
+
571
+ expect(response.status).toBe(500);
572
+
573
+ const body = (await response.json()) as UsageResponse;
574
+ expect(body.success).toBe(false);
575
+ expect(body.error).toBe('Configuration Error');
576
+ });
577
+ });
578
+
579
+ describe('GET /usage/costs', () => {
580
+ it('returns cost breakdown', async () => {
581
+ const request = createRequest('/usage/costs');
582
+ const response = await UsageWorker.fetch(request, env, mockCtx);
583
+
584
+ expect(response.status).toBe(200);
585
+
586
+ const body = (await response.json()) as UsageResponse;
587
+ expect(body.success).toBe(true);
588
+ expect(body.costs).toBeDefined();
589
+ expect(body.projectCosts).toBeDefined();
590
+ });
591
+
592
+ it('includes formatted costs', async () => {
593
+ const request = createRequest('/usage/costs');
594
+ const response = await UsageWorker.fetch(request, env, mockCtx);
595
+
596
+ const body = (await response.json()) as UsageResponse;
597
+
598
+ expect(body.costs?.formatted?.workers).toBeDefined();
599
+ expect(body.costs?.formatted?.d1).toBeDefined();
600
+ expect(body.costs?.formatted?.total).toBeDefined();
601
+ });
602
+ });
603
+
604
+ describe('GET /usage/thresholds', () => {
605
+ it('returns threshold analysis', async () => {
606
+ const request = createRequest('/usage/thresholds');
607
+ const response = await UsageWorker.fetch(request, env, mockCtx);
608
+
609
+ expect(response.status).toBe(200);
610
+
611
+ const body = (await response.json()) as UsageResponse;
612
+ expect(body.success).toBe(true);
613
+ expect(body.thresholds).toBeDefined();
614
+ });
615
+
616
+ it('respects period parameter', async () => {
617
+ const request = createRequest('/usage/thresholds?period=7d');
618
+ const response = await UsageWorker.fetch(request, env, mockCtx);
619
+
620
+ expect(response.status).toBe(200);
621
+
622
+ const body = (await response.json()) as UsageResponse;
623
+ expect(body.period).toBe('7d');
624
+ });
625
+ });
626
+
627
+ describe('GET /usage/enhanced', () => {
628
+ it('returns enhanced metrics with sparklines', async () => {
629
+ const request = createRequest('/usage/enhanced');
630
+ const response = await UsageWorker.fetch(request, env, mockCtx);
631
+
632
+ expect(response.status).toBe(200);
633
+
634
+ const body = (await response.json()) as UsageResponse;
635
+ expect(body.success).toBe(true);
636
+ expect(body.sparklines).toBeDefined();
637
+ expect(body.comparison).toBeDefined();
638
+ });
639
+
640
+ it('includes comparison data', async () => {
641
+ const request = createRequest('/usage/enhanced');
642
+ const response = await UsageWorker.fetch(request, env, mockCtx);
643
+
644
+ const body = (await response.json()) as UsageResponse;
645
+
646
+ expect(body.comparison?.workersRequests).toBeDefined();
647
+ expect(body.comparison?.totalCost).toBeDefined();
648
+ });
649
+ });
650
+
651
+ describe('GET /usage/compare', () => {
652
+ it('returns comparison with lastMonth mode', async () => {
653
+ const request = createRequest('/usage/compare?compare=lastMonth');
654
+ const response = await UsageWorker.fetch(request, env, mockCtx);
655
+
656
+ expect(response.status).toBe(200);
657
+
658
+ const body = (await response.json()) as UsageResponse;
659
+ expect(body.success).toBe(true);
660
+ expect(body.compareMode).toBe('lastMonth');
661
+ expect(body.current).toBeDefined();
662
+ expect(body.prior).toBeDefined();
663
+ expect(body.comparison).toBeDefined();
664
+ });
665
+
666
+ it('returns 400 for missing compare parameter', async () => {
667
+ const request = createRequest('/usage/compare');
668
+ const response = await UsageWorker.fetch(request, env, mockCtx);
669
+
670
+ expect(response.status).toBe(400);
671
+
672
+ const body = (await response.json()) as UsageResponse;
673
+ expect(body.success).toBe(false);
674
+ expect(body.error).toBe('Invalid compare mode');
675
+ });
676
+
677
+ it('returns 400 for invalid compare mode', async () => {
678
+ const request = createRequest('/usage/compare?compare=invalid');
679
+ const response = await UsageWorker.fetch(request, env, mockCtx);
680
+
681
+ expect(response.status).toBe(400);
682
+
683
+ const body = (await response.json()) as UsageResponse;
684
+ expect(body.success).toBe(false);
685
+ });
686
+
687
+ it('requires dates for custom compare mode', async () => {
688
+ const request = createRequest('/usage/compare?compare=custom');
689
+ const response = await UsageWorker.fetch(request, env, mockCtx);
690
+
691
+ expect(response.status).toBe(400);
692
+
693
+ const body = (await response.json()) as UsageResponse;
694
+ expect(body.success).toBe(false);
695
+ expect(body.error).toBe('Missing date parameters');
696
+ });
697
+
698
+ it('accepts custom date range', async () => {
699
+ const request = createRequest(
700
+ '/usage/compare?compare=custom&startDate=2025-11-01&endDate=2025-11-07'
701
+ );
702
+ const response = await UsageWorker.fetch(request, env, mockCtx);
703
+
704
+ expect(response.status).toBe(200);
705
+
706
+ const body = (await response.json()) as UsageResponse;
707
+ expect(body.success).toBe(true);
708
+ expect(body.compareMode).toBe('custom');
709
+ });
710
+ });
711
+
712
+ describe('GET /usage/settings', () => {
713
+ it('returns default thresholds when no custom config exists', async () => {
714
+ const request = createRequest('/usage/settings');
715
+ const response = await UsageWorker.fetch(request, env, mockCtx);
716
+
717
+ expect(response.status).toBe(200);
718
+
719
+ const body = (await response.json()) as UsageResponse;
720
+ expect(body.success).toBe(true);
721
+ expect(body.thresholds).toBeDefined();
722
+ expect(body.cached).toBe(false);
723
+ });
724
+
725
+ it('returns cached thresholds when custom config exists', async () => {
726
+ // Pre-populate KV with custom config
727
+ const customConfig = {
728
+ thresholds: {
729
+ workers: { warningPct: 60, highPct: 80, criticalPct: 95, absoluteMax: 10, enabled: true },
730
+ },
731
+ updated: '2025-11-01T00:00:00Z',
732
+ };
733
+ env.PLATFORM_CACHE.store.set('alert-thresholds:config', JSON.stringify(customConfig));
734
+
735
+ const request = createRequest('/usage/settings');
736
+ const response = await UsageWorker.fetch(request, env, mockCtx);
737
+
738
+ expect(response.status).toBe(200);
739
+
740
+ const body = (await response.json()) as UsageResponse;
741
+ expect(body.success).toBe(true);
742
+ expect(body.thresholds?.workers?.warningPct).toBe(60);
743
+ expect(body.cached).toBe(true);
744
+ });
745
+ });
746
+
747
+ describe('PUT /usage/settings', () => {
748
+ it('updates threshold configuration', async () => {
749
+ const request = createRequest('/usage/settings', 'PUT', {
750
+ thresholds: {
751
+ workers: { warningPct: 60, highPct: 80, criticalPct: 95, absoluteMax: 10, enabled: true },
752
+ },
753
+ });
754
+ const response = await UsageWorker.fetch(request, env, mockCtx);
755
+
756
+ expect(response.status).toBe(200);
757
+
758
+ const body = (await response.json()) as UsageResponse;
759
+ expect(body.success).toBe(true);
760
+ expect(body.thresholds?.workers?.warningPct).toBe(60);
761
+ });
762
+
763
+ it('returns 400 for missing thresholds', async () => {
764
+ const request = createRequest('/usage/settings', 'PUT', {});
765
+ const response = await UsageWorker.fetch(request, env, mockCtx);
766
+
767
+ expect(response.status).toBe(400);
768
+
769
+ const body = (await response.json()) as UsageResponse;
770
+ expect(body.success).toBe(false);
771
+ expect(body.error).toBe('Invalid request body');
772
+ });
773
+
774
+ it('validates percentage values (0-100)', async () => {
775
+ const request = createRequest('/usage/settings', 'PUT', {
776
+ thresholds: {
777
+ workers: { warningPct: 150 }, // Invalid: > 100
778
+ },
779
+ });
780
+ const response = await UsageWorker.fetch(request, env, mockCtx);
781
+
782
+ expect(response.status).toBe(400);
783
+
784
+ const body = (await response.json()) as UsageResponse;
785
+ expect(body.success).toBe(false);
786
+ expect(body.error).toBe('Invalid threshold value');
787
+ });
788
+
789
+ it('validates absoluteMax is non-negative', async () => {
790
+ const request = createRequest('/usage/settings', 'PUT', {
791
+ thresholds: {
792
+ workers: { absoluteMax: -5 }, // Invalid: < 0
793
+ },
794
+ });
795
+ const response = await UsageWorker.fetch(request, env, mockCtx);
796
+
797
+ expect(response.status).toBe(400);
798
+
799
+ const body = (await response.json()) as UsageResponse;
800
+ expect(body.success).toBe(false);
801
+ });
802
+
803
+ it('merges with existing configuration', async () => {
804
+ // Pre-populate with existing config
805
+ const existingConfig = {
806
+ thresholds: {
807
+ workers: { warningPct: 50, highPct: 75, criticalPct: 90, absoluteMax: 5, enabled: true },
808
+ d1: { warningPct: 50, highPct: 75, criticalPct: 90, absoluteMax: 10, enabled: true },
809
+ },
810
+ updated: '2025-11-01T00:00:00Z',
811
+ };
812
+ env.PLATFORM_CACHE.store.set('alert-thresholds:config', JSON.stringify(existingConfig));
813
+
814
+ // Update only workers
815
+ const request = createRequest('/usage/settings', 'PUT', {
816
+ thresholds: {
817
+ workers: { absoluteMax: 15 },
818
+ },
819
+ });
820
+ const response = await UsageWorker.fetch(request, env, mockCtx);
821
+
822
+ expect(response.status).toBe(200);
823
+
824
+ const body = (await response.json()) as UsageResponse;
825
+ // Workers should be updated
826
+ expect(body.thresholds?.workers?.absoluteMax).toBe(15);
827
+ // D1 should retain existing values
828
+ expect(body.thresholds?.d1?.absoluteMax).toBe(10);
829
+ });
830
+ });
831
+
832
+ describe('HTTP Methods', () => {
833
+ it('handles OPTIONS for CORS preflight', async () => {
834
+ const request = createRequest('/usage', 'OPTIONS');
835
+ const response = await UsageWorker.fetch(request, env, mockCtx);
836
+
837
+ expect(response.status).toBe(200);
838
+ expect(response.headers.get('Access-Control-Allow-Methods')).toContain('GET');
839
+ expect(response.headers.get('Access-Control-Allow-Methods')).toContain('PUT');
840
+ });
841
+
842
+ it('returns 405 for POST to /usage', async () => {
843
+ const request = createRequest('/usage', 'POST');
844
+ const response = await UsageWorker.fetch(request, env, mockCtx);
845
+
846
+ expect(response.status).toBe(405);
847
+ });
848
+
849
+ it('returns 404 for unknown paths', async () => {
850
+ const request = createRequest('/unknown');
851
+ const response = await UsageWorker.fetch(request, env, mockCtx);
852
+
853
+ expect(response.status).toBe(404);
854
+ });
855
+ });
856
+
857
+ describe('Caching Behaviour', () => {
858
+ it('returns cached data when available', async () => {
859
+ // First request populates cache
860
+ const request1 = createRequest('/usage');
861
+ await UsageWorker.fetch(request1, env, mockCtx);
862
+
863
+ // Second request should hit cache (within same hour)
864
+ const request2 = createRequest('/usage');
865
+ const response2 = await UsageWorker.fetch(request2, env, mockCtx);
866
+
867
+ const body = (await response2.json()) as UsageResponse;
868
+ // Note: cached flag depends on actual cache hit in KV mock
869
+ expect(body.success).toBe(true);
870
+ });
871
+
872
+ it('uses different cache keys for different periods', async () => {
873
+ // Request 24h
874
+ const request1 = createRequest('/usage?period=24h');
875
+ await UsageWorker.fetch(request1, env, mockCtx);
876
+
877
+ // Request 7d
878
+ const request2 = createRequest('/usage?period=7d');
879
+ await UsageWorker.fetch(request2, env, mockCtx);
880
+
881
+ // Verify both requests completed successfully
882
+ expect(env.PLATFORM_CACHE.store.size).toBeGreaterThanOrEqual(0);
883
+ });
884
+
885
+ it('uses different cache keys for different projects', async () => {
886
+ // Request all projects
887
+ const request1 = createRequest('/usage?project=all');
888
+ await UsageWorker.fetch(request1, env, mockCtx);
889
+
890
+ // Request my-project
891
+ const request2 = createRequest('/usage?project=my-project');
892
+ await UsageWorker.fetch(request2, env, mockCtx);
893
+
894
+ // Both should be cached separately
895
+ expect(env.PLATFORM_CACHE.store.size).toBeGreaterThanOrEqual(0);
896
+ });
897
+ });
898
+
899
+ describe('Response Time Tracking', () => {
900
+ it('includes responseTimeMs in response', async () => {
901
+ const request = createRequest('/usage');
902
+ const response = await UsageWorker.fetch(request, env, mockCtx);
903
+
904
+ const body = (await response.json()) as UsageResponse;
905
+ expect(body.responseTimeMs).toBeDefined();
906
+ expect(typeof body.responseTimeMs).toBe('number');
907
+ });
908
+ });
909
+ });