Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/storages/inLocalStorage/MySegmentsCacheInLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync {

getRegisteredSegments(): string[] {
const registeredSegments: string[] = [];
for (let i = 0; i < this.storage.length; i++) {
for (let i = 0, len = this.storage.length; i < len; i++) {
const segmentName = this.keys.extractSegmentName(this.storage.key(i)!);
if (segmentName) registeredSegments.push(segmentName);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { MySegmentsCacheInLocal } from '../MySegmentsCacheInLocal';
import { KeyBuilderCS, myLargeSegmentsKeyBuilder } from '../../KeyBuilderCS';
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
import { storages, PREFIX } from './wrapper.mock';

test('SEGMENT CACHE / in LocalStorage', () => {
test.each(storages)('SEGMENT CACHE / in LocalStorage', (storage) => {
const caches = [
new MySegmentsCacheInLocal(loggerMock, new KeyBuilderCS('SPLITIO', 'user'), localStorage),
new MySegmentsCacheInLocal(loggerMock, myLargeSegmentsKeyBuilder('SPLITIO', 'user'), localStorage)
new MySegmentsCacheInLocal(loggerMock, new KeyBuilderCS(PREFIX, 'user'), storage),
new MySegmentsCacheInLocal(loggerMock, myLargeSegmentsKeyBuilder(PREFIX, 'user'), storage)
];

caches.forEach(cache => {
Expand Down Expand Up @@ -33,8 +34,8 @@ test('SEGMENT CACHE / in LocalStorage', () => {
expect(cache.getKeysCount()).toBe(1);
});

expect(localStorage.getItem('SPLITIO.user.segment.mocked-segment-2')).toBe('1');
expect(localStorage.getItem('SPLITIO.user.segment.mocked-segment')).toBe(null);
expect(localStorage.getItem('SPLITIO.user.largeSegment.mocked-segment-2')).toBe('1');
expect(localStorage.getItem('SPLITIO.user.largeSegment.mocked-segment')).toBe(null);
expect(storage.getItem(PREFIX + '.user.segment.mocked-segment-2')).toBe('1');
expect(storage.getItem(PREFIX + '.user.segment.mocked-segment')).toBe(null);
expect(storage.getItem(PREFIX + '.user.largeSegment.mocked-segment-2')).toBe('1');
expect(storage.getItem(PREFIX + '.user.largeSegment.mocked-segment')).toBe(null);
});
323 changes: 163 additions & 160 deletions src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts

Large diffs are not rendered by default.

24 changes: 17 additions & 7 deletions src/storages/inLocalStorage/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,29 @@ describe('IN LOCAL STORAGE', () => {
fakeInMemoryStorageFactory.mockClear();
});

test('calls InMemoryStorage factory if LocalStorage API is not available', () => {

test('calls InMemoryStorage factory if LocalStorage API is not available or the provided storage wrapper is invalid', () => {
// Delete global localStorage property
const originalLocalStorage = Object.getOwnPropertyDescriptor(global, 'localStorage');
Object.defineProperty(global, 'localStorage', {}); // delete global localStorage property

const storageFactory = InLocalStorage({ prefix: 'prefix' });
const storage = storageFactory(internalSdkParams);
Object.defineProperty(global, 'localStorage', {});

// LocalStorage API is not available
let storageFactory = InLocalStorage({ prefix: 'prefix' });
let storage = storageFactory(internalSdkParams);
expect(fakeInMemoryStorageFactory).toBeCalledWith(internalSdkParams); // calls InMemoryStorage factory
expect(storage).toBe(fakeInMemoryStorage);

Object.defineProperty(global, 'localStorage', originalLocalStorage as PropertyDescriptor); // restore original localStorage
// @ts-expect-error Provided storage is invalid
storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: {} });
storage = storageFactory(internalSdkParams);
expect(storage).toBe(fakeInMemoryStorage);

// Provided storage is valid
storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: { getItem: () => Promise.resolve(null), setItem: () => Promise.resolve(), removeItem: () => Promise.resolve() } });
storage = storageFactory(internalSdkParams);
expect(storage).not.toBe(fakeInMemoryStorage);

// Restore original localStorage
Object.defineProperty(global, 'localStorage', originalLocalStorage as PropertyDescriptor);
});

test('calls its own storage factory if LocalStorage API is available', () => {
Expand Down
65 changes: 65 additions & 0 deletions src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { storageAdapter } from '../storageAdapter';
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';


const syncWrapper = {
getItem: jest.fn(() => JSON.stringify({ key1: 'value1' })),
setItem: jest.fn(),
removeItem: jest.fn(),
};

const asyncWrapper = {
getItem: jest.fn(() => Promise.resolve(JSON.stringify({ key1: 'value1' }))),
setItem: jest.fn(() => Promise.resolve()),
removeItem: jest.fn(() => Promise.resolve()),
};

test.each([
[syncWrapper],
[asyncWrapper],
])('storageAdapter', async (wrapper) => {

const storage = storageAdapter(loggerMock, 'prefix', wrapper);

expect(storage.length).toBe(0);

// Load cache from storage wrapper
await storage.load();

expect(wrapper.getItem).toHaveBeenCalledWith('prefix');
expect(storage.length).toBe(1);
expect(storage.key(0)).toBe('key1');
expect(storage.getItem('key1')).toBe('value1');

// Set item
storage.setItem('key2', 'value2');
expect(storage.getItem('key2')).toBe('value2');
expect(storage.length).toBe(2);

// Remove item
storage.removeItem('key1');
expect(storage.getItem('key1')).toBe(null);
expect(storage.length).toBe(1);

// Until a till key is set/removed, changes should not be saved/persisted
await storage.whenSaved();
expect(wrapper.setItem).not.toHaveBeenCalled();

// When setting a till key, changes should be saved/persisted immediately
storage.setItem('.till', '1');
expect(storage.length).toBe(2);
expect(storage.key(0)).toBe('key2');
expect(storage.key(1)).toBe('.till');

await storage.whenSaved();
expect(wrapper.setItem).toHaveBeenCalledWith('prefix', JSON.stringify({ key2: 'value2', '.till': '1' }));

// When removing a till key, changes should be saved/persisted immediately
storage.removeItem('.till');

await storage.whenSaved();
expect(wrapper.setItem).toHaveBeenCalledWith('prefix', JSON.stringify({ key2: 'value2' }));

await storage.whenSaved();
expect(wrapper.setItem).toHaveBeenCalledTimes(2);
});
75 changes: 38 additions & 37 deletions src/storages/inLocalStorage/__tests__/validateCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@ import { SplitsCacheInLocal } from '../SplitsCacheInLocal';
import { nearlyEqual } from '../../../__tests__/testUtils';
import { MySegmentsCacheInLocal } from '../MySegmentsCacheInLocal';
import { RBSegmentsCacheInLocal } from '../RBSegmentsCacheInLocal';
import { storages, PREFIX } from './wrapper.mock';

const FULL_SETTINGS_HASH = 'dc1f9817';

describe('validateCache', () => {
const keys = new KeyBuilderCS('SPLITIO', 'user');
describe.each(storages)('validateCache', (storage) => {
const keys = new KeyBuilderCS(PREFIX, 'user');
const logSpy = jest.spyOn(fullSettings.log, 'info');
const segments = new MySegmentsCacheInLocal(fullSettings.log, keys, localStorage);
const largeSegments = new MySegmentsCacheInLocal(fullSettings.log, keys, localStorage);
const splits = new SplitsCacheInLocal(fullSettings, keys, localStorage);
const rbSegments = new RBSegmentsCacheInLocal(fullSettings, keys, localStorage);
const segments = new MySegmentsCacheInLocal(fullSettings.log, keys, storage);
const largeSegments = new MySegmentsCacheInLocal(fullSettings.log, keys, storage);
const splits = new SplitsCacheInLocal(fullSettings, keys, storage);
const rbSegments = new RBSegmentsCacheInLocal(fullSettings, keys, storage);

jest.spyOn(splits, 'getChangeNumber');
jest.spyOn(splits, 'clear');
Expand All @@ -25,11 +26,11 @@ describe('validateCache', () => {

beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
for (let i = 0; i < storage.length; i++) storage.removeItem(storage.key(i) as string);
});

test('if there is no cache, it should return false', async () => {
expect(await validateCache({}, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
expect(await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);

expect(logSpy).not.toHaveBeenCalled();

Expand All @@ -39,15 +40,15 @@ describe('validateCache', () => {
expect(largeSegments.clear).not.toHaveBeenCalled();
expect(splits.getChangeNumber).toHaveBeenCalledTimes(1);

expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
expect(localStorage.getItem(keys.buildLastClear())).toBeNull();
expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
expect(storage.getItem(keys.buildLastClear())).toBeNull();
});

test('if there is cache and it must not be cleared, it should return true', async () => {
localStorage.setItem(keys.buildSplitsTillKey(), '1');
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
storage.setItem(keys.buildSplitsTillKey(), '1');
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);

expect(await validateCache({}, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
expect(await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);

expect(logSpy).not.toHaveBeenCalled();

Expand All @@ -57,16 +58,16 @@ describe('validateCache', () => {
expect(largeSegments.clear).not.toHaveBeenCalled();
expect(splits.getChangeNumber).toHaveBeenCalledTimes(1);

expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
expect(localStorage.getItem(keys.buildLastClear())).toBeNull();
expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
expect(storage.getItem(keys.buildLastClear())).toBeNull();
});

test('if there is cache and it has expired, it should clear cache and return false', async () => {
localStorage.setItem(keys.buildSplitsTillKey(), '1');
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
localStorage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago
storage.setItem(keys.buildSplitsTillKey(), '1');
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
storage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago

expect(await validateCache({ expirationDays: 1 }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
expect(await validateCache({ expirationDays: 1 }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);

expect(logSpy).toHaveBeenCalledWith('storage:localstorage: Cache expired more than 1 days ago. Cleaning up cache');

Expand All @@ -75,15 +76,15 @@ describe('validateCache', () => {
expect(segments.clear).toHaveBeenCalledTimes(1);
expect(largeSegments.clear).toHaveBeenCalledTimes(1);

expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
});

test('if there is cache and its hash has changed, it should clear cache and return false', async () => {
localStorage.setItem(keys.buildSplitsTillKey(), '1');
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
storage.setItem(keys.buildSplitsTillKey(), '1');
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);

expect(await validateCache({}, localStorage, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
expect(await validateCache({}, storage, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false);

expect(logSpy).toHaveBeenCalledWith('storage:localstorage: SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache');

Expand All @@ -92,16 +93,16 @@ describe('validateCache', () => {
expect(segments.clear).toHaveBeenCalledTimes(1);
expect(largeSegments.clear).toHaveBeenCalledTimes(1);

expect(localStorage.getItem(keys.buildHashKey())).toBe('45c6ba5d');
expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
expect(storage.getItem(keys.buildHashKey())).toBe('45c6ba5d');
expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
});

test('if there is cache and clearOnInit is true, it should clear cache and return false', async () => {
// Older cache version (without last clear)
localStorage.setItem(keys.buildSplitsTillKey(), '1');
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
storage.setItem(keys.buildSplitsTillKey(), '1');
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);

expect(await validateCache({ clearOnInit: true }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);

expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache');

Expand All @@ -110,25 +111,25 @@ describe('validateCache', () => {
expect(segments.clear).toHaveBeenCalledTimes(1);
expect(largeSegments.clear).toHaveBeenCalledTimes(1);

expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
const lastClear = localStorage.getItem(keys.buildLastClear());
expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
const lastClear = storage.getItem(keys.buildLastClear());
expect(nearlyEqual(parseInt(lastClear as string), Date.now())).toBe(true);

// If cache is cleared, it should not clear again until a day has passed
logSpy.mockClear();
localStorage.setItem(keys.buildSplitsTillKey(), '1');
expect(await validateCache({ clearOnInit: true }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
storage.setItem(keys.buildSplitsTillKey(), '1');
expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
expect(logSpy).not.toHaveBeenCalled();
expect(localStorage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed
expect(storage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed

// If a day has passed, it should clear again
localStorage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + '');
expect(await validateCache({ clearOnInit: true }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
storage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + '');
expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache');
expect(splits.clear).toHaveBeenCalledTimes(2);
expect(rbSegments.clear).toHaveBeenCalledTimes(2);
expect(segments.clear).toHaveBeenCalledTimes(2);
expect(largeSegments.clear).toHaveBeenCalledTimes(2);
expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
});
});
27 changes: 27 additions & 0 deletions src/storages/inLocalStorage/__tests__/wrapper.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { storageAdapter } from '../storageAdapter';
import SplitIO from '../../../../types/splitio';
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';

export const PREFIX = 'SPLITIO';

export function createMemoryStorage(): SplitIO.AsyncStorageWrapper {
let cache: Record<string, string> = {};
return {
getItem(key: string) {
return Promise.resolve(cache[key] || null);
},
setItem(key: string, value: string) {
cache[key] = value;
return Promise.resolve();
},
removeItem(key: string) {
delete cache[key];
return Promise.resolve();
}
};
}

export const storages = [
localStorage,
storageAdapter(loggerMock, PREFIX, createMemoryStorage())
];
16 changes: 11 additions & 5 deletions src/storages/inLocalStorage/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ImpressionsCacheInMemory } from '../inMemory/ImpressionsCacheInMemory';
import { ImpressionCountsCacheInMemory } from '../inMemory/ImpressionCountsCacheInMemory';
import { EventsCacheInMemory } from '../inMemory/EventsCacheInMemory';
import { IStorageFactoryParams, IStorageSync, IStorageSyncFactory } from '../types';
import { IStorageFactoryParams, IStorageSync, IStorageSyncFactory, StorageAdapter } from '../types';
import { validatePrefix } from '../KeyBuilder';
import { KeyBuilderCS, myLargeSegmentsKeyBuilder } from '../KeyBuilderCS';
import { isLocalStorageAvailable } from '../../utils/env/isLocalStorageAvailable';
import { isLocalStorageAvailable, isValidStorageWrapper } from '../../utils/env/isLocalStorageAvailable';
import { SplitsCacheInLocal } from './SplitsCacheInLocal';
import { RBSegmentsCacheInLocal } from './RBSegmentsCacheInLocal';
import { MySegmentsCacheInLocal } from './MySegmentsCacheInLocal';
Expand All @@ -17,8 +17,14 @@ import { getMatching } from '../../utils/key';
import { validateCache } from './validateCache';
import { ILogger } from '../../logger/types';
import SplitIO from '../../../types/splitio';
import { storageAdapter } from './storageAdapter';

function validateStorage(log: ILogger, prefix: string, wrapper?: SplitIO.SyncStorageWrapper | SplitIO.AsyncStorageWrapper): StorageAdapter | undefined {
if (wrapper) {
if (isValidStorageWrapper(wrapper)) return storageAdapter(log, prefix, wrapper);
log.warn(LOG_PREFIX + 'Invalid storage provided. Falling back to LocalStorage API');
}

function validateStorage(log: ILogger) {
if (isLocalStorageAvailable()) return localStorage;

log.warn(LOG_PREFIX + 'LocalStorage API is unavailable. Falling back to default MEMORY storage');
Expand All @@ -34,7 +40,7 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt
function InLocalStorageCSFactory(params: IStorageFactoryParams): IStorageSync {
const { settings, settings: { log, scheduler: { impressionsQueueSize, eventsQueueSize } } } = params;

const storage = validateStorage(log);
const storage = validateStorage(log, prefix, options.wrapper);
if (!storage) return InMemoryStorageCSFactory(params);

const matchingKey = getMatching(settings.core.key);
Expand Down Expand Up @@ -62,7 +68,7 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt
},

destroy() {
return Promise.resolve();
return storage.whenSaved && storage.whenSaved();
},

// When using shared instantiation with MEMORY we reuse everything but segments (they are customer per key).
Expand Down
Loading