1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

Feat/project status monthly (#2986)

This PR takes the project status API a step further by adding the
capability of providing a date to control the selection. We are
currently making calculations based on a gliding 30 day window, updated
once a day. The initial database structure and method for updating the
UI is outlined in this PR.
This commit is contained in:
Fredrik Strand Oseberg 2023-01-26 16:13:15 +01:00 committed by GitHub
parent b80e84b438
commit d8a250dc9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 586 additions and 57 deletions

View File

@ -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<IEvent[]> {
async query(operations: IQueryOperations[]): 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);
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<IEvent> {
const row = await this.db(TABLE).where({ id: key }).first();
return this.rowToEvent(row);

View File

@ -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<FeatureToggle[]> {
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

View File

@ -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),
};
};

View File

@ -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<void> {
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;

View File

@ -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);
});
});

View File

@ -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);

View File

@ -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),
);
}

View File

@ -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<void> {
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<number> {
async getStatusUpdates(projectId: string): Promise<ICalculateStatus> {
// 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(

View File

@ -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 {

View File

@ -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<IEvent, number>, EventEmitter {
store(event: IBaseEvent): Promise<void>;
@ -10,9 +11,5 @@ export interface IEventStore extends Store<IEvent, number>, EventEmitter {
count(): Promise<number>;
filteredCount(search: SearchEventsSchema): Promise<number>;
searchEvents(search: SearchEventsSchema): Promise<IEvent[]>;
getForFeatures(
features: string[],
environments: string[],
query: { type: string; projectId: string },
): Promise<IEvent[]>;
query(operations: IQueryOperations[]): Promise<IEvent[]>;
}

View File

@ -18,6 +18,13 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
revive(featureName: string): Promise<FeatureToggle>;
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
getByDate(queryModifiers: {
archived?: boolean;
project?: string;
date?: string;
range?: string[];
dateAccessor: string;
}): Promise<FeatureToggle[]>;
/**
* @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments)
* @param featureName

View File

@ -0,0 +1,5 @@
import { IProjectStats } from 'lib/services/project-service';
export interface IProjectStatsStore {
updateProjectStats(projectId: string, status: IProjectStats): Promise<void>;
}

View File

@ -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,
);
};

View File

@ -43,6 +43,7 @@ process.nextTick(async () => {
maintenance: false,
featuresExportImport: true,
newProjectOverview: true,
projectStatusApi: true,
},
},
authentication: {

View File

@ -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);
});

View File

@ -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<IEvent[]> {
if (operations) return [];
return [];
}
}
module.exports = FakeEventStore;

View File

@ -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<FeatureToggle[]> {
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<void> {
this.features.forEach((feature) => (feature.variants = []));
return Promise.resolve();

View File

@ -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<void> {
throw new Error('not implemented');
}
}

View File

@ -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(),
};
};