diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts index 0d42aa746882..afa0769be76e 100644 --- a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts +++ b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts @@ -73,6 +73,47 @@ export default class ClientFeatureToggleDeltaController extends Controller { }); } + async getDelta( + req: IAuthRequest, + res: Response, + ): Promise { + if (!this.flagResolver.isEnabled('deltaApi')) { + throw new NotFoundError(); + } + const query = await this.resolveQuery(req); + const etag = req.headers['if-none-match']; + + const currentSdkRevisionId = etag ? Number.parseInt(etag) : undefined; + + const changedFeatures = + await this.clientFeatureToggleService.getClientDelta( + currentSdkRevisionId, + query, + ); + + if (!changedFeatures) { + res.status(304); + res.getHeaderNames().forEach((header) => res.removeHeader(header)); + res.end(); + return; + } + + if (changedFeatures.revisionId === currentSdkRevisionId) { + res.status(304); + res.getHeaderNames().forEach((header) => res.removeHeader(header)); + res.end(); + return; + } + + res.setHeader('ETag', changedFeatures.revisionId.toString()); + this.openApiService.respondWithValidation( + 200, + res, + clientFeaturesDeltaSchema.$id, + changedFeatures, + ); + } + private async resolveQuery( req: IAuthRequest, ): Promise { @@ -139,45 +180,4 @@ export default class ClientFeatureToggleDeltaController extends Controller { return query; } - - async getDelta( - req: IAuthRequest, - res: Response, - ): Promise { - if (!this.flagResolver.isEnabled('deltaApi')) { - throw new NotFoundError(); - } - const query = await this.resolveQuery(req); - const etag = req.headers['if-none-match']; - - const currentSdkRevisionId = etag ? Number.parseInt(etag) : undefined; - - const changedFeatures = - await this.clientFeatureToggleService.getClientDelta( - currentSdkRevisionId, - query, - ); - - if (!changedFeatures) { - res.status(304); - res.getHeaderNames().forEach((header) => res.removeHeader(header)); - res.end(); - return; - } - - if (changedFeatures.revisionId === currentSdkRevisionId) { - res.status(304); - res.getHeaderNames().forEach((header) => res.removeHeader(header)); - res.end(); - return; - } - - res.setHeader('ETag', changedFeatures.revisionId.toString()); - this.openApiService.respondWithValidation( - 200, - res, - clientFeaturesDeltaSchema.$id, - changedFeatures, - ); - } } diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts index 0d50b8d5a6c9..7ece1722ea60 100644 --- a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts +++ b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts @@ -169,28 +169,6 @@ export class ClientFeatureToggleDelta { await this.updateSegments(); } - // TODO: 19.12 this logic seems to be not logical, when no revisionId is coming, it should not go to db, but take latest from cache - - // Should get the latest state if revision does not exist or if sdkRevision is not present - // We should be able to do this without going to the database by merging revisions from the delta with - // the base case - const firstTimeCalling = !sdkRevisionId; - if ( - firstTimeCalling || - (sdkRevisionId && - sdkRevisionId !== this.currentRevisionId && - !this.delta[environment].hasRevision(sdkRevisionId)) - ) { - //TODO: populate delta based on this? - return { - revisionId: this.currentRevisionId, - // @ts-ignore - updated: await this.getClientFeatures({ environment }), - segments: this.segments, - removed: [], - }; - } - if (requiredRevisionId >= this.currentRevisionId) { return undefined; } @@ -211,7 +189,7 @@ export class ClientFeatureToggleDelta { return Promise.resolve(revisionResponse); } - private async onUpdateRevisionEvent() { + public async onUpdateRevisionEvent() { if (this.flagResolver.isEnabled('deltaApi')) { await this.updateFeaturesDelta(); await this.updateSegments(); @@ -219,7 +197,11 @@ export class ClientFeatureToggleDelta { } } - public async updateFeaturesDelta() { + public resetDelta() { + this.delta = {}; + } + + private async updateFeaturesDelta() { const keys = Object.keys(this.delta); if (keys.length === 0) return; diff --git a/src/lib/features/client-feature-toggles/tests/client-feature-delta-api.e2e.test.ts b/src/lib/features/client-feature-toggles/tests/client-feature-delta-api.e2e.test.ts new file mode 100644 index 000000000000..ed729de825a0 --- /dev/null +++ b/src/lib/features/client-feature-toggles/tests/client-feature-delta-api.e2e.test.ts @@ -0,0 +1,142 @@ +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 { DEFAULT_ENV } from '../../../util/constants'; + +let app: IUnleashTest; +let db: ITestDb; + +const setupFeatures = async ( + db: ITestDb, + app: IUnleashTest, + project = 'default', +) => { + await app.createFeature('test1', project); + await app.createFeature('test2', project); + + await app.addStrategyToFeatureEnv( + { + name: 'flexibleRollout', + constraints: [], + parameters: { + rollout: '100', + stickiness: 'default', + groupId: 'test1', + }, + }, + DEFAULT_ENV, + 'test1', + project, + ); + await app.addStrategyToFeatureEnv( + { + name: 'default', + constraints: [ + { contextName: 'userId', operator: 'IN', values: ['123'] }, + ], + parameters: {}, + }, + DEFAULT_ENV, + 'test2', + project, + ); +}; + +beforeAll(async () => { + db = await dbInit('client_feature_toggles_delta', getLogger); + app = await setupAppWithCustomConfig( + db.stores, + { + experimental: { + flags: { + strictSchemaValidation: true, + deltaApi: true, + }, + }, + }, + db.rawDatabase, + ); +}); + +beforeEach(async () => { + await db.stores.eventStore.deleteAll(); + await db.stores.featureToggleStore.deleteAll(); + // @ts-ignore + app.services.clientFeatureToggleService.clientFeatureToggleDelta.resetDelta(); +}); + +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); + +test('should match with /api/client/delta', async () => { + await setupFeatures(db, app); + + const { body } = await app.request + .get('/api/client/features') + .expect('Content-Type', /json/) + .expect(200); + + const { body: deltaBody } = await app.request + .get('/api/client/delta') + .expect('Content-Type', /json/) + .expect(200); + + expect(body.features).toMatchObject(deltaBody.updated); +}); + +test('should get 304 if asked for latest revision', async () => { + await setupFeatures(db, app); + + const { body } = await app.request.get('/api/client/delta').expect(200); + const currentRevisionId = body.revisionId; + + await app.request + .set('If-None-Match', currentRevisionId) + .get('/api/client/delta') + .expect(304); +}); + +test('should return correct delta after feature created', async () => { + await app.createFeature('base_feature'); + await syncRevisions(); + const { body } = await app.request.get('/api/client/delta').expect(200); + const currentRevisionId = body.revisionId; + + expect(body).toMatchObject({ + updated: [ + { + name: 'base_feature', + }, + ], + }); + + await app.createFeature('new_feature'); + + await syncRevisions(); + + const { body: deltaBody } = await app.request + .get('/api/client/delta') + .set('If-None-Match', currentRevisionId) + .expect(200); + + expect(deltaBody).toMatchObject({ + updated: [ + { + name: 'new_feature', + }, + ], + }); +}); + +const syncRevisions = async () => { + await app.services.configurationRevisionService.updateMaxRevisionId(); + // @ts-ignore + await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent(); +};