1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-18 11:14:57 +02:00

test: project insights service test (#6661)

This commit is contained in:
Mateusz Kwasniewski 2024-03-22 09:48:29 +01:00 committed by GitHub
parent f5a7cc9125
commit 86f229a69d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 438 additions and 41 deletions

View File

@ -323,10 +323,26 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
getFeatureTypeCounts( async getFeatureTypeCounts(
params: IFeatureProjectUserParams, params: IFeatureProjectUserParams,
): Promise<IFeatureTypeCount[]> { ): Promise<IFeatureTypeCount[]> {
throw new Error('Method not implemented.'); const typeCounts = this.features.reduce(
(acc, feature) => {
if (!feature.type) {
return acc;
}
if (!acc[feature.type]) {
acc[feature.type] = { type: feature.type, count: 0 };
}
acc[feature.type].count += 1;
return acc;
},
{} as Record<string, IFeatureTypeCount>,
);
return Object.values(typeCounts);
} }
setCreatedByUserId(batchSize: number): Promise<number | undefined> { setCreatedByUserId(batchSize: number): Promise<number | undefined> {

View File

@ -53,17 +53,14 @@ export const createProjectInsightsService = (
); );
}; };
export const createFakeProjectInsightsService = ( export const createFakeProjectInsightsService = () => {
config: IUnleashConfig,
): ProjectInsightsService => {
const projectStore = new FakeProjectStore(); const projectStore = new FakeProjectStore();
const featureToggleStore = new FakeFeatureToggleStore(); const featureToggleStore = new FakeFeatureToggleStore();
const featureTypeStore = new FakeFeatureTypeStore(); const featureTypeStore = new FakeFeatureTypeStore();
const projectStatsStore = new FakeProjectStatsStore(); const projectStatsStore = new FakeProjectStatsStore();
const featureStrategiesStore = new FakeFeatureStrategiesStore(); const featureStrategiesStore = new FakeFeatureStrategiesStore();
const projectInsightsReadModel = new FakeProjectInsightsReadModel(); const projectInsightsReadModel = new FakeProjectInsightsReadModel();
const projectInsightsService = new ProjectInsightsService(
return new ProjectInsightsService(
{ {
projectStore, projectStore,
featureToggleStore, featureToggleStore,
@ -73,4 +70,12 @@ export const createFakeProjectInsightsService = (
}, },
projectInsightsReadModel, projectInsightsReadModel,
); );
return {
projectInsightsService,
projectStatsStore,
featureToggleStore,
projectStore,
projectInsightsReadModel,
};
}; };

View File

@ -8,12 +8,18 @@ const changeRequestCounts: ChangeRequestCounts = {
approved: 0, approved: 0,
applied: 0, applied: 0,
rejected: 0, rejected: 0,
reviewRequired: 10, reviewRequired: 0,
scheduled: 0, scheduled: 0,
}; };
export class FakeProjectInsightsReadModel implements IProjectInsightsReadModel { export class FakeProjectInsightsReadModel implements IProjectInsightsReadModel {
private counts: Record<string, ChangeRequestCounts> = {};
async getChangeRequests(projectId: string): Promise<ChangeRequestCounts> { async getChangeRequests(projectId: string): Promise<ChangeRequestCounts> {
return changeRequestCounts; return this.counts[projectId] ?? changeRequestCounts;
}
async setChangeRequests(projectId: string, counts: ChangeRequestCounts) {
this.counts[projectId] = counts;
} }
} }

View File

@ -0,0 +1,296 @@
import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init';
import getLogger from '../../../test/fixtures/no-logger';
import type FeatureToggleService from '../../../lib/features/feature-toggle/feature-toggle-service';
import type ProjectService from '../../../lib/features/project/project-service';
import { createTestConfig } from '../../../test/config/test-config';
import {
EventService,
type ProjectInsightsService,
} from '../../../lib/services';
import { FeatureEnvironmentEvent } from '../../../lib/types/events';
import { subDays } from 'date-fns';
import {
createFeatureToggleService,
createProjectService,
} from '../../../lib/features';
import type { IUnleashStores, IUser } from '../../../lib/types';
import type { User } from '../../../lib/server-impl';
import { createProjectInsightsService } from './createProjectInsightsService';
let stores: IUnleashStores;
let db: ITestDb;
let projectService: ProjectService;
let projectInsightsService: ProjectInsightsService;
let eventService: EventService;
let featureToggleService: FeatureToggleService;
let user: User; // many methods in this test use User instead of IUser
let opsUser: IUser;
beforeAll(async () => {
db = await dbInit('project_service_serial', getLogger);
stores = db.stores;
// @ts-ignore return type IUser type missing generateImageUrl
user = await stores.userStore.insert({
name: 'Some Name',
email: 'test@getunleash.io',
});
opsUser = await stores.userStore.insert({
name: 'Test user',
email: 'test@example.com',
});
const config = createTestConfig({
getLogger,
});
eventService = new EventService(stores, config);
featureToggleService = createFeatureToggleService(db.rawDatabase, config);
projectService = createProjectService(db.rawDatabase, config);
projectInsightsService = createProjectInsightsService(
db.rawDatabase,
config,
);
});
afterAll(async () => {
await db.destroy();
});
afterEach(async () => {
await stores.eventStore.deleteAll();
});
const updateFeature = async (featureName: string, update: any) => {
return db.rawDatabase
.table('features')
.update(update)
.where({ name: featureName });
};
test('should return average time to production per toggle', async () => {
const project = {
id: 'average-time-to-prod-per-toggle',
name: 'average-time-to-prod-per-toggle',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user);
const toggles = [
{ name: 'average-prod-time-pt', subdays: 7 },
{ name: 'average-prod-time-pt-2', subdays: 14 },
{ name: 'average-prod-time-pt-3', subdays: 40 },
{ name: 'average-prod-time-pt-4', subdays: 15 },
{ name: 'average-prod-time-pt-5', subdays: 2 },
];
const featureToggles = await Promise.all(
toggles.map((toggle) => {
return featureToggleService.createFeatureToggle(
project.id,
toggle,
user.email,
opsUser.id,
);
}),
);
await Promise.all(
featureToggles.map((toggle) => {
return eventService.storeEvent(
new FeatureEnvironmentEvent({
enabled: true,
project: project.id,
featureName: toggle.name,
environment: 'default',
createdBy: 'Fredrik',
createdByUserId: opsUser.id,
}),
);
}),
);
await Promise.all(
toggles.map((toggle) =>
updateFeature(toggle.name, {
created_at: subDays(new Date(), toggle.subdays),
}),
),
);
const result = await projectInsightsService.getDoraMetrics(project.id);
expect(result.features).toHaveLength(5);
expect(result.features[0].timeToProduction).toBeTruthy();
expect(result.projectAverage).toBeTruthy();
});
test('should return average time to production per toggle for a specific project', async () => {
const project1 = {
id: 'average-time-to-prod-per-toggle-1',
name: 'Project 1',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
const project2 = {
id: 'average-time-to-prod-per-toggle-2',
name: 'Project 2',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project1, user);
await projectService.createProject(project2, user);
const togglesProject1 = [
{ name: 'average-prod-time-pt-10', subdays: 7 },
{ name: 'average-prod-time-pt-11', subdays: 14 },
{ name: 'average-prod-time-pt-12', subdays: 40 },
];
const togglesProject2 = [
{ name: 'average-prod-time-pt-13', subdays: 15 },
{ name: 'average-prod-time-pt-14', subdays: 2 },
];
const featureTogglesProject1 = await Promise.all(
togglesProject1.map((toggle) => {
return featureToggleService.createFeatureToggle(
project1.id,
toggle,
user.email,
opsUser.id,
);
}),
);
const featureTogglesProject2 = await Promise.all(
togglesProject2.map((toggle) => {
return featureToggleService.createFeatureToggle(
project2.id,
toggle,
user.email,
opsUser.id,
);
}),
);
await Promise.all(
featureTogglesProject1.map((toggle) => {
return eventService.storeEvent(
new FeatureEnvironmentEvent({
enabled: true,
project: project1.id,
featureName: toggle.name,
environment: 'default',
createdBy: 'Fredrik',
createdByUserId: opsUser.id,
}),
);
}),
);
await Promise.all(
featureTogglesProject2.map((toggle) => {
return eventService.storeEvent(
new FeatureEnvironmentEvent({
enabled: true,
project: project2.id,
featureName: toggle.name,
environment: 'default',
createdBy: 'Fredrik',
createdByUserId: opsUser.id,
}),
);
}),
);
await Promise.all(
togglesProject1.map((toggle) =>
updateFeature(toggle.name, {
created_at: subDays(new Date(), toggle.subdays),
}),
),
);
await Promise.all(
togglesProject2.map((toggle) =>
updateFeature(toggle.name, {
created_at: subDays(new Date(), toggle.subdays),
}),
),
);
const resultProject1 = await projectInsightsService.getDoraMetrics(
project1.id,
);
const resultProject2 = await projectInsightsService.getDoraMetrics(
project2.id,
);
expect(resultProject1.features).toHaveLength(3);
expect(resultProject2.features).toHaveLength(2);
});
test('should return average time to production per toggle and include archived toggles', async () => {
const project1 = {
id: 'average-time-to-prod-per-toggle-12',
name: 'Project 1',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project1, user);
const togglesProject1 = [
{ name: 'average-prod-time-pta-10', subdays: 7 },
{ name: 'average-prod-time-pta-11', subdays: 14 },
{ name: 'average-prod-time-pta-12', subdays: 40 },
];
const featureTogglesProject1 = await Promise.all(
togglesProject1.map((toggle) => {
return featureToggleService.createFeatureToggle(
project1.id,
toggle,
user.email,
opsUser.id,
);
}),
);
await Promise.all(
featureTogglesProject1.map((toggle) => {
return eventService.storeEvent(
new FeatureEnvironmentEvent({
enabled: true,
project: project1.id,
featureName: toggle.name,
environment: 'default',
createdBy: 'Fredrik',
createdByUserId: opsUser.id,
}),
);
}),
);
await Promise.all(
togglesProject1.map((toggle) =>
updateFeature(toggle.name, {
created_at: subDays(new Date(), toggle.subdays),
}),
),
);
await featureToggleService.archiveToggle('average-prod-time-pta-12', user);
const resultProject1 = await projectInsightsService.getDoraMetrics(
project1.id,
);
expect(resultProject1.features).toHaveLength(3);
});

View File

@ -0,0 +1,70 @@
import { createFakeProjectInsightsService } from './createProjectInsightsService';
test('Return basic insights', async () => {
const {
projectInsightsService,
projectStatsStore,
featureToggleStore,
projectStore,
projectInsightsReadModel,
} = createFakeProjectInsightsService();
await featureToggleStore.create('default', {
name: 'irrelevant',
createdByUserId: 1,
type: 'release',
});
await projectInsightsReadModel.setChangeRequests('default', {
total: 5,
approved: 1,
applied: 1,
rejected: 1,
reviewRequired: 1,
scheduled: 1,
});
await projectStore.create({
id: 'default',
name: 'irrelevant',
});
await projectStatsStore.updateProjectStats('default', {
archivedCurrentWindow: 1,
archivedPastWindow: 1,
createdCurrentWindow: 1,
createdPastWindow: 1,
avgTimeToProdCurrentWindow: 1,
projectActivityCurrentWindow: 1,
projectActivityPastWindow: 1,
projectMembersAddedCurrentWindow: 1,
});
const insights = await projectInsightsService.getProjectInsights('default');
expect(insights).toEqual({
stats: {
archivedCurrentWindow: 1,
archivedPastWindow: 1,
createdCurrentWindow: 1,
createdPastWindow: 1,
avgTimeToProdCurrentWindow: 1,
projectActivityCurrentWindow: 1,
projectActivityPastWindow: 1,
projectMembersAddedCurrentWindow: 1,
},
featureTypeCounts: [{ type: 'release', count: 1 }],
health: {
activeCount: 0,
potentiallyStaleCount: 0,
staleCount: 0,
rating: 100,
},
leadTime: { features: [], projectAverage: 0 },
changeRequests: {
total: 5,
approved: 1,
applied: 1,
rejected: 1,
reviewRequired: 1,
scheduled: 1,
},
members: { currentMembers: 0, change: 0 },
});
});

View File

@ -54,9 +54,7 @@ export class ProjectInsightsService {
this.projectInsightsReadModel = projectInsightsReadModel; this.projectInsightsReadModel = projectInsightsReadModel;
} }
private async getDoraMetrics( async getDoraMetrics(projectId: string): Promise<ProjectDoraMetricsSchema> {
projectId: string,
): Promise<ProjectDoraMetricsSchema> {
const activeFeatureToggles = ( const activeFeatureToggles = (
await this.featureToggleStore.getAll({ project: projectId }) await this.featureToggleStore.getAll({ project: projectId })
).map((feature) => feature.name); ).map((feature) => feature.name);

View File

@ -118,6 +118,7 @@ export default class ProjectController extends Controller {
], ],
}); });
/** @deprecated use project insights instead */
this.route({ this.route({
method: 'get', method: 'get',
path: '/:projectId/dora', path: '/:projectId/dora',
@ -245,6 +246,7 @@ export default class ProjectController extends Controller {
); );
} }
/** @deprecated use projectInsights instead */
async getProjectDora( async getProjectDora(
req: IAuthRequest, req: IAuthRequest,
res: Response<ProjectDoraMetricsSchema>, res: Response<ProjectDoraMetricsSchema>,

View File

@ -1,31 +1,31 @@
import dbInit, { type ITestDb } from '../helpers/database-init'; import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init';
import getLogger from '../../fixtures/no-logger'; import getLogger from '../../../test/fixtures/no-logger';
import type FeatureToggleService from '../../../lib/features/feature-toggle/feature-toggle-service'; import type FeatureToggleService from '../feature-toggle/feature-toggle-service';
import type ProjectService from '../../../lib/features/project/project-service'; import type ProjectService from './project-service';
import type { AccessService } from '../../../lib/services/access-service'; import type { AccessService } from '../../services/access-service';
import { MOVE_FEATURE_TOGGLE } from '../../../lib/types/permissions'; import { MOVE_FEATURE_TOGGLE } from '../../types/permissions';
import { createTestConfig } from '../../config/test-config'; import { createTestConfig } from '../../../test/config/test-config';
import { RoleName } from '../../../lib/types/model'; import { RoleName } from '../../types/model';
import { randomId } from '../../../lib/util/random-id'; import { randomId } from '../../util/random-id';
import EnvironmentService from '../../../lib/features/project-environments/environment-service'; import EnvironmentService from '../project-environments/environment-service';
import IncompatibleProjectError from '../../../lib/error/incompatible-project-error'; import IncompatibleProjectError from '../../error/incompatible-project-error';
import { EventService } from '../../../lib/services'; import { EventService } from '../../services';
import { FeatureEnvironmentEvent } from '../../../lib/types/events'; import { FeatureEnvironmentEvent } from '../../types/events';
import { addDays, subDays } from 'date-fns'; import { addDays, subDays } from 'date-fns';
import { import {
createAccessService, createAccessService,
createFeatureToggleService, createFeatureToggleService,
createProjectService, createProjectService,
} from '../../../lib/features'; } from '../index';
import { import {
type IGroup, type IGroup,
type IUnleashStores, type IUnleashStores,
type IUser, type IUser,
SYSTEM_USER, SYSTEM_USER,
SYSTEM_USER_ID, SYSTEM_USER_ID,
} from '../../../lib/types'; } from '../../types';
import type { User } from '../../../lib/server-impl'; import type { User } from '../../server-impl';
import { InvalidOperationError } from '../../../lib/error'; import { InvalidOperationError } from '../../error';
let stores: IUnleashStores; let stores: IUnleashStores;
let db: ITestDb; let db: ITestDb;

View File

@ -945,6 +945,7 @@ export default class ProjectService {
} }
} }
/** @deprecated use projectInsightsService instead */
async getDoraMetrics(projectId: string): Promise<ProjectDoraMetricsSchema> { async getDoraMetrics(projectId: string): Promise<ProjectDoraMetricsSchema> {
const activeFeatureToggles = ( const activeFeatureToggles = (
await this.featureToggleStore.getAll({ project: projectId }) await this.featureToggleStore.getAll({ project: projectId })

View File

@ -270,7 +270,7 @@ export const createServices = (
: createFakeProjectService(config); : createFakeProjectService(config);
const projectInsightsService = db const projectInsightsService = db
? createProjectInsightsService(db, config) ? createProjectInsightsService(db, config)
: createFakeProjectInsightsService(config); : createFakeProjectInsightsService().projectInsightsService;
const projectHealthService = new ProjectHealthService( const projectHealthService = new ProjectHealthService(
stores, stores,

View File

@ -3,25 +3,28 @@ import type {
ICreateEnabledDates, ICreateEnabledDates,
IProjectStatsStore, IProjectStatsStore,
} from '../../lib/types/stores/project-stats-store-type'; } from '../../lib/types/stores/project-stats-store-type';
import type { DoraFeaturesSchema } from '../../lib/openapi';
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
export default class FakeProjectStatsStore implements IProjectStatsStore { export default class FakeProjectStatsStore implements IProjectStatsStore {
updateProjectStats( private stats: Record<string, IProjectStats> = {};
async updateProjectStats(
projectId: string, projectId: string,
status: IProjectStats, stats: IProjectStats,
): Promise<void> { ): Promise<void> {
throw new Error('not implemented'); this.stats[projectId] = stats;
} }
getProjectStats(projectId: string): Promise<IProjectStats> { async getProjectStats(projectId: string): Promise<IProjectStats> {
throw new Error('not implemented'); return this.stats[projectId];
} }
getTimeToProdDates(): Promise<ICreateEnabledDates[]> { async getTimeToProdDates(): Promise<ICreateEnabledDates[]> {
throw new Error('not implemented'); return [];
} }
getTimeToProdDatesForFeatureToggles(): Promise<any> { async getTimeToProdDatesForFeatureToggles(): Promise<DoraFeaturesSchema[]> {
throw new Error('not implemented'); return [];
} }
} }

View File

@ -166,13 +166,13 @@ export default class FakeProjectStore implements IProjectStore {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
getMembersCountByProjectAfterDate( async getMembersCountByProjectAfterDate(
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string, projectId: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
date: string, date: string,
): Promise<number> { ): Promise<number> {
throw new Error('Method not implemented'); return 0;
} }
updateDefaultStrategy( updateDefaultStrategy(