import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fc from 'fast-check';
import { BillingClient, BillingRecord, CostData } from './billing-client.js';
/**
* Feature: aws-billing-mcp-server, Property 4: Data structure consistency
* Validates: Requirements 2.2, 2.5
*
* Property: For any billing data retrieved from AWS, the parsed and cached data should
* maintain consistent structure and be queryable through the analysis interface
*/
describe('Billing Data Structure Property Tests', () => {
it('Property 4: Data structure consistency - billing records maintain required fields', () => {
fc.assert(
fc.property(
fc.array(
fc.record({
service: fc.string({ minLength: 1, maxLength: 50 }),
region: fc.option(fc.string({ minLength: 1, maxLength: 20 }), { nil: undefined }),
cost: fc.float({ min: 0, max: 10000, noNaN: true }),
currency: fc.constantFrom('USD', 'EUR', 'GBP'),
startDate: fc.date({ min: new Date('2020-01-01'), max: new Date() }).map(d => d.toISOString().split('T')[0]),
endDate: fc.date({ min: new Date('2020-01-01'), max: new Date() }).map(d => d.toISOString().split('T')[0])
}),
{ minLength: 1, maxLength: 100 }
),
fc.string({ minLength: 1, maxLength: 20 }), // accountId
(costDataArray: CostData[], accountId: string) => {
// Property: All cost data should have consistent structure
costDataArray.forEach(costData => {
expect(costData).toHaveProperty('service');
expect(costData).toHaveProperty('cost');
expect(costData).toHaveProperty('currency');
expect(costData).toHaveProperty('startDate');
expect(costData).toHaveProperty('endDate');
expect(typeof costData.service).toBe('string');
expect(typeof costData.cost).toBe('number');
expect(typeof costData.currency).toBe('string');
expect(typeof costData.startDate).toBe('string');
expect(typeof costData.endDate).toBe('string');
// Cost should be non-negative
expect(costData.cost).toBeGreaterThanOrEqual(0);
// Currency should be valid
expect(['USD', 'EUR', 'GBP']).toContain(costData.currency);
// Dates should be valid ISO date strings
expect(new Date(costData.startDate).toISOString().split('T')[0]).toBe(costData.startDate);
expect(new Date(costData.endDate).toISOString().split('T')[0]).toBe(costData.endDate);
});
}
),
{ numRuns: 100 }
);
});
it('Property 4: Data structure consistency - billing records conversion preserves data', () => {
fc.assert(
fc.property(
fc.record({
service: fc.string({ minLength: 1, maxLength: 50 }),
region: fc.option(fc.string({ minLength: 1, maxLength: 20 }), { nil: undefined }),
cost: fc.float({ min: 0, max: 10000, noNaN: true }),
currency: fc.constantFrom('USD', 'EUR', 'GBP'),
startDate: fc.date({ min: new Date('2020-01-01'), max: new Date() }).map(d => d.toISOString().split('T')[0]),
endDate: fc.date({ min: new Date('2020-01-01'), max: new Date() }).map(d => d.toISOString().split('T')[0]),
usageType: fc.option(fc.string({ minLength: 1, maxLength: 30 }), { nil: undefined })
}),
fc.string({ minLength: 1, maxLength: 20 }), // accountId
(costData: CostData, accountId: string) => {
// Simulate the conversion process from CostData to BillingRecord
const billingRecord: BillingRecord = {
id: 'test_' + Math.random().toString(36).substr(2),
accountId,
service: costData.service,
region: costData.region || 'Unknown',
usageType: costData.usageType || 'Unknown',
cost: costData.cost,
currency: costData.currency,
startDate: new Date(costData.startDate),
endDate: new Date(costData.endDate),
tags: {},
createdAt: new Date(),
updatedAt: new Date()
};
// Property: Conversion should preserve all essential data
expect(billingRecord.accountId).toBe(accountId);
expect(billingRecord.service).toBe(costData.service);
expect(billingRecord.cost).toBe(costData.cost);
expect(billingRecord.currency).toBe(costData.currency);
expect(billingRecord.startDate.toISOString().split('T')[0]).toBe(costData.startDate);
expect(billingRecord.endDate.toISOString().split('T')[0]).toBe(costData.endDate);
// Property: Default values should be applied consistently
if (costData.region) {
expect(billingRecord.region).toBe(costData.region);
} else {
expect(billingRecord.region).toBe('Unknown');
}
if (costData.usageType) {
expect(billingRecord.usageType).toBe(costData.usageType);
} else {
expect(billingRecord.usageType).toBe('Unknown');
}
// Property: Required fields should always be present
expect(billingRecord.id).toBeDefined();
expect(billingRecord.tags).toBeDefined();
expect(billingRecord.createdAt).toBeInstanceOf(Date);
expect(billingRecord.updatedAt).toBeInstanceOf(Date);
}
),
{ numRuns: 100 }
);
});
it('Property 4: Data structure consistency - cost aggregation maintains precision', () => {
fc.assert(
fc.property(
fc.array(
fc.record({
service: fc.string({ minLength: 1, maxLength: 50 }),
cost: fc.float({ min: 0, max: 1000, noNaN: true }),
currency: fc.constantFrom('USD', 'EUR', 'GBP')
}),
{ minLength: 2, maxLength: 10 }
),
(costRecords) => {
// Group by currency for aggregation
const byCurrency = costRecords.reduce((acc, record) => {
if (!acc[record.currency]) {
acc[record.currency] = [];
}
acc[record.currency].push(record.cost);
return acc;
}, {} as Record<string, number[]>);
// Property: Aggregation should maintain precision and consistency
Object.entries(byCurrency).forEach(([currency, costs]) => {
const total = costs.reduce((sum, cost) => sum + cost, 0);
const average = total / costs.length;
// Property: Total should equal sum of individual costs
const manualSum = costs.reduce((sum, cost) => sum + cost, 0);
expect(Math.abs(total - manualSum)).toBeLessThan(0.001); // Account for floating point precision
// Property: Average should be within expected range
const minCost = Math.min(...costs);
const maxCost = Math.max(...costs);
expect(average).toBeGreaterThanOrEqual(minCost);
expect(average).toBeLessThanOrEqual(maxCost);
// Property: All costs should be non-negative
costs.forEach(cost => {
expect(cost).toBeGreaterThanOrEqual(0);
});
});
}
),
{ numRuns: 100 }
);
});
it('Property 4: Data structure consistency - date ranges are logically valid', () => {
fc.assert(
fc.property(
fc.array(
fc.tuple(
fc.string({ minLength: 1, maxLength: 50 }), // service
fc.date({ min: new Date('2020-01-01'), max: new Date('2023-12-31') }), // startDate
fc.integer({ min: 0, max: 365 }), // days to add for endDate
fc.float({ min: 0, max: 1000, noNaN: true }) // cost
).map(([service, startDate, daysToAdd, cost]) => ({
service,
startDate,
endDate: new Date(startDate.getTime() + daysToAdd * 24 * 60 * 60 * 1000),
cost
})),
{ minLength: 1, maxLength: 50 }
),
(records) => {
records.forEach(record => {
// Property: Start date should be before or equal to end date
expect(record.startDate.getTime()).toBeLessThanOrEqual(record.endDate.getTime());
// Property: Dates should be valid Date objects
expect(record.startDate).toBeInstanceOf(Date);
expect(record.endDate).toBeInstanceOf(Date);
expect(record.startDate.getTime()).not.toBeNaN();
expect(record.endDate.getTime()).not.toBeNaN();
// Property: Date range should be reasonable (not more than 2 years)
const daysDiff = (record.endDate.getTime() - record.startDate.getTime()) / (1000 * 60 * 60 * 24);
expect(daysDiff).toBeLessThanOrEqual(730); // 2 years
});
}
),
{ numRuns: 100 }
);
});
});
/**
* Feature: aws-billing-mcp-server, Property 5: Retry logic consistency
* Validates: Requirements 2.3, 6.1
*
* Property: For any AWS API failure scenario, the system should implement exponential
* backoff and retry logic consistently
*/
describe('Retry Logic Property Tests', () => {
// Mock retry configuration for testing
const testRetryConfig = {
maxRetries: 3,
baseDelay: 100, // Reduced for testing
maxDelay: 1000,
backoffMultiplier: 2
};
function calculateExpectedDelay(attempt: number, config: typeof testRetryConfig): number {
const delay = config.baseDelay * Math.pow(config.backoffMultiplier, attempt);
return Math.min(delay, config.maxDelay);
}
it('Property 5: Retry logic consistency - exponential backoff delays increase correctly', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 10 }), // attempt number
fc.record({
baseDelay: fc.integer({ min: 10, max: 1000 }),
maxDelay: fc.integer({ min: 1000, max: 60000 }),
backoffMultiplier: fc.float({ min: Math.fround(1.1), max: Math.fround(3.0), noNaN: true })
}),
(attempt: number, config) => {
const delay1 = calculateExpectedDelay(attempt, { ...testRetryConfig, ...config });
const delay2 = calculateExpectedDelay(attempt + 1, { ...testRetryConfig, ...config });
// Property: Delay should increase with attempt number (unless capped by maxDelay)
if (delay1 < config.maxDelay) {
expect(delay2).toBeGreaterThanOrEqual(delay1);
}
// Property: Delay should never exceed maxDelay
expect(delay1).toBeLessThanOrEqual(config.maxDelay);
expect(delay2).toBeLessThanOrEqual(config.maxDelay);
// Property: Delay should be at least baseDelay for attempt 0
if (attempt === 0) {
expect(delay1).toBeGreaterThanOrEqual(config.baseDelay);
}
}
),
{ numRuns: 100 }
);
});
it('Property 5: Retry logic consistency - retry attempts are bounded', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 10 }), // maxRetries
fc.array(fc.boolean(), { minLength: 1, maxLength: 20 }), // success/failure pattern
(maxRetries: number, failurePattern: boolean[]) => {
let attempts = 0;
let succeeded = false;
// Simulate retry logic
for (let i = 0; i <= maxRetries && i < failurePattern.length; i++) {
attempts++;
if (failurePattern[i]) {
succeeded = true;
break;
}
}
// Property: Number of attempts should never exceed maxRetries + 1
expect(attempts).toBeLessThanOrEqual(maxRetries + 1);
// Property: If operation succeeds, attempts should stop
if (succeeded) {
const successIndex = failurePattern.findIndex(success => success);
expect(attempts).toBe(successIndex + 1);
}
// Property: If all attempts fail, should attempt exactly maxRetries + 1 times
if (!succeeded && failurePattern.length > maxRetries) {
expect(attempts).toBe(maxRetries + 1);
}
}
),
{ numRuns: 100 }
);
});
it('Property 5: Retry logic consistency - error types determine retry behavior', () => {
fc.assert(
fc.property(
fc.constantFrom(
'ThrottlingException',
'ServiceUnavailableException',
'InternalServerError',
'InvalidParameterException',
'AccessDeniedException'
),
fc.integer({ min: 1, max: 5 }), // maxRetries
(errorType: string, maxRetries: number) => {
// Define which errors should be retried
const retryableErrors = [
'ThrottlingException',
'ServiceUnavailableException',
'InternalServerError'
];
const nonRetryableErrors = [
'InvalidParameterException',
'AccessDeniedException'
];
const shouldRetry = retryableErrors.includes(errorType);
const shouldNotRetry = nonRetryableErrors.includes(errorType);
// Property: Retryable errors should allow multiple attempts
if (shouldRetry) {
// Simulate that we would retry up to maxRetries times
expect(maxRetries).toBeGreaterThan(0);
}
// Property: Non-retryable errors should fail immediately
if (shouldNotRetry) {
// These should not be retried regardless of maxRetries setting
expect(shouldRetry).toBe(false);
}
// Property: Error classification should be consistent
expect(shouldRetry).not.toBe(shouldNotRetry);
}
),
{ numRuns: 50 }
);
});
it('Property 5: Retry logic consistency - backoff timing is deterministic', () => {
fc.assert(
fc.property(
fc.record({
baseDelay: fc.integer({ min: 10, max: 1000 }),
backoffMultiplier: fc.float({ min: Math.fround(1.1), max: Math.fround(3.0), noNaN: true }),
maxDelay: fc.integer({ min: 1000, max: 10000 })
}),
fc.integer({ min: 0, max: 5 }), // attempt
(config, attempt: number) => {
// Calculate delay multiple times with same inputs
const delay1 = calculateExpectedDelay(attempt, { ...testRetryConfig, ...config });
const delay2 = calculateExpectedDelay(attempt, { ...testRetryConfig, ...config });
const delay3 = calculateExpectedDelay(attempt, { ...testRetryConfig, ...config });
// Property: Same inputs should produce same delay (deterministic)
expect(delay1).toBe(delay2);
expect(delay2).toBe(delay3);
// Property: Delay calculation should be mathematically correct
const expectedDelay = Math.min(
config.baseDelay * Math.pow(config.backoffMultiplier, attempt),
config.maxDelay
);
expect(delay1).toBe(expectedDelay);
}
),
{ numRuns: 100 }
);
});
it('Property 5: Retry logic consistency - jitter bounds are respected', () => {
fc.assert(
fc.property(
fc.integer({ min: 100, max: 5000 }), // base delay
fc.float({ min: 0, max: 1, noNaN: true }), // jitter factor (0-100%)
(baseDelay: number, jitterFactor: number) => {
// Simulate adding jitter to delay
const jitterAmount = baseDelay * jitterFactor;
const minDelay = baseDelay - jitterAmount;
const maxDelay = baseDelay + jitterAmount;
// Generate some jittered delays
const jitteredDelays = Array.from({ length: 10 }, () => {
const randomJitter = (Math.random() - 0.5) * 2 * jitterAmount;
return baseDelay + randomJitter;
});
// Property: All jittered delays should be within expected bounds
jitteredDelays.forEach(delay => {
expect(delay).toBeGreaterThanOrEqual(minDelay);
expect(delay).toBeLessThanOrEqual(maxDelay);
});
// Property: Jitter should not make delays negative
expect(minDelay).toBeGreaterThanOrEqual(0);
}
),
{ numRuns: 50 }
);
});
});