@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,956 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration Tests for Platform Usage Worker (Data Warehouse)
|
|
3
|
+
*
|
|
4
|
+
* Tests the platform-usage worker including:
|
|
5
|
+
* - Scheduled handler with adaptive sampling
|
|
6
|
+
* - Circuit breaker middleware
|
|
7
|
+
* - Anomaly detection
|
|
8
|
+
* - API endpoints reading from D1
|
|
9
|
+
* - Projected burn calculations
|
|
10
|
+
*
|
|
11
|
+
* @module tests/integration/platform-usage
|
|
12
|
+
* @created 2026-01-09
|
|
13
|
+
* @task Phase 8 - Write tests for platform-usage worker
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Test Utilities
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mock KV namespace for testing
|
|
24
|
+
*/
|
|
25
|
+
interface MockKV {
|
|
26
|
+
get: (key: string) => Promise<string | null>;
|
|
27
|
+
put: (key: string, value: string) => Promise<void>;
|
|
28
|
+
delete: (key: string) => Promise<void>;
|
|
29
|
+
_store: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createMockKV(initialData: Record<string, string> = {}): MockKV {
|
|
33
|
+
const store = { ...initialData };
|
|
34
|
+
|
|
35
|
+
const mockKV: MockKV = {
|
|
36
|
+
_store: store,
|
|
37
|
+
get: vi.fn((key: string) => Promise.resolve(store[key] || null)),
|
|
38
|
+
put: vi.fn((key: string, value: string) => {
|
|
39
|
+
store[key] = value;
|
|
40
|
+
return Promise.resolve();
|
|
41
|
+
}),
|
|
42
|
+
delete: vi.fn((key: string) => {
|
|
43
|
+
delete store[key];
|
|
44
|
+
return Promise.resolve();
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return mockKV;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Sampling Mode Constants (mirror worker)
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
enum SamplingMode {
|
|
56
|
+
FULL = 1,
|
|
57
|
+
HALF = 2,
|
|
58
|
+
QUARTER = 4,
|
|
59
|
+
MINIMAL = 24,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const D1_WRITE_LIMIT = 1_000_000;
|
|
63
|
+
const SAMPLING_THRESHOLDS = {
|
|
64
|
+
FULL: 0.6,
|
|
65
|
+
HALF: 0.8,
|
|
66
|
+
QUARTER: 0.9,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ============================================================================
|
|
70
|
+
// Circuit Breaker Constants (mirror middleware)
|
|
71
|
+
// ============================================================================
|
|
72
|
+
|
|
73
|
+
const CB_PROJECT_KEYS = {
|
|
74
|
+
GLOBAL_STOP: 'GLOBAL_STOP_ALL',
|
|
75
|
+
TEST_PROJECT: 'PROJECT:TEST-PROJECT:STATUS',
|
|
76
|
+
MY_PROJECT: 'PROJECT:MY-PROJECT:STATUS',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const CB_ERROR_CODES = {
|
|
80
|
+
GLOBAL: 'GLOBAL_CIRCUIT_BREAKER',
|
|
81
|
+
PROJECT: 'PROJECT_CIRCUIT_BREAKER',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// Adaptive Sampling Tests
|
|
86
|
+
// ============================================================================
|
|
87
|
+
|
|
88
|
+
describe('Platform Usage - Adaptive Sampling', () => {
|
|
89
|
+
describe('determineSamplingMode', () => {
|
|
90
|
+
const determineSamplingMode = (writes24h: number): SamplingMode => {
|
|
91
|
+
const usageRatio = writes24h / D1_WRITE_LIMIT;
|
|
92
|
+
if (usageRatio > SAMPLING_THRESHOLDS.QUARTER) return SamplingMode.MINIMAL;
|
|
93
|
+
if (usageRatio > SAMPLING_THRESHOLDS.HALF) return SamplingMode.QUARTER;
|
|
94
|
+
if (usageRatio > SAMPLING_THRESHOLDS.FULL) return SamplingMode.HALF;
|
|
95
|
+
return SamplingMode.FULL;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
it('returns FULL when writes < 60% of limit', () => {
|
|
99
|
+
// Less than 600k writes
|
|
100
|
+
expect(determineSamplingMode(500_000)).toBe(SamplingMode.FULL);
|
|
101
|
+
expect(determineSamplingMode(0)).toBe(SamplingMode.FULL);
|
|
102
|
+
expect(determineSamplingMode(599_999)).toBe(SamplingMode.FULL);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns HALF when writes between 60-80% of limit', () => {
|
|
106
|
+
// Between 600k and 800k writes
|
|
107
|
+
expect(determineSamplingMode(600_001)).toBe(SamplingMode.HALF);
|
|
108
|
+
expect(determineSamplingMode(700_000)).toBe(SamplingMode.HALF);
|
|
109
|
+
expect(determineSamplingMode(799_999)).toBe(SamplingMode.HALF);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns QUARTER when writes between 80-90% of limit', () => {
|
|
113
|
+
// Between 800k and 900k writes
|
|
114
|
+
expect(determineSamplingMode(800_001)).toBe(SamplingMode.QUARTER);
|
|
115
|
+
expect(determineSamplingMode(850_000)).toBe(SamplingMode.QUARTER);
|
|
116
|
+
expect(determineSamplingMode(899_999)).toBe(SamplingMode.QUARTER);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('returns MINIMAL when writes > 90% of limit', () => {
|
|
120
|
+
// More than 900k writes
|
|
121
|
+
expect(determineSamplingMode(900_001)).toBe(SamplingMode.MINIMAL);
|
|
122
|
+
expect(determineSamplingMode(950_000)).toBe(SamplingMode.MINIMAL);
|
|
123
|
+
expect(determineSamplingMode(999_999)).toBe(SamplingMode.MINIMAL);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('shouldRunThisHour', () => {
|
|
128
|
+
const shouldRunThisHour = (mode: SamplingMode, hour: number): boolean => {
|
|
129
|
+
switch (mode) {
|
|
130
|
+
case SamplingMode.FULL:
|
|
131
|
+
return true;
|
|
132
|
+
case SamplingMode.HALF:
|
|
133
|
+
return hour % 2 === 0;
|
|
134
|
+
case SamplingMode.QUARTER:
|
|
135
|
+
return hour % 4 === 0;
|
|
136
|
+
case SamplingMode.MINIMAL:
|
|
137
|
+
return hour === 0;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
it('FULL mode runs every hour', () => {
|
|
142
|
+
for (let hour = 0; hour < 24; hour++) {
|
|
143
|
+
expect(shouldRunThisHour(SamplingMode.FULL, hour)).toBe(true);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('HALF mode runs on even hours only', () => {
|
|
148
|
+
expect(shouldRunThisHour(SamplingMode.HALF, 0)).toBe(true);
|
|
149
|
+
expect(shouldRunThisHour(SamplingMode.HALF, 1)).toBe(false);
|
|
150
|
+
expect(shouldRunThisHour(SamplingMode.HALF, 2)).toBe(true);
|
|
151
|
+
expect(shouldRunThisHour(SamplingMode.HALF, 3)).toBe(false);
|
|
152
|
+
expect(shouldRunThisHour(SamplingMode.HALF, 12)).toBe(true);
|
|
153
|
+
expect(shouldRunThisHour(SamplingMode.HALF, 23)).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('QUARTER mode runs every 4 hours', () => {
|
|
157
|
+
expect(shouldRunThisHour(SamplingMode.QUARTER, 0)).toBe(true);
|
|
158
|
+
expect(shouldRunThisHour(SamplingMode.QUARTER, 1)).toBe(false);
|
|
159
|
+
expect(shouldRunThisHour(SamplingMode.QUARTER, 4)).toBe(true);
|
|
160
|
+
expect(shouldRunThisHour(SamplingMode.QUARTER, 8)).toBe(true);
|
|
161
|
+
expect(shouldRunThisHour(SamplingMode.QUARTER, 12)).toBe(true);
|
|
162
|
+
expect(shouldRunThisHour(SamplingMode.QUARTER, 15)).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('MINIMAL mode runs at midnight only', () => {
|
|
166
|
+
expect(shouldRunThisHour(SamplingMode.MINIMAL, 0)).toBe(true);
|
|
167
|
+
for (let hour = 1; hour < 24; hour++) {
|
|
168
|
+
expect(shouldRunThisHour(SamplingMode.MINIMAL, hour)).toBe(false);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('D1 Write Counting', () => {
|
|
174
|
+
it('stores write count in KV with 24h expiry pattern', async () => {
|
|
175
|
+
const kv = createMockKV();
|
|
176
|
+
const CB_KEYS = { D1_WRITES_24H: 'platform-usage:d1-writes-24h' };
|
|
177
|
+
|
|
178
|
+
// Simulate incrementing write count
|
|
179
|
+
const currentWrites = 1000;
|
|
180
|
+
await kv.put(CB_KEYS.D1_WRITES_24H, String(currentWrites + 50));
|
|
181
|
+
|
|
182
|
+
expect(kv.put).toHaveBeenCalledWith(CB_KEYS.D1_WRITES_24H, '1050');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('handles missing write count (returns 0)', async () => {
|
|
186
|
+
const kv = createMockKV(); // Empty KV
|
|
187
|
+
const CB_KEYS = { D1_WRITES_24H: 'platform-usage:d1-writes-24h' };
|
|
188
|
+
|
|
189
|
+
const stored = await kv.get(CB_KEYS.D1_WRITES_24H);
|
|
190
|
+
const count = stored ? parseInt(stored, 10) : 0;
|
|
191
|
+
|
|
192
|
+
expect(count).toBe(0);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ============================================================================
|
|
198
|
+
// Circuit Breaker Tests
|
|
199
|
+
// ============================================================================
|
|
200
|
+
|
|
201
|
+
describe('Platform Usage - Circuit Breaker', () => {
|
|
202
|
+
describe('checkCircuitBreaker middleware', () => {
|
|
203
|
+
const checkCircuitBreaker = async (
|
|
204
|
+
projectKey: string,
|
|
205
|
+
kv: ReturnType<typeof createMockKV>
|
|
206
|
+
): Promise<{ response: boolean; code: string | null; status: number }> => {
|
|
207
|
+
// Check global stop first
|
|
208
|
+
const globalStop = await kv.get(CB_PROJECT_KEYS.GLOBAL_STOP);
|
|
209
|
+
if (globalStop === 'true') {
|
|
210
|
+
return { response: true, code: CB_ERROR_CODES.GLOBAL, status: 503 };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check project-specific status
|
|
214
|
+
const projectStatus = await kv.get(projectKey);
|
|
215
|
+
if (projectStatus === 'paused') {
|
|
216
|
+
return { response: true, code: CB_ERROR_CODES.PROJECT, status: 503 };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { response: false, code: null, status: 200 };
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
it('returns null when circuit breaker is open (OK to proceed)', async () => {
|
|
223
|
+
const kv = createMockKV(); // No flags set
|
|
224
|
+
const result = await checkCircuitBreaker(CB_PROJECT_KEYS.TEST_PROJECT, kv);
|
|
225
|
+
|
|
226
|
+
expect(result.response).toBe(false);
|
|
227
|
+
expect(result.code).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('returns 503 when GLOBAL_STOP_ALL is set', async () => {
|
|
231
|
+
const kv = createMockKV({ [CB_PROJECT_KEYS.GLOBAL_STOP]: 'true' });
|
|
232
|
+
const result = await checkCircuitBreaker(CB_PROJECT_KEYS.TEST_PROJECT, kv);
|
|
233
|
+
|
|
234
|
+
expect(result.response).toBe(true);
|
|
235
|
+
expect(result.code).toBe(CB_ERROR_CODES.GLOBAL);
|
|
236
|
+
expect(result.status).toBe(503);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('returns 503 when PROJECT:TEST-PROJECT:STATUS is paused', async () => {
|
|
240
|
+
const kv = createMockKV({ [CB_PROJECT_KEYS.TEST_PROJECT]: 'paused' });
|
|
241
|
+
const result = await checkCircuitBreaker(CB_PROJECT_KEYS.TEST_PROJECT, kv);
|
|
242
|
+
|
|
243
|
+
expect(result.response).toBe(true);
|
|
244
|
+
expect(result.code).toBe(CB_ERROR_CODES.PROJECT);
|
|
245
|
+
expect(result.status).toBe(503);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('returns 503 when PROJECT:MY-PROJECT:STATUS is paused', async () => {
|
|
249
|
+
const kv = createMockKV({ [CB_PROJECT_KEYS.MY_PROJECT]: 'paused' });
|
|
250
|
+
const result = await checkCircuitBreaker(CB_PROJECT_KEYS.MY_PROJECT, kv);
|
|
251
|
+
|
|
252
|
+
expect(result.response).toBe(true);
|
|
253
|
+
expect(result.code).toBe(CB_ERROR_CODES.PROJECT);
|
|
254
|
+
expect(result.status).toBe(503);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('global stop takes precedence over project pause', async () => {
|
|
258
|
+
const kv = createMockKV({
|
|
259
|
+
[CB_PROJECT_KEYS.GLOBAL_STOP]: 'true',
|
|
260
|
+
[CB_PROJECT_KEYS.TEST_PROJECT]: 'paused',
|
|
261
|
+
});
|
|
262
|
+
const result = await checkCircuitBreaker(CB_PROJECT_KEYS.TEST_PROJECT, kv);
|
|
263
|
+
|
|
264
|
+
expect(result.code).toBe(CB_ERROR_CODES.GLOBAL);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('Auto-Trip Logic', () => {
|
|
269
|
+
it('trips circuit breakers when D1 writes exceed limit', async () => {
|
|
270
|
+
const kv = createMockKV({
|
|
271
|
+
'platform-usage:d1-writes-24h': String(D1_WRITE_LIMIT + 1),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const writes24h = parseInt((await kv.get('platform-usage:d1-writes-24h')) || '0', 10);
|
|
275
|
+
|
|
276
|
+
if (writes24h >= D1_WRITE_LIMIT) {
|
|
277
|
+
await kv.put(CB_PROJECT_KEYS.TEST_PROJECT, 'paused');
|
|
278
|
+
await kv.put(CB_PROJECT_KEYS.MY_PROJECT, 'paused');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
expect(kv.put).toHaveBeenCalledWith(CB_PROJECT_KEYS.TEST_PROJECT, 'paused');
|
|
282
|
+
expect(kv.put).toHaveBeenCalledWith(CB_PROJECT_KEYS.MY_PROJECT, 'paused');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('does not trip when writes below limit', async () => {
|
|
286
|
+
const kv = createMockKV({
|
|
287
|
+
'platform-usage:d1-writes-24h': '500000',
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const writes24h = parseInt((await kv.get('platform-usage:d1-writes-24h')) || '0', 10);
|
|
291
|
+
|
|
292
|
+
if (writes24h >= D1_WRITE_LIMIT) {
|
|
293
|
+
await kv.put(CB_PROJECT_KEYS.TEST_PROJECT, 'paused');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// put should only be called with the initial store setup, not for pausing
|
|
297
|
+
expect(kv._store[CB_PROJECT_KEYS.TEST_PROJECT]).toBeUndefined();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('Circuit Breaker Response Format', () => {
|
|
302
|
+
it('includes correct headers in 503 response', () => {
|
|
303
|
+
const errorInfo = {
|
|
304
|
+
error: 'Service paused due to resource limits',
|
|
305
|
+
code: CB_ERROR_CODES.PROJECT,
|
|
306
|
+
retryAfterSeconds: 1800,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const headers = {
|
|
310
|
+
'Content-Type': 'application/json',
|
|
311
|
+
'Retry-After': String(errorInfo.retryAfterSeconds),
|
|
312
|
+
'X-Circuit-Breaker': errorInfo.code,
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
expect(headers['Content-Type']).toBe('application/json');
|
|
316
|
+
expect(headers['Retry-After']).toBe('1800');
|
|
317
|
+
expect(headers['X-Circuit-Breaker']).toBe(CB_ERROR_CODES.PROJECT);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('global circuit breaker has 1 hour retry', () => {
|
|
321
|
+
const errorInfo = {
|
|
322
|
+
code: CB_ERROR_CODES.GLOBAL,
|
|
323
|
+
retryAfterSeconds: 3600,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
expect(errorInfo.retryAfterSeconds).toBe(3600);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('project circuit breaker has 30 minute retry', () => {
|
|
330
|
+
const errorInfo = {
|
|
331
|
+
code: CB_ERROR_CODES.PROJECT,
|
|
332
|
+
retryAfterSeconds: 1800,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
expect(errorInfo.retryAfterSeconds).toBe(1800);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ============================================================================
|
|
341
|
+
// Anomaly Detection Tests
|
|
342
|
+
// ============================================================================
|
|
343
|
+
|
|
344
|
+
describe('Platform Usage - Anomaly Detection', () => {
|
|
345
|
+
describe('7-Day Rolling Stats', () => {
|
|
346
|
+
it('calculates average from daily rollups', () => {
|
|
347
|
+
const dailyValues = [100, 120, 110, 130, 115, 125, 105];
|
|
348
|
+
const avg = dailyValues.reduce((sum, v) => sum + v, 0) / dailyValues.length;
|
|
349
|
+
|
|
350
|
+
expect(avg).toBeCloseTo(115, 1);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('calculates standard deviation correctly', () => {
|
|
354
|
+
const values = [100, 120, 110, 130, 115, 125, 105];
|
|
355
|
+
const avg = values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
356
|
+
const variance = values.reduce((sum, v) => sum + Math.pow(v - avg, 2), 0) / values.length;
|
|
357
|
+
const stddev = Math.sqrt(variance);
|
|
358
|
+
|
|
359
|
+
expect(stddev).toBeGreaterThan(0);
|
|
360
|
+
expect(stddev).toBeLessThan(15); // Should be around 9-10
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('requires minimum 7 days of data', () => {
|
|
364
|
+
const samples = 5; // Less than 7
|
|
365
|
+
const shouldAlert = samples >= 7;
|
|
366
|
+
|
|
367
|
+
expect(shouldAlert).toBe(false);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe('Anomaly Threshold', () => {
|
|
372
|
+
it('detects anomaly when value > 3 stddev above mean', () => {
|
|
373
|
+
const avg = 100;
|
|
374
|
+
const stddev = 10;
|
|
375
|
+
const currentValue = 135; // 3.5 stddev above
|
|
376
|
+
|
|
377
|
+
const deviation = (currentValue - avg) / stddev;
|
|
378
|
+
|
|
379
|
+
expect(deviation).toBeGreaterThan(3);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('does not detect anomaly for normal variation', () => {
|
|
383
|
+
const avg = 100;
|
|
384
|
+
const stddev = 10;
|
|
385
|
+
const currentValue = 120; // 2 stddev above
|
|
386
|
+
|
|
387
|
+
const deviation = (currentValue - avg) / stddev;
|
|
388
|
+
|
|
389
|
+
expect(deviation).toBeLessThan(3);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('handles zero stddev gracefully', () => {
|
|
393
|
+
const avg = 100;
|
|
394
|
+
const stddev = 0;
|
|
395
|
+
const currentValue = 150;
|
|
396
|
+
|
|
397
|
+
// Use 1 as minimum stddev to avoid division by zero
|
|
398
|
+
const safeStddev = stddev || 1;
|
|
399
|
+
const deviation = (currentValue - avg) / safeStddev;
|
|
400
|
+
|
|
401
|
+
expect(deviation).toBe(50);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe('Anomaly Recording', () => {
|
|
406
|
+
it('stores anomaly with correct fields', () => {
|
|
407
|
+
const anomaly = {
|
|
408
|
+
id: '1704758400000-abc1234',
|
|
409
|
+
detected_at: Math.floor(Date.now() / 1000),
|
|
410
|
+
metric_name: 'total_cost_usd',
|
|
411
|
+
project: 'all',
|
|
412
|
+
current_value: 150,
|
|
413
|
+
rolling_avg: 100,
|
|
414
|
+
rolling_stddev: 10,
|
|
415
|
+
deviation_factor: 5,
|
|
416
|
+
alert_sent: 0,
|
|
417
|
+
resolved: 0,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
expect(anomaly.metric_name).toBe('total_cost_usd');
|
|
421
|
+
expect(anomaly.deviation_factor).toBeGreaterThan(3);
|
|
422
|
+
expect(anomaly.alert_sent).toBe(0);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('tracks which metrics to monitor', () => {
|
|
426
|
+
const metricsToMonitor = ['workers_requests', 'd1_rows_written', 'total_cost_usd'];
|
|
427
|
+
|
|
428
|
+
expect(metricsToMonitor).toContain('workers_requests');
|
|
429
|
+
expect(metricsToMonitor).toContain('d1_rows_written');
|
|
430
|
+
expect(metricsToMonitor).toContain('total_cost_usd');
|
|
431
|
+
expect(metricsToMonitor).toHaveLength(3);
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// ============================================================================
|
|
437
|
+
// API Endpoint Tests (D1 Data Source)
|
|
438
|
+
// ============================================================================
|
|
439
|
+
|
|
440
|
+
describe('Platform Usage - API Endpoints', () => {
|
|
441
|
+
describe('GET /usage', () => {
|
|
442
|
+
it('parses period parameter correctly', () => {
|
|
443
|
+
const parseQueryParams = (url: URL): { period: string; project: string } => {
|
|
444
|
+
const period = url.searchParams.get('period') || '24h';
|
|
445
|
+
const project = url.searchParams.get('project') || 'all';
|
|
446
|
+
return { period, project };
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const url = new URL('https://example.com/usage?period=7d&project=test-project');
|
|
450
|
+
const params = parseQueryParams(url);
|
|
451
|
+
|
|
452
|
+
expect(params.period).toBe('7d');
|
|
453
|
+
expect(params.project).toBe('test-project');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('defaults to 24h period and all project', () => {
|
|
457
|
+
const parseQueryParams = (url: URL): { period: string; project: string } => {
|
|
458
|
+
const period = url.searchParams.get('period') || '24h';
|
|
459
|
+
const project = url.searchParams.get('project') || 'all';
|
|
460
|
+
return { period, project };
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const url = new URL('https://example.com/usage');
|
|
464
|
+
const params = parseQueryParams(url);
|
|
465
|
+
|
|
466
|
+
expect(params.period).toBe('24h');
|
|
467
|
+
expect(params.project).toBe('all');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('includes dataSource field indicating D1 or GraphQL', () => {
|
|
471
|
+
const response = {
|
|
472
|
+
success: true,
|
|
473
|
+
period: '24h',
|
|
474
|
+
dataSource: 'd1',
|
|
475
|
+
timestamp: new Date().toISOString(),
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
expect(response.dataSource).toBe('d1');
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
describe('GET /usage/daily', () => {
|
|
483
|
+
it('returns daily cost breakdown in correct format', () => {
|
|
484
|
+
const dailyCosts = {
|
|
485
|
+
days: [
|
|
486
|
+
{
|
|
487
|
+
date: '2026-01-08',
|
|
488
|
+
workers: 0.5,
|
|
489
|
+
d1: 0.2,
|
|
490
|
+
kv: 0.1,
|
|
491
|
+
r2: 0.3,
|
|
492
|
+
durableObjects: 0.05,
|
|
493
|
+
vectorize: 0.02,
|
|
494
|
+
aiGateway: 0.1,
|
|
495
|
+
workersAI: 0.15,
|
|
496
|
+
queues: 0.01,
|
|
497
|
+
total: 1.43,
|
|
498
|
+
},
|
|
499
|
+
],
|
|
500
|
+
totals: {
|
|
501
|
+
workers: 0.5,
|
|
502
|
+
d1: 0.2,
|
|
503
|
+
kv: 0.1,
|
|
504
|
+
r2: 0.3,
|
|
505
|
+
durableObjects: 0.05,
|
|
506
|
+
vectorize: 0.02,
|
|
507
|
+
aiGateway: 0.1,
|
|
508
|
+
workersAI: 0.15,
|
|
509
|
+
queues: 0.01,
|
|
510
|
+
total: 1.43,
|
|
511
|
+
},
|
|
512
|
+
period: {
|
|
513
|
+
start: '2026-01-08',
|
|
514
|
+
end: '2026-01-08',
|
|
515
|
+
},
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
expect(dailyCosts.days).toHaveLength(1);
|
|
519
|
+
expect(dailyCosts.days[0].date).toBe('2026-01-08');
|
|
520
|
+
expect(dailyCosts.totals.total).toBeCloseTo(1.43, 2);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('falls back to GraphQL when D1 has no data', () => {
|
|
524
|
+
const d1Data = { days: [] };
|
|
525
|
+
const shouldFallback = !d1Data || d1Data.days.length === 0;
|
|
526
|
+
|
|
527
|
+
expect(shouldFallback).toBe(true);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
describe('Response Caching', () => {
|
|
532
|
+
it('caches successful responses in KV', async () => {
|
|
533
|
+
const kv = createMockKV();
|
|
534
|
+
const cacheKey = 'usage:24h:all';
|
|
535
|
+
const cacheData = JSON.stringify({ success: true, data: {} });
|
|
536
|
+
|
|
537
|
+
await kv.put(cacheKey, cacheData);
|
|
538
|
+
|
|
539
|
+
expect(kv.put).toHaveBeenCalledWith(cacheKey, cacheData);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('uses correct cache key format', () => {
|
|
543
|
+
const getCacheKey = (prefix: string, period: string, project: string): string => {
|
|
544
|
+
return `${prefix}:${period}:${project}`;
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
expect(getCacheKey('usage', '24h', 'all')).toBe('usage:24h:all');
|
|
548
|
+
expect(getCacheKey('usage', '7d', 'test-project')).toBe('usage:7d:test-project');
|
|
549
|
+
expect(getCacheKey('daily', '30d', 'my-project')).toBe('daily:30d:my-project');
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// ============================================================================
|
|
555
|
+
// Projected Burn Tests
|
|
556
|
+
// ============================================================================
|
|
557
|
+
|
|
558
|
+
describe('Platform Usage - Projected Burn', () => {
|
|
559
|
+
describe('Daily Burn Rate Calculation', () => {
|
|
560
|
+
it('calculates daily rate from current month data', () => {
|
|
561
|
+
const monthData = [
|
|
562
|
+
{ date: '2026-01-01', total_cost_usd: 5.0 },
|
|
563
|
+
{ date: '2026-01-02', total_cost_usd: 6.0 },
|
|
564
|
+
{ date: '2026-01-03', total_cost_usd: 5.5 },
|
|
565
|
+
{ date: '2026-01-04', total_cost_usd: 6.5 },
|
|
566
|
+
{ date: '2026-01-05', total_cost_usd: 5.0 },
|
|
567
|
+
];
|
|
568
|
+
|
|
569
|
+
const totalCost = monthData.reduce((sum, d) => sum + d.total_cost_usd, 0);
|
|
570
|
+
const days = monthData.length;
|
|
571
|
+
const dailyRate = totalCost / days;
|
|
572
|
+
|
|
573
|
+
expect(dailyRate).toBeCloseTo(5.6, 1);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('handles zero days gracefully', () => {
|
|
577
|
+
const monthData: { date: string; total_cost_usd: number }[] = [];
|
|
578
|
+
const days = monthData.length;
|
|
579
|
+
const dailyRate =
|
|
580
|
+
days > 0 ? monthData.reduce((sum, d) => sum + d.total_cost_usd, 0) / days : 0;
|
|
581
|
+
|
|
582
|
+
expect(dailyRate).toBe(0);
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
describe('Monthly Projection', () => {
|
|
587
|
+
it('projects full month cost correctly', () => {
|
|
588
|
+
const currentDate = new Date('2026-01-10');
|
|
589
|
+
const dayOfMonth = currentDate.getDate(); // 10
|
|
590
|
+
const daysInMonth = 31;
|
|
591
|
+
const daysRemaining = daysInMonth - dayOfMonth; // 21
|
|
592
|
+
|
|
593
|
+
const currentSpend = 50; // $50 in first 10 days
|
|
594
|
+
const dailyRate = currentSpend / dayOfMonth; // $5/day
|
|
595
|
+
const projectedTotal = currentSpend + dailyRate * daysRemaining;
|
|
596
|
+
|
|
597
|
+
expect(projectedTotal).toBeCloseTo(155, 0); // $50 + $5 * 21 = $155
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('calculates percentage change vs last month', () => {
|
|
601
|
+
const projectedMonthly = 155;
|
|
602
|
+
const lastMonthCost = 140;
|
|
603
|
+
const pctChange = ((projectedMonthly - lastMonthCost) / lastMonthCost) * 100;
|
|
604
|
+
|
|
605
|
+
expect(pctChange).toBeCloseTo(10.7, 1);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
describe('Confidence Level', () => {
|
|
610
|
+
it('returns low confidence with < 10 days data', () => {
|
|
611
|
+
const days = 5;
|
|
612
|
+
const confidence = days >= 20 ? 'high' : days >= 10 ? 'medium' : 'low';
|
|
613
|
+
|
|
614
|
+
expect(confidence).toBe('low');
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('returns medium confidence with 10-19 days data', () => {
|
|
618
|
+
const days = 15;
|
|
619
|
+
const confidence = days >= 20 ? 'high' : days >= 10 ? 'medium' : 'low';
|
|
620
|
+
|
|
621
|
+
expect(confidence).toBe('medium');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('returns high confidence with >= 20 days data', () => {
|
|
625
|
+
const days = 25;
|
|
626
|
+
const confidence = days >= 20 ? 'high' : days >= 10 ? 'medium' : 'low';
|
|
627
|
+
|
|
628
|
+
expect(confidence).toBe('high');
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
describe('ProjectedBurn Interface', () => {
|
|
633
|
+
it('includes all required fields', () => {
|
|
634
|
+
const projectedBurn = {
|
|
635
|
+
current_period_days: 10,
|
|
636
|
+
current_period_cost: 50,
|
|
637
|
+
daily_burn_rate: 5,
|
|
638
|
+
projected_monthly_cost: 155,
|
|
639
|
+
projected_vs_last_month_pct: 10.7,
|
|
640
|
+
last_month_cost: 140,
|
|
641
|
+
confidence: 'medium' as const,
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
expect(projectedBurn.current_period_days).toBe(10);
|
|
645
|
+
expect(projectedBurn.daily_burn_rate).toBe(5);
|
|
646
|
+
expect(projectedBurn.confidence).toBe('medium');
|
|
647
|
+
expect(projectedBurn.projected_monthly_cost).toBe(155);
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// ============================================================================
|
|
653
|
+
// Data Warehouse Tests (3-Tier Rollup)
|
|
654
|
+
// ============================================================================
|
|
655
|
+
|
|
656
|
+
describe('Platform Usage - Data Warehouse', () => {
|
|
657
|
+
describe('Hourly Snapshots (Tier 1)', () => {
|
|
658
|
+
it('generates valid snapshot hour format', () => {
|
|
659
|
+
const getCurrentHour = (): string => {
|
|
660
|
+
const now = new Date();
|
|
661
|
+
now.setMinutes(0, 0, 0);
|
|
662
|
+
// Format: YYYY-MM-DDTHH:00:00Z (replace :MM:SS.sssZ with :00:00Z)
|
|
663
|
+
return now.toISOString().replace(/:\d{2}:\d{2}\.\d{3}Z$/, ':00:00Z');
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const hour = getCurrentHour();
|
|
667
|
+
expect(hour).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:00:00Z$/);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it('includes all cost categories', () => {
|
|
671
|
+
const snapshot = {
|
|
672
|
+
workers_cost_usd: 0.5,
|
|
673
|
+
d1_cost_usd: 0.2,
|
|
674
|
+
kv_cost_usd: 0.1,
|
|
675
|
+
r2_cost_usd: 0.3,
|
|
676
|
+
do_cost_usd: 0.05,
|
|
677
|
+
vectorize_cost_usd: 0.02,
|
|
678
|
+
aigateway_cost_usd: 0.1,
|
|
679
|
+
pages_cost_usd: 0.0,
|
|
680
|
+
queues_cost_usd: 0.01,
|
|
681
|
+
workersai_cost_usd: 0.15,
|
|
682
|
+
total_cost_usd: 1.43,
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
const calculatedTotal =
|
|
686
|
+
snapshot.workers_cost_usd +
|
|
687
|
+
snapshot.d1_cost_usd +
|
|
688
|
+
snapshot.kv_cost_usd +
|
|
689
|
+
snapshot.r2_cost_usd +
|
|
690
|
+
snapshot.do_cost_usd +
|
|
691
|
+
snapshot.vectorize_cost_usd +
|
|
692
|
+
snapshot.aigateway_cost_usd +
|
|
693
|
+
snapshot.pages_cost_usd +
|
|
694
|
+
snapshot.queues_cost_usd +
|
|
695
|
+
snapshot.workersai_cost_usd;
|
|
696
|
+
|
|
697
|
+
expect(calculatedTotal).toBeCloseTo(snapshot.total_cost_usd, 2);
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
describe('Daily Rollup (Tier 2)', () => {
|
|
702
|
+
it('aggregates hourly snapshots correctly', () => {
|
|
703
|
+
const hourlySnapshots = [
|
|
704
|
+
{ workers_requests: 100, total_cost_usd: 0.5 },
|
|
705
|
+
{ workers_requests: 150, total_cost_usd: 0.7 },
|
|
706
|
+
{ workers_requests: 120, total_cost_usd: 0.6 },
|
|
707
|
+
];
|
|
708
|
+
|
|
709
|
+
const dailyRollup = {
|
|
710
|
+
workers_requests: hourlySnapshots.reduce((sum, h) => sum + h.workers_requests, 0),
|
|
711
|
+
total_cost_usd: hourlySnapshots.reduce((sum, h) => sum + h.total_cost_usd, 0),
|
|
712
|
+
samples_count: hourlySnapshots.length,
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
expect(dailyRollup.workers_requests).toBe(370);
|
|
716
|
+
expect(dailyRollup.total_cost_usd).toBeCloseTo(1.8, 1);
|
|
717
|
+
expect(dailyRollup.samples_count).toBe(3);
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
describe('Monthly Rollup (Tier 3)', () => {
|
|
722
|
+
it('aggregates daily rollups correctly', () => {
|
|
723
|
+
const dailyRollups = [
|
|
724
|
+
{ total_cost_usd: 5.0 },
|
|
725
|
+
{ total_cost_usd: 6.0 },
|
|
726
|
+
{ total_cost_usd: 5.5 },
|
|
727
|
+
];
|
|
728
|
+
|
|
729
|
+
const monthlyRollup = {
|
|
730
|
+
total_cost_usd: dailyRollups.reduce((sum, d) => sum + d.total_cost_usd, 0),
|
|
731
|
+
days_count: dailyRollups.length,
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
expect(monthlyRollup.total_cost_usd).toBeCloseTo(16.5, 1);
|
|
735
|
+
expect(monthlyRollup.days_count).toBe(3);
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
describe('Data Retention', () => {
|
|
740
|
+
it('hourly data retained for 7 days', () => {
|
|
741
|
+
const HOURLY_RETENTION_DAYS = 7;
|
|
742
|
+
expect(HOURLY_RETENTION_DAYS).toBe(7);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it('daily data retained for 90 days', () => {
|
|
746
|
+
const DAILY_RETENTION_DAYS = 90;
|
|
747
|
+
expect(DAILY_RETENTION_DAYS).toBe(90);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('monthly data retained forever', () => {
|
|
751
|
+
const MONTHLY_RETENTION = 'forever';
|
|
752
|
+
expect(MONTHLY_RETENTION).toBe('forever');
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// ============================================================================
|
|
758
|
+
// GitHub Billing Integration Tests
|
|
759
|
+
// ============================================================================
|
|
760
|
+
|
|
761
|
+
describe('Platform Usage - GitHub Enterprise Billing', () => {
|
|
762
|
+
describe('GitHub API Endpoints', () => {
|
|
763
|
+
it('queries correct billing endpoints', () => {
|
|
764
|
+
const endpoints = [
|
|
765
|
+
'https://api.github.com/orgs/my-org/settings/billing/advanced-security',
|
|
766
|
+
'https://api.github.com/orgs/my-org/settings/billing/actions',
|
|
767
|
+
'https://api.github.com/orgs/my-org/settings/billing/shared-storage',
|
|
768
|
+
];
|
|
769
|
+
|
|
770
|
+
expect(endpoints).toHaveLength(3);
|
|
771
|
+
expect(endpoints[0]).toContain('advanced-security');
|
|
772
|
+
expect(endpoints[1]).toContain('actions');
|
|
773
|
+
expect(endpoints[2]).toContain('shared-storage');
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it('requires GITHUB_TOKEN to collect billing', () => {
|
|
777
|
+
const env = { GITHUB_TOKEN: undefined };
|
|
778
|
+
const canCollect = !!env.GITHUB_TOKEN;
|
|
779
|
+
|
|
780
|
+
expect(canCollect).toBe(false);
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
describe('Third-Party Usage Storage', () => {
|
|
785
|
+
it('stores GitHub billing data with correct provider', () => {
|
|
786
|
+
const usageRecord = {
|
|
787
|
+
provider: 'github',
|
|
788
|
+
resource_type: 'advanced_security_seats',
|
|
789
|
+
usage_value: 5,
|
|
790
|
+
usage_unit: 'seats',
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
expect(usageRecord.provider).toBe('github');
|
|
794
|
+
expect(usageRecord.resource_type).toBe('advanced_security_seats');
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('stores multiple GitHub metrics', () => {
|
|
798
|
+
const metricsToStore = [
|
|
799
|
+
{ resource_type: 'advanced_security_seats', usage_unit: 'seats' },
|
|
800
|
+
{ resource_type: 'actions_minutes', usage_unit: 'minutes' },
|
|
801
|
+
{ resource_type: 'storage_bytes', usage_unit: 'bytes' },
|
|
802
|
+
];
|
|
803
|
+
|
|
804
|
+
expect(metricsToStore).toHaveLength(3);
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// ============================================================================
|
|
810
|
+
// Slack Alert Tests
|
|
811
|
+
// ============================================================================
|
|
812
|
+
|
|
813
|
+
describe('Platform Usage - Slack Alerts', () => {
|
|
814
|
+
describe('Anomaly Alert Format', () => {
|
|
815
|
+
it('includes all required fields', () => {
|
|
816
|
+
const alert = {
|
|
817
|
+
text: ':warning: Usage Anomaly Detected',
|
|
818
|
+
attachments: [
|
|
819
|
+
{
|
|
820
|
+
color: 'warning',
|
|
821
|
+
fields: [
|
|
822
|
+
{ title: 'Metric', value: 'total_cost_usd', short: true },
|
|
823
|
+
{ title: 'Deviation', value: '3.5 stddev', short: true },
|
|
824
|
+
{ title: 'Current', value: '$150.00', short: true },
|
|
825
|
+
{ title: '7-Day Avg', value: '$100.00', short: true },
|
|
826
|
+
{ title: 'Projected Monthly', value: '$450.00', short: true },
|
|
827
|
+
],
|
|
828
|
+
},
|
|
829
|
+
],
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
expect(alert.text).toContain('Anomaly');
|
|
833
|
+
expect(alert.attachments[0].fields).toHaveLength(5);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('uses danger colour for high deviation', () => {
|
|
837
|
+
const deviation = 5.5;
|
|
838
|
+
const colour = deviation > 5 ? 'danger' : 'warning';
|
|
839
|
+
|
|
840
|
+
expect(colour).toBe('danger');
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it('uses warning colour for moderate deviation', () => {
|
|
844
|
+
const deviation = 3.5;
|
|
845
|
+
const colour = deviation > 5 ? 'danger' : 'warning';
|
|
846
|
+
|
|
847
|
+
expect(colour).toBe('warning');
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
describe('Circuit Breaker Alert Format', () => {
|
|
852
|
+
it('includes write count and limit', () => {
|
|
853
|
+
const alert = {
|
|
854
|
+
text: ':rotating_light: Circuit Breaker Tripped',
|
|
855
|
+
attachments: [
|
|
856
|
+
{
|
|
857
|
+
color: 'danger',
|
|
858
|
+
fields: [
|
|
859
|
+
{ title: 'D1 Writes (24h)', value: '1,000,001', short: true },
|
|
860
|
+
{ title: 'Limit', value: '1,000,000', short: true },
|
|
861
|
+
{ title: 'Action', value: 'Test Project & My Project paused for 24h', short: true },
|
|
862
|
+
],
|
|
863
|
+
},
|
|
864
|
+
],
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
expect(alert.attachments[0].color).toBe('danger');
|
|
868
|
+
expect(alert.attachments[0].fields.find((f) => f.title === 'Limit')?.value).toBe('1,000,000');
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
describe('Alert Rate Limiting', () => {
|
|
873
|
+
it('skips alert when webhook URL not configured', () => {
|
|
874
|
+
const env = { SLACK_WEBHOOK_URL: undefined };
|
|
875
|
+
const shouldSendAlert = !!env.SLACK_WEBHOOK_URL;
|
|
876
|
+
|
|
877
|
+
expect(shouldSendAlert).toBe(false);
|
|
878
|
+
});
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
// ============================================================================
|
|
883
|
+
// HTTP Endpoint Tests
|
|
884
|
+
// ============================================================================
|
|
885
|
+
|
|
886
|
+
describe('Platform Usage - HTTP Endpoints', () => {
|
|
887
|
+
describe('Health Check', () => {
|
|
888
|
+
it('returns health status JSON', () => {
|
|
889
|
+
const response = {
|
|
890
|
+
status: 'ok',
|
|
891
|
+
service: 'platform-usage',
|
|
892
|
+
timestamp: expect.any(String),
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
expect(response.status).toBe('ok');
|
|
896
|
+
expect(response.service).toBe('platform-usage');
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
describe('Circuit Breaker Status Endpoint', () => {
|
|
901
|
+
it('returns all circuit breaker states', () => {
|
|
902
|
+
const response = {
|
|
903
|
+
success: true,
|
|
904
|
+
circuitBreakers: {
|
|
905
|
+
globalStop: false,
|
|
906
|
+
testProjectPaused: false,
|
|
907
|
+
brandCopilotPaused: false,
|
|
908
|
+
},
|
|
909
|
+
sampling: {
|
|
910
|
+
currentMode: 'FULL',
|
|
911
|
+
d1Writes24h: 50000,
|
|
912
|
+
d1WriteLimit: 1000000,
|
|
913
|
+
d1WritePercentage: 5,
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
expect(response.circuitBreakers).toBeDefined();
|
|
918
|
+
expect(response.sampling).toBeDefined();
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
describe('Settings Endpoint', () => {
|
|
923
|
+
it('accepts threshold settings updates', () => {
|
|
924
|
+
const settings = {
|
|
925
|
+
workers: { warningPct: 50, highPct: 75, criticalPct: 90, absoluteMax: 5, enabled: true },
|
|
926
|
+
d1: { warningPct: 40, highPct: 60, criticalPct: 80, absoluteMax: 20, enabled: true },
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
expect(settings.workers.absoluteMax).toBe(5);
|
|
930
|
+
expect(settings.d1.absoluteMax).toBe(20);
|
|
931
|
+
});
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
describe('Available Endpoints', () => {
|
|
935
|
+
it('lists all endpoints correctly', () => {
|
|
936
|
+
const endpoints = [
|
|
937
|
+
'/',
|
|
938
|
+
'/usage',
|
|
939
|
+
'/usage/daily',
|
|
940
|
+
'/usage/compare',
|
|
941
|
+
'/costs',
|
|
942
|
+
'/costs/thresholds',
|
|
943
|
+
'/costs/enhanced',
|
|
944
|
+
'/health',
|
|
945
|
+
'/settings',
|
|
946
|
+
'/circuit-breaker/status',
|
|
947
|
+
'/circuit-breaker/reset',
|
|
948
|
+
'/workersai',
|
|
949
|
+
];
|
|
950
|
+
|
|
951
|
+
expect(endpoints).toContain('/usage');
|
|
952
|
+
expect(endpoints).toContain('/usage/daily');
|
|
953
|
+
expect(endpoints).toContain('/circuit-breaker/status');
|
|
954
|
+
});
|
|
955
|
+
});
|
|
956
|
+
});
|