UNPKG

unleash-server

Version:

Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.

422 lines • 17 kB
import dbInit from '../../../test/e2e/helpers/database-init.js'; import getLogger from '../../../test/fixtures/no-logger.js'; import { setupAppWithCustomConfig, } from '../../../test/e2e/helpers/test-helper.js'; import { randomId } from '../../util/random-id.js'; import User from '../../types/user.js'; import { DEFAULT_SEGMENT_VALUES_LIMIT, DEFAULT_STRATEGY_SEGMENTS_LIMIT, } from '../../util/segments.js'; import { collectIds } from '../../util/collect-ids.js'; import { arraysHaveSameItems } from '../../util/arraysHaveSameItems.js'; import { DEFAULT_ENV, extractAuditInfoFromUser } from '../../util/index.js'; import { DEFAULT_PROJECT, TEST_AUDIT_USER } from '../../types/index.js'; import { beforeAll, afterAll, afterEach, test, describe, expect } from 'vitest'; let db; let app; const FEATURES_CLIENT_BASE_PATH = '/api/client/features'; const fetchSegments = () => { return app.services.segmentService.getAll(); }; const fetchFeatures = () => { return app.request .get(`/api/admin/projects/default/features`) .expect(200) .then((res) => res.body.features); }; const getFeatureStrategiesPath = (featureName) => { return `/api/admin/projects/default/features/${featureName}/environments/${DEFAULT_ENV}/strategies`; }; const fetchFeatureStrategies = (featureName) => app.request .get(getFeatureStrategiesPath(featureName)) .expect(200) .then((res) => res.body); const fetchClientFeatures = async () => { const res = await app.request.get(FEATURES_CLIENT_BASE_PATH).expect(200); return res.body.features; }; const createSegment = (postData) => { return app.services.segmentService.create(postData, TEST_AUDIT_USER); }; const updateSegment = (id, postData) => { return app.services.segmentService.update(id, postData, { email: 'test@example.com', id: 1, }, TEST_AUDIT_USER); }; const mockStrategy = (segments = []) => { return { name: 'flexibleRollout', parameters: {}, constraints: [], segments, }; }; const createProjects = async (projects = [DEFAULT_PROJECT]) => { for (const project of projects) { await db.stores.projectStore.create({ name: project, description: '', id: project, mode: 'open', }); await app.request .post(`/api/admin/projects/${project}/environments`) .send({ environment: DEFAULT_ENV, }) .expect(200); } }; const createFeatureToggle = async (feature, strategies = [mockStrategy()], project = DEFAULT_PROJECT, environment = DEFAULT_ENV, expectStatusCode = 201, expectSegmentStatusCodes = [ { status: 200 }, ]) => { await app.createFeature(feature, project, expectStatusCode); let processed = 0; for (const strategy of strategies) { const { body, status } = await app.request .post(`/api/admin/projects/${project}/features/${feature.name}/environments/${environment}/strategies`) .send(strategy); const expectation = expectSegmentStatusCodes[processed++]; expect(status).toBe(expectation.status); if (expectation.message) { expect(JSON.stringify(body)).toContain(expectation.message); } } }; const updateFeatureStrategy = async (featureName, strategy, project = DEFAULT_PROJECT, environment = DEFAULT_ENV, expectedStatus = 200) => { const { status } = await app.request .put(`/api/admin/projects/${project}/features/${featureName}/environments/${environment}/strategies/${strategy.id}`) .send(strategy); expect(status).toBe(expectedStatus); }; const mockFeatureToggle = () => { return { name: randomId(), }; }; const mockConstraints = () => { return Array.from({ length: 5 }).map(() => ({ values: ['x', 'y', 'z'], operator: 'IN', contextName: 'a', })); }; const mockConstraintValues = (length) => { return Array.from({ length }).map(() => { return randomId(); }); }; const fetchClientResponse = () => { return app.request .get(FEATURES_CLIENT_BASE_PATH) .set('Unleash-Client-Spec', '4.2.0') .expect(200) .then((res) => res.body); }; const createTestSegments = async () => { const constraints = mockConstraints(); const segment1 = await createSegment({ name: 'S1', constraints }); const segment2 = await createSegment({ name: 'S2', constraints }); const segment3 = await createSegment({ name: 'S3', constraints }); await createFeatureToggle(mockFeatureToggle(), [ mockStrategy([segment1.id, segment2.id]), ]); await createFeatureToggle(mockFeatureToggle(), [ mockStrategy([segment2.id]), ]); await createFeatureToggle(mockFeatureToggle()); return [segment1, segment2, segment3]; }; beforeAll(async () => { db = await dbInit('segments', getLogger); app = await setupAppWithCustomConfig(db.stores, { experimental: { flags: {}, }, }, db.rawDatabase); }); afterAll(async () => { await app.destroy(); await db.destroy(); }); afterEach(async () => { await db.stores.segmentStore.deleteAll(); await db.stores.featureToggleStore.deleteAll(); await db.stores.eventStore.deleteAll(); }); test('should validate segment constraint values limit', async () => { const constraints = [ { contextName: randomId(), operator: 'IN', values: mockConstraintValues(DEFAULT_SEGMENT_VALUES_LIMIT + 1), }, ]; await expect(createSegment({ name: randomId(), constraints })).rejects.toThrowError(`Segments may not have more than ${DEFAULT_SEGMENT_VALUES_LIMIT} values`); }); test('should validate segment constraint values limit with multiple constraints', async () => { const constraints = [ { contextName: randomId(), operator: 'IN', values: mockConstraintValues(DEFAULT_SEGMENT_VALUES_LIMIT), }, { contextName: randomId(), operator: 'IN', values: mockConstraintValues(1), }, ]; await expect(createSegment({ name: randomId(), constraints })).rejects.toThrowError(`Segments may not have more than ${DEFAULT_SEGMENT_VALUES_LIMIT} values`); }); test('should validate feature strategy segment limit', async () => { const segments = []; for (const id of [1, 2, 3, 4, 5, 6]) { segments.push(await createSegment({ name: `S${id}`, constraints: [] })); } await createFeatureToggle(mockFeatureToggle(), [mockStrategy(segments.map((s) => s.id))], DEFAULT_PROJECT, DEFAULT_ENV, 201, [ { status: 400, message: `Strategies may not have more than ${DEFAULT_STRATEGY_SEGMENTS_LIMIT} segments`, }, ]); }); test('should clone feature strategy segments', async () => { const constraints = mockConstraints(); const segment1 = await createSegment({ name: 'S1', constraints }); await createFeatureToggle(mockFeatureToggle(), [ mockStrategy([segment1.id]), ]); await createFeatureToggle(mockFeatureToggle()); const [feature1, feature2] = await fetchFeatures(); const [feature1Strategy] = await fetchFeatureStrategies(feature1.name); const [feature2Strategy] = await fetchFeatureStrategies(feature2.name); const strategy1 = feature1Strategy.id; const strategy2 = feature2Strategy.id; let segments1 = await app.services.segmentService.getByStrategy(strategy1); let segments2 = await app.services.segmentService.getByStrategy(strategy2); expect(collectIds(segments1)).toEqual([segment1.id]); expect(collectIds(segments2)).toEqual([]); await app.services.segmentService.cloneStrategySegments(strategy1, strategy2); segments1 = await app.services.segmentService.getByStrategy(strategy1); segments2 = await app.services.segmentService.getByStrategy(strategy2); expect(collectIds(segments1)).toEqual([segment1.id]); expect(collectIds(segments2)).toEqual([segment1.id]); }); test('should store segment-created and segment-deleted events', async () => { const constraints = mockConstraints(); const user = new User({ id: 1, email: 'test@example.com' }); await createSegment({ name: 'S1', constraints }); const [segment1] = await fetchSegments(); await app.services.segmentService.delete(segment1.id, user, extractAuditInfoFromUser(user)); const events = await db.stores.eventStore.getEvents(); expect(events[0].type).toEqual('segment-deleted'); expect(events[0].preData.id).toEqual(segment1.id); expect(events[1].type).toEqual('segment-created'); expect(events[1].data.id).toEqual(segment1.id); }); test('should inline segment constraints into features by default', async () => { await createTestSegments(); const [feature1, feature2, feature3] = await fetchFeatures(); const [, , segment3] = await fetchSegments(); // add segment3 to all features for (const feature of [feature1, feature2, feature3]) { const [strt] = await fetchFeatureStrategies(feature.name); const strategy = { id: strt.id, name: strt.name, constraints: strt.constraints, parameters: strt.parameters, variants: strt.variants, segments: strt.segments ?? [], }; await updateFeatureStrategy(feature.name, { ...strategy, segments: [...strategy.segments, segment3.id], }); } const clientFeatures = await fetchClientFeatures(); const clientStrategies = clientFeatures.flatMap((f) => f.strategies); const clientConstraints = clientStrategies.flatMap((s) => s.constraints || []); const clientValues = clientConstraints.flatMap((c) => c.values); const uniqueValues = [...new Set(clientValues)]; expect(clientFeatures.length).toEqual(3); expect(clientStrategies.length).toEqual(3); expect(clientConstraints.length).toEqual(5 * 6); expect(clientValues.length).toEqual(5 * 6 * 3); expect(uniqueValues.length).toEqual(3); }); test('should only return segments to clients that support the spec', async () => { await createTestSegments(); const [segment1, segment2] = await fetchSegments(); const segmentIds = collectIds([segment1, segment2]); const unknownClientResponse = await app.request .get(FEATURES_CLIENT_BASE_PATH) .expect(200) .then((res) => res.body); const unknownClientConstraints = unknownClientResponse.features .flatMap((f) => f.strategies) .flatMap((s) => s.constraints); expect(unknownClientResponse.segments).toEqual(undefined); expect(unknownClientConstraints.length).toEqual(15); const supportedClientResponse = await app.request .get(FEATURES_CLIENT_BASE_PATH) .set('Unleash-Client-Spec', '4.2.0') .expect(200) .then((res) => res.body); const supportedClientConstraints = supportedClientResponse.features .flatMap((f) => f.strategies) .flatMap((s) => s.constraints); expect(collectIds(supportedClientResponse.segments)).toEqual(segmentIds); expect(supportedClientConstraints.length).toEqual(0); }); test('should return segments in base of toggle response if inline is disabled', async () => { await createTestSegments(); const clientFeatures = await fetchClientResponse(); expect(clientFeatures.segments.length).toBeDefined(); }); test('should only send segments that are in use', async () => { await createTestSegments(); const clientFeatures = await fetchClientResponse(); expect(clientFeatures.segments.length).toEqual(2); }); test('should send all segments that are in use by feature', async () => { await createTestSegments(); const clientFeatures = await fetchClientResponse(); const globalSegments = clientFeatures.segments; expect(globalSegments).toHaveLength(2); const globalSegmentIds = globalSegments.map((segment) => segment.id); const allSegmentIds = clientFeatures.features .flatMap((feat) => feat.strategies.map((strategy) => strategy.segments)) .flat() .filter((x) => !!x); const toggleSegmentIds = [...new Set(allSegmentIds)]; expect(arraysHaveSameItems(globalSegmentIds, toggleSegmentIds)).toEqual(true); }); describe('project-specific segments', () => { test(`can create a toggle with a project-specific segment`, async () => { const segmentName = 'my-segment'; const project = randomId(); await createProjects([project]); const segment = await createSegment({ name: segmentName, project, constraints: [], }); const strategy = { name: 'default', parameters: {}, constraints: [], segments: [segment.id], }; await createFeatureToggle({ name: 'first_feature', description: 'the #1 feature', }, [strategy], project); }); test(`can't create a toggle with a segment from a different project`, async () => { const segmentName = 'my-segment'; const project1 = randomId(); const project2 = randomId(); await createProjects([project1, project2]); const segment = await createSegment({ name: segmentName, project: project1, constraints: [], }); const strategy = { name: 'default', parameters: {}, constraints: [], segments: [segment.id], }; await createFeatureToggle({ name: 'first_feature', description: 'the #1 feature', }, [strategy], project2, DEFAULT_ENV, 201, [{ status: 400 }]); }); test(`can't set a different segment project when being used by another project`, async () => { const segmentName = 'my-segment'; const project1 = randomId(); const project2 = randomId(); await createProjects([project1, project2]); const segment = await createSegment({ name: segmentName, project: project1, constraints: [], }); const strategy = { name: 'default', parameters: {}, constraints: [], segments: [segment.id], }; await createFeatureToggle({ name: 'first_feature', description: 'the #1 feature', }, [strategy], project1); await expect(() => updateSegment(segment.id, { ...segment, project: project2, })).rejects.toThrowError(`Invalid project. Segment is being used by strategies in other projects: ${project1}`); }); test('can promote a segment project to global even when being used by a specific project', async () => { const segmentName = 'my-segment'; const project1 = randomId(); const project2 = randomId(); await createProjects([project1, project2]); const segment = await createSegment({ name: segmentName, project: project1, constraints: [], }); const strategy = { name: 'default', parameters: {}, constraints: [], segments: [segment.id], }; await createFeatureToggle({ name: 'first_feature', description: 'the #1 feature', }, [strategy], project1); await expect(updateSegment(segment.id, { ...segment, project: '', })).resolves.toBeUndefined(); }); test(`can't set a specific segment project when being used by multiple projects (global)`, async () => { const segmentName = 'my-segment'; const project1 = randomId(); const project2 = randomId(); await createProjects([project1, project2]); const segment = await createSegment({ name: segmentName, project: '', constraints: [], }); const strategy = { name: 'default', parameters: {}, constraints: [], segments: [segment.id], }; const strategy2 = { name: 'default', parameters: {}, constraints: [], segments: [segment.id], }; await createFeatureToggle({ name: 'first_feature', description: 'the #1 feature', }, [strategy], project1); await createFeatureToggle({ name: 'second_feature', description: 'the #2 feature', }, [strategy2], project2); await expect(updateSegment(segment.id, { ...segment, project: project1, })).rejects.toThrowError(`Invalid project. Segment is being used by strategies in other projects: ${project1}, ${project2}`); }); }); //# sourceMappingURL=client-segment.e2e.test.js.map