diff --git a/src/lib/features/traffic-data-usage/fake-traffic-data-usage-store.ts b/src/lib/features/traffic-data-usage/fake-traffic-data-usage-store.ts index b3e6c0f2bf..fa2262f7f7 100644 --- a/src/lib/features/traffic-data-usage/fake-traffic-data-usage-store.ts +++ b/src/lib/features/traffic-data-usage/fake-traffic-data-usage-store.ts @@ -1,9 +1,15 @@ import type { IStatTrafficUsageKey, IStatTrafficUsage, + IStatMonthlyTrafficUsage, } from './traffic-data-usage-store-type'; import type { ITrafficDataUsageStore } from '../../types'; -import { isSameMonth, parse } from 'date-fns'; +import { + differenceInCalendarMonths, + format, + isSameMonth, + parse, +} from 'date-fns'; export class FakeTrafficDataUsageStore implements ITrafficDataUsageStore { private trafficData: IStatTrafficUsage[] = []; @@ -50,4 +56,37 @@ export class FakeTrafficDataUsageStore implements ITrafficDataUsageStore { isSameMonth(data.day, periodDate), ); } + + async getTrafficDataForMonthRange( + monthsBack: number, + ): Promise { + const now = new Date(); + + const data: { [key: string]: IStatMonthlyTrafficUsage } = + this.trafficData + .filter( + (entry) => + differenceInCalendarMonths(now, entry.day) <= + monthsBack, + ) + .reduce((acc, entry) => { + const month = format(entry.day, 'yyyy-MM'); + const key = `${month}-${entry.trafficGroup}-${entry.statusCodeSeries}`; + + if (acc[key]) { + acc[key].count += entry.count; + } else { + acc[key] = { + month, + trafficGroup: entry.trafficGroup, + statusCodeSeries: entry.statusCodeSeries, + count: entry.count, + }; + } + + return acc; + }, {}); + + return Object.values(data); + } } diff --git a/src/lib/features/traffic-data-usage/traffic-data-usage-store-type.ts b/src/lib/features/traffic-data-usage/traffic-data-usage-store-type.ts index f92a1640d3..d24711c756 100644 --- a/src/lib/features/traffic-data-usage/traffic-data-usage-store-type.ts +++ b/src/lib/features/traffic-data-usage/traffic-data-usage-store-type.ts @@ -7,6 +7,13 @@ export type IStatTrafficUsage = { count: number; }; +export type IStatMonthlyTrafficUsage = { + month: string; + trafficGroup: string; + statusCodeSeries: number; + count: number; +}; + export interface IStatTrafficUsageKey { day: Date; trafficGroup: string; @@ -17,4 +24,7 @@ export interface ITrafficDataUsageStore extends Store { upsert(trafficDataUsage: IStatTrafficUsage): Promise; getTrafficDataUsageForPeriod(period: string): Promise; + getTrafficDataForMonthRange( + monthsBack: number, + ): Promise; } diff --git a/src/lib/features/traffic-data-usage/traffic-data-usage-store.test.ts b/src/lib/features/traffic-data-usage/traffic-data-usage-store.test.ts index 771a3cff0c..3d40f19c40 100644 --- a/src/lib/features/traffic-data-usage/traffic-data-usage-store.test.ts +++ b/src/lib/features/traffic-data-usage/traffic-data-usage-store.test.ts @@ -1,3 +1,4 @@ +import { differenceInCalendarMonths, subMonths } from 'date-fns'; import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; import getLogger from '../../../test/fixtures/no-logger'; import type { ITrafficDataUsageStore, IUnleashStores } from '../../types'; @@ -20,6 +21,10 @@ afterAll(async () => { await db.destroy(); }); +beforeEach(async () => { + await trafficDataUsageStore.deleteAll(); +}); + test('upsert stores new entries', async () => { const data = { day: new Date(), @@ -62,7 +67,6 @@ test('upsert upserts', async () => { }); test('getAll returns all', async () => { - await trafficDataUsageStore.deleteAll(); const data1 = { day: new Date(), trafficGroup: 'default3', @@ -83,7 +87,6 @@ test('getAll returns all', async () => { }); test('delete deletes the specified item', async () => { - await trafficDataUsageStore.deleteAll(); const data1 = { day: new Date(), trafficGroup: 'default3', @@ -110,7 +113,6 @@ test('delete deletes the specified item', async () => { }); test('can query for specific items', async () => { - await trafficDataUsageStore.deleteAll(); const data1 = { day: new Date(), trafficGroup: 'default3', @@ -154,7 +156,6 @@ test('can query for specific items', async () => { }); test('can query for data from specific periods', async () => { - await trafficDataUsageStore.deleteAll(); const data1 = { day: new Date(2024, 2, 12), trafficGroup: 'default-period-query', @@ -195,3 +196,57 @@ test('can query for data from specific periods', async () => { expect(traffic_period_usage_older.length).toBe(1); expect(traffic_period_usage_older[0].count).toBe(12); }); + +test('can query for monthly aggregation of data for a specified range', async () => { + const now = new Date(); + + const expectedValues: { groupA: number; groupB: number }[] = []; + + // fill in with data for the last 13 months + for (let i = 0; i <= 12; i++) { + const then = subMonths(now, i); + let monthAggregateA = 0; + let monthAggregateB = 0; + for (let day = 1; day <= 5; day++) { + const dayValue = i + day; + const dayValueB = dayValue * 2; + monthAggregateA += dayValue; + monthAggregateB += dayValueB; + const dataA = { + day: new Date(then.getFullYear(), then.getMonth(), day), + trafficGroup: 'groupA', + statusCodeSeries: 200, + count: dayValue, + }; + await trafficDataUsageStore.upsert(dataA); + const dataB = { + day: new Date(then.getFullYear(), then.getMonth(), day), + trafficGroup: 'groupB', + statusCodeSeries: 200, + count: dayValueB, + }; + await trafficDataUsageStore.upsert(dataB); + } + expectedValues.push({ + groupA: monthAggregateA, + groupB: monthAggregateB, + }); + } + + for (const monthsBack of [3, 6, 12]) { + const result = + await trafficDataUsageStore.getTrafficDataForMonthRange(monthsBack); + + // should have the current month and the preceding n months (one entry per group) + expect(result.length).toBe((monthsBack + 1) * 2); + + for (const entry of result) { + const index = differenceInCalendarMonths( + now, + new Date(entry.month), + ); + const expectedCount = expectedValues[index]; + expect(entry.count).toBe(expectedCount[entry.trafficGroup]); + } + } +}); diff --git a/src/lib/features/traffic-data-usage/traffic-data-usage-store.ts b/src/lib/features/traffic-data-usage/traffic-data-usage-store.ts index 5d288089ba..ba686ea867 100644 --- a/src/lib/features/traffic-data-usage/traffic-data-usage-store.ts +++ b/src/lib/features/traffic-data-usage/traffic-data-usage-store.ts @@ -1,6 +1,7 @@ import type { Db } from '../../db/db'; import type { Logger, LogProvider } from '../../logger'; import type { + IStatMonthlyTrafficUsage, IStatTrafficUsage, IStatTrafficUsageKey, ITrafficDataUsageStore, @@ -55,7 +56,7 @@ export class TrafficDataUsageStore implements ITrafficDataUsageStore { } async exists(key: IStatTrafficUsageKey): Promise { const result = await this.db.raw( - `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE + `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE day = ? AND traffic_group = ? AND status_code_series ?) AS present`, @@ -97,4 +98,37 @@ export class TrafficDataUsageStore implements ITrafficDataUsageStore { ); return rows.map(mapRow); } + + async getTrafficDataForMonthRange( + monthsBack: number, + ): Promise { + const rows = await this.db(TABLE) + .select( + 'traffic_group', + 'status_code_series', + this.db.raw(`to_char(day, 'YYYY-MM') AS month`), + this.db.raw(`SUM(count) AS count`), + ) + .whereRaw( + `day >= date_trunc('month', CURRENT_DATE) - make_interval(months := ?)`, + [monthsBack], + ) + .groupBy([ + 'traffic_group', + this.db.raw(`to_char(day, 'YYYY-MM')`), + 'status_code_series', + ]) + .orderBy([ + { column: 'month', order: 'desc' }, + { column: 'traffic_group', order: 'asc' }, + ]); + return rows.map( + ({ traffic_group, status_code_series, month, count }) => ({ + trafficGroup: traffic_group, + statusCodeSeries: status_code_series, + month, + count: Number.parseInt(count), + }), + ); + } }