mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Poc: calculate etag based on query and latest revison id (#3062)
This is very much POC and WIP
This commit is contained in:
		
							parent
							
								
									32e1ad44ed
								
							
						
					
					
						commit
						dc5b53fa4d
					
				| @ -99,6 +99,7 @@ | ||||
|     "db-migrate": "0.11.13", | ||||
|     "db-migrate-pg": "1.2.2", | ||||
|     "db-migrate-shared": "1.2.0", | ||||
|     "deep-object-diff": "^1.1.9", | ||||
|     "deepmerge": "^4.2.2", | ||||
|     "errorhandler": "^1.5.1", | ||||
|     "express": "^4.18.2", | ||||
| @ -106,6 +107,7 @@ | ||||
|     "express-session": "^1.17.1", | ||||
|     "fast-json-patch": "^3.1.0", | ||||
|     "gravatar-url": "^3.1.0", | ||||
|     "hash-sum": "^2.0.0", | ||||
|     "helmet": "^6.0.0", | ||||
|     "ip": "^1.1.8", | ||||
|     "joi": "^17.3.0", | ||||
| @ -149,6 +151,7 @@ | ||||
|     "@types/express": "4.17.17", | ||||
|     "@types/express-session": "1.17.6", | ||||
|     "@types/faker": "5.5.9", | ||||
|     "@types/hash-sum": "^1.0.0", | ||||
|     "@types/jest": "29.4.0", | ||||
|     "@types/js-yaml": "4.0.5", | ||||
|     "@types/make-fetch-happen": "10.0.1", | ||||
|  | ||||
| @ -81,6 +81,8 @@ exports[`should create default config 1`] = ` | ||||
|       "messageBanner": false, | ||||
|       "newProjectOverview": false, | ||||
|       "notifications": false, | ||||
|       "optimal304": false, | ||||
|       "optimal304Differ": false, | ||||
|       "proPlanAutoCharge": false, | ||||
|       "projectMode": false, | ||||
|       "projectScopedSegments": false, | ||||
| @ -107,6 +109,8 @@ exports[`should create default config 1`] = ` | ||||
|       "messageBanner": false, | ||||
|       "newProjectOverview": false, | ||||
|       "notifications": false, | ||||
|       "optimal304": false, | ||||
|       "optimal304Differ": false, | ||||
|       "proPlanAutoCharge": false, | ||||
|       "projectMode": false, | ||||
|       "projectScopedSegments": false, | ||||
|  | ||||
| @ -149,6 +149,16 @@ class EventStore implements IEventStore { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     async getMaxRevisionId(largerThan: number = 0): Promise<number> { | ||||
|         const row = await this.db(TABLE) | ||||
|             .max('id') | ||||
|             .whereNotNull('feature_name') | ||||
|             .orWhere('type', 'segment-update') | ||||
|             .andWhere('id', '>=', largerThan) | ||||
|             .first(); | ||||
|         return row ? row.max : -1; | ||||
|     } | ||||
| 
 | ||||
|     async delete(key: number): Promise<void> { | ||||
|         await this.db(TABLE).where({ id: key }).del(); | ||||
|     } | ||||
|  | ||||
| @ -141,6 +141,12 @@ export default class MetricsMonitor { | ||||
|             labelNames: ['sdk_name', 'sdk_version'], | ||||
|         }); | ||||
| 
 | ||||
|         const optimal304DiffingCounter = new client.Counter({ | ||||
|             name: 'optimal_304_diffing', | ||||
|             help: 'Count the Optimal 304 diffing with status', | ||||
|             labelNames: ['status'], | ||||
|         }); | ||||
| 
 | ||||
|         async function collectStaticCounters() { | ||||
|             try { | ||||
|                 const stats = await instanceStatsService.getStats(); | ||||
| @ -204,6 +210,10 @@ export default class MetricsMonitor { | ||||
|             }, | ||||
|         ); | ||||
| 
 | ||||
|         eventBus.on('optimal304Differ', ({ status }) => { | ||||
|             optimal304DiffingCounter.labels(status).inc(); | ||||
|         }); | ||||
| 
 | ||||
|         eventBus.on(events.DB_TIME, ({ store, action, time }) => { | ||||
|             dbDuration.labels(store, action).observe(time); | ||||
|         }); | ||||
|  | ||||
| @ -43,12 +43,19 @@ let request; | ||||
| let destroy; | ||||
| let featureToggleClientStore; | ||||
| 
 | ||||
| let flagResolver; | ||||
| 
 | ||||
| beforeEach(async () => { | ||||
|     const setup = await getSetup(); | ||||
|     base = setup.base; | ||||
|     request = setup.request; | ||||
|     featureToggleClientStore = setup.featureToggleClientStore; | ||||
|     destroy = setup.destroy; | ||||
|     flagResolver = { | ||||
|         isEnabled: () => { | ||||
|             return false; | ||||
|         }, | ||||
|     }; | ||||
| }); | ||||
| 
 | ||||
| afterEach(() => { | ||||
| @ -92,6 +99,7 @@ test('if caching is enabled should memoize', async () => { | ||||
|                 enabled: true, | ||||
|                 maxAge: secondsToMilliseconds(10), | ||||
|             }, | ||||
|             flagResolver, | ||||
|         }, | ||||
|     ); | ||||
| 
 | ||||
| @ -126,6 +134,7 @@ test('if caching is not enabled all calls goes to service', async () => { | ||||
|                 enabled: false, | ||||
|                 maxAge: secondsToMilliseconds(10), | ||||
|             }, | ||||
|             flagResolver, | ||||
|         }, | ||||
|     ); | ||||
| 
 | ||||
|  | ||||
| @ -1,7 +1,11 @@ | ||||
| import memoizee from 'memoizee'; | ||||
| import { Response } from 'express'; | ||||
| // eslint-disable-next-line import/no-extraneous-dependencies
 | ||||
| import hasSum from 'hash-sum'; | ||||
| // eslint-disable-next-line import/no-extraneous-dependencies
 | ||||
| import { diff } from 'deep-object-diff'; | ||||
| import Controller from '../controller'; | ||||
| import { IUnleashConfig, IUnleashServices } from '../../types'; | ||||
| import { IFlagResolver, IUnleashConfig, IUnleashServices } from '../../types'; | ||||
| import FeatureToggleService from '../../services/feature-toggle-service'; | ||||
| import { Logger } from '../../logger'; | ||||
| import { querySchema } from '../../schema/feature-schema'; | ||||
| @ -25,6 +29,10 @@ import { | ||||
|     ClientFeaturesSchema, | ||||
| } from '../../openapi/spec/client-features-schema'; | ||||
| import { ISegmentService } from 'lib/segments/segment-service-interface'; | ||||
| import { EventService } from 'lib/services'; | ||||
| import { hoursToMilliseconds } from 'date-fns'; | ||||
| import { isEmpty } from '../../util/isEmpty'; | ||||
| import EventEmitter from 'events'; | ||||
| 
 | ||||
| const version = 2; | ||||
| 
 | ||||
| @ -33,9 +41,17 @@ interface QueryOverride { | ||||
|     environment?: string; | ||||
| } | ||||
| 
 | ||||
| interface IMeta { | ||||
|     revisionId: number; | ||||
|     etag: string; | ||||
|     queryHash: string; | ||||
| } | ||||
| 
 | ||||
| export default class FeatureController extends Controller { | ||||
|     private readonly logger: Logger; | ||||
| 
 | ||||
|     private eventBus: EventEmitter; | ||||
| 
 | ||||
|     private featureToggleServiceV2: FeatureToggleService; | ||||
| 
 | ||||
|     private segmentService: ISegmentService; | ||||
| @ -44,32 +60,43 @@ export default class FeatureController extends Controller { | ||||
| 
 | ||||
|     private openApiService: OpenApiService; | ||||
| 
 | ||||
|     private eventService: EventService; | ||||
| 
 | ||||
|     private readonly cache: boolean; | ||||
| 
 | ||||
|     private cachedFeatures: any; | ||||
| 
 | ||||
|     private cachedFeatures2: any; | ||||
| 
 | ||||
|     private flagResolver: IFlagResolver; | ||||
| 
 | ||||
|     constructor( | ||||
|         { | ||||
|             featureToggleServiceV2, | ||||
|             segmentService, | ||||
|             clientSpecService, | ||||
|             openApiService, | ||||
|             eventService, | ||||
|         }: Pick< | ||||
|             IUnleashServices, | ||||
|             | 'featureToggleServiceV2' | ||||
|             | 'segmentService' | ||||
|             | 'clientSpecService' | ||||
|             | 'openApiService' | ||||
|             | 'eventService' | ||||
|         >, | ||||
|         config: IUnleashConfig, | ||||
|     ) { | ||||
|         super(config); | ||||
|         const { clientFeatureCaching } = config; | ||||
|         const { clientFeatureCaching, flagResolver, eventBus } = config; | ||||
|         this.featureToggleServiceV2 = featureToggleServiceV2; | ||||
|         this.segmentService = segmentService; | ||||
|         this.clientSpecService = clientSpecService; | ||||
|         this.openApiService = openApiService; | ||||
|         this.flagResolver = flagResolver; | ||||
|         this.eventService = eventService; | ||||
|         this.logger = config.getLogger('client-api/feature.js'); | ||||
|         this.eventBus = eventBus; | ||||
| 
 | ||||
|         this.route({ | ||||
|             method: 'get', | ||||
| @ -117,11 +144,25 @@ export default class FeatureController extends Controller { | ||||
|                 }, | ||||
|             ); | ||||
|         } | ||||
|         this.cachedFeatures2 = memoizee( | ||||
|             // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
|             (query: IFeatureToggleQuery, etag: string) => | ||||
|                 this.resolveFeaturesAndSegments(query), | ||||
|             { | ||||
|                 promise: true, | ||||
|                 maxAge: hoursToMilliseconds(1), | ||||
|                 normalizer(args) { | ||||
|                     // args is arguments object as accessible in memoized function
 | ||||
|                     return args[1]; | ||||
|                 }, | ||||
|             }, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     private async resolveFeaturesAndSegments( | ||||
|         query?: IFeatureToggleQuery, | ||||
|     ): Promise<[FeatureConfigurationClient[], ISegment[]]> { | ||||
|         this.logger.debug('bypass cache'); | ||||
|         return Promise.all([ | ||||
|             this.featureToggleServiceV2.getClientFeatures(query), | ||||
|             this.segmentService.getActive(), | ||||
| @ -175,7 +216,7 @@ export default class FeatureController extends Controller { | ||||
|             !environment && | ||||
|             !inlineSegmentConstraints | ||||
|         ) { | ||||
|             return null; | ||||
|             return {}; | ||||
|         } | ||||
| 
 | ||||
|         const tagQuery = this.paramToArray(tag); | ||||
| @ -199,12 +240,22 @@ export default class FeatureController extends Controller { | ||||
|         req: IAuthRequest, | ||||
|         res: Response<ClientFeaturesSchema>, | ||||
|     ): Promise<void> { | ||||
|         if (this.flagResolver.isEnabled('optimal304')) { | ||||
|             return this.optimal304(req, res); | ||||
|         } | ||||
| 
 | ||||
|         const query = await this.resolveQuery(req); | ||||
| 
 | ||||
|         const [features, segments] = this.cache | ||||
|             ? await this.cachedFeatures(query) | ||||
|             : await this.resolveFeaturesAndSegments(query); | ||||
| 
 | ||||
|         if (this.flagResolver.isEnabled('optimal304Differ')) { | ||||
|             process.nextTick(async () => | ||||
|                 this.doOptimal304Diffing(features, query), | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         if (this.clientSpecService.requestSupportsSpec(req, 'segments')) { | ||||
|             this.openApiService.respondWithValidation( | ||||
|                 200, | ||||
| @ -222,6 +273,103 @@ export default class FeatureController extends Controller { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * This helper method is used to validate that the new way of calculating | ||||
|      * cache-key based on query hash and revision id, with an internal memoization | ||||
|      * of 1hr still ends up producing the same result. | ||||
|      * | ||||
|      * It's worth to note that it is expected that a diff will occur immediately after | ||||
|      * a toggle changes due to the nature of two individual caches and how fast they | ||||
|      * detect the change. The diffs should however go away as soon as they both have | ||||
|      * the latest feature toggle configuration, which will happen within 600ms on the | ||||
|      * default configurations. | ||||
|      * | ||||
|      * This method is experimental and will only be used to validate our internal state | ||||
|      * to make sure our new way of caching is correct and stable. | ||||
|      * | ||||
|      * @deprecated | ||||
|      */ | ||||
|     async doOptimal304Diffing( | ||||
|         features: FeatureConfigurationClient[], | ||||
|         query: IFeatureToggleQuery, | ||||
|     ): Promise<void> { | ||||
|         try { | ||||
|             const { etag } = await this.calculateMeta(query); | ||||
|             const [featuresNew] = await this.cachedFeatures2(query, etag); | ||||
|             const theDiffedObject = diff(features, featuresNew); | ||||
| 
 | ||||
|             if (isEmpty(theDiffedObject)) { | ||||
|                 this.logger.warn('The diff is: <Empty>'); | ||||
|                 this.eventBus.emit('optimal304Differ', { status: 'equal' }); | ||||
|             } else { | ||||
|                 this.logger.warn( | ||||
|                     `The diff is: ${JSON.stringify(theDiffedObject)}`, | ||||
|                 ); | ||||
|                 this.eventBus.emit('optimal304Differ', { status: 'diff' }); | ||||
|             } | ||||
|         } catch (e) { | ||||
|             this.logger.error('The diff checker crashed', e); | ||||
|             this.eventBus.emit('optimal304Differ', { status: 'crash' }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     async calculateMeta(query: IFeatureToggleQuery): Promise<IMeta> { | ||||
|         // TODO: We will need to standardize this to be able to implement this a cross languages (Edge in Rust?).
 | ||||
|         const revisionId = await this.eventService.getMaxRevisionId(); | ||||
| 
 | ||||
|         // TODO: We will need to standardize this to be able to implement this a cross languages (Edge in Rust?).
 | ||||
|         const queryHash = hasSum(query); | ||||
|         const etag = `${queryHash}:${revisionId}`; | ||||
|         return { revisionId, etag, queryHash }; | ||||
|     } | ||||
| 
 | ||||
|     async optimal304( | ||||
|         req: IAuthRequest, | ||||
|         res: Response<ClientFeaturesSchema>, | ||||
|     ): Promise<void> { | ||||
|         const query = await this.resolveQuery(req); | ||||
| 
 | ||||
|         const userVersion = req.headers['if-none-match']; | ||||
|         const meta = await this.calculateMeta(query); | ||||
|         const { etag } = meta; | ||||
| 
 | ||||
|         res.setHeader('etag', etag); | ||||
| 
 | ||||
|         if (etag === userVersion) { | ||||
|             res.status(304); | ||||
|             res.end(); | ||||
|             return; | ||||
|         } else { | ||||
|             this.logger.debug( | ||||
|                 `Provided revision: ${userVersion}, calculated revision: ${etag}`, | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         const [features, segments] = await this.cachedFeatures2(query, etag); | ||||
| 
 | ||||
|         if (this.clientSpecService.requestSupportsSpec(req, 'segments')) { | ||||
|             this.openApiService.respondWithValidation( | ||||
|                 200, | ||||
|                 res, | ||||
|                 clientFeaturesSchema.$id, | ||||
|                 { | ||||
|                     version, | ||||
|                     features, | ||||
|                     query: { ...query }, | ||||
|                     segments, | ||||
|                     meta, | ||||
|                 }, | ||||
|             ); | ||||
|         } else { | ||||
|             this.openApiService.respondWithValidation( | ||||
|                 200, | ||||
|                 res, | ||||
|                 clientFeaturesSchema.$id, | ||||
|                 { version, features, query, meta }, | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     async getFeatureToggle( | ||||
|         req: IAuthRequest<{ featureName: string }, ClientFeaturesQuerySchema>, | ||||
|         res: Response<ClientFeatureSchema>, | ||||
|  | ||||
| @ -10,6 +10,8 @@ export default class EventService { | ||||
| 
 | ||||
|     private eventStore: IEventStore; | ||||
| 
 | ||||
|     private revisionId: number; | ||||
| 
 | ||||
|     constructor( | ||||
|         { eventStore }: Pick<IUnleashStores, 'eventStore'>, | ||||
|         { getLogger }: Pick<IUnleashConfig, 'getLogger'>, | ||||
| @ -35,6 +37,21 @@ export default class EventService { | ||||
|             totalEvents, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     async getMaxRevisionId(): Promise<number> { | ||||
|         if (this.revisionId) { | ||||
|             return this.revisionId; | ||||
|         } else { | ||||
|             return this.updateMaxRevisionId(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     async updateMaxRevisionId(): Promise<number> { | ||||
|         this.revisionId = await this.eventStore.getMaxRevisionId( | ||||
|             this.revisionId, | ||||
|         ); | ||||
|         return this.revisionId; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| module.exports = EventService; | ||||
|  | ||||
| @ -39,7 +39,11 @@ import { LastSeenService } from './client-metrics/last-seen-service'; | ||||
| import { InstanceStatsService } from './instance-stats-service'; | ||||
| import { FavoritesService } from './favorites-service'; | ||||
| import MaintenanceService from './maintenance-service'; | ||||
| import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns'; | ||||
| import { | ||||
|     hoursToMilliseconds, | ||||
|     minutesToMilliseconds, | ||||
|     secondsToMilliseconds, | ||||
| } from 'date-fns'; | ||||
| import { AccountService } from './account-service'; | ||||
| import { SchedulerService } from './scheduler-service'; | ||||
| import { Knex } from 'knex'; | ||||
| @ -61,6 +65,7 @@ export const scheduleServices = ( | ||||
|         clientInstanceService, | ||||
|         projectService, | ||||
|         projectHealthService, | ||||
|         eventService, | ||||
|     } = services; | ||||
| 
 | ||||
|     schedulerService.schedule( | ||||
| @ -96,6 +101,11 @@ export const scheduleServices = ( | ||||
|         projectHealthService.setHealthRating.bind(projectHealthService), | ||||
|         hoursToMilliseconds(1), | ||||
|     ); | ||||
| 
 | ||||
|     schedulerService.schedule( | ||||
|         eventService.updateMaxRevisionId.bind(eventService), | ||||
|         secondsToMilliseconds(1), | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| export const createServices = ( | ||||
|  | ||||
| @ -74,6 +74,14 @@ const flags = { | ||||
|     ), | ||||
|     projectMode: parseEnvVarBoolean(process.env.PROJECT_MODE, false), | ||||
|     cleanClientApi: parseEnvVarBoolean(process.env.CLEAN_CLIENT_API, false), | ||||
|     optimal304: parseEnvVarBoolean( | ||||
|         process.env.UNLEASH_EXPERIMENTAL_OPTIMAL_304, | ||||
|         false, | ||||
|     ), | ||||
|     optimal304Differ: parseEnvVarBoolean( | ||||
|         process.env.UNLEASH_EXPERIMENTAL_OPTIMAL_304_DIFFER, | ||||
|         false, | ||||
|     ), | ||||
| }; | ||||
| 
 | ||||
| export const defaultExperimentalOptions: IExperimentalOptions = { | ||||
|  | ||||
| @ -13,5 +13,6 @@ export interface IEventStore | ||||
|     count(): Promise<number>; | ||||
|     filteredCount(search: SearchEventsSchema): Promise<number>; | ||||
|     searchEvents(search: SearchEventsSchema): Promise<IEvent[]>; | ||||
|     getMaxRevisionId(currentMax?: number): Promise<number>; | ||||
|     query(operations: IQueryOperations[]): Promise<IEvent[]>; | ||||
| } | ||||
|  | ||||
							
								
								
									
										3
									
								
								src/lib/util/isEmpty.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/lib/util/isEmpty.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | ||||
| export const isEmpty = (object: Object): boolean => { | ||||
|     return Object.keys(object).length === 0; | ||||
| }; | ||||
| @ -45,6 +45,8 @@ process.nextTick(async () => { | ||||
|                         showProjectApiAccess: true, | ||||
|                         projectScopedSegments: true, | ||||
|                         projectScopedStickiness: true, | ||||
|                         optimal304: true, | ||||
|                         optimal304Differ: false, | ||||
|                     }, | ||||
|                 }, | ||||
|                 authentication: { | ||||
| @ -58,6 +60,12 @@ process.nextTick(async () => { | ||||
|                         }, | ||||
|                     ], | ||||
|                 }, | ||||
|                 /* can be tweaked to control configuration caching for /api/client/features | ||||
|                 clientFeatureCaching: { | ||||
|                     enabled: true, | ||||
|                     maxAge: 4000, | ||||
|                 }, | ||||
|                 */ | ||||
|             }), | ||||
|         ); | ||||
|     } catch (error) { | ||||
|  | ||||
							
								
								
									
										154
									
								
								src/test/e2e/api/client/feature.optimal304.e2e.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								src/test/e2e/api/client/feature.optimal304.e2e.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,154 @@ | ||||
| import { | ||||
|     IUnleashTest, | ||||
|     setupAppWithCustomConfig, | ||||
| } from '../../helpers/test-helper'; | ||||
| import dbInit, { ITestDb } from '../../helpers/database-init'; | ||||
| import getLogger from '../../../fixtures/no-logger'; | ||||
| // import { DEFAULT_ENV } from '../../../../lib/util/constants';
 | ||||
| 
 | ||||
| let app: IUnleashTest; | ||||
| let db: ITestDb; | ||||
| 
 | ||||
| beforeAll(async () => { | ||||
|     db = await dbInit('feature_304_api_client', getLogger); | ||||
|     app = await setupAppWithCustomConfig(db.stores, { | ||||
|         experimental: { | ||||
|             flags: { | ||||
|                 strictSchemaValidation: true, | ||||
|                 optimal304: true, | ||||
|             }, | ||||
|         }, | ||||
|     }); | ||||
|     await app.services.featureToggleServiceV2.createFeatureToggle( | ||||
|         'default', | ||||
|         { | ||||
|             name: 'featureX', | ||||
|             description: 'the #1 feature', | ||||
|             impressionData: true, | ||||
|         }, | ||||
|         'test', | ||||
|     ); | ||||
|     await app.services.featureToggleServiceV2.createFeatureToggle( | ||||
|         'default', | ||||
|         { | ||||
|             name: 'featureY', | ||||
|             description: 'soon to be the #1 feature', | ||||
|         }, | ||||
|         'test', | ||||
|     ); | ||||
|     await app.services.featureToggleServiceV2.createFeatureToggle( | ||||
|         'default', | ||||
|         { | ||||
|             name: 'featureZ', | ||||
|             description: 'terrible feature', | ||||
|         }, | ||||
|         'test', | ||||
|     ); | ||||
|     await app.services.featureToggleServiceV2.createFeatureToggle( | ||||
|         'default', | ||||
|         { | ||||
|             name: 'featureArchivedX', | ||||
|             description: 'the #1 feature', | ||||
|         }, | ||||
|         'test', | ||||
|     ); | ||||
| 
 | ||||
|     await app.services.featureToggleServiceV2.archiveToggle( | ||||
|         'featureArchivedX', | ||||
|         'test', | ||||
|     ); | ||||
| 
 | ||||
|     await app.services.featureToggleServiceV2.createFeatureToggle( | ||||
|         'default', | ||||
|         { | ||||
|             name: 'featureArchivedY', | ||||
|             description: 'soon to be the #1 feature', | ||||
|         }, | ||||
|         'test', | ||||
|     ); | ||||
| 
 | ||||
|     await app.services.featureToggleServiceV2.archiveToggle( | ||||
|         'featureArchivedY', | ||||
|         'test', | ||||
|     ); | ||||
|     await app.services.featureToggleServiceV2.createFeatureToggle( | ||||
|         'default', | ||||
|         { | ||||
|             name: 'featureArchivedZ', | ||||
|             description: 'terrible feature', | ||||
|         }, | ||||
|         'test', | ||||
|     ); | ||||
|     await app.services.featureToggleServiceV2.archiveToggle( | ||||
|         'featureArchivedZ', | ||||
|         'test', | ||||
|     ); | ||||
|     await app.services.featureToggleServiceV2.createFeatureToggle( | ||||
|         'default', | ||||
|         { | ||||
|             name: 'feature.with.variants', | ||||
|             description: 'A feature toggle with variants', | ||||
|         }, | ||||
|         'test', | ||||
|     ); | ||||
|     await app.services.featureToggleServiceV2.saveVariants( | ||||
|         'feature.with.variants', | ||||
|         'default', | ||||
|         [ | ||||
|             { | ||||
|                 name: 'control', | ||||
|                 weight: 50, | ||||
|                 weightType: 'fix', | ||||
|                 stickiness: 'default', | ||||
|             }, | ||||
|             { | ||||
|                 name: 'new', | ||||
|                 weight: 50, | ||||
|                 weightType: 'variable', | ||||
|                 stickiness: 'default', | ||||
|             }, | ||||
|         ], | ||||
|         'ivar', | ||||
|     ); | ||||
| }); | ||||
| 
 | ||||
| afterAll(async () => { | ||||
|     await app.destroy(); | ||||
|     await db.destroy(); | ||||
| }); | ||||
| 
 | ||||
| test('returns calculated hash', async () => { | ||||
|     const res = await app.request | ||||
|         .get('/api/client/features') | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200); | ||||
|     expect(res.headers.etag).toBe('ae443048:19'); | ||||
|     expect(res.body.meta.etag).toBe('ae443048:19'); | ||||
| }); | ||||
| 
 | ||||
| test('returns 304 for pre-calculated hash', async () => { | ||||
|     return app.request | ||||
|         .get('/api/client/features') | ||||
|         .set('if-none-match', 'ae443048:19') | ||||
|         .expect(304); | ||||
| }); | ||||
| 
 | ||||
| test('returns 200 when content updates and hash does not match anymore', async () => { | ||||
|     await app.services.featureToggleServiceV2.createFeatureToggle( | ||||
|         'default', | ||||
|         { | ||||
|             name: 'featureNew304', | ||||
|             description: 'the #1 feature', | ||||
|         }, | ||||
|         'test', | ||||
|     ); | ||||
|     await app.services.eventService.updateMaxRevisionId(); | ||||
| 
 | ||||
|     const res = await app.request | ||||
|         .get('/api/client/features') | ||||
|         .set('if-none-match', 'ae443048:19') | ||||
|         .expect(200); | ||||
| 
 | ||||
|     expect(res.headers.etag).toBe('ae443048:20'); | ||||
|     expect(res.body.meta.etag).toBe('ae443048:20'); | ||||
| }); | ||||
							
								
								
									
										4
									
								
								src/test/fixtures/fake-event-store.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								src/test/fixtures/fake-event-store.ts
									
									
									
									
										vendored
									
									
								
							| @ -15,6 +15,10 @@ class FakeEventStore implements IEventStore { | ||||
|         this.events = []; | ||||
|     } | ||||
| 
 | ||||
|     getMaxRevisionId(): Promise<number> { | ||||
|         throw new Error('Method not implemented.'); | ||||
|     } | ||||
| 
 | ||||
|     store(event: IEvent): Promise<void> { | ||||
|         this.events.push(event); | ||||
|         this.eventEmitter.emit(event.type, event); | ||||
|  | ||||
							
								
								
									
										15
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								yarn.lock
									
									
									
									
									
								
							| @ -1125,6 +1125,11 @@ | ||||
|   dependencies: | ||||
|     "@types/node" "*" | ||||
| 
 | ||||
| "@types/hash-sum@^1.0.0": | ||||
|   version "1.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/@types/hash-sum/-/hash-sum-1.0.0.tgz#838f4e8627887d42b162d05f3d96ca636c2bc504" | ||||
|   integrity sha512-FdLBT93h3kcZ586Aee66HPCVJ6qvxVjBlDWNmxSGSbCZe9hTsjRKdSsl4y1T+3zfujxo9auykQMnFsfyHWD7wg== | ||||
| 
 | ||||
| "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": | ||||
|   version "2.0.4" | ||||
|   resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" | ||||
| @ -2430,6 +2435,11 @@ deep-is@^0.1.3: | ||||
|   resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" | ||||
|   integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== | ||||
| 
 | ||||
| deep-object-diff@^1.1.9: | ||||
|   version "1.1.9" | ||||
|   resolved "https://registry.yarnpkg.com/deep-object-diff/-/deep-object-diff-1.1.9.tgz#6df7ef035ad6a0caa44479c536ed7b02570f4595" | ||||
|   integrity sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA== | ||||
| 
 | ||||
| deepmerge@^4.2.2: | ||||
|   version "4.2.2" | ||||
|   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" | ||||
| @ -3602,6 +3612,11 @@ has@^1.0.3: | ||||
|   dependencies: | ||||
|     function-bind "^1.1.1" | ||||
| 
 | ||||
| hash-sum@^2.0.0: | ||||
|   version "2.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/hash-sum/-/hash-sum-2.0.0.tgz#81d01bb5de8ea4a214ad5d6ead1b523460b0b45a" | ||||
|   integrity sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg== | ||||
| 
 | ||||
| helmet@^6.0.0: | ||||
|   version "6.0.1" | ||||
|   resolved "https://registry.yarnpkg.com/helmet/-/helmet-6.0.1.tgz#52ec353638b2e87f14fe079d142b368ac11e79a4" | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user