1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-13 13:48:59 +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.');
}
getFeatureTypeCounts(
async getFeatureTypeCounts(
params: IFeatureProjectUserParams,
): 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> {

View File

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

View File

@ -8,12 +8,18 @@ const changeRequestCounts: ChangeRequestCounts = {
approved: 0,
applied: 0,
rejected: 0,
reviewRequired: 10,
reviewRequired: 0,
scheduled: 0,
};
export class FakeProjectInsightsReadModel implements IProjectInsightsReadModel {
private counts: Record<string, 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;
}
private async getDoraMetrics(
projectId: string,
): Promise<ProjectDoraMetricsSchema> {
async getDoraMetrics(projectId: string): Promise<ProjectDoraMetricsSchema> {
const activeFeatureToggles = (
await this.featureToggleStore.getAll({ project: projectId })
).map((feature) => feature.name);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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