mirror of
https://github.com/Unleash/unleash.git
synced 2025-03-23 00:16:25 +01:00
This PR updates the project status service (and schemas and UI) to use the project's current health instead of the 4-week average. I nabbed the `calculateHealthRating` from `src/lib/services/project-health-service.ts` instead of relying on the service itself, because that service relies on the project service, which relies on pretty much everything in the entire system. However, I think we can split the health service into a service that *does* need the project service (which is used for 1 of 3 methods) and a service (or read model) that doesn't. We could then rely on the second one for this service without too much overhead. Or we could extract the `calculateHealthRating` into a shared function that takes its stores as arguments. ... but I suggest doing that in a follow-up PR. Because the calculation has been tested other places (especially if we rely on a service / shared function for it), I've simplified the tests to just verify that it's present. I've changed the schema's `averageHealth` into an object in case we want to include average health etc. in the future, but this is up for debate.
296 lines
8.0 KiB
TypeScript
296 lines
8.0 KiB
TypeScript
import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init';
|
|
import {
|
|
type IUnleashTest,
|
|
setupAppWithCustomConfig,
|
|
} from '../../../test/e2e/helpers/test-helper';
|
|
import getLogger from '../../../test/fixtures/no-logger';
|
|
import {
|
|
FEATURE_CREATED,
|
|
type IUser,
|
|
RoleName,
|
|
type IAuditUser,
|
|
type IUnleashConfig,
|
|
} from '../../types';
|
|
import type { EventService } from '../../services';
|
|
import { createEventsService } from '../events/createEventsService';
|
|
import { createTestConfig } from '../../../test/config/test-config';
|
|
import { randomId } from '../../util';
|
|
import { ApiTokenType } from '../../types/models/api-token';
|
|
|
|
let app: IUnleashTest;
|
|
let db: ITestDb;
|
|
let eventService: EventService;
|
|
|
|
const TEST_USER_ID = -9999;
|
|
const config: IUnleashConfig = createTestConfig();
|
|
|
|
const insertHealthScore = (id: string, health: number) => {
|
|
const irrelevantFlagTrendDetails = {
|
|
total_flags: 10,
|
|
stale_flags: 10,
|
|
potentially_stale_flags: 10,
|
|
};
|
|
return db.rawDatabase('flag_trends').insert({
|
|
...irrelevantFlagTrendDetails,
|
|
id,
|
|
project: 'default',
|
|
health,
|
|
});
|
|
};
|
|
|
|
const getCurrentDateStrings = () => {
|
|
const today = new Date();
|
|
const todayString = today.toISOString().split('T')[0];
|
|
const yesterday = new Date(today);
|
|
yesterday.setDate(today.getDate() - 1);
|
|
const yesterdayString = yesterday.toISOString().split('T')[0];
|
|
return { todayString, yesterdayString };
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
db = await dbInit('projects_status', getLogger);
|
|
app = await setupAppWithCustomConfig(
|
|
db.stores,
|
|
{
|
|
experimental: {
|
|
flags: {
|
|
strictSchemaValidation: true,
|
|
},
|
|
},
|
|
},
|
|
db.rawDatabase,
|
|
);
|
|
eventService = createEventsService(db.rawDatabase, config);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.destroy();
|
|
await db.destroy();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await db.stores.clientMetricsStoreV2.deleteAll();
|
|
await db.rawDatabase('flag_trends').delete();
|
|
});
|
|
|
|
test('project insights should return correct count for each day', async () => {
|
|
await eventService.storeEvent({
|
|
type: FEATURE_CREATED,
|
|
project: 'default',
|
|
data: { featureName: 'today-event' },
|
|
createdBy: 'test-user',
|
|
createdByUserId: TEST_USER_ID,
|
|
ip: '127.0.0.1',
|
|
});
|
|
|
|
await eventService.storeEvent({
|
|
type: FEATURE_CREATED,
|
|
project: 'default',
|
|
data: { featureName: 'today-event-two' },
|
|
createdBy: 'test-user',
|
|
createdByUserId: TEST_USER_ID,
|
|
ip: '127.0.0.1',
|
|
});
|
|
|
|
await eventService.storeEvent({
|
|
type: FEATURE_CREATED,
|
|
project: 'default',
|
|
data: { featureName: 'yesterday-event' },
|
|
createdBy: 'test-user',
|
|
createdByUserId: TEST_USER_ID,
|
|
ip: '127.0.0.1',
|
|
});
|
|
|
|
const { events } = await eventService.getEvents();
|
|
|
|
const yesterdayEvent = events.find(
|
|
(e) => e.data.featureName === 'yesterday-event',
|
|
);
|
|
|
|
const { todayString, yesterdayString } = getCurrentDateStrings();
|
|
|
|
await db.rawDatabase.raw(`UPDATE events SET created_at = ? where id = ?`, [
|
|
yesterdayString,
|
|
yesterdayEvent?.id,
|
|
]);
|
|
|
|
const { body } = await app.request
|
|
.get('/api/admin/projects/default/status')
|
|
.expect('Content-Type', /json/)
|
|
.expect(200);
|
|
|
|
expect(body).toMatchObject({
|
|
activityCountByDate: [
|
|
{ date: yesterdayString, count: 1 },
|
|
{ date: todayString, count: 2 },
|
|
],
|
|
});
|
|
});
|
|
|
|
test('project resources should contain the right data', async () => {
|
|
const { body: noResourcesBody } = await app.request
|
|
.get('/api/admin/projects/default/status')
|
|
.expect('Content-Type', /json/)
|
|
.expect(200);
|
|
|
|
expect(noResourcesBody.resources).toMatchObject({
|
|
members: 0,
|
|
apiTokens: 0,
|
|
segments: 0,
|
|
});
|
|
|
|
const flagName = randomId();
|
|
await app.createFeature(flagName);
|
|
|
|
const environment = 'default';
|
|
await db.stores.clientMetricsStoreV2.batchInsertMetrics([
|
|
{
|
|
featureName: flagName,
|
|
appName: `web2`,
|
|
environment,
|
|
timestamp: new Date(),
|
|
yes: 5,
|
|
no: 2,
|
|
},
|
|
]);
|
|
|
|
await app.services.apiTokenService.createApiTokenWithProjects({
|
|
tokenName: 'test-token',
|
|
projects: ['default'],
|
|
type: ApiTokenType.CLIENT,
|
|
environment: 'default',
|
|
});
|
|
|
|
await app.services.segmentService.create(
|
|
{
|
|
name: 'test-segment',
|
|
project: 'default',
|
|
constraints: [],
|
|
},
|
|
{} as IAuditUser,
|
|
);
|
|
|
|
const admin = await app.services.userService.createUser({
|
|
username: 'admin',
|
|
rootRole: RoleName.ADMIN,
|
|
});
|
|
const user = await app.services.userService.createUser({
|
|
username: 'test-user',
|
|
rootRole: RoleName.EDITOR,
|
|
});
|
|
|
|
await app.services.projectService.addAccess('default', [4], [], [user.id], {
|
|
...admin,
|
|
ip: '',
|
|
} as IAuditUser);
|
|
|
|
const { body } = await app.request
|
|
.get('/api/admin/projects/default/status')
|
|
.expect('Content-Type', /json/)
|
|
.expect(200);
|
|
|
|
expect(body.resources).toMatchObject({
|
|
members: 1,
|
|
apiTokens: 1,
|
|
segments: 1,
|
|
});
|
|
});
|
|
|
|
test('project health contains the current health score', async () => {
|
|
const { body } = await app.request
|
|
.get('/api/admin/projects/default/status')
|
|
.expect('Content-Type', /json/)
|
|
.expect(200);
|
|
|
|
expect(body.health.current).toBe(100);
|
|
});
|
|
|
|
test('project status contains lifecycle data', async () => {
|
|
const { body } = await app.request
|
|
.get('/api/admin/projects/default/status')
|
|
.expect('Content-Type', /json/)
|
|
.expect(200);
|
|
|
|
expect(body.lifecycleSummary).toMatchObject({
|
|
initial: {
|
|
averageDays: null,
|
|
currentFlags: 0,
|
|
},
|
|
preLive: {
|
|
averageDays: null,
|
|
currentFlags: 0,
|
|
},
|
|
live: {
|
|
averageDays: null,
|
|
currentFlags: 0,
|
|
},
|
|
completed: {
|
|
averageDays: null,
|
|
currentFlags: 0,
|
|
},
|
|
archived: {
|
|
currentFlags: 0,
|
|
last30Days: 0,
|
|
},
|
|
});
|
|
});
|
|
|
|
test('project status includes stale flags', async () => {
|
|
const otherProject = await app.services.projectService.createProject(
|
|
{
|
|
name: 'otherProject',
|
|
id: randomId(),
|
|
},
|
|
{} as IUser,
|
|
{} as IAuditUser,
|
|
);
|
|
|
|
function cartesianProduct(...arrays: any[][]): any[][] {
|
|
return arrays.reduce(
|
|
(acc, array) => {
|
|
return acc.flatMap((accItem) =>
|
|
array.map((item) => [...accItem, item]),
|
|
);
|
|
},
|
|
[[]] as any[][],
|
|
);
|
|
}
|
|
|
|
// of all 16 (2^4) permutations, only 3 are unhealthy flags in a given project.
|
|
const combinations = cartesianProduct(
|
|
[false, true], // stale
|
|
[false, true], // potentially stale
|
|
[false, true], // archived
|
|
['default', otherProject.id], // project
|
|
);
|
|
|
|
for (const [stale, potentiallyStale, archived, project] of combinations) {
|
|
const name = `flag-${project}-stale-${stale}-potentially-stale-${potentiallyStale}-archived-${archived}`;
|
|
await app.createFeature(
|
|
{
|
|
name,
|
|
stale,
|
|
},
|
|
project,
|
|
);
|
|
if (potentiallyStale) {
|
|
await db
|
|
.rawDatabase('features')
|
|
.update('potentially_stale', true)
|
|
.where({ name });
|
|
}
|
|
if (archived) {
|
|
await app.archiveFeature(name, project);
|
|
}
|
|
}
|
|
|
|
const { body } = await app.request
|
|
.get('/api/admin/projects/default/status')
|
|
.expect('Content-Type', /json/)
|
|
.expect(200);
|
|
|
|
expect(body.staleFlags).toMatchObject({
|
|
total: 3,
|
|
});
|
|
});
|