Skip to content

Commit 38dc2f3

Browse files
authored
Merge pull request #21 from route06/issue-8-fix-cost-aggregation-logic
Correct cost aggregation logic to resolve diff in monthly cost
2 parents f73e734 + 2d9a375 commit 38dc2f3

File tree

2 files changed

+211
-6
lines changed

2 files changed

+211
-6
lines changed

src/cost.test.ts

+201
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import AWS from 'aws-sdk';
2+
import { AWSConfig } from './config';
3+
import { getRawCostByService, getTotalCosts } from './cost';
4+
import AWSMock from 'aws-sdk-mock';
5+
import dayjs from 'dayjs';
6+
7+
// Use Apr 2024 (30 days) as the 'last month'
8+
// Thus 'today' is someday in May 2024
9+
const costDataLength = 65;
10+
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)
11+
const fixedFirstDay = dayjs(fixedToday).subtract(costDataLength, 'day');
12+
13+
const generateMockPricingData = () => {
14+
const resultsByTime = [];
15+
for (let i = 0; i < costDataLength; i++) {
16+
const date = dayjs(fixedFirstDay).add(i, 'day').format('YYYY-MM-DD');
17+
const month = dayjs(date).month(); // 0-indexed (0 = January, 1 = February, etc.)
18+
let service1Cost;
19+
20+
switch (month) {
21+
case 2: // March
22+
service1Cost = 0.9;
23+
break;
24+
case 3: // April
25+
service1Cost = 1.0; // Total cost of service1 in April will be 30.00
26+
break;
27+
case 4: // May
28+
service1Cost = 1.1;
29+
break;
30+
default:
31+
service1Cost = 0.0; // Default cost if none of the above
32+
}
33+
34+
resultsByTime.push({
35+
TimePeriod: {
36+
Start: date,
37+
End: dayjs(date).add(1, 'day').format('YYYY-MM-DD'),
38+
},
39+
Groups: [
40+
{
41+
Keys: ['service1'],
42+
Metrics: {
43+
UnblendedCost: {
44+
Amount: String(service1Cost),
45+
Unit: 'USD',
46+
},
47+
},
48+
},
49+
{
50+
Keys: ['service2'],
51+
Metrics: {
52+
UnblendedCost: {
53+
Amount: String(service1Cost * 100),
54+
Unit: 'USD',
55+
},
56+
},
57+
},
58+
],
59+
});
60+
}
61+
return { ResultsByTime: resultsByTime };
62+
};
63+
64+
describe('Cost Functions', () => {
65+
beforeAll(() => {
66+
AWSMock.setSDKInstance(AWS);
67+
});
68+
69+
afterAll(() => {
70+
AWSMock.restore();
71+
});
72+
73+
beforeEach(() => {
74+
jest.useFakeTimers('modern');
75+
jest.setSystemTime(new Date(fixedToday).getTime());
76+
});
77+
78+
afterEach(() => {
79+
jest.useRealTimers();
80+
});
81+
82+
describe('getRawCostByService', () => {
83+
it('should return raw cost by service', async () => {
84+
const awsConfig: AWSConfig = {
85+
credentials: {
86+
accessKeyId: 'testAccessKeyId',
87+
secretAccessKey: 'testSecretAccessKey',
88+
sessionToken: 'testSessionToken',
89+
},
90+
region: 'us-east-1',
91+
};
92+
93+
const mockPricingData = generateMockPricingData();
94+
95+
AWSMock.mock('CostExplorer', 'getCostAndUsage', (params, callback) => {
96+
callback(null, mockPricingData);
97+
});
98+
99+
const rawCostByService = await getRawCostByService(awsConfig);
100+
101+
const expectedRawCostByService = {
102+
service1: {},
103+
service2: {},
104+
};
105+
for (let i = 0; i < costDataLength; i++) {
106+
const date = dayjs(fixedFirstDay).add(i, 'day').format('YYYY-MM-DD');
107+
const month = dayjs(date).month();
108+
let service1Cost;
109+
110+
switch (month) {
111+
case 2: // March
112+
service1Cost = 0.9;
113+
break;
114+
case 3: // April
115+
service1Cost = 1.0; // Total cost of service1 in April will be 30.00
116+
break;
117+
case 4: // May
118+
service1Cost = 1.1;
119+
break;
120+
default:
121+
service1Cost = 0.0; // Default cost if none of the above
122+
}
123+
124+
expectedRawCostByService.service1[date] = service1Cost;
125+
expectedRawCostByService.service2[date] = service1Cost * 100;
126+
}
127+
128+
expect(rawCostByService).toEqual(expectedRawCostByService);
129+
130+
AWSMock.restore('CostExplorer');
131+
});
132+
});
133+
134+
describe('getTotalCosts', () => {
135+
it('should return total costs', async () => {
136+
const awsConfig: AWSConfig = {
137+
credentials: {
138+
accessKeyId: 'testAccessKeyId',
139+
secretAccessKey: 'testSecretAccessKey',
140+
sessionToken: 'testSessionToken',
141+
},
142+
region: 'us-east-1',
143+
};
144+
145+
const mockPricingData = generateMockPricingData();
146+
147+
AWSMock.mock('CostExplorer', 'getCostAndUsage', (params, callback) => {
148+
callback(null, mockPricingData);
149+
});
150+
151+
const totalCosts = await getTotalCosts(awsConfig);
152+
153+
const expectedTotalCosts = {
154+
totals: {
155+
lastMonth: 30 * (1 + 100), // Apr
156+
thisMonth: 10 * (1.1 + 110), // sum of May 1..May 10
157+
last7Days: 7 * 1.1 + 7 * 110, // sum of May 4..May 10
158+
yesterday: 1.1 + 110, // on May 10
159+
},
160+
totalsByService: {
161+
lastMonth: {
162+
// Apr
163+
service1: 30.0,
164+
service2: 3000.0,
165+
},
166+
thisMonth: {
167+
service1: 11.0, // 10 days of May
168+
service2: 1100.0,
169+
},
170+
last7Days: {
171+
service1: 7.7,
172+
service2: 770.0,
173+
},
174+
yesterday: {
175+
service1: 1.1,
176+
service2: 110.0,
177+
},
178+
},
179+
};
180+
181+
const roundToTwoDecimals = (num: number) => Math.round(num * 100) / 100;
182+
183+
Object.keys(totalCosts.totals).forEach((key) => {
184+
expect(roundToTwoDecimals(totalCosts.totals[key])).toBeCloseTo(
185+
expectedTotalCosts.totals[key],
186+
1,
187+
);
188+
});
189+
190+
Object.keys(totalCosts.totalsByService).forEach((period) => {
191+
Object.keys(totalCosts.totalsByService[period]).forEach((service) => {
192+
expect(
193+
roundToTwoDecimals(totalCosts.totalsByService[period][service]),
194+
).toBeCloseTo(expectedTotalCosts.totalsByService[period][service], 1);
195+
});
196+
});
197+
198+
AWSMock.restore('CostExplorer');
199+
});
200+
});
201+
});

src/cost.ts

+10-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import AWS from 'aws-sdk';
22
import dayjs from 'dayjs';
3+
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
4+
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
5+
dayjs.extend(isSameOrAfter);
6+
dayjs.extend(isSameOrBefore);
37
import { AWSConfig } from './config';
48
import { showSpinner } from './logger';
59

@@ -15,7 +19,7 @@ export async function getRawCostByService(
1519
showSpinner('Getting pricing data');
1620

1721
const costExplorer = new AWS.CostExplorer(awsConfig);
18-
const endDate = dayjs().subtract(1, 'day');
22+
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)
1923
const startDate = endDate.subtract(65, 'day');
2024

2125
const groupByConfig = [
@@ -74,7 +78,7 @@ export async function getRawCostByService(
7478
const filterKeys = group.Keys;
7579
const serviceName = filterKeys.find((key) => !/^\d{12}$/.test(key)); // AWS service name is non-12-digits string
7680
const cost = group.Metrics.UnblendedCost.Amount;
77-
const costDate = day.TimePeriod.End;
81+
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)
7882

7983
costByService[serviceName] = costByService[serviceName] || {};
8084
costByService[serviceName][costDate] = parseFloat(cost);
@@ -118,8 +122,8 @@ function calculateServiceTotals(
118122

119123
const startOfLastMonth = dayjs().subtract(1, 'month').startOf('month');
120124
const startOfThisMonth = dayjs().startOf('month');
121-
const startOfLast7Days = dayjs().subtract(7, 'day');
122-
const startOfYesterday = dayjs().subtract(1, 'day');
125+
const startOfLast7Days = dayjs().subtract(7, 'day').startOf('day');
126+
const startOfYesterday = dayjs().subtract(1, 'day').startOf('day');
123127

124128
for (const service of Object.keys(rawCostByService)) {
125129
const servicePrices = rawCostByService[service];
@@ -142,8 +146,8 @@ function calculateServiceTotals(
142146
}
143147

144148
if (
145-
dateObj.isSame(startOfLast7Days, 'week') &&
146-
!dateObj.isSame(startOfYesterday, 'day')
149+
dateObj.isSameOrAfter(startOfLast7Days) &&
150+
dateObj.isSameOrBefore(dayjs().startOf('day'))
147151
) {
148152
last7DaysServiceTotal += price;
149153
}

0 commit comments

Comments
 (0)