From 1bc0f97101b84dca3f0c56eab038dd627495a8f4 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Mon, 2 Sep 2024 11:39:47 +0200 Subject: [PATCH] feat: onboarding service composition root (#8035) --- .../onboarding/createOnboardingService.ts | 48 ++++++++++++ .../onboarding/fake-onboarding-store.ts | 5 +- .../onboarding/onboarding-service.e2e.test.ts | 74 +++++++++++++++++-- .../features/onboarding/onboarding-service.ts | 1 + .../onboarding/onboarding-store-type.ts | 2 + .../features/onboarding/onboarding-store.ts | 7 ++ src/lib/services/index.ts | 12 +++ src/lib/types/services.ts | 2 + 8 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 src/lib/features/onboarding/createOnboardingService.ts diff --git a/src/lib/features/onboarding/createOnboardingService.ts b/src/lib/features/onboarding/createOnboardingService.ts new file mode 100644 index 0000000000..bd206182df --- /dev/null +++ b/src/lib/features/onboarding/createOnboardingService.ts @@ -0,0 +1,48 @@ +import type { IUnleashConfig } from '../../types'; +import type { Db } from '../../db/db'; +import { OnboardingService } from './onboarding-service'; +import { OnboardingStore } from './onboarding-store'; +import { ProjectReadModel } from '../project/project-read-model'; +import UserStore from '../../db/user-store'; +import FakeUserStore from '../../../test/fixtures/fake-user-store'; +import { FakeProjectReadModel } from '../project/fake-project-read-model'; +import { FakeOnboardingStore } from './fake-onboarding-store'; + +export const createOnboardingService = + (config: IUnleashConfig) => + (db: Db): OnboardingService => { + const { eventBus, flagResolver, getLogger } = config; + const onboardingStore = new OnboardingStore(db); + const projectReadModel = new ProjectReadModel( + db, + eventBus, + flagResolver, + ); + const userStore = new UserStore(db, getLogger, flagResolver); + const onboardingService = new OnboardingService( + { + onboardingStore, + projectReadModel, + userStore, + }, + config, + ); + + return onboardingService; + }; + +export const createFakeOnboardingService = (config: IUnleashConfig) => { + const onboardingStore = new FakeOnboardingStore(); + const projectReadModel = new FakeProjectReadModel(); + const userStore = new FakeUserStore(); + const onboardingService = new OnboardingService( + { + onboardingStore, + projectReadModel, + userStore, + }, + config, + ); + + return { onboardingService, projectReadModel, userStore, onboardingStore }; +}; diff --git a/src/lib/features/onboarding/fake-onboarding-store.ts b/src/lib/features/onboarding/fake-onboarding-store.ts index 979d15de0a..50316ee823 100644 --- a/src/lib/features/onboarding/fake-onboarding-store.ts +++ b/src/lib/features/onboarding/fake-onboarding-store.ts @@ -8,7 +8,10 @@ export class FakeOnboardingStore implements IOnboardingStore { insertProjectEvent(event: ProjectEvent): Promise { throw new Error('Method not implemented.'); } - insertInstanceEvent(event: InstanceEvent): Promise { + async insertInstanceEvent(event: InstanceEvent): Promise { + throw new Error('Method not implemented.'); + } + async deleteAll(): Promise { throw new Error('Method not implemented.'); } } diff --git a/src/lib/features/onboarding/onboarding-service.e2e.test.ts b/src/lib/features/onboarding/onboarding-service.e2e.test.ts index 3e751c57ac..da438facb8 100644 --- a/src/lib/features/onboarding/onboarding-service.e2e.test.ts +++ b/src/lib/features/onboarding/onboarding-service.e2e.test.ts @@ -1,13 +1,19 @@ -import type { IUnleashStores } from '../../../lib/types'; +import type { IOnboardingReadModel, IUnleashStores } from '../../../lib/types'; import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; import getLogger from '../../../test/fixtures/no-logger'; import { minutesToMilliseconds } from 'date-fns'; -import { OnboardingService } from './onboarding-service'; +import type { OnboardingService } from './onboarding-service'; import { createTestConfig } from '../../../test/config/test-config'; +import { createOnboardingService } from './createOnboardingService'; +import type EventEmitter from 'events'; +import { STAGE_ENTERED, USER_LOGIN } from '../../metric-events'; +import { OnboardingReadModel } from './onboarding-read-model'; let db: ITestDb; let stores: IUnleashStores; let onboardingService: OnboardingService; +let eventBus: EventEmitter; +let onboardingReadModel: IOnboardingReadModel; beforeAll(async () => { db = await dbInit('onboarding_store', getLogger); @@ -15,11 +21,10 @@ beforeAll(async () => { experimental: { flags: { onboardingMetrics: true } }, }); stores = db.stores; - const { userStore, onboardingStore, projectReadModel } = stores; - onboardingService = new OnboardingService( - { onboardingStore, userStore, projectReadModel }, - config, - ); + eventBus = config.eventBus; + onboardingService = createOnboardingService(config)(db.rawDatabase); + onboardingService.listen(); + onboardingReadModel = new OnboardingReadModel(db.rawDatabase); }); afterAll(async () => { @@ -27,6 +32,9 @@ afterAll(async () => { }); beforeEach(async () => { + await stores.featureToggleStore.deleteAll(); + await stores.projectStore.deleteAll(); + await stores.onboardingStore.deleteAll(); jest.useRealTimers(); }); @@ -85,3 +93,55 @@ test('Storing onboarding events', async () => { { event: 'first-live', time_to_event: 300, project: 'test_project' }, ]); }); + +const reachedOnboardingEvents = (count: number) => { + let processedOnboardingEvents = 0; + return new Promise((resolve) => { + eventBus.on('onboarding-event', () => { + processedOnboardingEvents += 1; + if (processedOnboardingEvents === count) resolve('done'); + }); + }); +}; + +test('Reacting to events', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date()); + const { userStore, featureToggleStore, projectStore, projectReadModel } = + stores; + const user = await userStore.insert({}); + await projectStore.create({ id: 'test_project', name: 'irrelevant' }); + await featureToggleStore.create('test_project', { + name: 'test', + createdByUserId: user.id, + }); + jest.advanceTimersByTime(minutesToMilliseconds(1)); + + eventBus.emit(USER_LOGIN, { loginOrder: 0 }); + eventBus.emit(USER_LOGIN, { loginOrder: 1 }); + eventBus.emit(STAGE_ENTERED, { stage: 'initial', feature: 'test' }); + eventBus.emit(STAGE_ENTERED, { stage: 'pre-live', feature: 'test' }); + eventBus.emit(STAGE_ENTERED, { stage: 'live', feature: 'test' }); + await reachedOnboardingEvents(5); + + const instanceMetrics = + await onboardingReadModel.getInstanceOnboardingMetrics(); + const projectMetrics = + await onboardingReadModel.getProjectsOnboardingMetrics(); + + expect(instanceMetrics).toMatchObject({ + firstLogin: 60, + secondLogin: 60, + firstFeatureFlag: 60, + firstPreLive: 60, + firstLive: 60, + }); + expect(projectMetrics).toMatchObject([ + { + project: 'test_project', + firstFeatureFlag: 60, + firstPreLive: 60, + firstLive: 60, + }, + ]); +}); diff --git a/src/lib/features/onboarding/onboarding-service.ts b/src/lib/features/onboarding/onboarding-service.ts index bb88262909..ea3514f6ed 100644 --- a/src/lib/features/onboarding/onboarding-service.ts +++ b/src/lib/features/onboarding/onboarding-service.ts @@ -96,6 +96,7 @@ export class OnboardingService { if ('flag' in event) { await this.insertProjectEvent(event); } + this.eventBus.emit('onboarding-event'); } private async insertInstanceEvent(event: { diff --git a/src/lib/features/onboarding/onboarding-store-type.ts b/src/lib/features/onboarding/onboarding-store-type.ts index bd48b74e3f..c6faa78fa4 100644 --- a/src/lib/features/onboarding/onboarding-store-type.ts +++ b/src/lib/features/onboarding/onboarding-store-type.ts @@ -13,4 +13,6 @@ export interface IOnboardingStore { insertProjectEvent(event: ProjectEvent): Promise; insertInstanceEvent(event: InstanceEvent): Promise; + + deleteAll(): Promise; } diff --git a/src/lib/features/onboarding/onboarding-store.ts b/src/lib/features/onboarding/onboarding-store.ts index af132bdb87..897fd1a39b 100644 --- a/src/lib/features/onboarding/onboarding-store.ts +++ b/src/lib/features/onboarding/onboarding-store.ts @@ -69,4 +69,11 @@ export class OnboardingStore implements IOnboardingStore { .onConflict() .ignore(); } + + async deleteAll(): Promise { + await Promise.all([ + this.db('onboarding_events_project').del(), + this.db('onboarding_events_instance').del(), + ]); + } } diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 3fbb33f499..ff98fc293c 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -141,6 +141,11 @@ import { createFakePlaygroundService, createPlaygroundService, } from '../features/playground/createPlaygroundService'; +import { + createFakeOnboardingService, + createOnboardingService, +} from '../features/onboarding/createOnboardingService'; +import { OnboardingService } from '../features/onboarding/onboarding-service'; export const createServices = ( stores: IUnleashStores, @@ -391,6 +396,11 @@ export const createServices = ( const featureLifecycleService = transactionalFeatureLifecycleService; featureLifecycleService.listen(); + const onboardingService = db + ? createOnboardingService(config)(db) + : createFakeOnboardingService(config).onboardingService; + onboardingService.listen(); + return { accessService, accountService, @@ -453,6 +463,7 @@ export const createServices = ( featureLifecycleService, transactionalFeatureLifecycleService, integrationEventsService, + onboardingService, }; }; @@ -502,4 +513,5 @@ export { JobService, FeatureLifecycleService, IntegrationEventsService, + OnboardingService, }; diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index 99c31a146a..6386b686f6 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -55,6 +55,7 @@ import type { ProjectInsightsService } from '../features/project-insights/projec import type { JobService } from '../features/scheduler/job-service'; import type { FeatureLifecycleService } from '../features/feature-lifecycle/feature-lifecycle-service'; import type { IntegrationEventsService } from '../features/integration-events/integration-events-service'; +import type { OnboardingService } from '../features/onboarding/onboarding-service'; export interface IUnleashServices { accessService: AccessService; @@ -121,4 +122,5 @@ export interface IUnleashServices { featureLifecycleService: FeatureLifecycleService; transactionalFeatureLifecycleService: WithTransactional; integrationEventsService: IntegrationEventsService; + onboardingService: OnboardingService; }