diff --git a/src/lib/db/event-store.ts b/src/lib/db/event-store.ts index 0f041ba86d..9060210093 100644 --- a/src/lib/db/event-store.ts +++ b/src/lib/db/event-store.ts @@ -17,7 +17,48 @@ const EVENT_COLUMNS = [ 'feature_name', 'project', 'environment', -]; +] as const; + +export type IQueryOperations = + | IWhereOperation + | IBeforeDateOperation + | IBetweenDatesOperation + | IForFeaturesOperation; + +interface IWhereOperation { + op: 'where'; + parameters: { + [key: string]: string; + }; +} + +interface IBeforeDateOperation { + op: 'beforeDate'; + parameters: { + dateAccessor: string; + date: string; + }; +} + +interface IBetweenDatesOperation { + op: 'betweenDate'; + parameters: { + dateAccessor: string; + range: string[]; + }; +} + +interface IForFeaturesOperation { + op: 'forFeatures'; + parameters: IForFeaturesParams; +} + +interface IForFeaturesParams { + type: string; + projectId: string; + environments: string[]; + features: string[]; +} export interface IEventTable { id: number; @@ -120,25 +161,77 @@ class EventStore extends AnyEventEmitter implements IEventStore { return present; } - async getForFeatures( - features: string[], - environments: string[], - query: { type: string; projectId: string }, - ): Promise { + async query(operations: IQueryOperations[]): Promise { try { - const rows = await this.db - .select(EVENT_COLUMNS) - .from(TABLE) - .where({ type: query.type, project: query.projectId }) - .whereIn('feature_name', features) - .whereIn('environment', environments); + let query: Knex.QueryBuilder = this.select(); + operations.forEach((operation) => { + if (operation.op === 'where') { + query = this.where(query, operation.parameters); + } + + if (operation.op === 'forFeatures') { + query = this.forFeatures(query, operation.parameters); + } + + if (operation.op === 'beforeDate') { + query = this.beforeDate(query, operation.parameters); + } + + if (operation.op === 'betweenDate') { + query = this.betweenDate(query, operation.parameters); + } + }); + + const rows = await query; return rows.map(this.rowToEvent); } catch (e) { return []; } } + where( + query: Knex.QueryBuilder, + parameters: { [key: string]: string }, + ): Knex.QueryBuilder { + return query.where(parameters); + } + + beforeDate( + query: Knex.QueryBuilder, + parameters: { dateAccessor: string; date: string }, + ): Knex.QueryBuilder { + return query.andWhere(parameters.dateAccessor, '>=', parameters.date); + } + + betweenDate( + query: Knex.QueryBuilder, + parameters: { dateAccessor: string; range: string[] }, + ): Knex.QueryBuilder { + if (parameters.range && parameters.range.length === 2) { + return query.andWhereBetween(parameters.dateAccessor, [ + parameters.range[0], + parameters.range[1], + ]); + } + + return query; + } + + select(): Knex.QueryBuilder { + return this.db.select(EVENT_COLUMNS).from(TABLE); + } + + forFeatures( + query: Knex.QueryBuilder, + parameters: IForFeaturesParams, + ): Knex.QueryBuilder { + return query + .where({ type: parameters.type, project: parameters.projectId }) + .whereIn('feature_name', parameters.features) + .whereIn('environment', parameters.environments); + } + async get(key: number): Promise { const row = await this.db(TABLE).where({ id: key }).first(); return this.rowToEvent(row); diff --git a/src/lib/db/feature-toggle-store.ts b/src/lib/db/feature-toggle-store.ts index 840fee69c0..d32d600cd2 100644 --- a/src/lib/db/feature-toggle-store.ts +++ b/src/lib/db/feature-toggle-store.ts @@ -111,6 +111,35 @@ export default class FeatureToggleStore implements IFeatureToggleStore { return rows.map(this.rowToFeature); } + async getByDate(queryModifiers: { + archived?: boolean; + project?: string; + date?: string; + range?: string[]; + dateAccessor: string; + }): Promise { + const { project, archived, dateAccessor } = queryModifiers; + let query = this.db + .select(FEATURE_COLUMNS) + .from(TABLE) + .where({ project }) + .modify(FeatureToggleStore.filterByArchived, archived); + + if (queryModifiers.date) { + query.andWhere(dateAccessor, '>=', queryModifiers.date); + } + + if (queryModifiers.range && queryModifiers.range.length === 2) { + query.andWhereBetween(dateAccessor, [ + queryModifiers.range[0], + queryModifiers.range[1], + ]); + } + + const rows = await query; + return rows.map(this.rowToFeature); + } + /** * Get projectId from feature filtered by name. Used by Rbac middleware * @deprecated diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 659bd0f300..c5442482f5 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -35,6 +35,7 @@ import { PublicSignupTokenStore } from './public-signup-token-store'; import { FavoriteFeaturesStore } from './favorite-features-store'; import { FavoriteProjectsStore } from './favorite-projects-store'; import { AccountStore } from './account-store'; +import ProjectStatsStore from './project-stats-store'; export const createStores = ( config: IUnleashConfig, @@ -113,6 +114,7 @@ export const createStores = ( eventBus, getLogger, ), + projectStatsStore: new ProjectStatsStore(db, eventBus, getLogger), }; }; diff --git a/src/lib/db/project-stats-store.ts b/src/lib/db/project-stats-store.ts new file mode 100644 index 0000000000..d9a4de3e6f --- /dev/null +++ b/src/lib/db/project-stats-store.ts @@ -0,0 +1,52 @@ +import { Knex } from 'knex'; +import { Logger, LogProvider } from '../logger'; + +import metricsHelper from '../util/metrics-helper'; +import { DB_TIME } from '../metric-events'; +import EventEmitter from 'events'; +import { IProjectStats } from 'lib/services/project-service'; +import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type'; + +const TABLE = 'project_stats'; + +class ProjectStatsStore implements IProjectStatsStore { + private db: Knex; + + private logger: Logger; + + private timer: Function; + + constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { + this.db = db; + this.logger = getLogger('project-stats-store.ts'); + this.timer = (action) => + metricsHelper.wrapTimer(eventBus, DB_TIME, { + store: 'project_stats', + action, + }); + } + + async updateProjectStats( + projectId: string, + status: IProjectStats, + ): Promise { + await this.db(TABLE) + .insert({ + avg_time_to_prod_current_window: + status.avgTimeToProdCurrentWindow, + avg_time_to_prod_past_window: status.avgTimeToProdPastWindow, + project: projectId, + features_created_current_window: status.createdCurrentWindow, + features_created_past_window: status.createdPastWindow, + features_archived_current_window: status.archivedCurrentWindow, + features_archived_past_window: status.archivedPastWindow, + project_changes_current_window: + status.projectActivityCurrentWindow, + project_changes_past_window: status.projectActivityPastWindow, + }) + .onConflict('project') + .merge(); + } +} + +export default ProjectStatsStore; diff --git a/src/lib/read-models/project-status/project-status.test.ts b/src/lib/read-models/time-to-production/time-to-production.test.ts similarity index 90% rename from src/lib/read-models/project-status/project-status.test.ts rename to src/lib/read-models/time-to-production/time-to-production.test.ts index 25f5485b52..a0f50419c4 100644 --- a/src/lib/read-models/project-status/project-status.test.ts +++ b/src/lib/read-models/time-to-production/time-to-production.test.ts @@ -1,6 +1,6 @@ import { addDays, subDays } from 'date-fns'; import { IEvent } from 'lib/types'; -import { ProjectStatus } from './project-status'; +import { TimeToProduction } from './time-to-production'; const modifyEventCreatedAt = (events: IEvent[], days: number): IEvent[] => { return events.map((event) => { @@ -97,7 +97,7 @@ const features = [ type: 'release', project: 'average-time-to-prod', stale: false, - createdAt: new Date('2023-01-19T09:37:32.483Z'), + createdAt: new Date('2022-12-05T09:37:32.483Z'), lastSeenAt: null, impressionData: false, archivedAt: null, @@ -143,7 +143,11 @@ const features = [ describe('calculate average time to production', () => { test('should build a map of feature events', () => { - const projectStatus = new ProjectStatus(features, environments, events); + const projectStatus = new TimeToProduction( + features, + environments, + events, + ); const featureEvents = projectStatus.getFeatureEvents(); @@ -153,15 +157,19 @@ describe('calculate average time to production', () => { }); test('should calculate average correctly', () => { - const projectStatus = new ProjectStatus(features, environments, events); + const projectStatus = new TimeToProduction( + features, + environments, + events, + ); const timeToProduction = projectStatus.calculateAverageTimeToProd(); - expect(timeToProduction).toBe(9.75); + expect(timeToProduction).toBe(21); }); test('should sort events by createdAt', () => { - const projectStatus = new ProjectStatus(features, environments, [ + const projectStatus = new TimeToProduction(features, environments, [ ...modifyEventCreatedAt(events, 5), ...events, ]); @@ -192,7 +200,7 @@ describe('calculate average time to production', () => { }); test('should not count events that are development environments', () => { - const projectStatus = new ProjectStatus(features, environments, [ + const projectStatus = new TimeToProduction(features, environments, [ createEvent('development', { createdAt: subDays(new Date('2023-01-25T09:37:32.504Z'), 10), }), @@ -203,6 +211,6 @@ describe('calculate average time to production', () => { ]); const timeToProduction = projectStatus.calculateAverageTimeToProd(); - expect(timeToProduction).toBe(9.75); + expect(timeToProduction).toBe(21); }); }); diff --git a/src/lib/read-models/project-status/project-status.ts b/src/lib/read-models/time-to-production/time-to-production.ts similarity index 86% rename from src/lib/read-models/project-status/project-status.ts rename to src/lib/read-models/time-to-production/time-to-production.ts index 9a3fbb21cb..b402bd7fee 100644 --- a/src/lib/read-models/project-status/project-status.ts +++ b/src/lib/read-models/time-to-production/time-to-production.ts @@ -10,7 +10,7 @@ interface IFeatureTimeToProdData { events: IEvent[]; } -export class ProjectStatus { +export class TimeToProduction { private features: FeatureToggle[]; private productionEnvironments: IProjectEnvironment[]; @@ -31,16 +31,25 @@ export class ProjectStatus { const featureEvents = this.getFeatureEvents(); const sortedFeatureEvents = this.sortFeatureEventsByCreatedAt(featureEvents); + const timeToProdPerFeature = this.calculateTimeToProdForFeatures(sortedFeatureEvents); + if (timeToProdPerFeature.length) { + const sum = timeToProdPerFeature.reduce( + (acc, curr) => acc + curr, + 0, + ); - const sum = timeToProdPerFeature.reduce((acc, curr) => acc + curr, 0); + return Number( + (sum / Object.keys(sortedFeatureEvents).length).toFixed(1), + ); + } - return sum / Object.keys(sortedFeatureEvents).length; + return 0; } getFeatureEvents(): IFeatureTimeToProdCalculationMap { - return this.filterEvents(this.events).reduce((acc, event) => { + return this.getProductionEvents(this.events).reduce((acc, event) => { if (acc[event.featureName]) { acc[event.featureName].events.push(event); } else { @@ -55,7 +64,7 @@ export class ProjectStatus { }, {}); } - filterEvents(events: IEvent[]): IEvent[] { + getProductionEvents(events: IEvent[]): IEvent[] { return events.filter((event) => { const found = this.productionEnvironments.find( (env) => env.name === event.environment, @@ -74,6 +83,7 @@ export class ProjectStatus { ): number[] { return Object.keys(featureEvents).map((featureName) => { const feature = featureEvents[featureName]; + const earliestEvent = feature.events[0]; const createdAtDate = new Date(feature.createdAt); diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index a3d72efc65..54c1adf673 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -164,7 +164,7 @@ export const createServices = ( if (config.flagResolver.isEnabled('projectStatusApi')) { const ONE_DAY = 1440; schedulerService.schedule( - projectService.statusJob.bind(projectHealthService), + projectService.statusJob.bind(projectService), minutesToMilliseconds(ONE_DAY), ); } diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index a0e7da9c31..0c5e039d59 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -1,3 +1,4 @@ +import { subDays } from 'date-fns'; import User, { IUser } from '../types/user'; import { AccessService } from './access-service'; import NameExistsError from '../error/name-exists-error'; @@ -47,7 +48,8 @@ import { arraysHaveSameItems } from '../util/arraysHaveSameItems'; import { GroupService } from './group-service'; import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group'; import { FavoritesService } from './favorites-service'; -import { ProjectStatus } from '../read-models/project-status/project-status'; +import { TimeToProduction } from '../read-models/time-to-production/time-to-production'; +import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type'; const getCreatedBy = (user: IUser) => user.email || user.username; @@ -57,6 +59,25 @@ export interface AccessWithRoles { groups: IGroupModelWithProjectRole[]; } +type Days = number; +type Count = number; + +export interface IProjectStats { + avgTimeToProdCurrentWindow: Days; + avgTimeToProdPastWindow: Days; + createdCurrentWindow: Count; + createdPastWindow: Count; + archivedCurrentWindow: Count; + archivedPastWindow: Count; + projectActivityCurrentWindow: Count; + projectActivityPastWindow: Count; +} + +interface ICalculateStatus { + projectId: string; + updates: IProjectStats; +} + export default class ProjectService { private store: IProjectStore; @@ -84,6 +105,8 @@ export default class ProjectService { private favoritesService: FavoritesService; + private projectStatsStore: IProjectStatsStore; + constructor( { projectStore, @@ -94,6 +117,7 @@ export default class ProjectService { featureEnvironmentStore, featureTagStore, accountStore, + projectStatsStore, }: Pick< IUnleashStores, | 'projectStore' @@ -104,6 +128,7 @@ export default class ProjectService { | 'featureEnvironmentStore' | 'featureTagStore' | 'accountStore' + | 'projectStatsStore' >, config: IUnleashConfig, accessService: AccessService, @@ -123,6 +148,7 @@ export default class ProjectService { this.tagStore = featureTagStore; this.accountStore = accountStore; this.groupService = groupService; + this.projectStatsStore = projectStatsStore; this.logger = config.getLogger('services/project-service.js'); } @@ -596,20 +622,82 @@ export default class ProjectService { async statusJob(): Promise { const projects = await this.store.getAll(); + const statusUpdates = await Promise.all( + projects.map((project) => this.getStatusUpdates(project.id)), + ); + await Promise.all( - projects.map((project) => - this.calculateAverageTimeToProd(project.id), - ), + statusUpdates.map((statusUpdate) => { + return this.projectStatsStore.updateProjectStats( + statusUpdate.projectId, + statusUpdate.updates, + ); + }), ); } - async calculateAverageTimeToProd(projectId: string): Promise { + async getStatusUpdates(projectId: string): Promise { // Get all features for project with type release const features = await this.featureToggleStore.getAll({ type: 'release', project: projectId, }); + const dateMinusThirtyDays = subDays(new Date(), 30).toISOString(); + const dateMinusSixtyDays = subDays(new Date(), 60).toISOString(); + + const [createdCurrentWindow, createdPastWindow] = await Promise.all([ + await this.featureToggleStore.getByDate({ + project: projectId, + dateAccessor: 'created_at', + date: dateMinusThirtyDays, + }), + await this.featureToggleStore.getByDate({ + project: projectId, + dateAccessor: 'created_at', + range: [dateMinusSixtyDays, dateMinusThirtyDays], + }), + ]); + + const [archivedCurrentWindow, archivedPastWindow] = await Promise.all([ + await this.featureToggleStore.getByDate({ + project: projectId, + archived: true, + dateAccessor: 'archived_at', + date: dateMinusThirtyDays, + }), + await this.featureToggleStore.getByDate({ + project: projectId, + archived: true, + dateAccessor: 'archived_at', + range: [dateMinusSixtyDays, dateMinusThirtyDays], + }), + ]); + + const [projectActivityCurrentWindow, projectActivityPastWindow] = + await Promise.all([ + this.eventStore.query([ + { op: 'where', parameters: { project: projectId } }, + { + op: 'beforeDate', + parameters: { + dateAccessor: 'created_at', + date: dateMinusThirtyDays, + }, + }, + ]), + this.eventStore.query([ + { op: 'where', parameters: { project: projectId } }, + { + op: 'betweenDate', + parameters: { + dateAccessor: 'created_at', + range: [dateMinusSixtyDays, dateMinusThirtyDays], + }, + }, + ]), + ]); + // Get all project environments with type of production const productionEnvironments = await this.environmentStore.getProjectEnvironments(projectId, { @@ -618,21 +706,73 @@ export default class ProjectService { // Get all events for features that correspond to feature toggle environment ON // Filter out events that are not a production evironment - const events = await this.eventStore.getForFeatures( - features.map((feature) => feature.name), - productionEnvironments.map((env) => env.name), - { - type: FEATURE_ENVIRONMENT_ENABLED, - projectId, - }, - ); - const projectStatus = new ProjectStatus( + const eventsCurrentWindow = await this.eventStore.query([ + { + op: 'forFeatures', + parameters: { + features: features.map((feature) => feature.name), + environments: productionEnvironments.map((env) => env.name), + type: FEATURE_ENVIRONMENT_ENABLED, + projectId, + }, + }, + { + op: 'beforeDate', + parameters: { + dateAccessor: 'created_at', + date: dateMinusThirtyDays, + }, + }, + ]); + + const eventsPastWindow = await this.eventStore.query([ + { + op: 'forFeatures', + parameters: { + features: features.map((feature) => feature.name), + environments: productionEnvironments.map((env) => env.name), + type: FEATURE_ENVIRONMENT_ENABLED, + projectId, + }, + }, + { + op: 'betweenDate', + parameters: { + dateAccessor: 'created_at', + range: [dateMinusSixtyDays, dateMinusThirtyDays], + }, + }, + ]); + + const currentWindowTimeToProdReadModel = new TimeToProduction( features, productionEnvironments, - events, + eventsCurrentWindow, ); - return projectStatus.calculateAverageTimeToProd(); + + const pastWindowTimeToProdReadModel = new TimeToProduction( + features, + productionEnvironments, + eventsPastWindow, + ); + + return { + projectId, + updates: { + avgTimeToProdCurrentWindow: + currentWindowTimeToProdReadModel.calculateAverageTimeToProd(), + avgTimeToProdPastWindow: + pastWindowTimeToProdReadModel.calculateAverageTimeToProd(), + createdCurrentWindow: createdCurrentWindow.length, + createdPastWindow: createdPastWindow.length, + archivedCurrentWindow: archivedCurrentWindow.length, + archivedPastWindow: archivedPastWindow.length, + projectActivityCurrentWindow: + projectActivityCurrentWindow.length, + projectActivityPastWindow: projectActivityPastWindow.length, + }, + }; } async getProjectOverview( diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index d917199de0..20e01a39eb 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -31,6 +31,7 @@ import { IPublicSignupTokenStore } from './stores/public-signup-token-store'; import { IFavoriteFeaturesStore } from './stores/favorite-features'; import { IFavoriteProjectsStore } from './stores/favorite-projects'; import { IAccountStore } from './stores/account-store'; +import { IProjectStatsStore } from './stores/project-stats-store-type'; export interface IUnleashStores { accessStore: IAccessStore; @@ -66,6 +67,7 @@ export interface IUnleashStores { publicSignupTokenStore: IPublicSignupTokenStore; favoriteFeaturesStore: IFavoriteFeaturesStore; favoriteProjectsStore: IFavoriteProjectsStore; + projectStatsStore: IProjectStatsStore; } export { diff --git a/src/lib/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts index 19be70501d..5a92287b19 100644 --- a/src/lib/types/stores/event-store.ts +++ b/src/lib/types/stores/event-store.ts @@ -2,6 +2,7 @@ import { IBaseEvent, IEvent } from '../events'; import { Store } from './store'; import { SearchEventsSchema } from '../../openapi/spec/search-events-schema'; import EventEmitter from 'events'; +import { IQueryOperations } from 'lib/db/event-store'; export interface IEventStore extends Store, EventEmitter { store(event: IBaseEvent): Promise; @@ -10,9 +11,5 @@ export interface IEventStore extends Store, EventEmitter { count(): Promise; filteredCount(search: SearchEventsSchema): Promise; searchEvents(search: SearchEventsSchema): Promise; - getForFeatures( - features: string[], - environments: string[], - query: { type: string; projectId: string }, - ): Promise; + query(operations: IQueryOperations[]): Promise; } diff --git a/src/lib/types/stores/feature-toggle-store.ts b/src/lib/types/stores/feature-toggle-store.ts index 6c8ecc1808..71ba8637b9 100644 --- a/src/lib/types/stores/feature-toggle-store.ts +++ b/src/lib/types/stores/feature-toggle-store.ts @@ -18,6 +18,13 @@ export interface IFeatureToggleStore extends Store { revive(featureName: string): Promise; getAll(query?: Partial): Promise; getAllByNames(names: string[]): Promise; + getByDate(queryModifiers: { + archived?: boolean; + project?: string; + date?: string; + range?: string[]; + dateAccessor: string; + }): Promise; /** * @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments) * @param featureName diff --git a/src/lib/types/stores/project-stats-store-type.ts b/src/lib/types/stores/project-stats-store-type.ts new file mode 100644 index 0000000000..c8747134ce --- /dev/null +++ b/src/lib/types/stores/project-stats-store-type.ts @@ -0,0 +1,5 @@ +import { IProjectStats } from 'lib/services/project-service'; + +export interface IProjectStatsStore { + updateProjectStats(projectId: string, status: IProjectStats): Promise; +} diff --git a/src/migrations/20230125065315-project-stats-table.js b/src/migrations/20230125065315-project-stats-table.js new file mode 100644 index 0000000000..69e224c89b --- /dev/null +++ b/src/migrations/20230125065315-project-stats-table.js @@ -0,0 +1,28 @@ +exports.up = function (db, cb) { + db.runSql( + `CREATE TABLE IF NOT EXISTS project_stats ( + project VARCHAR(255) NOT NULL, + avg_time_to_prod_current_window FLOAT DEFAULT 0, + avg_time_to_prod_past_window FLOAT DEFAULT 0, + project_changes_current_window INTEGER DEFAULT 0, + project_changes_past_window INTEGER DEFAULT 0, + features_created_current_window INTEGER DEFAULT 0, + features_created_past_window INTEGER DEFAULT 0, + features_archived_current_window INTEGER DEFAULT 0, + features_archived_past_window INTEGER DEFAULT 0, + FOREIGN KEY (project) references projects(id) ON DELETE CASCADE, + UNIQUE(project) + ); + `, + cb, + ); +}; + +exports.down = function (db, cb) { + db.runSql( + ` + DROP TABLE project_status; + `, + cb, + ); +}; diff --git a/src/server-dev.ts b/src/server-dev.ts index 37ad823135..b9b6ca07af 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -43,6 +43,7 @@ process.nextTick(async () => { maintenance: false, featuresExportImport: true, newProjectOverview: true, + projectStatusApi: true, }, }, authentication: { diff --git a/src/test/e2e/services/project-service.e2e.test.ts b/src/test/e2e/services/project-service.e2e.test.ts index 19bf80bfb0..af7970f97a 100644 --- a/src/test/e2e/services/project-service.e2e.test.ts +++ b/src/test/e2e/services/project-service.e2e.test.ts @@ -13,7 +13,7 @@ import { SegmentService } from '../../../lib/services/segment-service'; import { GroupService } from '../../../lib/services/group-service'; import { FavoritesService } from '../../../lib/services'; import { FeatureEnvironmentEvent } from '../../../lib/types/events'; -import { addDays } from 'date-fns'; +import { subDays } from 'date-fns'; let stores; let db: ITestDb; @@ -1057,13 +1057,20 @@ test('should only count active feature toggles for project', async () => { expect(theProject?.featureCount).toBe(1); }); -const updateEventCreatedAt = async (days: number, featureName: string) => { - await db.rawDatabase +const updateEventCreatedAt = async (date: Date, featureName: string) => { + return db.rawDatabase .table('events') - .update({ created_at: addDays(new Date(), days) }) + .update({ created_at: date }) .where({ feature_name: featureName }); }; +const updateFeature = async (featureName: string, update: any) => { + return db.rawDatabase + .table('features') + .update(update) + .where({ name: featureName }); +}; + test('should calculate average time to production', async () => { const project = { id: 'average-time-to-prod', @@ -1077,6 +1084,7 @@ test('should calculate average time to production', async () => { { name: 'average-prod-time-2' }, { name: 'average-prod-time-3' }, { name: 'average-prod-time-4' }, + { name: 'average-prod-time-5' }, ]; const featureToggles = await Promise.all( @@ -1104,11 +1112,103 @@ test('should calculate average time to production', async () => { }), ); - await updateEventCreatedAt(6, 'average-prod-time'); - await updateEventCreatedAt(12, 'average-prod-time-2'); - await updateEventCreatedAt(7, 'average-prod-time-3'); - await updateEventCreatedAt(14, 'average-prod-time-4'); + await updateEventCreatedAt(subDays(new Date(), 31), 'average-prod-time-5'); - const result = await projectService.calculateAverageTimeToProd(project.id); - expect(result).toBe(9.75); + await Promise.all( + featureToggles.map((toggle) => + updateFeature(toggle.name, { created_at: subDays(new Date(), 15) }), + ), + ); + + await updateFeature('average-prod-time-5', { + created_at: subDays(new Date(), 33), + }); + + const result = await projectService.getStatusUpdates(project.id); + expect(result.updates.avgTimeToProdCurrentWindow).toBe(14); + expect(result.updates.avgTimeToProdPastWindow).toBe(1); +}); + +test('should get correct amount of features created in current and past window', async () => { + const project = { + id: 'features-created', + name: 'features-created', + }; + + await projectService.createProject(project, user.id); + + const toggles = [ + { name: 'features-created' }, + { name: 'features-created-2' }, + { name: 'features-created-3' }, + { name: 'features-created-4' }, + ]; + + await Promise.all( + toggles.map((toggle) => { + return featureToggleService.createFeatureToggle( + project.id, + toggle, + user, + ); + }), + ); + + await Promise.all([ + updateFeature(toggles[2].name, { created_at: subDays(new Date(), 31) }), + updateFeature(toggles[3].name, { created_at: subDays(new Date(), 31) }), + ]); + + const result = await projectService.getStatusUpdates(project.id); + expect(result.updates.createdCurrentWindow).toBe(2); + expect(result.updates.createdPastWindow).toBe(2); +}); + +test('should get correct amount of features archived in current and past window', async () => { + const project = { + id: 'features-archived', + name: 'features-archived', + }; + + await projectService.createProject(project, user.id); + + const toggles = [ + { name: 'features-archived' }, + { name: 'features-archived-2' }, + { name: 'features-archived-3' }, + { name: 'features-archived-4' }, + ]; + + await Promise.all( + toggles.map((toggle) => { + return featureToggleService.createFeatureToggle( + project.id, + toggle, + user, + ); + }), + ); + + await Promise.all([ + updateFeature(toggles[0].name, { + archived_at: new Date(), + archived: true, + }), + updateFeature(toggles[1].name, { + archived_at: new Date(), + archived: true, + }), + updateFeature(toggles[2].name, { + archived_at: subDays(new Date(), 31), + archived: true, + }), + updateFeature(toggles[3].name, { + archived_at: subDays(new Date(), 31), + archived: true, + }), + ]); + + const result = await projectService.getStatusUpdates(project.id); + expect(result.updates.archivedCurrentWindow).toBe(2); + expect(result.updates.archivedPastWindow).toBe(2); }); diff --git a/src/test/fixtures/fake-event-store.ts b/src/test/fixtures/fake-event-store.ts index 5c75ecfcdf..5d2486ecb3 100644 --- a/src/test/fixtures/fake-event-store.ts +++ b/src/test/fixtures/fake-event-store.ts @@ -1,6 +1,7 @@ import { IEventStore } from '../../lib/types/stores/event-store'; import { IEvent } from '../../lib/types/events'; import { AnyEventEmitter } from '../../lib/util/anyEventEmitter'; +import { IQueryOperations } from 'lib/db/event-store'; class FakeEventStore extends AnyEventEmitter implements IEventStore { events: IEvent[]; @@ -80,6 +81,11 @@ class FakeEventStore extends AnyEventEmitter implements IEventStore { ); }); } + + async query(operations: IQueryOperations[]): Promise { + if (operations) return []; + return []; + } } module.exports = FakeEventStore; diff --git a/src/test/fixtures/fake-feature-toggle-store.ts b/src/test/fixtures/fake-feature-toggle-store.ts index 1c35568057..095098acea 100644 --- a/src/test/fixtures/fake-feature-toggle-store.ts +++ b/src/test/fixtures/fake-feature-toggle-store.ts @@ -173,6 +173,41 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { return Promise.resolve(newVariants); } + async getByDate(queryModifiers: { + archived?: boolean; + project?: string; + date?: string; + range?: string[]; + dateAccessor: string; + }): Promise { + return this.features.filter((feature) => { + if (feature.archived === queryModifiers.archived) { + return true; + } + + if (feature.project === queryModifiers.project) { + return true; + } + + if ( + new Date(feature[queryModifiers.dateAccessor]).getTime() >= + new Date(queryModifiers.date).getTime() + ) { + return true; + } + + const featureDate = new Date( + feature[queryModifiers.dateAccessor], + ).getTime(); + if ( + featureDate >= new Date(queryModifiers.range[0]).getTime() && + featureDate <= new Date(queryModifiers.range[1]).getTime() + ) { + return true; + } + }); + } + dropAllVariants(): Promise { this.features.forEach((feature) => (feature.variants = [])); return Promise.resolve(); diff --git a/src/test/fixtures/fake-project-stats-store.ts b/src/test/fixtures/fake-project-stats-store.ts new file mode 100644 index 0000000000..38b3d319b7 --- /dev/null +++ b/src/test/fixtures/fake-project-stats-store.ts @@ -0,0 +1,12 @@ +import { IProjectStats } from 'lib/services/project-service'; +import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type'; +/* eslint-disable @typescript-eslint/no-unused-vars */ + +export default class FakeProjectStatsStore implements IProjectStatsStore { + updateProjectStats( + projectId: string, + status: IProjectStats, + ): Promise { + throw new Error('not implemented'); + } +} diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index 8bde38c8c6..58f599b658 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -32,6 +32,7 @@ import FakePublicSignupStore from './fake-public-signup-store'; import FakeFavoriteFeaturesStore from './fake-favorite-features-store'; import FakeFavoriteProjectsStore from './fake-favorite-projects-store'; import { FakeAccountStore } from './fake-account-store'; +import FakeProjectStatsStore from './fake-project-stats-store'; const createStores: () => IUnleashStores = () => { const db = { @@ -75,6 +76,7 @@ const createStores: () => IUnleashStores = () => { publicSignupTokenStore: new FakePublicSignupStore(), favoriteFeaturesStore: new FakeFavoriteFeaturesStore(), favoriteProjectsStore: new FakeFavoriteProjectsStore(), + projectStatsStore: new FakeProjectStatsStore(), }; };