@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.
- package/README.md +4 -7
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +206 -4
- package/package.json +1 -1
- package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
- package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
- package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
- package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
- package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
- package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
- package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
- package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
- package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
- package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
- package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
- package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
- package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
- package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
- package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
- package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
- package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
- package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
- package/templates/full/dashboard/src/lib/search/api.ts +258 -0
- package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
- package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
- package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
- package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
- package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
- package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
- package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
- package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
- package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
- package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
- package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
- package/templates/full/dashboard/src/pages/feedback.astro +365 -0
- package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
- package/templates/full/dashboard/src/pages/map.astro +561 -0
- package/templates/full/dashboard/src/pages/revenue.astro +72 -0
- package/templates/full/dashboard/src/pages/tests.astro +431 -0
- package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
- package/templates/full/scripts/ops/verify-account-total.ts +256 -0
- package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
- package/templates/full/tests/integration/r2-archive.test.ts +108 -0
- package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
- package/templates/shared/.github/workflows/validate-controls.yml +27 -0
- package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
- package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
- package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
- package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
- package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
- package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
- package/templates/shared/dashboard/src/components/Toast.astro +170 -0
- package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
- package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
- package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
- package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
- package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
- package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
- package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
- package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
- package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
- package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
- package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
- package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
- package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
- package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
- package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
- package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
- package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
- package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
- package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
- package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
- package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
- package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
- package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
- package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
- package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
- package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
- package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
- package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
- package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
- package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
- package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
- package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
- package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
- package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
- package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
- package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
- package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
- package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
- package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
- package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
- package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
- package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
- package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
- package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
- package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
- package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
- package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
- package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
- package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
- package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
- package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
- package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
- package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
- package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
- package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
- package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
- package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
- package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
- package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
- package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
- package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
- package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
- package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
- package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
- package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
- package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
- package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
- package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
- package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
- package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
- package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
- package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
- package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
- package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
- package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
- package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
- package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
- package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
- package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
- package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
- package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
- package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
- package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
- package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
- package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
- package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
- package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
- package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
- package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
- package/templates/shared/tests/helpers/mock-storage.ts +166 -0
- package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
- package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
- package/templates/shared/tests/unit/billing.test.ts +331 -0
- package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
- package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
- package/templates/shared/tests/unit/control.test.ts +226 -0
- package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
- package/templates/shared/tests/unit/economics.test.ts +365 -0
- package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
- package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
- package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
- package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
- package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
- package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
- package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
- package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
- package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
- package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
- package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
- package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
- package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
- package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
- package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
- package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
- package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
- package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
- package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
- package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
- package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
- package/templates/standard/tests/integration/connectors.test.ts +241 -0
- package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
- package/templates/standard/tests/integration/ingestion.test.ts +211 -0
- package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
- package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
- package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
- package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
|
@@ -0,0 +1,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
|
+
});
|