import { describe, it, expect, beforeEach } from 'vitest';
import fc from 'fast-check';
import { BillingAnalyzer, QueryFilters } from './billing-analyzer.js';
import { BillingRecord } from './billing-client.js';
describe('BillingAnalyzer', () => {
let analyzer: BillingAnalyzer;
beforeEach(() => {
analyzer = BillingAnalyzer.getInstance();
});
// Helper function to generate valid billing records
const billingRecordArbitrary = fc.record({
id: fc.string({ minLength: 1, maxLength: 50 }),
accountId: fc.string({ minLength: 1, maxLength: 20 }),
service: fc.constantFrom('EC2', 'S3', 'RDS', 'Lambda', 'CloudFront', 'EBS', 'VPC'),
region: fc.constantFrom('us-east-1', 'us-west-2', 'eu-west-1', 'ap-southeast-1'),
usageType: fc.string({ minLength: 1, maxLength: 30 }),
cost: fc.float({ min: 0, max: 10000, noNaN: true }),
currency: fc.constant('USD'),
startDate: fc.date({ min: new Date('2023-01-01'), max: new Date('2024-12-31') }),
endDate: fc.date({ min: new Date('2023-01-01'), max: new Date('2024-12-31') }),
tags: fc.dictionary(fc.string({ minLength: 1, maxLength: 10 }), fc.string({ minLength: 1, maxLength: 20 })),
createdAt: fc.date(),
updatedAt: fc.date()
}).map(record => ({
...record,
endDate: new Date(Math.max(record.startDate.getTime(), record.endDate.getTime()))
}));
const queryFiltersArbitrary = fc.record({
accountId: fc.option(fc.string({ minLength: 1, maxLength: 20 })),
services: fc.option(fc.array(fc.constantFrom('EC2', 'S3', 'RDS', 'Lambda'), { minLength: 1, maxLength: 3 })),
regions: fc.option(fc.array(fc.constantFrom('us-east-1', 'us-west-2', 'eu-west-1'), { minLength: 1, maxLength: 2 })),
startDate: fc.option(fc.date({ min: new Date('2023-01-01'), max: new Date('2024-06-01') })),
endDate: fc.option(fc.date({ min: new Date('2024-06-01'), max: new Date('2024-12-31') })),
minCost: fc.option(fc.float({ min: 0, max: 1000, noNaN: true })),
maxCost: fc.option(fc.float({ min: 1000, max: 10000, noNaN: true })),
tags: fc.option(fc.dictionary(fc.string({ minLength: 1, maxLength: 10 }), fc.string({ minLength: 1, maxLength: 20 })))
});
/**
* **Feature: aws-billing-mcp-server, Property 6: Query filtering accuracy**
* For any billing query with filters (time period, service, region),
* the returned results should contain only data matching all specified filter criteria
*/
it('Property 6: Query filtering accuracy', () => {
fc.assert(
fc.property(
fc.array(billingRecordArbitrary, { minLength: 1, maxLength: 100 }),
queryFiltersArbitrary,
(records, filters) => {
const filteredRecords = analyzer.filterBillingRecords(records, filters);
// All filtered records should match the filter criteria
for (const record of filteredRecords) {
// Account ID filter
if (filters.accountId !== null && filters.accountId !== undefined) {
expect(record.accountId).toBe(filters.accountId);
}
// Services filter
if (filters.services !== null && filters.services !== undefined && filters.services.length > 0) {
expect(filters.services).toContain(record.service);
}
// Regions filter
if (filters.regions !== null && filters.regions !== undefined && filters.regions.length > 0) {
expect(filters.regions).toContain(record.region);
}
// Start date filter
if (filters.startDate !== null && filters.startDate !== undefined) {
expect(record.startDate.getTime()).toBeGreaterThanOrEqual(filters.startDate.getTime());
}
// End date filter
if (filters.endDate !== null && filters.endDate !== undefined) {
expect(record.endDate.getTime()).toBeLessThanOrEqual(filters.endDate.getTime());
}
// Min cost filter
if (filters.minCost !== null && filters.minCost !== undefined) {
expect(record.cost).toBeGreaterThanOrEqual(filters.minCost);
}
// Max cost filter
if (filters.maxCost !== null && filters.maxCost !== undefined) {
expect(record.cost).toBeLessThanOrEqual(filters.maxCost);
}
// Tags filter
if (filters.tags !== null && filters.tags !== undefined && Object.keys(filters.tags).length > 0) {
for (const [key, value] of Object.entries(filters.tags)) {
expect(record.tags[key]).toBe(value);
}
}
}
// Filtered records should be a subset of original records
expect(filteredRecords.length).toBeLessThanOrEqual(records.length);
// All filtered records should exist in original records
for (const filteredRecord of filteredRecords) {
expect(records).toContainEqual(filteredRecord);
}
}
),
{ numRuns: 100 }
);
});
/**
* **Feature: aws-billing-mcp-server, Property 7: Cost calculation accuracy**
* For any cost comparison or trend analysis request,
* the calculated percentages and differences should be mathematically correct based on the input data
*/
it('Property 7: Cost calculation accuracy', () => {
fc.assert(
fc.property(
fc.array(billingRecordArbitrary, { minLength: 1, maxLength: 50 }),
fc.array(billingRecordArbitrary, { minLength: 1, maxLength: 50 }),
(currentRecords, previousRecords) => {
// Ensure records have valid costs (no NaN, no negative)
const validCurrentRecords = currentRecords.filter(r =>
!isNaN(r.cost) && isFinite(r.cost) && r.cost >= 0
);
const validPreviousRecords = previousRecords.filter(r =>
!isNaN(r.cost) && isFinite(r.cost) && r.cost >= 0
);
if (validCurrentRecords.length === 0 || validPreviousRecords.length === 0) {
return true; // Skip if no valid records
}
const comparison = analyzer.compareUsage(validCurrentRecords, validPreviousRecords);
// Verify total cost calculations
const expectedCurrentTotal = validCurrentRecords.reduce((sum, r) => sum + r.cost, 0);
const expectedPreviousTotal = validPreviousRecords.reduce((sum, r) => sum + r.cost, 0);
expect(comparison.currentPeriod.totalCost).toBeCloseTo(expectedCurrentTotal, 2);
expect(comparison.previousPeriod.totalCost).toBeCloseTo(expectedPreviousTotal, 2);
// Verify absolute change calculation
const expectedAbsoluteChange = expectedCurrentTotal - expectedPreviousTotal;
expect(comparison.comparison.absoluteChange).toBeCloseTo(expectedAbsoluteChange, 2);
// Verify percentage change calculation
if (expectedPreviousTotal > 0) {
const expectedPercentageChange = (expectedAbsoluteChange / expectedPreviousTotal) * 100;
expect(comparison.comparison.percentageChange).toBeCloseTo(expectedPercentageChange, 2);
} else {
expect(comparison.comparison.percentageChange).toBe(0);
}
// Verify trend direction
if (Math.abs(comparison.comparison.percentageChange) <= 5) {
expect(comparison.comparison.trend).toBe('stable');
} else if (comparison.comparison.percentageChange > 5) {
expect(comparison.comparison.trend).toBe('increasing');
} else {
expect(comparison.comparison.trend).toBe('decreasing');
}
// Verify service breakdown calculations
for (const serviceComparison of comparison.serviceBreakdown) {
const currentServiceCost = validCurrentRecords
.filter(r => r.service === serviceComparison.service)
.reduce((sum, r) => sum + r.cost, 0);
const previousServiceCost = validPreviousRecords
.filter(r => r.service === serviceComparison.service)
.reduce((sum, r) => sum + r.cost, 0);
expect(serviceComparison.currentCost).toBeCloseTo(currentServiceCost, 2);
expect(serviceComparison.previousCost).toBeCloseTo(previousServiceCost, 2);
const expectedChange = currentServiceCost - previousServiceCost;
expect(serviceComparison.change).toBeCloseTo(expectedChange, 2);
if (previousServiceCost > 0) {
const expectedChangePercent = (expectedChange / previousServiceCost) * 100;
expect(serviceComparison.changePercent).toBeCloseTo(expectedChangePercent, 2);
} else {
expect(serviceComparison.changePercent).toBe(0);
}
}
}
),
{ numRuns: 100 }
);
});
/**
* **Feature: aws-billing-mcp-server, Property 8: Anomaly detection consistency**
* For any billing data set with defined baselines,
* anomaly detection should consistently identify deviations beyond the configured threshold
*/
it('Property 8: Anomaly detection consistency', () => {
fc.assert(
fc.property(
fc.float({ min: 1.0, max: 5.0, noNaN: true }),
(thresholdMultiplier) => {
// Generate exactly 7 records with guaranteed unique dates to ensure we have enough data points
const baseDate = new Date('2024-01-01');
const validRecords = Array.from({ length: 7 }, (_, index) => ({
id: `rec_${index}`,
accountId: 'test_account',
service: 'EC2',
region: 'us-east-1',
usageType: 'test',
cost: Math.random() * 1000, // Random cost between 0-1000
currency: 'USD',
startDate: new Date(baseDate.getTime() + index * 24 * 60 * 60 * 1000), // Unique dates
endDate: new Date(baseDate.getTime() + index * 24 * 60 * 60 * 1000),
tags: {},
createdAt: new Date(),
updatedAt: new Date()
}));
const result = analyzer.detectAnomalies(validRecords, thresholdMultiplier);
// Verify baseline calculations
const costs = validRecords.reduce((acc, record) => {
const dateKey = record.startDate.toISOString().split('T')[0];
acc[dateKey] = (acc[dateKey] || 0) + record.cost;
return acc;
}, {} as Record<string, number>);
const costValues = Object.values(costs);
const expectedMean = costValues.reduce((sum, cost) => sum + cost, 0) / costValues.length;
const expectedVariance = costValues.reduce((sum, cost) => sum + Math.pow(cost - expectedMean, 2), 0) / costValues.length;
const expectedStdDev = Math.sqrt(expectedVariance);
const expectedThreshold = expectedStdDev * thresholdMultiplier;
expect(result.baseline.mean).toBeCloseTo(expectedMean, 2);
expect(result.baseline.standardDeviation).toBeCloseTo(expectedStdDev, 2);
expect(result.baseline.threshold).toBeCloseTo(expectedThreshold, 2);
// Verify anomaly detection logic
for (const anomaly of result.anomalies) {
const deviation = Math.abs(anomaly.actualCost - result.baseline.mean);
expect(deviation).toBeGreaterThan(result.baseline.threshold);
// Verify severity classification
if (deviation > result.baseline.threshold * 2) {
expect(anomaly.severity).toBe('high');
} else if (deviation > result.baseline.threshold * 1.5) {
expect(anomaly.severity).toBe('medium');
} else {
expect(anomaly.severity).toBe('low');
}
expect(anomaly.deviation).toBeCloseTo(deviation, 2);
expect(anomaly.expectedCost).toBeCloseTo(result.baseline.mean, 2);
}
// Verify that non-anomalous points are not included
for (const [dateStr, cost] of Object.entries(costs)) {
const deviation = Math.abs(cost - result.baseline.mean);
const isAnomaly = result.anomalies.some(a =>
a.date.toISOString().split('T')[0] === dateStr
);
if (deviation <= result.baseline.threshold) {
expect(isAnomaly).toBe(false);
}
}
}
),
{ numRuns: 100 }
);
});
// Unit tests for edge cases and specific functionality
describe('Unit Tests', () => {
it('should handle empty records for filtering', () => {
const result = analyzer.filterBillingRecords([], {});
expect(result).toEqual([]);
});
it('should throw error for cost analysis with no records', () => {
expect(() => analyzer.analyzeCosts([])).toThrow('No billing records provided for analysis');
});
it('should handle insufficient data for anomaly detection', () => {
const records: BillingRecord[] = Array.from({ length: 3 }, (_, i) => ({
id: `rec_${i}`,
accountId: 'acc_1',
service: 'EC2',
region: 'us-east-1',
usageType: 'test',
cost: 100,
currency: 'USD',
startDate: new Date(`2024-01-0${i + 1}`),
endDate: new Date(`2024-01-0${i + 1}`),
tags: {},
createdAt: new Date(),
updatedAt: new Date()
}));
expect(() => analyzer.detectAnomalies(records)).toThrow('Insufficient data for anomaly detection');
});
it('should calculate correct service breakdown percentages', () => {
const records: BillingRecord[] = [
{
id: 'rec_1',
accountId: 'acc_1',
service: 'EC2',
region: 'us-east-1',
usageType: 'test',
cost: 300,
currency: 'USD',
startDate: new Date('2024-01-01'),
endDate: new Date('2024-01-01'),
tags: {},
createdAt: new Date(),
updatedAt: new Date()
},
{
id: 'rec_2',
accountId: 'acc_1',
service: 'S3',
region: 'us-east-1',
usageType: 'test',
cost: 200,
currency: 'USD',
startDate: new Date('2024-01-01'),
endDate: new Date('2024-01-01'),
tags: {},
createdAt: new Date(),
updatedAt: new Date()
}
];
const result = analyzer.analyzeCosts(records);
expect(result.totalCost).toBe(500);
expect(result.breakdown).toHaveLength(2);
expect(result.breakdown[0]).toEqual({
service: 'EC2',
cost: 300,
percentage: 60
});
expect(result.breakdown[1]).toEqual({
service: 'S3',
cost: 200,
percentage: 40
});
});
});
});