unleash-server
Version:
Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.
629 lines • 23.4 kB
JavaScript
import { APPLICATION_CREATED, FEATURE_CREATED, FEATURE_DELETED, FEATURE_FAVORITED, FEATURE_PROJECT_CHANGE, FEATURE_TAGGED, FEATURE_UPDATED, SEGMENT_UPDATED, } from '../../../lib/events/index.js';
import { FeatureChangeProjectEvent, FeatureCreatedEvent, FeatureDeletedEvent, FeatureTaggedEvent, FeatureUpdatedEvent, } from '../../../lib/types/index.js';
import { ALL_ENVS } from '../../../lib/util/index.js';
import dbInit from '../helpers/database-init.js';
import getLogger from '../../fixtures/no-logger.js';
import { withTransactional, } from '../../../lib/db/transaction.js';
import { EventStore } from '../../../lib/features/events/event-store.js';
import { vi } from 'vitest';
let db;
let stores;
let eventStore;
const TEST_USER_ID = -9999;
const testAudit = {
id: TEST_USER_ID,
username: 'test@example.com',
ip: '127.0.0.1',
};
beforeAll(async () => {
db = await dbInit('event_store_serial', getLogger);
stores = db.stores;
eventStore = stores.eventStore;
});
beforeEach(async () => {
await eventStore.deleteAll();
});
afterAll(async () => {
if (db) {
await db.destroy();
}
});
test('Should include id and createdAt when saving', async () => {
vi.useFakeTimers();
const event1 = {
type: APPLICATION_CREATED,
createdBy: '127.0.0.1',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: {
clientIp: '127.0.0.1',
appName: 'test1',
},
};
const seen = [];
eventStore.on(APPLICATION_CREATED, (e) => seen.push(e));
await eventStore.store(event1);
await eventStore.publishUnannouncedEvents();
expect(seen).toHaveLength(1);
expect(seen[0].id).toBeTruthy();
expect(seen[0].createdAt).toBeTruthy();
expect(seen[0].data.clientIp).toBe(event1.data.clientIp);
expect(seen[0].data.appName).toBe(event1.data.appName);
vi.useRealTimers();
});
test('Should include empty tags array for new event', async () => {
expect.assertions(2);
const event = {
type: FEATURE_CREATED,
createdBy: 'me@mail.com',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: {
name: 'someName',
enabled: true,
strategies: [{ name: 'default' }],
},
};
const promise = new Promise((resolve) => {
eventStore.on(FEATURE_CREATED, (storedEvent) => {
expect(storedEvent.data.name).toBe(event.data.name);
expect(Array.isArray(storedEvent.tags)).toBe(true);
resolve();
});
});
// Trigger
await eventStore.store(event);
await eventStore.publishUnannouncedEvents();
return promise;
});
test('Should be able to store multiple events at once', async () => {
vi.useFakeTimers();
const event1 = {
type: APPLICATION_CREATED,
createdByUserId: TEST_USER_ID,
createdBy: '127.0.0.1',
data: {
clientIp: '127.0.0.1',
appName: 'test1',
},
ip: '127.0.0.1',
};
const event2 = {
type: APPLICATION_CREATED,
createdByUserId: TEST_USER_ID,
createdBy: '127.0.0.1',
data: {
clientIp: '127.0.0.1',
appName: 'test2',
},
ip: '127.0.0.1',
};
const event3 = {
type: APPLICATION_CREATED,
createdByUserId: TEST_USER_ID,
createdBy: '127.0.0.1',
data: {
clientIp: '127.0.0.1',
appName: 'test3',
},
tags: [{ type: 'simple', value: 'mytest' }],
ip: '127.0.0.1',
};
const seen = [];
eventStore.on(APPLICATION_CREATED, (e) => seen.push(e));
await eventStore.batchStore([event1, event2, event3]);
await eventStore.publishUnannouncedEvents();
expect(seen.length).toBe(3);
seen.forEach((e) => {
expect(e.id).toBeTruthy();
expect(e.createdAt).toBeTruthy();
});
vi.useRealTimers();
});
test('Should get all stored events', async () => {
const event = {
type: FEATURE_CREATED,
createdBy: 'me@mail.com',
createdByUserId: TEST_USER_ID,
data: {
name: 'someName',
enabled: true,
strategies: [{ name: 'default' }],
},
ip: '127.0.0.1',
};
await eventStore.store(event);
const events = await eventStore.getAll();
const lastEvent = events[0];
expect(lastEvent.type).toBe(event.type);
expect(lastEvent.createdBy).toBe(event.createdBy);
});
test('Should delete stored event', async () => {
const event = {
type: FEATURE_CREATED,
createdByUserId: TEST_USER_ID,
createdBy: 'me@mail.com',
data: {
name: 'someName',
enabled: true,
strategies: [{ name: 'default' }],
},
ip: '127.0.0.1',
};
await eventStore.store(event);
await eventStore.store(event);
const events = await eventStore.getAll();
const lastEvent = events[0];
await eventStore.delete(lastEvent.id);
const eventsAfterDelete = await eventStore.getAll();
const lastEventAfterDelete = eventsAfterDelete[0];
expect(events.length - eventsAfterDelete.length).toBe(1);
expect(lastEventAfterDelete.id).not.toBe(lastEvent.id);
});
test('Should get stored event by id', async () => {
const event = {
type: FEATURE_CREATED,
createdBy: 'me@mail.com',
createdByUserId: TEST_USER_ID,
data: {
name: 'someName',
enabled: true,
strategies: [{ name: 'default' }],
},
ip: '127.0.0.1',
};
await eventStore.store(event);
const events = await eventStore.getAll();
const lastEvent = events[0];
const exists = await eventStore.exists(lastEvent.id);
const byId = await eventStore.get(lastEvent.id);
expect(lastEvent).toStrictEqual(byId);
expect(exists).toBe(true);
});
test('Should delete all stored events', async () => {
await eventStore.deleteAll();
const events = await eventStore.getAll();
expect(events).toHaveLength(0);
});
test('Should get all events of type', async () => {
const data = { name: 'someName', project: 'test-project' };
await Promise.all([0, 1, 2, 3, 4, 5].map(async (id) => {
const event = id % 2 === 0
? new FeatureCreatedEvent({
project: data.project,
featureName: data.name,
auditUser: testAudit,
data,
})
: new FeatureDeletedEvent({
project: data.project,
preData: data,
featureName: data.name,
auditUser: testAudit,
tags: [],
});
return eventStore.store(event);
}));
const featureCreatedEvents = await eventStore.searchEvents({
offset: 0,
limit: 10,
}, [
{
field: 'type',
operator: 'IS',
values: [FEATURE_CREATED],
},
]);
expect(featureCreatedEvents).toHaveLength(3);
const featureDeletedEvents = await eventStore.searchEvents({
offset: 0,
limit: 10,
}, [
{
field: 'type',
operator: 'IS',
values: [FEATURE_DELETED],
},
]);
expect(featureDeletedEvents).toHaveLength(3);
});
test('getMaxRevisionId returns the latest id for relevant feature and segment events', async () => {
const featureName = 'test-feature';
const project = 'test-project';
const featureUpdatedEvent = new FeatureUpdatedEvent({
project,
featureName,
auditUser: testAudit,
data: { name: featureName, enabled: false },
});
const segmentUpdatedEvent = {
type: SEGMENT_UPDATED,
createdBy: testAudit.username,
createdByUserId: testAudit.id,
ip: testAudit.ip,
data: { id: 1, name: 'test-segment' },
};
const featureTaggedEvent = new FeatureTaggedEvent({
featureName,
project,
auditUser: testAudit,
data: { type: 'simple', value: 'crazy' },
});
await eventStore.store(featureUpdatedEvent);
const maxAfterUpdate = await eventStore.getMaxRevisionId();
await eventStore.store(segmentUpdatedEvent);
const maxAfterSegment = await eventStore.getMaxRevisionId();
await eventStore.store(featureTaggedEvent);
const maxAfterTag = await eventStore.getMaxRevisionId();
const allEvents = await eventStore.getAll();
const updatedEvent = allEvents.find((e) => e.type === FEATURE_UPDATED);
const segmentEvent = allEvents.find((e) => e.type === SEGMENT_UPDATED);
const taggedEvent = allEvents.find((e) => e.type === FEATURE_TAGGED);
expect(maxAfterUpdate).toBe(updatedEvent.id);
expect(maxAfterSegment).toBe(segmentEvent.id);
expect(maxAfterTag).toBe(taggedEvent.id);
expect(updatedEvent).toBeDefined();
expect(segmentEvent).toBeDefined();
expect(taggedEvent).toBeDefined();
expect(segmentEvent.id).toBeGreaterThan(updatedEvent.id);
expect(taggedEvent.id).toBeGreaterThan(segmentEvent.id);
});
describe('getDeltaRevisionState', () => {
test('returns per-project max revision ids and segment revision', async () => {
const defaultEvent = new FeatureUpdatedEvent({
project: 'default',
featureName: 'feature-a',
auditUser: testAudit,
data: { name: 'feature-a', enabled: true },
});
const otherEvent = new FeatureUpdatedEvent({
project: 'other',
featureName: 'feature-b',
auditUser: testAudit,
data: { name: 'feature-b', enabled: false },
});
const segmentEvent = {
type: SEGMENT_UPDATED,
createdBy: testAudit.username,
createdByUserId: testAudit.id,
ip: testAudit.ip,
data: { id: 123, name: 'segment-a' },
};
await eventStore.store(defaultEvent);
await eventStore.store(otherEvent);
await eventStore.store(segmentEvent);
const allEvents = await eventStore.getAll();
const defaultStored = allEvents.find((e) => e.type === FEATURE_UPDATED && e.project === 'default');
const otherStored = allEvents.find((e) => e.type === FEATURE_UPDATED && e.project === 'other');
const segmentStored = allEvents.find((e) => e.type === SEGMENT_UPDATED);
const state = await eventStore.getDeltaRevisionState(ALL_ENVS);
expect(state.projectRevisions.get('default')).toBe(defaultStored.id);
expect(state.projectRevisions.get('other')).toBe(otherStored.id);
expect(state.globalSegmentRevision).toBe(segmentStored.id);
});
test('respects environment filtering and includes null-environment feature events', async () => {
const devEvent = {
type: FEATURE_UPDATED,
createdBy: testAudit.username,
createdByUserId: testAudit.id,
ip: testAudit.ip,
featureName: 'feature-a',
project: 'default',
environment: 'development',
data: { name: 'feature-a', enabled: true },
};
const prodEvent = {
type: FEATURE_UPDATED,
createdBy: testAudit.username,
createdByUserId: testAudit.id,
ip: testAudit.ip,
featureName: 'feature-a',
project: 'default',
environment: 'production',
data: { name: 'feature-a', enabled: false },
};
const nullEnvEvent = {
type: FEATURE_UPDATED,
createdBy: testAudit.username,
createdByUserId: testAudit.id,
ip: testAudit.ip,
featureName: 'feature-a',
project: 'default',
data: { name: 'feature-a', enabled: true },
};
await eventStore.store(devEvent);
await eventStore.store(prodEvent);
await eventStore.store(nullEnvEvent);
const allEvents = await eventStore.getAll();
const devStored = allEvents.find((e) => e.environment === 'development');
const prodStored = allEvents.find((e) => e.environment === 'production');
const nullEnvStored = allEvents.find((e) => e.environment == null);
const state = await eventStore.getDeltaRevisionState('development');
expect(state.projectRevisions.get('default')).toBe(Math.max(devStored.id, nullEnvStored.id));
expect(state.projectRevisions.get('default')).not.toBe(prodStored.id);
});
test('includes oldProject revisions from feature-project-change', async () => {
const moveEvent = new FeatureChangeProjectEvent({
oldProject: 'old-project',
newProject: 'new-project',
featureName: 'moved-feature',
auditUser: testAudit,
});
await eventStore.store(moveEvent);
const storedMove = (await eventStore.getAll()).find((e) => e.type === FEATURE_PROJECT_CHANGE);
const state = await eventStore.getDeltaRevisionState(ALL_ENVS);
expect(state.projectRevisions.get('old-project')).toBe(storedMove.id);
expect(state.projectRevisions.get('new-project')).toBe(storedMove.id);
expect(state.globalSegmentRevision).toBe(0);
});
test('ignores non-interesting feature events', async () => {
const favoritedEvent = {
type: FEATURE_FAVORITED,
createdBy: testAudit.username,
createdByUserId: testAudit.id,
ip: testAudit.ip,
featureName: 'feature-a',
project: 'default',
data: {},
};
await eventStore.store(favoritedEvent);
const state = await eventStore.getDeltaRevisionState(ALL_ENVS);
expect(state.projectRevisions.size).toBe(0);
expect(state.globalSegmentRevision).toBe(0);
});
test('returns latest project and segment revisions', async () => {
const firstFeature = new FeatureUpdatedEvent({
project: 'default',
featureName: 'feature-a',
auditUser: testAudit,
data: { name: 'feature-a', enabled: true },
});
const segmentEvent = {
type: SEGMENT_UPDATED,
createdBy: testAudit.username,
createdByUserId: testAudit.id,
ip: testAudit.ip,
data: { id: 999, name: 'segment-a' },
};
const secondFeature = new FeatureUpdatedEvent({
project: 'default',
featureName: 'feature-a',
auditUser: testAudit,
data: { name: 'feature-a', enabled: false },
});
await eventStore.store(firstFeature);
await eventStore.store(segmentEvent);
await eventStore.store(secondFeature);
const allEvents = await eventStore.getAll();
const firstStored = allEvents.find((e) => e.type === FEATURE_UPDATED && e.data.enabled === true);
const segmentStored = allEvents.find((e) => e.type === SEGMENT_UPDATED);
const secondStored = allEvents.find((e) => e.type === FEATURE_UPDATED && e.data.enabled === false);
const state = await eventStore.getDeltaRevisionState(ALL_ENVS);
expect(state.projectRevisions.get('default')).not.toBe(firstStored.id);
expect(state.projectRevisions.get('default')).toBe(secondStored.id);
expect(state.globalSegmentRevision).toBe(segmentStored.id);
});
});
test('Should filter events by ID using IS operator', async () => {
const event1 = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: { name: 'feature1' },
};
const event2 = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: { name: 'feature2' },
};
const event3 = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: { name: 'feature3' },
};
await eventStore.store(event1);
await eventStore.store(event2);
await eventStore.store(event3);
const allEvents = await eventStore.getAll();
const targetEvent = allEvents.find((e) => e.data.name === 'feature2');
const filteredEvents = await eventStore.searchEvents({
offset: 0,
limit: 10,
}, [
{
field: 'id',
operator: 'IS',
values: [targetEvent.id.toString()],
},
]);
expect(filteredEvents).toHaveLength(1);
expect(filteredEvents[0].id).toBe(targetEvent.id);
expect(filteredEvents[0].data.name).toBe('feature2');
});
test('Should filter events by ID using IS_ANY_OF operator', async () => {
const event1 = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: { name: 'feature1' },
};
const event2 = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: { name: 'feature2' },
};
const event3 = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: { name: 'feature3' },
};
await eventStore.store(event1);
await eventStore.store(event2);
await eventStore.store(event3);
const allEvents = await eventStore.getAll();
const targetEvent1 = allEvents.find((e) => e.data.name === 'feature1');
const targetEvent3 = allEvents.find((e) => e.data.name === 'feature3');
const filteredEvents = await eventStore.searchEvents({
offset: 0,
limit: 10,
}, [
{
field: 'id',
operator: 'IS_ANY_OF',
values: [
targetEvent1.id.toString(),
targetEvent3.id.toString(),
],
},
]);
expect(filteredEvents).toHaveLength(2);
const eventIds = filteredEvents.map((e) => e.id);
expect(eventIds).toContain(targetEvent1.id);
expect(eventIds).toContain(targetEvent3.id);
expect(eventIds).not.toContain(allEvents.find((e) => e.data.name === 'feature2').id);
});
test('Should return empty result when filtering by non-existent ID', async () => {
const event = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: { name: 'feature1' },
};
await eventStore.store(event);
const filteredEvents = await eventStore.searchEvents({
offset: 0,
limit: 10,
}, [
{
field: 'id',
operator: 'IS',
values: ['999999'],
},
]);
expect(filteredEvents).toHaveLength(0);
});
test('Should store and retrieve transaction context fields', async () => {
const mockTransactionContext = {
type: 'change-request',
id: '01HQVX5K8P9EXAMPLE123456',
};
const eventStoreService = withTransactional((db) => new EventStore(db, getLogger), db.rawDatabase);
const event = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
featureName: 'test-feature-with-context',
project: 'test-project',
ip: '127.0.0.1',
data: {
name: 'test-feature-with-context',
enabled: true,
strategies: [{ name: 'default' }],
},
};
await eventStoreService.transactional(async (transactionalEventStore) => {
await transactionalEventStore.store(event);
}, mockTransactionContext);
const events = await eventStore.getAll();
const storedEvent = events.find((e) => e.featureName === 'test-feature-with-context');
expect(storedEvent).toBeTruthy();
expect(storedEvent.groupType).toBe('change-request');
expect(storedEvent.groupId).toBe('01HQVX5K8P9EXAMPLE123456');
});
test('Should handle missing transaction context gracefully', async () => {
const event = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
featureName: 'test-feature-no-context',
project: 'test-project',
ip: '127.0.0.1',
data: {
name: 'test-feature-no-context',
enabled: true,
strategies: [{ name: 'default' }],
},
};
await eventStore.store(event);
const events = await eventStore.getAll();
const storedEvent = events.find((e) => e.featureName === 'test-feature-no-context');
expect(storedEvent).toBeTruthy();
expect(storedEvent.groupType).toBeUndefined();
expect(storedEvent.groupId).toBeUndefined();
});
test('Should store transaction context in batch operations', async () => {
const mockTransactionContext = {
type: 'transaction',
id: '01HQVX5K8P9BATCH123456',
};
const eventStoreService = withTransactional((db) => new EventStore(db, getLogger), db.rawDatabase);
const events = [
{
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
featureName: 'batch-feature-1',
project: 'test-project',
ip: '127.0.0.1',
data: { name: 'batch-feature-1' },
},
{
type: FEATURE_UPDATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
featureName: 'batch-feature-2',
project: 'test-project',
ip: '127.0.0.1',
data: { name: 'batch-feature-2' },
},
];
await eventStoreService.transactional(async (transactionalEventStore) => {
await transactionalEventStore.batchStore(events);
}, mockTransactionContext);
const allEvents = await eventStore.getAll();
const batchEvents = allEvents.filter((e) => e.featureName === 'batch-feature-1' ||
e.featureName === 'batch-feature-2');
expect(batchEvents).toHaveLength(2);
batchEvents.forEach((event) => {
expect(event.groupType).toBe('transaction');
expect(event.groupId).toBe('01HQVX5K8P9BATCH123456');
});
});
test('Should auto-generate transaction context when none provided', async () => {
const eventStoreService = withTransactional((db) => new EventStore(db, getLogger), db.rawDatabase);
const event = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
featureName: 'test-feature-auto-context',
project: 'test-project',
ip: '127.0.0.1',
data: {
name: 'test-feature-auto-context',
enabled: true,
strategies: [{ name: 'default' }],
},
};
await eventStoreService.transactional(async (transactionalEventStore) => {
await transactionalEventStore.store(event);
});
const events = await eventStore.getAll();
const storedEvent = events.find((e) => e.featureName === 'test-feature-auto-context');
expect(storedEvent).toBeTruthy();
expect(storedEvent.groupType).toBe('transaction');
expect(storedEvent.groupId).toBeTruthy();
expect(typeof storedEvent.groupId).toBe('string');
expect(storedEvent.groupId).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
});
//# sourceMappingURL=event-store.e2e.test.js.map