mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-14 00:19:16 +01:00
parent
467eb57fb8
commit
f4d857285b
@ -79,6 +79,7 @@ exports[`should create default config 1`] = `
|
|||||||
"messageBanner": false,
|
"messageBanner": false,
|
||||||
"networkView": false,
|
"networkView": false,
|
||||||
"newProjectOverview": false,
|
"newProjectOverview": false,
|
||||||
|
"projectStatusApi": false,
|
||||||
"proxyReturnAllToggles": false,
|
"proxyReturnAllToggles": false,
|
||||||
"responseTimeWithAppName": false,
|
"responseTimeWithAppName": false,
|
||||||
"serviceAccounts": false,
|
"serviceAccounts": false,
|
||||||
@ -99,6 +100,7 @@ exports[`should create default config 1`] = `
|
|||||||
"messageBanner": false,
|
"messageBanner": false,
|
||||||
"networkView": false,
|
"networkView": false,
|
||||||
"newProjectOverview": false,
|
"newProjectOverview": false,
|
||||||
|
"projectStatusApi": false,
|
||||||
"proxyReturnAllToggles": false,
|
"proxyReturnAllToggles": false,
|
||||||
"responseTimeWithAppName": false,
|
"responseTimeWithAppName": false,
|
||||||
"serviceAccounts": false,
|
"serviceAccounts": false,
|
||||||
|
@ -183,6 +183,7 @@ export default class EnvironmentStore implements IEnvironmentStore {
|
|||||||
|
|
||||||
async getProjectEnvironments(
|
async getProjectEnvironments(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
query?: Object,
|
||||||
): Promise<IProjectEnvironment[]> {
|
): Promise<IProjectEnvironment[]> {
|
||||||
let qB = this.db<IEnvironmentsWithProjectCountsTable>(TABLE)
|
let qB = this.db<IEnvironmentsWithProjectCountsTable>(TABLE)
|
||||||
.select(
|
.select(
|
||||||
@ -200,7 +201,13 @@ export default class EnvironmentStore implements IEnvironmentStore {
|
|||||||
{ column: 'sort_order', order: 'asc' },
|
{ column: 'sort_order', order: 'asc' },
|
||||||
{ column: 'created_at', order: 'asc' },
|
{ column: 'created_at', order: 'asc' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
qB = qB.where(query);
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await qB;
|
const rows = await qB;
|
||||||
|
|
||||||
return rows.map(mapRowWithProjectCounts);
|
return rows.map(mapRowWithProjectCounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,6 +120,25 @@ class EventStore extends AnyEventEmitter implements IEventStore {
|
|||||||
return present;
|
return present;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getForFeatures(
|
||||||
|
features: string[],
|
||||||
|
environments: string[],
|
||||||
|
query: { type: string; projectId: string },
|
||||||
|
): Promise<IEvent[]> {
|
||||||
|
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);
|
||||||
|
|
||||||
|
return rows.map(this.rowToEvent);
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async get(key: number): Promise<IEvent> {
|
async get(key: number): Promise<IEvent> {
|
||||||
const row = await this.db(TABLE).where({ id: key }).first();
|
const row = await this.db(TABLE).where({ id: key }).first();
|
||||||
return this.rowToEvent(row);
|
return this.rowToEvent(row);
|
||||||
|
208
src/lib/read-models/project-status/project-status.test.ts
Normal file
208
src/lib/read-models/project-status/project-status.test.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import { addDays, subDays } from 'date-fns';
|
||||||
|
import { IEvent } from 'lib/types';
|
||||||
|
import { ProjectStatus } from './project-status';
|
||||||
|
|
||||||
|
const modifyEventCreatedAt = (events: IEvent[], days: number): IEvent[] => {
|
||||||
|
return events.map((event) => {
|
||||||
|
const newEvent = { ...event };
|
||||||
|
newEvent.createdAt = addDays(newEvent.createdAt, days);
|
||||||
|
newEvent.id = newEvent.id + days;
|
||||||
|
return newEvent;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createEvent = (env: string, overrides: Partial<IEvent>) => {
|
||||||
|
return {
|
||||||
|
id: Math.floor(Math.random() * 1000),
|
||||||
|
type: 'feature-environment-enabled',
|
||||||
|
createdBy: 'Fredrik',
|
||||||
|
createdAt: new Date('2023-01-25T09:37:32.504Z'),
|
||||||
|
data: null,
|
||||||
|
preData: null,
|
||||||
|
tags: [],
|
||||||
|
featureName: 'average-prod-time',
|
||||||
|
project: 'average-time-to-prod',
|
||||||
|
environment: env,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 65,
|
||||||
|
type: 'feature-environment-enabled',
|
||||||
|
createdBy: 'Fredrik',
|
||||||
|
createdAt: new Date('2023-01-25T09:37:32.504Z'),
|
||||||
|
data: null,
|
||||||
|
preData: null,
|
||||||
|
tags: [],
|
||||||
|
featureName: 'average-prod-time',
|
||||||
|
project: 'average-time-to-prod',
|
||||||
|
environment: 'default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 66,
|
||||||
|
type: 'feature-environment-enabled',
|
||||||
|
createdBy: 'Fredrik',
|
||||||
|
createdAt: new Date('2023-01-31T09:37:32.506Z'),
|
||||||
|
data: null,
|
||||||
|
preData: null,
|
||||||
|
tags: [],
|
||||||
|
featureName: 'average-prod-time-2',
|
||||||
|
project: 'average-time-to-prod',
|
||||||
|
environment: 'default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 67,
|
||||||
|
type: 'feature-environment-enabled',
|
||||||
|
createdBy: 'Fredrik',
|
||||||
|
createdAt: new Date('2023-01-26T09:37:32.508Z'),
|
||||||
|
data: null,
|
||||||
|
preData: null,
|
||||||
|
tags: [],
|
||||||
|
featureName: 'average-prod-time-3',
|
||||||
|
project: 'average-time-to-prod',
|
||||||
|
environment: 'default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 68,
|
||||||
|
type: 'feature-environment-enabled',
|
||||||
|
createdBy: 'Fredrik',
|
||||||
|
createdAt: new Date('2023-02-02T09:37:32.509Z'),
|
||||||
|
data: null,
|
||||||
|
preData: null,
|
||||||
|
tags: [],
|
||||||
|
featureName: 'average-prod-time-4',
|
||||||
|
project: 'average-time-to-prod',
|
||||||
|
environment: 'default',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const environments = [
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
type: 'production',
|
||||||
|
sortOrder: 1,
|
||||||
|
enabled: true,
|
||||||
|
protected: true,
|
||||||
|
projectApiTokenCount: 0,
|
||||||
|
projectEnabledToggleCount: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
name: 'average-prod-time',
|
||||||
|
description: null,
|
||||||
|
type: 'release',
|
||||||
|
project: 'average-time-to-prod',
|
||||||
|
stale: false,
|
||||||
|
createdAt: new Date('2023-01-19T09:37:32.483Z'),
|
||||||
|
lastSeenAt: null,
|
||||||
|
impressionData: false,
|
||||||
|
archivedAt: null,
|
||||||
|
archived: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'average-prod-time-4',
|
||||||
|
description: null,
|
||||||
|
type: 'release',
|
||||||
|
project: 'average-time-to-prod',
|
||||||
|
stale: false,
|
||||||
|
createdAt: new Date('2023-01-19T09:37:32.484Z'),
|
||||||
|
lastSeenAt: null,
|
||||||
|
impressionData: false,
|
||||||
|
archivedAt: null,
|
||||||
|
archived: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'average-prod-time-2',
|
||||||
|
description: null,
|
||||||
|
type: 'release',
|
||||||
|
project: 'average-time-to-prod',
|
||||||
|
stale: false,
|
||||||
|
createdAt: new Date('2023-01-19T09:37:32.484Z'),
|
||||||
|
lastSeenAt: null,
|
||||||
|
impressionData: false,
|
||||||
|
archivedAt: null,
|
||||||
|
archived: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'average-prod-time-3',
|
||||||
|
description: null,
|
||||||
|
type: 'release',
|
||||||
|
project: 'average-time-to-prod',
|
||||||
|
stale: false,
|
||||||
|
createdAt: new Date('2023-01-19T09:37:32.486Z'),
|
||||||
|
lastSeenAt: null,
|
||||||
|
impressionData: false,
|
||||||
|
archivedAt: null,
|
||||||
|
archived: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('calculate average time to production', () => {
|
||||||
|
test('should build a map of feature events', () => {
|
||||||
|
const projectStatus = new ProjectStatus(features, environments, events);
|
||||||
|
|
||||||
|
const featureEvents = projectStatus.getFeatureEvents();
|
||||||
|
|
||||||
|
expect(Object.keys(featureEvents).length).toBe(4);
|
||||||
|
expect(featureEvents['average-prod-time'].createdAt).toBeTruthy();
|
||||||
|
expect(featureEvents['average-prod-time'].events).toBeInstanceOf(Array);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should calculate average correctly', () => {
|
||||||
|
const projectStatus = new ProjectStatus(features, environments, events);
|
||||||
|
|
||||||
|
const timeToProduction = projectStatus.calculateAverageTimeToProd();
|
||||||
|
|
||||||
|
expect(timeToProduction).toBe(9.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should sort events by createdAt', () => {
|
||||||
|
const projectStatus = new ProjectStatus(features, environments, [
|
||||||
|
...modifyEventCreatedAt(events, 5),
|
||||||
|
...events,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const featureEvents = projectStatus.getFeatureEvents();
|
||||||
|
const sortedFeatureEvents =
|
||||||
|
projectStatus.sortFeatureEventsByCreatedAt(featureEvents);
|
||||||
|
|
||||||
|
const [firstEvent, secondEvent] =
|
||||||
|
sortedFeatureEvents['average-prod-time'].events;
|
||||||
|
|
||||||
|
const firstEventCreatedAt = new Date(firstEvent.createdAt);
|
||||||
|
const secondEventCreatedAt = new Date(secondEvent.createdAt);
|
||||||
|
|
||||||
|
expect(firstEventCreatedAt.getTime()).toBeLessThan(
|
||||||
|
secondEventCreatedAt.getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [firstEvent2, secondEvent2] =
|
||||||
|
sortedFeatureEvents['average-prod-time-2'].events;
|
||||||
|
|
||||||
|
const firstEventCreatedAt2 = new Date(firstEvent2.createdAt);
|
||||||
|
const secondEventCreatedAt2 = new Date(secondEvent2.createdAt);
|
||||||
|
|
||||||
|
expect(firstEventCreatedAt2.getTime()).toBeLessThan(
|
||||||
|
secondEventCreatedAt2.getTime(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not count events that are development environments', () => {
|
||||||
|
const projectStatus = new ProjectStatus(features, environments, [
|
||||||
|
createEvent('development', {
|
||||||
|
createdAt: subDays(new Date('2023-01-25T09:37:32.504Z'), 10),
|
||||||
|
}),
|
||||||
|
createEvent('development', {
|
||||||
|
createdAt: subDays(new Date('2023-01-25T09:37:32.504Z'), 10),
|
||||||
|
}),
|
||||||
|
...events,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const timeToProduction = projectStatus.calculateAverageTimeToProd();
|
||||||
|
expect(timeToProduction).toBe(9.75);
|
||||||
|
});
|
||||||
|
});
|
111
src/lib/read-models/project-status/project-status.ts
Normal file
111
src/lib/read-models/project-status/project-status.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { differenceInDays } from 'date-fns';
|
||||||
|
import { FeatureToggle, IEvent, IProjectEnvironment } from 'lib/types';
|
||||||
|
|
||||||
|
interface IFeatureTimeToProdCalculationMap {
|
||||||
|
[index: string]: IFeatureTimeToProdData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFeatureTimeToProdData {
|
||||||
|
createdAt: string;
|
||||||
|
events: IEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectStatus {
|
||||||
|
private features: FeatureToggle[];
|
||||||
|
|
||||||
|
private productionEnvironments: IProjectEnvironment[];
|
||||||
|
|
||||||
|
private events: IEvent[];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
features: FeatureToggle[],
|
||||||
|
productionEnvironments: IProjectEnvironment[],
|
||||||
|
events: IEvent[],
|
||||||
|
) {
|
||||||
|
this.features = features;
|
||||||
|
this.productionEnvironments = productionEnvironments;
|
||||||
|
this.events = events;
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateAverageTimeToProd(): number {
|
||||||
|
const featureEvents = this.getFeatureEvents();
|
||||||
|
const sortedFeatureEvents =
|
||||||
|
this.sortFeatureEventsByCreatedAt(featureEvents);
|
||||||
|
const timeToProdPerFeature =
|
||||||
|
this.calculateTimeToProdForFeatures(sortedFeatureEvents);
|
||||||
|
|
||||||
|
const sum = timeToProdPerFeature.reduce((acc, curr) => acc + curr, 0);
|
||||||
|
|
||||||
|
return sum / Object.keys(sortedFeatureEvents).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFeatureEvents(): IFeatureTimeToProdCalculationMap {
|
||||||
|
return this.filterEvents(this.events).reduce((acc, event) => {
|
||||||
|
if (acc[event.featureName]) {
|
||||||
|
acc[event.featureName].events.push(event);
|
||||||
|
} else {
|
||||||
|
const foundFeature = this.features.find(
|
||||||
|
(feature) => feature.name === event.featureName,
|
||||||
|
);
|
||||||
|
acc[event.featureName] = { events: [event] };
|
||||||
|
acc[event.featureName].createdAt = foundFeature?.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
filterEvents(events: IEvent[]): IEvent[] {
|
||||||
|
return events.filter((event) => {
|
||||||
|
const found = this.productionEnvironments.find(
|
||||||
|
(env) => env.name === event.environment,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
return found.type === 'production';
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateTimeToProdForFeatures(
|
||||||
|
featureEvents: IFeatureTimeToProdCalculationMap,
|
||||||
|
): number[] {
|
||||||
|
return Object.keys(featureEvents).map((featureName) => {
|
||||||
|
const feature = featureEvents[featureName];
|
||||||
|
const earliestEvent = feature.events[0];
|
||||||
|
|
||||||
|
const createdAtDate = new Date(feature.createdAt);
|
||||||
|
const eventDate = new Date(earliestEvent.createdAt);
|
||||||
|
const diff = differenceInDays(eventDate, createdAtDate);
|
||||||
|
|
||||||
|
return diff;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sortFeatureEventsByCreatedAt(
|
||||||
|
featureEvents: IFeatureTimeToProdCalculationMap,
|
||||||
|
): IFeatureTimeToProdCalculationMap {
|
||||||
|
return Object.keys(featureEvents).reduce((acc, featureName) => {
|
||||||
|
const feature = featureEvents[featureName];
|
||||||
|
acc[featureName] = {
|
||||||
|
...feature,
|
||||||
|
events: feature.events.sort((a, b) => {
|
||||||
|
const aDate = new Date(a.createdAt);
|
||||||
|
const bDate = new Date(b.createdAt);
|
||||||
|
|
||||||
|
if (aDate > bDate) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (aDate < bDate) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
}
|
@ -161,6 +161,14 @@ export const createServices = (
|
|||||||
minutesToMilliseconds(5),
|
minutesToMilliseconds(5),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (config.flagResolver.isEnabled('projectStatusApi')) {
|
||||||
|
const ONE_DAY = 1440;
|
||||||
|
schedulerService.schedule(
|
||||||
|
projectService.statusJob.bind(projectHealthService),
|
||||||
|
minutesToMilliseconds(ONE_DAY),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessService,
|
accessService,
|
||||||
accountService,
|
accountService,
|
||||||
|
@ -15,6 +15,7 @@ import {
|
|||||||
ProjectGroupAddedEvent,
|
ProjectGroupAddedEvent,
|
||||||
ProjectGroupRemovedEvent,
|
ProjectGroupRemovedEvent,
|
||||||
ProjectGroupUpdateRoleEvent,
|
ProjectGroupUpdateRoleEvent,
|
||||||
|
FEATURE_ENVIRONMENT_ENABLED,
|
||||||
} from '../types/events';
|
} from '../types/events';
|
||||||
import { IUnleashStores, IUnleashConfig, IAccountStore } from '../types';
|
import { IUnleashStores, IUnleashConfig, IAccountStore } from '../types';
|
||||||
import {
|
import {
|
||||||
@ -46,6 +47,7 @@ import { arraysHaveSameItems } from '../util/arraysHaveSameItems';
|
|||||||
import { GroupService } from './group-service';
|
import { GroupService } from './group-service';
|
||||||
import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group';
|
import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group';
|
||||||
import { FavoritesService } from './favorites-service';
|
import { FavoritesService } from './favorites-service';
|
||||||
|
import { ProjectStatus } from '../read-models/project-status/project-status';
|
||||||
|
|
||||||
const getCreatedBy = (user: IUser) => user.email || user.username;
|
const getCreatedBy = (user: IUser) => user.email || user.username;
|
||||||
|
|
||||||
@ -591,6 +593,48 @@ export default class ProjectService {
|
|||||||
return this.store.getProjectsByUser(userId);
|
return this.store.getProjectsByUser(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async statusJob(): Promise<void> {
|
||||||
|
const projects = await this.store.getAll();
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
projects.map((project) =>
|
||||||
|
this.calculateAverageTimeToProd(project.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateAverageTimeToProd(projectId: string): Promise<number> {
|
||||||
|
// Get all features for project with type release
|
||||||
|
const features = await this.featureToggleStore.getAll({
|
||||||
|
type: 'release',
|
||||||
|
project: projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all project environments with type of production
|
||||||
|
const productionEnvironments =
|
||||||
|
await this.environmentStore.getProjectEnvironments(projectId, {
|
||||||
|
type: 'production',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
features,
|
||||||
|
productionEnvironments,
|
||||||
|
events,
|
||||||
|
);
|
||||||
|
return projectStatus.calculateAverageTimeToProd();
|
||||||
|
}
|
||||||
|
|
||||||
async getProjectOverview(
|
async getProjectOverview(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
archived: boolean = false,
|
archived: boolean = false,
|
||||||
|
@ -10,6 +10,10 @@ const flags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY,
|
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
|
projectStatusApi: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_PROJECT_STATUS_API,
|
||||||
|
false,
|
||||||
|
),
|
||||||
newProjectOverview: parseEnvVarBoolean(
|
newProjectOverview: parseEnvVarBoolean(
|
||||||
process.env.NEW_PROJECT_OVERVIEW,
|
process.env.NEW_PROJECT_OVERVIEW,
|
||||||
false,
|
false,
|
||||||
|
@ -24,5 +24,8 @@ export interface IEnvironmentStore extends Store<IEnvironment, string> {
|
|||||||
enable(environments: IEnvironment[]): Promise<void>;
|
enable(environments: IEnvironment[]): Promise<void>;
|
||||||
count(): Promise<number>;
|
count(): Promise<number>;
|
||||||
getAllWithCounts(): Promise<IEnvironment[]>;
|
getAllWithCounts(): Promise<IEnvironment[]>;
|
||||||
getProjectEnvironments(projectId: string): Promise<IProjectEnvironment[]>;
|
getProjectEnvironments(
|
||||||
|
projectId: string,
|
||||||
|
query?: Object,
|
||||||
|
): Promise<IProjectEnvironment[]>;
|
||||||
}
|
}
|
||||||
|
@ -10,4 +10,9 @@ export interface IEventStore extends Store<IEvent, number>, EventEmitter {
|
|||||||
count(): Promise<number>;
|
count(): Promise<number>;
|
||||||
filteredCount(search: SearchEventsSchema): Promise<number>;
|
filteredCount(search: SearchEventsSchema): Promise<number>;
|
||||||
searchEvents(search: SearchEventsSchema): Promise<IEvent[]>;
|
searchEvents(search: SearchEventsSchema): Promise<IEvent[]>;
|
||||||
|
getForFeatures(
|
||||||
|
features: string[],
|
||||||
|
environments: string[],
|
||||||
|
query: { type: string; projectId: string },
|
||||||
|
): Promise<IEvent[]>;
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ export interface IFeatureToggleQuery {
|
|||||||
archived: boolean;
|
archived: boolean;
|
||||||
project: string;
|
project: string;
|
||||||
stale: boolean;
|
stale: boolean;
|
||||||
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
||||||
|
@ -10,7 +10,7 @@ import EnvironmentStore from '../../../lib/db/environment-store';
|
|||||||
import { IUnleashStores } from '../../../lib/types';
|
import { IUnleashStores } from '../../../lib/types';
|
||||||
import { IFeatureEnvironmentStore } from '../../../lib/types/stores/feature-environment-store';
|
import { IFeatureEnvironmentStore } from '../../../lib/types/stores/feature-environment-store';
|
||||||
import { DEFAULT_ENV } from '../../../lib/util/constants';
|
import { DEFAULT_ENV } from '../../../lib/util/constants';
|
||||||
import { IUnleashOptions } from 'lib/server-impl';
|
import { IUnleashOptions, Knex } from 'lib/server-impl';
|
||||||
|
|
||||||
// require('db-migrate-shared').log.silence(false);
|
// require('db-migrate-shared').log.silence(false);
|
||||||
|
|
||||||
@ -76,6 +76,7 @@ export interface ITestDb {
|
|||||||
stores: IUnleashStores;
|
stores: IUnleashStores;
|
||||||
reset: () => Promise<void>;
|
reset: () => Promise<void>;
|
||||||
destroy: () => Promise<void>;
|
destroy: () => Promise<void>;
|
||||||
|
rawDatabase: Knex;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function init(
|
export default async function init(
|
||||||
@ -108,6 +109,7 @@ export default async function init(
|
|||||||
await setupDatabase(stores);
|
await setupDatabase(stores);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
rawDatabase: testDb,
|
||||||
stores,
|
stores,
|
||||||
reset: async () => {
|
reset: async () => {
|
||||||
await resetDatabase(testDb);
|
await resetDatabase(testDb);
|
||||||
|
@ -12,6 +12,8 @@ import IncompatibleProjectError from '../../../lib/error/incompatible-project-er
|
|||||||
import { SegmentService } from '../../../lib/services/segment-service';
|
import { SegmentService } from '../../../lib/services/segment-service';
|
||||||
import { GroupService } from '../../../lib/services/group-service';
|
import { GroupService } from '../../../lib/services/group-service';
|
||||||
import { FavoritesService } from '../../../lib/services';
|
import { FavoritesService } from '../../../lib/services';
|
||||||
|
import { FeatureEnvironmentEvent } from '../../../lib/types/events';
|
||||||
|
import { addDays } from 'date-fns';
|
||||||
|
|
||||||
let stores;
|
let stores;
|
||||||
let db: ITestDb;
|
let db: ITestDb;
|
||||||
@ -72,6 +74,7 @@ afterEach(async () => {
|
|||||||
const wipeUserPermissions = users.map(async (u) => {
|
const wipeUserPermissions = users.map(async (u) => {
|
||||||
await stores.accessStore.unlinkUserRoles(u.id);
|
await stores.accessStore.unlinkUserRoles(u.id);
|
||||||
});
|
});
|
||||||
|
await stores.eventStore.deleteAll();
|
||||||
await Promise.allSettled(deleteEnvs);
|
await Promise.allSettled(deleteEnvs);
|
||||||
await Promise.allSettled(wipeUserPermissions);
|
await Promise.allSettled(wipeUserPermissions);
|
||||||
});
|
});
|
||||||
@ -1053,3 +1056,59 @@ test('should only count active feature toggles for project', async () => {
|
|||||||
const theProject = projects.find((p) => p.id === project.id);
|
const theProject = projects.find((p) => p.id === project.id);
|
||||||
expect(theProject?.featureCount).toBe(1);
|
expect(theProject?.featureCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateEventCreatedAt = async (days: number, featureName: string) => {
|
||||||
|
await db.rawDatabase
|
||||||
|
.table('events')
|
||||||
|
.update({ created_at: addDays(new Date(), days) })
|
||||||
|
.where({ feature_name: featureName });
|
||||||
|
};
|
||||||
|
|
||||||
|
test('should calculate average time to production', async () => {
|
||||||
|
const project = {
|
||||||
|
id: 'average-time-to-prod',
|
||||||
|
name: 'average-time-to-prod',
|
||||||
|
};
|
||||||
|
|
||||||
|
await projectService.createProject(project, user.id);
|
||||||
|
|
||||||
|
const toggles = [
|
||||||
|
{ name: 'average-prod-time' },
|
||||||
|
{ name: 'average-prod-time-2' },
|
||||||
|
{ name: 'average-prod-time-3' },
|
||||||
|
{ name: 'average-prod-time-4' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const featureToggles = await Promise.all(
|
||||||
|
toggles.map((toggle) => {
|
||||||
|
return featureToggleService.createFeatureToggle(
|
||||||
|
project.id,
|
||||||
|
toggle,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
featureToggles.map((toggle) => {
|
||||||
|
return stores.eventStore.store(
|
||||||
|
new FeatureEnvironmentEvent({
|
||||||
|
enabled: true,
|
||||||
|
project: project.id,
|
||||||
|
featureName: toggle.name,
|
||||||
|
environment: 'default',
|
||||||
|
createdBy: 'Fredrik',
|
||||||
|
tags: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
const result = await projectService.calculateAverageTimeToProd(project.id);
|
||||||
|
expect(result).toBe(9.75);
|
||||||
|
});
|
||||||
|
15
src/test/fixtures/fake-event-store.ts
vendored
15
src/test/fixtures/fake-event-store.ts
vendored
@ -65,6 +65,21 @@ class FakeEventStore extends AnyEventEmitter implements IEventStore {
|
|||||||
async searchEvents(): Promise<IEvent[]> {
|
async searchEvents(): Promise<IEvent[]> {
|
||||||
throw new Error('Method not implemented.');
|
throw new Error('Method not implemented.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getForFeatures(
|
||||||
|
features: string[],
|
||||||
|
environments: string[],
|
||||||
|
query: { type: string; projectId: string },
|
||||||
|
): Promise<IEvent[]> {
|
||||||
|
return this.events.filter((event) => {
|
||||||
|
return (
|
||||||
|
event.type === query.type &&
|
||||||
|
event.project === query.projectId &&
|
||||||
|
features.includes(event.data.featureName) &&
|
||||||
|
environments.includes(event.data.environment)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = FakeEventStore;
|
module.exports = FakeEventStore;
|
||||||
|
Loading…
Reference in New Issue
Block a user