@littlebearapps/platform-admin-sdk 2.1.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 (115) hide show
  1. package/README.md +2 -5
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +121 -3
  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/FeatureUsageReport.tsx +339 -0
  9. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  10. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  11. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  12. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  13. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  14. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  15. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  16. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  17. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  18. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  19. package/templates/full/dashboard/src/pages/map.astro +561 -0
  20. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  21. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  22. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  23. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  24. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  25. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  26. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  27. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  28. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  29. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  30. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  31. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  32. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  33. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  34. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  35. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  36. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  37. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  38. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  39. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  40. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  41. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  42. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  43. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  44. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  45. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  46. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  47. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  48. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  49. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  50. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  51. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  52. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  53. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  54. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  55. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  56. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  57. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  58. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  59. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  60. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  61. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  62. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  63. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  64. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  65. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  66. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  67. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  68. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  69. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  70. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  71. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  72. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  73. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  74. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  75. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  76. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  77. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  78. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  79. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  80. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  81. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  82. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  83. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  84. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  85. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  86. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  87. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  88. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  89. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  90. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  91. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  92. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  93. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  94. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  95. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  96. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  97. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  98. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  99. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  100. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  101. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  102. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  103. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  104. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  105. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  106. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  107. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  108. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  109. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  110. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  111. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  112. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  113. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  114. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  115. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
@@ -0,0 +1,784 @@
1
+ /**
2
+ * E2E Tests for Usage Export Functionality
3
+ *
4
+ * Tests the CSV and JSON export features of the Usage Dashboard.
5
+ * Validates data transformation, filename generation, and content structure.
6
+ *
7
+ * @module tests/e2e/usage-export
8
+ * @created 2026-01-05
9
+ * @task task-17.26 - E2E tests for export functionality
10
+ */
11
+
12
+ import { describe, expect, it } from 'vitest';
13
+ import type { UnifiedResource } from '../../dashboard/src/components/usage/types';
14
+
15
+ /**
16
+ * Mock filter state matching the dashboard's FilterState interface
17
+ */
18
+ interface MockFilterState {
19
+ period: string;
20
+ project: string;
21
+ serviceTypes: string[];
22
+ searchQuery: string;
23
+ onlyChanged: boolean;
24
+ nonZeroCost: boolean;
25
+ compareMode: string;
26
+ }
27
+
28
+ /**
29
+ * Generate export filename matching dashboard logic
30
+ */
31
+ function getExportFilename(ext: string, filterState: MockFilterState): string {
32
+ const date = new Date().toISOString().split('T')[0];
33
+ const period = filterState.period;
34
+ const project = filterState.project === 'all' ? 'all-projects' : filterState.project;
35
+ return `cloudflare-usage-${project}-${period}-${date}.${ext}`;
36
+ }
37
+
38
+ /**
39
+ * Generate CSV content matching dashboard logic
40
+ */
41
+ function exportToCsv(resources: UnifiedResource[]): string {
42
+ const headers = [
43
+ 'Name',
44
+ 'Type',
45
+ 'Project',
46
+ 'Usage',
47
+ 'Unit',
48
+ 'Current Cost ($)',
49
+ 'Prior Cost ($)',
50
+ 'Change (%)',
51
+ 'Status',
52
+ ];
53
+ const rows = resources.map((r) => [
54
+ r.name,
55
+ r.type,
56
+ r.project,
57
+ r.usage.value.toString(),
58
+ r.usage.unit,
59
+ r.costCurrent.toFixed(4),
60
+ r.costPrior.toFixed(4),
61
+ r.costDeltaPct === 'NEW' ? 'NEW' : (r.costDeltaPct?.toFixed(1) ?? '0'),
62
+ r.status,
63
+ ]);
64
+
65
+ return [headers, ...rows]
66
+ .map((row) => row.map((cell) => `"${cell.toString().replace(/"/g, '""')}"`).join(','))
67
+ .join('\n');
68
+ }
69
+
70
+ /**
71
+ * Generate JSON export content matching dashboard logic
72
+ */
73
+ function exportToJson(
74
+ resources: UnifiedResource[],
75
+ filterState: MockFilterState
76
+ ): {
77
+ exportedAt: string;
78
+ filters: MockFilterState;
79
+ summary: {
80
+ totalResources: number;
81
+ totalCurrentCost: number;
82
+ totalPriorCost: number;
83
+ resourcesByType: Record<string, number>;
84
+ };
85
+ resources: Partial<UnifiedResource>[];
86
+ } {
87
+ return {
88
+ exportedAt: new Date().toISOString(),
89
+ filters: {
90
+ period: filterState.period,
91
+ project: filterState.project,
92
+ serviceTypes: filterState.serviceTypes,
93
+ searchQuery: filterState.searchQuery,
94
+ onlyChanged: filterState.onlyChanged,
95
+ nonZeroCost: filterState.nonZeroCost,
96
+ compareMode: filterState.compareMode,
97
+ },
98
+ summary: {
99
+ totalResources: resources.length,
100
+ totalCurrentCost: resources.reduce((sum, r) => sum + r.costCurrent, 0),
101
+ totalPriorCost: resources.reduce((sum, r) => sum + r.costPrior, 0),
102
+ resourcesByType: resources.reduce(
103
+ (acc, r) => {
104
+ acc[r.type] = (acc[r.type] || 0) + 1;
105
+ return acc;
106
+ },
107
+ {} as Record<string, number>
108
+ ),
109
+ },
110
+ resources: resources.map((r) => ({
111
+ id: r.id,
112
+ name: r.name,
113
+ type: r.type,
114
+ project: r.project,
115
+ usage: r.usage,
116
+ costCurrent: r.costCurrent,
117
+ costPrior: r.costPrior,
118
+ costDelta: r.costDelta,
119
+ costDeltaPct: r.costDeltaPct,
120
+ status: r.status,
121
+ })),
122
+ };
123
+ }
124
+
125
+ describe('Usage Export - Filename Generation', () => {
126
+ it('generates correct CSV filename for all projects', () => {
127
+ const filterState: MockFilterState = {
128
+ period: '30d',
129
+ project: 'all',
130
+ serviceTypes: [],
131
+ searchQuery: '',
132
+ onlyChanged: false,
133
+ nonZeroCost: false,
134
+ compareMode: 'none',
135
+ };
136
+
137
+ const filename = getExportFilename('csv', filterState);
138
+ const today = new Date().toISOString().split('T')[0];
139
+
140
+ expect(filename).toBe(`cloudflare-usage-all-projects-30d-${today}.csv`);
141
+ });
142
+
143
+ it('generates correct JSON filename for specific project', () => {
144
+ const filterState: MockFilterState = {
145
+ period: '7d',
146
+ project: 'brand-copilot',
147
+ serviceTypes: [],
148
+ searchQuery: '',
149
+ onlyChanged: false,
150
+ nonZeroCost: false,
151
+ compareMode: 'none',
152
+ };
153
+
154
+ const filename = getExportFilename('json', filterState);
155
+ const today = new Date().toISOString().split('T')[0];
156
+
157
+ expect(filename).toBe(`cloudflare-usage-brand-copilot-7d-${today}.json`);
158
+ });
159
+
160
+ it('includes period in filename', () => {
161
+ const periods = ['24h', '7d', '30d'];
162
+ const filterState: MockFilterState = {
163
+ period: '',
164
+ project: 'all',
165
+ serviceTypes: [],
166
+ searchQuery: '',
167
+ onlyChanged: false,
168
+ nonZeroCost: false,
169
+ compareMode: 'none',
170
+ };
171
+
172
+ for (const period of periods) {
173
+ filterState.period = period;
174
+ const filename = getExportFilename('csv', filterState);
175
+ expect(filename).toContain(period);
176
+ }
177
+ });
178
+
179
+ it('includes correct date format (YYYY-MM-DD)', () => {
180
+ const filterState: MockFilterState = {
181
+ period: '30d',
182
+ project: 'all',
183
+ serviceTypes: [],
184
+ searchQuery: '',
185
+ onlyChanged: false,
186
+ nonZeroCost: false,
187
+ compareMode: 'none',
188
+ };
189
+
190
+ const filename = getExportFilename('csv', filterState);
191
+ const datePattern = /\d{4}-\d{2}-\d{2}/;
192
+
193
+ expect(filename).toMatch(datePattern);
194
+ });
195
+ });
196
+
197
+ describe('Usage Export - CSV Content', () => {
198
+ const mockResources: UnifiedResource[] = [
199
+ {
200
+ id: 'worker-api',
201
+ name: 'brand-copilot-api',
202
+ type: 'worker',
203
+ project: 'brand-copilot',
204
+ usage: { value: 100000, unit: 'requests', formatted: '100K' },
205
+ costCurrent: 5.5,
206
+ costPrior: 4.0,
207
+ costDelta: 1.5,
208
+ costDeltaPct: 37.5,
209
+ status: 'healthy',
210
+ },
211
+ {
212
+ id: 'd1-main',
213
+ name: 'platform-db',
214
+ type: 'd1',
215
+ project: 'platform',
216
+ usage: { value: 50000, unit: 'rows read', formatted: '50K' },
217
+ costCurrent: 2.0,
218
+ costPrior: 0,
219
+ costDelta: 2.0,
220
+ costDeltaPct: 'NEW',
221
+ status: 'warning',
222
+ },
223
+ ];
224
+
225
+ it('includes correct CSV headers', () => {
226
+ const csv = exportToCsv(mockResources);
227
+ const headerLine = csv.split('\n')[0];
228
+
229
+ expect(headerLine).toContain('"Name"');
230
+ expect(headerLine).toContain('"Type"');
231
+ expect(headerLine).toContain('"Project"');
232
+ expect(headerLine).toContain('"Usage"');
233
+ expect(headerLine).toContain('"Unit"');
234
+ expect(headerLine).toContain('"Current Cost ($)"');
235
+ expect(headerLine).toContain('"Prior Cost ($)"');
236
+ expect(headerLine).toContain('"Change (%)"');
237
+ expect(headerLine).toContain('"Status"');
238
+ });
239
+
240
+ it('includes all resources in CSV', () => {
241
+ const csv = exportToCsv(mockResources);
242
+ const lines = csv.split('\n');
243
+
244
+ // Header + 2 data rows
245
+ expect(lines).toHaveLength(3);
246
+ });
247
+
248
+ it('formats costs with 4 decimal places', () => {
249
+ const csv = exportToCsv(mockResources);
250
+
251
+ expect(csv).toContain('"5.5000"');
252
+ expect(csv).toContain('"4.0000"');
253
+ expect(csv).toContain('"2.0000"');
254
+ });
255
+
256
+ it('handles NEW delta percentage', () => {
257
+ const csv = exportToCsv(mockResources);
258
+
259
+ expect(csv).toContain('"NEW"');
260
+ });
261
+
262
+ it('formats numeric delta as percentage', () => {
263
+ const csv = exportToCsv(mockResources);
264
+
265
+ // 37.5% formatted as 37.5
266
+ expect(csv).toContain('"37.5"');
267
+ });
268
+
269
+ it('escapes double quotes in values', () => {
270
+ const resourceWithQuotes: UnifiedResource[] = [
271
+ {
272
+ id: 'worker-test',
273
+ name: 'Test "Worker"',
274
+ type: 'worker',
275
+ project: 'test',
276
+ usage: { value: 1000, unit: 'requests', formatted: '1K' },
277
+ costCurrent: 1.0,
278
+ costPrior: 0,
279
+ costDelta: 1.0,
280
+ costDeltaPct: 100,
281
+ status: 'healthy',
282
+ },
283
+ ];
284
+
285
+ const csv = exportToCsv(resourceWithQuotes);
286
+
287
+ // Double quotes should be escaped as ""
288
+ expect(csv).toContain('Test ""Worker""');
289
+ });
290
+
291
+ it('returns empty CSV for empty resources', () => {
292
+ const csv = exportToCsv([]);
293
+ const lines = csv.split('\n');
294
+
295
+ // Only header row
296
+ expect(lines).toHaveLength(1);
297
+ });
298
+
299
+ it('preserves resource order in CSV', () => {
300
+ const csv = exportToCsv(mockResources);
301
+ const lines = csv.split('\n');
302
+
303
+ expect(lines[1]).toContain('brand-copilot-api');
304
+ expect(lines[2]).toContain('platform-db');
305
+ });
306
+ });
307
+
308
+ describe('Usage Export - JSON Content', () => {
309
+ const mockResources: UnifiedResource[] = [
310
+ {
311
+ id: 'worker-api',
312
+ name: 'brand-copilot-api',
313
+ type: 'worker',
314
+ project: 'brand-copilot',
315
+ usage: { value: 100000, unit: 'requests', formatted: '100K' },
316
+ costCurrent: 5.5,
317
+ costPrior: 4.0,
318
+ costDelta: 1.5,
319
+ costDeltaPct: 37.5,
320
+ status: 'healthy',
321
+ },
322
+ {
323
+ id: 'kv-cache',
324
+ name: 'platform-cache',
325
+ type: 'kv',
326
+ project: 'platform',
327
+ usage: { value: 10000, unit: 'operations', formatted: '10K' },
328
+ costCurrent: 1.0,
329
+ costPrior: 0.5,
330
+ costDelta: 0.5,
331
+ costDeltaPct: 100,
332
+ status: 'healthy',
333
+ },
334
+ ];
335
+
336
+ const mockFilterState: MockFilterState = {
337
+ period: '30d',
338
+ project: 'all',
339
+ serviceTypes: ['worker', 'kv'],
340
+ searchQuery: 'api',
341
+ onlyChanged: true,
342
+ nonZeroCost: true,
343
+ compareMode: 'lastMonth',
344
+ };
345
+
346
+ it('includes exportedAt timestamp', () => {
347
+ const json = exportToJson(mockResources, mockFilterState);
348
+
349
+ expect(json.exportedAt).toBeDefined();
350
+ expect(new Date(json.exportedAt).toISOString()).toBe(json.exportedAt);
351
+ });
352
+
353
+ it('includes all filter state', () => {
354
+ const json = exportToJson(mockResources, mockFilterState);
355
+
356
+ expect(json.filters.period).toBe('30d');
357
+ expect(json.filters.project).toBe('all');
358
+ expect(json.filters.serviceTypes).toEqual(['worker', 'kv']);
359
+ expect(json.filters.searchQuery).toBe('api');
360
+ expect(json.filters.onlyChanged).toBe(true);
361
+ expect(json.filters.nonZeroCost).toBe(true);
362
+ expect(json.filters.compareMode).toBe('lastMonth');
363
+ });
364
+
365
+ it('calculates correct summary totals', () => {
366
+ const json = exportToJson(mockResources, mockFilterState);
367
+
368
+ expect(json.summary.totalResources).toBe(2);
369
+ expect(json.summary.totalCurrentCost).toBe(6.5); // 5.5 + 1.0
370
+ expect(json.summary.totalPriorCost).toBe(4.5); // 4.0 + 0.5
371
+ });
372
+
373
+ it('counts resources by type', () => {
374
+ const json = exportToJson(mockResources, mockFilterState);
375
+
376
+ expect(json.summary.resourcesByType.worker).toBe(1);
377
+ expect(json.summary.resourcesByType.kv).toBe(1);
378
+ });
379
+
380
+ it('includes all resource fields', () => {
381
+ const json = exportToJson(mockResources, mockFilterState);
382
+
383
+ const resource = json.resources[0];
384
+ expect(resource.id).toBe('worker-api');
385
+ expect(resource.name).toBe('brand-copilot-api');
386
+ expect(resource.type).toBe('worker');
387
+ expect(resource.project).toBe('brand-copilot');
388
+ expect(resource.usage).toEqual({ value: 100000, unit: 'requests', formatted: '100K' });
389
+ expect(resource.costCurrent).toBe(5.5);
390
+ expect(resource.costPrior).toBe(4.0);
391
+ expect(resource.costDelta).toBe(1.5);
392
+ expect(resource.costDeltaPct).toBe(37.5);
393
+ expect(resource.status).toBe('healthy');
394
+ });
395
+
396
+ it('handles empty resources', () => {
397
+ const json = exportToJson([], mockFilterState);
398
+
399
+ expect(json.summary.totalResources).toBe(0);
400
+ expect(json.summary.totalCurrentCost).toBe(0);
401
+ expect(json.summary.totalPriorCost).toBe(0);
402
+ expect(json.resources).toHaveLength(0);
403
+ });
404
+
405
+ it('preserves NEW delta percentage in JSON', () => {
406
+ const resourceWithNew: UnifiedResource[] = [
407
+ {
408
+ id: 'd1-new',
409
+ name: 'new-database',
410
+ type: 'd1',
411
+ project: 'platform',
412
+ usage: { value: 1000, unit: 'rows', formatted: '1K' },
413
+ costCurrent: 1.0,
414
+ costPrior: 0,
415
+ costDelta: 1.0,
416
+ costDeltaPct: 'NEW',
417
+ status: 'healthy',
418
+ },
419
+ ];
420
+
421
+ const json = exportToJson(resourceWithNew, mockFilterState);
422
+
423
+ expect(json.resources[0].costDeltaPct).toBe('NEW');
424
+ });
425
+ });
426
+
427
+ describe('Usage Export - Filter Application', () => {
428
+ const allResources: UnifiedResource[] = [
429
+ {
430
+ id: 'worker-1',
431
+ name: 'worker-active',
432
+ type: 'worker',
433
+ project: 'brand-copilot',
434
+ usage: { value: 1000, unit: 'requests', formatted: '1K' },
435
+ costCurrent: 5.0,
436
+ costPrior: 4.0,
437
+ costDelta: 1.0,
438
+ costDeltaPct: 25,
439
+ status: 'healthy',
440
+ },
441
+ {
442
+ id: 'd1-1',
443
+ name: 'd1-active',
444
+ type: 'd1',
445
+ project: 'platform',
446
+ usage: { value: 500, unit: 'rows', formatted: '500' },
447
+ costCurrent: 0,
448
+ costPrior: 0,
449
+ costDelta: 0,
450
+ costDeltaPct: 0,
451
+ status: 'healthy',
452
+ },
453
+ {
454
+ id: 'kv-1',
455
+ name: 'kv-changed',
456
+ type: 'kv',
457
+ project: 'brand-copilot',
458
+ usage: { value: 200, unit: 'ops', formatted: '200' },
459
+ costCurrent: 2.0,
460
+ costPrior: 0,
461
+ costDelta: 2.0,
462
+ costDeltaPct: 'NEW',
463
+ status: 'warning',
464
+ },
465
+ ];
466
+
467
+ it('exports respects project filter', () => {
468
+ // Simulate filtering
469
+ const filtered = allResources.filter((r) => r.project === 'brand-copilot');
470
+
471
+ expect(filtered).toHaveLength(2);
472
+ expect(filtered.every((r) => r.project === 'brand-copilot')).toBe(true);
473
+ });
474
+
475
+ it('exports respects nonZeroCost filter', () => {
476
+ // Simulate filtering
477
+ const filtered = allResources.filter((r) => r.costCurrent > 0);
478
+
479
+ expect(filtered).toHaveLength(2);
480
+ expect(filtered.every((r) => r.costCurrent > 0)).toBe(true);
481
+ });
482
+
483
+ it('exports respects onlyChanged filter', () => {
484
+ // Simulate filtering (>5% change or NEW)
485
+ const filtered = allResources.filter((r) => {
486
+ if (r.costDeltaPct === 'NEW') return true;
487
+ if (typeof r.costDeltaPct === 'number') return Math.abs(r.costDeltaPct) > 5;
488
+ return false;
489
+ });
490
+
491
+ expect(filtered).toHaveLength(2); // 25% change and NEW
492
+ });
493
+
494
+ it('exports respects serviceType filter', () => {
495
+ // Simulate filtering
496
+ const filtered = allResources.filter((r) => ['worker', 'd1'].includes(r.type));
497
+
498
+ expect(filtered).toHaveLength(2);
499
+ expect(filtered.some((r) => r.type === 'kv')).toBe(false);
500
+ });
501
+
502
+ it('exports respects searchQuery filter', () => {
503
+ // Simulate filtering (case insensitive)
504
+ const filtered = allResources.filter((r) => r.name.toLowerCase().includes('active'));
505
+
506
+ expect(filtered).toHaveLength(2);
507
+ });
508
+
509
+ it('exports combined filters correctly', () => {
510
+ // Simulate: brand-copilot project + non-zero cost
511
+ const filtered = allResources.filter((r) => r.project === 'brand-copilot' && r.costCurrent > 0);
512
+
513
+ expect(filtered).toHaveLength(2);
514
+ expect(filtered.every((r) => r.project === 'brand-copilot' && r.costCurrent > 0)).toBe(true);
515
+ });
516
+ });
517
+
518
+ describe('Usage Export - Edge Cases', () => {
519
+ it('handles special characters in resource names', () => {
520
+ const specialResources: UnifiedResource[] = [
521
+ {
522
+ id: 'worker-special',
523
+ name: 'worker-with-comma, quote" and newline\n',
524
+ type: 'worker',
525
+ project: 'test',
526
+ usage: { value: 100, unit: 'requests', formatted: '100' },
527
+ costCurrent: 1.0,
528
+ costPrior: 0,
529
+ costDelta: 1.0,
530
+ costDeltaPct: 100,
531
+ status: 'healthy',
532
+ },
533
+ ];
534
+
535
+ const csv = exportToCsv(specialResources);
536
+
537
+ // CSV should properly escape the content
538
+ expect(csv).toContain('""'); // Escaped quote
539
+ });
540
+
541
+ it('handles null/undefined delta percentage', () => {
542
+ const nullDeltaResources: UnifiedResource[] = [
543
+ {
544
+ id: 'worker-null',
545
+ name: 'null-delta',
546
+ type: 'worker',
547
+ project: 'test',
548
+ usage: { value: 100, unit: 'requests', formatted: '100' },
549
+ costCurrent: 1.0,
550
+ costPrior: 0,
551
+ costDelta: 0,
552
+ costDeltaPct: null,
553
+ status: 'healthy',
554
+ },
555
+ ];
556
+
557
+ const csv = exportToCsv(nullDeltaResources);
558
+
559
+ // Should show 0 for null delta
560
+ expect(csv).toContain('"0"');
561
+ });
562
+
563
+ it('handles very large usage values', () => {
564
+ const largeResources: UnifiedResource[] = [
565
+ {
566
+ id: 'worker-large',
567
+ name: 'high-traffic',
568
+ type: 'worker',
569
+ project: 'test',
570
+ usage: { value: 999999999, unit: 'requests', formatted: '999.9M' },
571
+ costCurrent: 100.0,
572
+ costPrior: 50.0,
573
+ costDelta: 50.0,
574
+ costDeltaPct: 100,
575
+ status: 'critical',
576
+ },
577
+ ];
578
+
579
+ const csv = exportToCsv(largeResources);
580
+
581
+ expect(csv).toContain('999999999');
582
+ expect(csv).toContain('"100.0000"');
583
+ });
584
+
585
+ it('handles very small cost values', () => {
586
+ const smallResources: UnifiedResource[] = [
587
+ {
588
+ id: 'worker-small',
589
+ name: 'low-usage',
590
+ type: 'worker',
591
+ project: 'test',
592
+ usage: { value: 10, unit: 'requests', formatted: '10' },
593
+ costCurrent: 0.0001,
594
+ costPrior: 0.00005,
595
+ costDelta: 0.00005,
596
+ costDeltaPct: 100,
597
+ status: 'healthy',
598
+ },
599
+ ];
600
+
601
+ const csv = exportToCsv(smallResources);
602
+
603
+ expect(csv).toContain('"0.0001"');
604
+ expect(csv).toContain('"0.0001"'); // Prior rounded
605
+ });
606
+
607
+ it('handles negative delta percentage', () => {
608
+ const negativeResources: UnifiedResource[] = [
609
+ {
610
+ id: 'worker-negative',
611
+ name: 'reduced-usage',
612
+ type: 'worker',
613
+ project: 'test',
614
+ usage: { value: 100, unit: 'requests', formatted: '100' },
615
+ costCurrent: 2.0,
616
+ costPrior: 5.0,
617
+ costDelta: -3.0,
618
+ costDeltaPct: -60,
619
+ status: 'healthy',
620
+ },
621
+ ];
622
+
623
+ const csv = exportToCsv(negativeResources);
624
+
625
+ expect(csv).toContain('"-60.0"');
626
+ });
627
+
628
+ it('handles all resource types', () => {
629
+ const allTypes: UnifiedResource[] = [
630
+ 'worker',
631
+ 'd1',
632
+ 'kv',
633
+ 'r2',
634
+ 'vectorize',
635
+ 'ai-gateway',
636
+ 'pages',
637
+ 'queues',
638
+ 'workflows',
639
+ 'durable-objects',
640
+ ].map((type, i) => ({
641
+ id: `${type}-1`,
642
+ name: `${type}-resource`,
643
+ type: type as UnifiedResource['type'],
644
+ project: 'test',
645
+ usage: { value: 100 * (i + 1), unit: 'units', formatted: `${100 * (i + 1)}` },
646
+ costCurrent: 1.0 * (i + 1),
647
+ costPrior: 0.5 * (i + 1),
648
+ costDelta: 0.5 * (i + 1),
649
+ costDeltaPct: 100,
650
+ status: 'healthy' as const,
651
+ }));
652
+
653
+ const csv = exportToCsv(allTypes);
654
+ const lines = csv.split('\n');
655
+
656
+ // Header + 10 resource types
657
+ expect(lines).toHaveLength(11);
658
+ });
659
+
660
+ it('handles all status types', () => {
661
+ const allStatuses: UnifiedResource[] = ['healthy', 'warning', 'high', 'critical'].map(
662
+ (status, _i) => ({
663
+ id: `status-${status}`,
664
+ name: `${status}-resource`,
665
+ type: 'worker' as const,
666
+ project: 'test',
667
+ usage: { value: 100, unit: 'requests', formatted: '100' },
668
+ costCurrent: 1.0,
669
+ costPrior: 0.5,
670
+ costDelta: 0.5,
671
+ costDeltaPct: 100,
672
+ status: status as UnifiedResource['status'],
673
+ })
674
+ );
675
+
676
+ const csv = exportToCsv(allStatuses);
677
+
678
+ expect(csv).toContain('"healthy"');
679
+ expect(csv).toContain('"warning"');
680
+ expect(csv).toContain('"high"');
681
+ expect(csv).toContain('"critical"');
682
+ });
683
+ });
684
+
685
+ describe('Usage Export - JSON Serialisation', () => {
686
+ it('produces valid JSON', () => {
687
+ const resources: UnifiedResource[] = [
688
+ {
689
+ id: 'worker-1',
690
+ name: 'test-worker',
691
+ type: 'worker',
692
+ project: 'test',
693
+ usage: { value: 1000, unit: 'requests', formatted: '1K' },
694
+ costCurrent: 5.0,
695
+ costPrior: 4.0,
696
+ costDelta: 1.0,
697
+ costDeltaPct: 25,
698
+ status: 'healthy',
699
+ },
700
+ ];
701
+
702
+ const filterState: MockFilterState = {
703
+ period: '30d',
704
+ project: 'all',
705
+ serviceTypes: [],
706
+ searchQuery: '',
707
+ onlyChanged: false,
708
+ nonZeroCost: false,
709
+ compareMode: 'none',
710
+ };
711
+
712
+ const json = exportToJson(resources, filterState);
713
+ const serialised = JSON.stringify(json);
714
+
715
+ expect(() => JSON.parse(serialised)).not.toThrow();
716
+ });
717
+
718
+ it('preserves numeric precision', () => {
719
+ const resources: UnifiedResource[] = [
720
+ {
721
+ id: 'worker-1',
722
+ name: 'test',
723
+ type: 'worker',
724
+ project: 'test',
725
+ usage: { value: 1000, unit: 'requests', formatted: '1K' },
726
+ costCurrent: 5.123456789,
727
+ costPrior: 4.987654321,
728
+ costDelta: 0.135802468,
729
+ costDeltaPct: 2.72,
730
+ status: 'healthy',
731
+ },
732
+ ];
733
+
734
+ const filterState: MockFilterState = {
735
+ period: '30d',
736
+ project: 'all',
737
+ serviceTypes: [],
738
+ searchQuery: '',
739
+ onlyChanged: false,
740
+ nonZeroCost: false,
741
+ compareMode: 'none',
742
+ };
743
+
744
+ const json = exportToJson(resources, filterState);
745
+
746
+ // JSON preserves full precision
747
+ expect(json.resources[0].costCurrent).toBe(5.123456789);
748
+ expect(json.resources[0].costPrior).toBe(4.987654321);
749
+ });
750
+
751
+ it('handles Unicode characters in project names', () => {
752
+ const resources: UnifiedResource[] = [
753
+ {
754
+ id: 'worker-unicode',
755
+ name: 'worker-\u65e5\u672c\u8a9e',
756
+ type: 'worker',
757
+ project: '\u30d7\u30ed\u30b8\u30a7\u30af\u30c8',
758
+ usage: { value: 1000, unit: 'requests', formatted: '1K' },
759
+ costCurrent: 5.0,
760
+ costPrior: 4.0,
761
+ costDelta: 1.0,
762
+ costDeltaPct: 25,
763
+ status: 'healthy',
764
+ },
765
+ ];
766
+
767
+ const filterState: MockFilterState = {
768
+ period: '30d',
769
+ project: 'all',
770
+ serviceTypes: [],
771
+ searchQuery: '',
772
+ onlyChanged: false,
773
+ nonZeroCost: false,
774
+ compareMode: 'none',
775
+ };
776
+
777
+ const json = exportToJson(resources, filterState);
778
+ const serialised = JSON.stringify(json);
779
+ const parsed = JSON.parse(serialised);
780
+
781
+ expect(parsed.resources[0].name).toBe('worker-\u65e5\u672c\u8a9e');
782
+ expect(parsed.resources[0].project).toBe('\u30d7\u30ed\u30b8\u30a7\u30af\u30c8');
783
+ });
784
+ });