diff --git a/src/cost.test.ts b/src/cost.test.ts new file mode 100644 index 0000000..21f1572 --- /dev/null +++ b/src/cost.test.ts @@ -0,0 +1,201 @@ +import AWS from 'aws-sdk'; +import { AWSConfig } from './config'; +import { getRawCostByService, getTotalCosts } from './cost'; +import AWSMock from 'aws-sdk-mock'; +import dayjs from 'dayjs'; + +// Use Apr 2024 (30 days) as the 'last month' +// Thus 'today' is someday in May 2024 +const costDataLength = 65; +const fixedToday = '2024-05-11'; // cost of 'this month' will be sum of 10 days from May 1 to May 10 ('today' is omitted because its cost is incomplete) +const fixedFirstDay = dayjs(fixedToday).subtract(costDataLength, 'day'); + +const generateMockPricingData = () => { + const resultsByTime = []; + for (let i = 0; i < costDataLength; i++) { + const date = dayjs(fixedFirstDay).add(i, 'day').format('YYYY-MM-DD'); + const month = dayjs(date).month(); // 0-indexed (0 = January, 1 = February, etc.) + let service1Cost; + + switch (month) { + case 2: // March + service1Cost = 0.9; + break; + case 3: // April + service1Cost = 1.0; // Total cost of service1 in April will be 30.00 + break; + case 4: // May + service1Cost = 1.1; + break; + default: + service1Cost = 0.0; // Default cost if none of the above + } + + resultsByTime.push({ + TimePeriod: { + Start: date, + End: dayjs(date).add(1, 'day').format('YYYY-MM-DD'), + }, + Groups: [ + { + Keys: ['service1'], + Metrics: { + UnblendedCost: { + Amount: String(service1Cost), + Unit: 'USD', + }, + }, + }, + { + Keys: ['service2'], + Metrics: { + UnblendedCost: { + Amount: String(service1Cost * 100), + Unit: 'USD', + }, + }, + }, + ], + }); + } + return { ResultsByTime: resultsByTime }; +}; + +describe('Cost Functions', () => { + beforeAll(() => { + AWSMock.setSDKInstance(AWS); + }); + + afterAll(() => { + AWSMock.restore(); + }); + + beforeEach(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date(fixedToday).getTime()); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('getRawCostByService', () => { + it('should return raw cost by service', async () => { + const awsConfig: AWSConfig = { + credentials: { + accessKeyId: 'testAccessKeyId', + secretAccessKey: 'testSecretAccessKey', + sessionToken: 'testSessionToken', + }, + region: 'us-east-1', + }; + + const mockPricingData = generateMockPricingData(); + + AWSMock.mock('CostExplorer', 'getCostAndUsage', (params, callback) => { + callback(null, mockPricingData); + }); + + const rawCostByService = await getRawCostByService(awsConfig); + + const expectedRawCostByService = { + service1: {}, + service2: {}, + }; + for (let i = 0; i < costDataLength; i++) { + const date = dayjs(fixedFirstDay).add(i, 'day').format('YYYY-MM-DD'); + const month = dayjs(date).month(); + let service1Cost; + + switch (month) { + case 2: // March + service1Cost = 0.9; + break; + case 3: // April + service1Cost = 1.0; // Total cost of service1 in April will be 30.00 + break; + case 4: // May + service1Cost = 1.1; + break; + default: + service1Cost = 0.0; // Default cost if none of the above + } + + expectedRawCostByService.service1[date] = service1Cost; + expectedRawCostByService.service2[date] = service1Cost * 100; + } + + expect(rawCostByService).toEqual(expectedRawCostByService); + + AWSMock.restore('CostExplorer'); + }); + }); + + describe('getTotalCosts', () => { + it('should return total costs', async () => { + const awsConfig: AWSConfig = { + credentials: { + accessKeyId: 'testAccessKeyId', + secretAccessKey: 'testSecretAccessKey', + sessionToken: 'testSessionToken', + }, + region: 'us-east-1', + }; + + const mockPricingData = generateMockPricingData(); + + AWSMock.mock('CostExplorer', 'getCostAndUsage', (params, callback) => { + callback(null, mockPricingData); + }); + + const totalCosts = await getTotalCosts(awsConfig); + + const expectedTotalCosts = { + totals: { + lastMonth: 30 * (1 + 100), // Apr + thisMonth: 10 * (1.1 + 110), // sum of May 1..May 10 + last7Days: 7 * 1.1 + 7 * 110, // sum of May 4..May 10 + yesterday: 1.1 + 110, // on May 10 + }, + totalsByService: { + lastMonth: { + // Apr + service1: 30.0, + service2: 3000.0, + }, + thisMonth: { + service1: 11.0, // 10 days of May + service2: 1100.0, + }, + last7Days: { + service1: 7.7, + service2: 770.0, + }, + yesterday: { + service1: 1.1, + service2: 110.0, + }, + }, + }; + + const roundToTwoDecimals = (num: number) => Math.round(num * 100) / 100; + + Object.keys(totalCosts.totals).forEach((key) => { + expect(roundToTwoDecimals(totalCosts.totals[key])).toBeCloseTo( + expectedTotalCosts.totals[key], + 1, + ); + }); + + Object.keys(totalCosts.totalsByService).forEach((period) => { + Object.keys(totalCosts.totalsByService[period]).forEach((service) => { + expect( + roundToTwoDecimals(totalCosts.totalsByService[period][service]), + ).toBeCloseTo(expectedTotalCosts.totalsByService[period][service], 1); + }); + }); + + AWSMock.restore('CostExplorer'); + }); + }); +}); diff --git a/src/cost.ts b/src/cost.ts index a4914ee..8cce24e 100644 --- a/src/cost.ts +++ b/src/cost.ts @@ -1,5 +1,9 @@ import AWS from 'aws-sdk'; import dayjs from 'dayjs'; +import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; +import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; +dayjs.extend(isSameOrAfter); +dayjs.extend(isSameOrBefore); import { AWSConfig } from './config'; import { showSpinner } from './logger'; @@ -15,7 +19,7 @@ export async function getRawCostByService( showSpinner('Getting pricing data'); const costExplorer = new AWS.CostExplorer(awsConfig); - const endDate = dayjs().subtract(1, 'day'); + const endDate = dayjs(); // `endDate` is set to 'today' but its cost be omitted because of API spec (see: https://docs.aws.amazon.com/aws-cost-management/latest/APIReference/API_GetCostAndUsage.html#API_GetCostAndUsage_RequestSyntax) const startDate = endDate.subtract(65, 'day'); const groupByConfig = [ @@ -74,7 +78,7 @@ export async function getRawCostByService( const filterKeys = group.Keys; const serviceName = filterKeys.find((key) => !/^\d{12}$/.test(key)); // AWS service name is non-12-digits string const cost = group.Metrics.UnblendedCost.Amount; - const costDate = day.TimePeriod.End; + const costDate = day.TimePeriod.Start; // must be set to `Start` not `End` because the end of `Period` parameter will be omitted (see: https://docs.aws.amazon.com/aws-cost-management/latest/APIReference/API_GetCostAndUsage.html#API_GetCostAndUsage_RequestSyntax) costByService[serviceName] = costByService[serviceName] || {}; costByService[serviceName][costDate] = parseFloat(cost); @@ -118,8 +122,8 @@ function calculateServiceTotals( const startOfLastMonth = dayjs().subtract(1, 'month').startOf('month'); const startOfThisMonth = dayjs().startOf('month'); - const startOfLast7Days = dayjs().subtract(7, 'day'); - const startOfYesterday = dayjs().subtract(1, 'day'); + const startOfLast7Days = dayjs().subtract(7, 'day').startOf('day'); + const startOfYesterday = dayjs().subtract(1, 'day').startOf('day'); for (const service of Object.keys(rawCostByService)) { const servicePrices = rawCostByService[service]; @@ -142,8 +146,8 @@ function calculateServiceTotals( } if ( - dateObj.isSame(startOfLast7Days, 'week') && - !dateObj.isSame(startOfYesterday, 'day') + dateObj.isSameOrAfter(startOfLast7Days) && + dateObj.isSameOrBefore(dayjs().startOf('day')) ) { last7DaysServiceTotal += price; }