From 352ee82cda90bd20a5149363ce2a5e3e57ac0f81 Mon Sep 17 00:00:00 2001 From: Oscar Bazaldua <511911+oscb@users.noreply.github.com> Date: Thu, 20 Jul 2023 09:25:52 -0700 Subject: [PATCH 1/5] feat: storage options, change priority or use custom --- .changeset/smooth-seahorses-unite.md | 5 + .../analytics/__tests__/integration.test.ts | 28 ++ packages/browser/src/core/analytics/index.ts | 95 +++- .../storage/__tests__/cookieStorage.test.ts | 76 ++++ .../storage/__tests__/localStorage.test.ts | 70 +++ .../__tests__/prioritizedListStorage.test.ts | 159 +++++++ .../core/storage/__tests__/test-helpers.ts | 19 + .../browser/src/core/storage/cookieStorage.ts | 97 +++++ packages/browser/src/core/storage/index.ts | 64 +++ .../browser/src/core/storage/localStorage.ts | 60 +++ .../browser/src/core/storage/memoryStorage.ts | 30 ++ packages/browser/src/core/storage/settings.ts | 28 ++ packages/browser/src/core/storage/types.ts | 93 ++++ .../src/core/storage/universalStorage.ts | 55 +++ .../src/core/user/__tests__/index.test.ts | 358 ++++----------- packages/browser/src/core/user/index.ts | 409 ++++-------------- .../src/plugins/segmentio/normalize.ts | 7 +- 17 files changed, 1037 insertions(+), 616 deletions(-) create mode 100644 .changeset/smooth-seahorses-unite.md create mode 100644 packages/browser/src/core/storage/__tests__/cookieStorage.test.ts create mode 100644 packages/browser/src/core/storage/__tests__/localStorage.test.ts create mode 100644 packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts create mode 100644 packages/browser/src/core/storage/__tests__/test-helpers.ts create mode 100644 packages/browser/src/core/storage/cookieStorage.ts create mode 100644 packages/browser/src/core/storage/index.ts create mode 100644 packages/browser/src/core/storage/localStorage.ts create mode 100644 packages/browser/src/core/storage/memoryStorage.ts create mode 100644 packages/browser/src/core/storage/settings.ts create mode 100644 packages/browser/src/core/storage/types.ts create mode 100644 packages/browser/src/core/storage/universalStorage.ts diff --git a/.changeset/smooth-seahorses-unite.md b/.changeset/smooth-seahorses-unite.md new file mode 100644 index 000000000..ae8fa2dc9 --- /dev/null +++ b/.changeset/smooth-seahorses-unite.md @@ -0,0 +1,5 @@ +--- +'@segment/analytics-next': minor +--- + +Adds storage option in analytics client to specify priority of storage (e.g use cookies over localstorage) or use a custom implementation diff --git a/packages/browser/src/core/analytics/__tests__/integration.test.ts b/packages/browser/src/core/analytics/__tests__/integration.test.ts index 64711a803..5e2045b15 100644 --- a/packages/browser/src/core/analytics/__tests__/integration.test.ts +++ b/packages/browser/src/core/analytics/__tests__/integration.test.ts @@ -7,7 +7,9 @@ import { import { Context } from '../../context' import { Plugin } from '../../plugin' import { EventQueue } from '../../queue/event-queue' +import { StoreType } from '../../storage' import { Analytics } from '../index' +import jar from 'js-cookie' import { TestAfterPlugin, TestBeforePlugin, @@ -271,4 +273,30 @@ describe('Analytics', () => { expect(fn).toHaveBeenCalledTimes(1) }) }) + + describe('storage', () => { + beforeEach(() => { + clearAjsBrowserStorage() + }) + + it('handles custom priority storage', async () => { + const setCookieSpy = jest.spyOn(jar, 'set') + const expected = 'CookieValue' + jar.set('ajs_anonymous_id', expected) + localStorage.setItem('ajs_anonymous_id', 'localStorageValue') + + const analytics = new Analytics( + { writeKey: '' }, + { + storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + } + ) + + expect(analytics.user().anonymousId()).toEqual(expected) + + analytics.user().id('known-user') + expect(analytics.user().id()).toEqual('known-user') + expect(setCookieSpy).toHaveBeenCalled() + }) + }) }) diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts index 0ffe4eb9b..d958e974f 100644 --- a/packages/browser/src/core/analytics/index.ts +++ b/packages/browser/src/core/analytics/index.ts @@ -24,15 +24,7 @@ import { } from '../events' import type { Plugin } from '../plugin' import { EventQueue } from '../queue/event-queue' -import { - CookieOptions, - getAvailableStorageOptions, - Group, - ID, - UniversalStorage, - User, - UserOptions, -} from '../user' +import { Group, ID, User, UserOptions } from '../user' import autoBind from '../../lib/bind-all' import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted' import type { LegacyDestination } from '../../plugins/ajs-destination' @@ -50,6 +42,18 @@ import { getGlobal } from '../../lib/get-global' import { AnalyticsClassic, AnalyticsCore } from './interfaces' import { HighEntropyHint } from '../../lib/client-hints/interfaces' import type { LegacySettings } from '../../browser' +import { + CookieOptions, + MemoryStorage, + UniversalStorage, + Storage, + StorageSettings, + StoreType, + applyCookieOptions, + initializeStorages, + isArrayOfStoreType, + isStorageObject, +} from '../storage' const deprecationWarning = 'This is being deprecated and will be not be available in future releases of Analytics JS' @@ -93,6 +97,7 @@ export interface InitOptions { disableAutoISOConversion?: boolean initialPageview?: boolean cookie?: CookieOptions + storage?: StorageSettings user?: UserOptions group?: UserOptions integrations?: Integrations @@ -133,9 +138,7 @@ export class Analytics private _group: Group private eventFactory: EventFactory private _debug = false - private _universalStorage: UniversalStorage<{ - [k: string]: unknown - }> + private _universalStorage: Storage initialized = false integrations: Integrations @@ -162,25 +165,33 @@ export class Analytics disablePersistance ) - this._universalStorage = new UniversalStorage( - disablePersistance ? ['memory'] : ['localStorage', 'cookie', 'memory'], - getAvailableStorageOptions(cookieOptions) + const storageSetting = options?.storage + this._universalStorage = this.createStore( + disablePersistance, + storageSetting, + cookieOptions ) this._user = user ?? new User( - disablePersistance - ? { ...options?.user, persist: false } - : options?.user, + { + persist: !disablePersistance, + storage: options?.storage, + // Any User specific options override everything else + ...options?.user, + }, cookieOptions ).load() this._group = group ?? new Group( - disablePersistance - ? { ...options?.group, persist: false } - : options?.group, + { + persist: !disablePersistance, + storage: options?.storage, + // Any group specific options override everything else + ...options?.group, + }, cookieOptions ).load() this.eventFactory = new EventFactory(this._user) @@ -194,7 +205,47 @@ export class Analytics return this._user } - get storage(): UniversalStorage { + /** + * Creates the storage system based on the settings received + * @returns Storage + */ + private createStore( + disablePersistance: boolean, + storageSetting: InitOptions['storage'], + cookieOptions?: CookieOptions | undefined + ): Storage { + // DisablePersistance option overrides all, no storage will be used outside of memory even if specified + if (disablePersistance) { + return new MemoryStorage() + } else { + if (storageSetting !== undefined && storageSetting !== null) { + if (isArrayOfStoreType(storageSetting)) { + // We will create the store with the priority for customer settings + return new UniversalStorage( + initializeStorages( + applyCookieOptions(storageSetting, cookieOptions) + ) + ) + } else if (isStorageObject(storageSetting)) { + // If it is an object we will use the customer provided storage + return storageSetting + } + } + } + // We default to our multi storage with priority + return new UniversalStorage( + initializeStorages([ + StoreType.LocalStorage, + { + name: StoreType.Cookie, + settings: cookieOptions, + }, + StoreType.Memory, + ]) + ) + } + + get storage(): Storage { return this._universalStorage } diff --git a/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts b/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts new file mode 100644 index 000000000..da02beccc --- /dev/null +++ b/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts @@ -0,0 +1,76 @@ +import { CookieStorage } from '../cookieStorage' +import jar from 'js-cookie' +import { disableCookies } from './test-helpers' + +describe('cookieStorage', () => { + function clearCookies() { + document.cookie.split(';').forEach(function (c) { + document.cookie = c + .replace(/^ +/, '') + .replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/') + }) + } + + afterEach(() => { + clearCookies() + }) + + describe('#available', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('is available', () => { + const cookie = new CookieStorage() + expect(cookie.available).toBe(true) + }) + + it("is unavailable if can't write cookies", () => { + disableCookies() + const cookie = new CookieStorage() + expect(cookie.available).toBe(false) + }) + }) + + describe('cookie options', () => { + it('should have default cookie options', () => { + const cookie = new CookieStorage() + expect(cookie['options'].domain).toBe(undefined) + expect(cookie['options'].maxage).toBe(365) + expect(cookie['options'].path).toBe('/') + expect(cookie['options'].sameSite).toBe('Lax') + expect(cookie['options'].secure).toBe(undefined) + }) + + it('should set options properly', () => { + const cookie = new CookieStorage({ + domain: 'foo', + secure: true, + path: '/test', + }) + expect(cookie['options'].domain).toBe('foo') + expect(cookie['options'].secure).toBe(true) + expect(cookie['options'].path).toBe('/test') + expect(cookie['options'].secure).toBe(true) + }) + + it('should pass options when creating cookie', () => { + const jarSpy = jest.spyOn(jar, 'set') + const cookie = new CookieStorage({ + domain: 'foo', + secure: true, + path: '/test', + }) + + cookie.set('foo', 'bar') + + expect(jarSpy).toHaveBeenCalledWith('foo', 'bar', { + domain: 'foo', + expires: 365, + path: '/test', + sameSite: 'Lax', + secure: true, + }) + }) + }) +}) diff --git a/packages/browser/src/core/storage/__tests__/localStorage.test.ts b/packages/browser/src/core/storage/__tests__/localStorage.test.ts new file mode 100644 index 000000000..30c73a121 --- /dev/null +++ b/packages/browser/src/core/storage/__tests__/localStorage.test.ts @@ -0,0 +1,70 @@ +import { LocalStorage } from '../localStorage' + +describe('LocalStorage', function () { + let store: LocalStorage + + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}) // silence console spam. + store = new LocalStorage() + }) + + afterEach(() => { + localStorage.clear() + }) + + describe('#get', function () { + it('should return null if localStorage throws an error (or does not exist)', function () { + const getItemSpy = jest + .spyOn(global.Storage.prototype, 'getItem') + .mockImplementationOnce(() => { + throw new Error('getItem fail.') + }) + store.set('foo', 'some value') + expect(store.get('foo')).toBeNull() + expect(getItemSpy).toBeCalledTimes(1) + }) + + it('should not get an empty record', function () { + expect(store.get('abc')).toBe(null) + }) + + it('should get an existing record', function () { + store.set('x', { a: 'b' }) + store.set('a', 'hello world') + store.set('b', '') + store.set('c', false) + store.set('d', null) + store.set('e', undefined) + + expect(store.get('x')).toStrictEqual({ a: 'b' }) + expect(store.get('a')).toBe('hello world') + expect(store.get('b')).toBe('') + expect(store.get('c')).toBe(false) + expect(store.get('d')).toBe(null) + expect(store.get('e')).toBe('undefined') + }) + }) + + describe('#set', function () { + it('should be able to set a record', function () { + store.set('x', { a: 'b' }) + expect(store.get('x')).toStrictEqual({ a: 'b' }) + }) + + it('should catch localStorage quota exceeded errors', () => { + const val = 'x'.repeat(10 * 1024 * 1024) + store.set('foo', val) + + expect(store.get('foo')).toBe(null) + }) + }) + + describe('#clear', function () { + it('should be able to remove a record', function () { + store.set('x', { a: 'b' }) + expect(store.get('x')).toStrictEqual({ a: 'b' }) + store.clear('x') + expect(store.get('x')).toBe(null) + }) + }) +}) diff --git a/packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts b/packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts new file mode 100644 index 000000000..f05f5ad9d --- /dev/null +++ b/packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts @@ -0,0 +1,159 @@ +import jar from 'js-cookie' +import { CookieStorage } from '../cookieStorage' +import { LocalStorage } from '../localStorage' +import { MemoryStorage } from '../memoryStorage' +import { UniversalStorage } from '../universalStorage' +describe('prioritizedListStorage', function () { + const defaultTargets = [ + new CookieStorage(), + new LocalStorage(), + new MemoryStorage(), + ] + const getFromLS = (key: string) => JSON.parse(localStorage.getItem(key) ?? '') + beforeEach(function () { + clear() + }) + + function clear(): void { + document.cookie.split(';').forEach(function (c) { + document.cookie = c + .replace(/^ +/, '') + .replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/') + }) + localStorage.clear() + } + + describe('#get', function () { + it('picks data from cookies first', function () { + jar.set('ajs_test_key', 'ðŠ') + localStorage.setItem('ajs_test_key', 'ðū') + const us = new UniversalStorage(defaultTargets) + expect(us.get('ajs_test_key')).toEqual('ðŠ') + }) + + it('picks data from localStorage if there is no cookie target', function () { + jar.set('ajs_test_key', 'ðŠ') + localStorage.setItem('ajs_test_key', 'ðū') + const us = new UniversalStorage([new LocalStorage(), new MemoryStorage()]) + expect(us.get('ajs_test_key')).toEqual('ðū') + }) + + it('get data from memory', function () { + jar.set('ajs_test_key', 'ðŠ') + localStorage.setItem('ajs_test_key', 'ðū') + const us = new UniversalStorage([new MemoryStorage()]) + expect(us.get('ajs_test_key')).toBeNull() + }) + + it('order of default targets matters!', function () { + jar.set('ajs_test_key', 'ðŠ') + localStorage.setItem('ajs_test_key', 'ðū') + const us = new UniversalStorage(defaultTargets) + expect(us.get('ajs_test_key')).toEqual('ðŠ') + }) + + it('returns null if there are no storage targets', function () { + jar.set('ajs_test_key', 'ðŠ') + localStorage.setItem('ajs_test_key', 'ðū') + const us = new UniversalStorage([]) + expect(us.get('ajs_test_key')).toBeNull() + }) + + // it('can override the default targets', function () { + // jar.set('ajs_test_key', 'ðŠ') + // localStorage.setItem('ajs_test_key', 'ðū') + // const us = new PrioritizedListStorage( + // defaultTargets + // ) + // expect(us.get('ajs_test_key', ['localStorage'])).toEqual('ðū') + // expect(us.get('ajs_test_key', ['localStorage', 'memory'])).toEqual('ðū') + // expect(us.get('ajs_test_key', ['cookie', 'memory'])).toEqual('ðŠ') + // expect(us.get('ajs_test_key', ['cookie', 'localStorage'])).toEqual('ðŠ') + // expect(us.get('ajs_test_key', ['cookie'])).toEqual('ðŠ') + // expect(us.get('ajs_test_key', ['memory'])).toEqual(null) + // }) + }) + + describe('#set', function () { + it('set the data in all storage types', function () { + const us = new UniversalStorage<{ ajs_test_key: string }>(defaultTargets) + us.set('ajs_test_key', 'ð°') + expect(jar.get('ajs_test_key')).toEqual('ð°') + expect(getFromLS('ajs_test_key')).toEqual('ð°') + }) + + it('skip saving data to localStorage', function () { + const us = new UniversalStorage([ + new CookieStorage(), + new MemoryStorage(), + ]) + us.set('ajs_test_key', 'ð°') + expect(jar.get('ajs_test_key')).toEqual('ð°') + expect(localStorage.getItem('ajs_test_key')).toEqual(null) + }) + + it('skip saving data to cookie', function () { + const us = new UniversalStorage([new LocalStorage(), new MemoryStorage()]) + us.set('ajs_test_key', 'ð°') + expect(jar.get('ajs_test_key')).toEqual(undefined) + expect(getFromLS('ajs_test_key')).toEqual('ð°') + }) + + it('can save and retrieve from memory when there is no other storage', function () { + const us = new UniversalStorage([new MemoryStorage()]) + us.set('ajs_test_key', 'ð°') + expect(jar.get('ajs_test_key')).toEqual(undefined) + expect(localStorage.getItem('ajs_test_key')).toEqual(null) + expect(us.get('ajs_test_key')).toEqual('ð°') + }) + + it('does not write to cookies when cookies are not available', function () { + const cookieStore = new CookieStorage() + jest.spyOn(cookieStore, 'available', 'get').mockReturnValueOnce(false) + const us = new UniversalStorage([ + new LocalStorage(), + cookieStore, + new MemoryStorage(), + ]) + us.set('ajs_test_key', 'ð°') + expect(jar.get('ajs_test_key')).toEqual(undefined) + expect(getFromLS('ajs_test_key')).toEqual('ð°') + expect(us.get('ajs_test_key')).toEqual('ð°') + }) + + it('does not write to LS when LS is not available', function () { + const localStorage = new LocalStorage() + jest.spyOn(localStorage, 'available', 'get').mockReturnValueOnce(false) + const us = new UniversalStorage([ + localStorage, + new CookieStorage(), + new MemoryStorage(), + ]) + us.set('ajs_test_key', 'ð°') + expect(jar.get('ajs_test_key')).toEqual('ð°') + expect(localStorage.get('ajs_test_key')).toEqual(null) + expect(us.get('ajs_test_key')).toEqual('ð°') + }) + + // it('can override the default targets', function () { + // const us = new UniversalStorage( + // defaultTargets, + // getAvailableStorageOptions() + // ) + // us.set('ajs_test_key', 'ð°', ['localStorage']) + // expect(jar.get('ajs_test_key')).toEqual(undefined) + // expect(getFromLS('ajs_test_key')).toEqual('ð°') + // expect(us.get('ajs_test_key')).toEqual('ð°') + + // us.set('ajs_test_key_2', 'ðĶī', ['cookie']) + // expect(jar.get('ajs_test_key_2')).toEqual('ðĶī') + // expect(localStorage.getItem('ajs_test_key_2')).toEqual(null) + // expect(us.get('ajs_test_key_2')).toEqual('ðĶī') + + // us.set('ajs_test_key_3', 'ðŧ', []) + // expect(jar.get('ajs_test_key_3')).toEqual(undefined) + // expect(localStorage.getItem('ajs_test_key_3')).toEqual(null) + // expect(us.get('ajs_test_key_3')).toEqual(null) + // }) + }) +}) diff --git a/packages/browser/src/core/storage/__tests__/test-helpers.ts b/packages/browser/src/core/storage/__tests__/test-helpers.ts new file mode 100644 index 000000000..7bd5af18b --- /dev/null +++ b/packages/browser/src/core/storage/__tests__/test-helpers.ts @@ -0,0 +1,19 @@ +/** + * Disables Cookies + * @returns jest spy + */ +export function disableCookies(): jest.SpyInstance { + return jest + .spyOn(window.navigator, 'cookieEnabled', 'get') + .mockReturnValue(false) +} + +/** + * Disables LocalStorage + * @returns jest spy + */ +export function disableLocalStorage(): jest.SpyInstance { + return jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error() + }) +} diff --git a/packages/browser/src/core/storage/cookieStorage.ts b/packages/browser/src/core/storage/cookieStorage.ts new file mode 100644 index 000000000..02f5eb50a --- /dev/null +++ b/packages/browser/src/core/storage/cookieStorage.ts @@ -0,0 +1,97 @@ +import { BaseStorage, StorageObject, StoreType } from './types' +import jar from 'js-cookie' +import { tld } from '../user/tld' + +const ONE_YEAR = 365 + +export interface CookieOptions { + maxage?: number + domain?: string + path?: string + secure?: boolean + sameSite?: string +} + +/** + * Data storage using browser cookies + */ +export class CookieStorage< + Data extends StorageObject = StorageObject +> extends BaseStorage { + get available(): boolean { + let cookieEnabled = window.navigator.cookieEnabled + + if (!cookieEnabled) { + jar.set('ajs:cookies', 'test') + cookieEnabled = document.cookie.includes('ajs:cookies') + jar.remove('ajs:cookies') + } + + return cookieEnabled + } + + get type() { + return StoreType.Cookie + } + + static get defaults(): CookieOptions { + return { + maxage: ONE_YEAR, + domain: tld(window.location.href), + path: '/', + sameSite: 'Lax', + } + } + + private options: Required<CookieOptions> + + constructor(options: CookieOptions = CookieStorage.defaults) { + super() + this.options = { + ...CookieStorage.defaults, + ...options, + } as Required<CookieOptions> + } + + private opts(): jar.CookieAttributes { + return { + sameSite: this.options.sameSite as jar.CookieAttributes['sameSite'], + expires: this.options.maxage, + domain: this.options.domain, + path: this.options.path, + secure: this.options.secure, + } + } + + get<K extends keyof Data>(key: K): Data[K] | null { + try { + const value = jar.get(key) + + if (value === undefined || value === null) { + return null + } + + try { + return JSON.parse(value) ?? null + } catch (e) { + return (value ?? null) as unknown as Data[K] | null + } + } catch (e) { + return null + } + } + + set<K extends keyof Data>(key: K, value: Data[K] | null): void { + if (typeof value === 'string') { + jar.set(key, value, this.opts()) + } else if (value === null) { + jar.remove(key, this.opts()) + } else { + jar.set(key, JSON.stringify(value), this.opts()) + } + } + + clear<K extends keyof Data>(key: K): void { + return jar.remove(key, this.opts()) + } +} diff --git a/packages/browser/src/core/storage/index.ts b/packages/browser/src/core/storage/index.ts new file mode 100644 index 000000000..5793795b0 --- /dev/null +++ b/packages/browser/src/core/storage/index.ts @@ -0,0 +1,64 @@ +import { CookieOptions, CookieStorage } from './cookieStorage' +import { LocalStorage } from './localStorage' +import { MemoryStorage } from './memoryStorage' +import { isStoreTypeWithSettings } from './settings' +import { StoreType, Storage, InitializeStorageArgs } from './types' + +export * from './types' +export * from './localStorage' +export * from './cookieStorage' +export * from './memoryStorage' +export * from './universalStorage' +export * from './settings' + +/** + * Creates multiple storage systems from an array of StoreType and options + * @param args StoreType and options + * @returns Storage array + */ +export function initializeStorages(args: InitializeStorageArgs): Storage[] { + const storages = args.map((s) => { + let type: StoreType + let settings + + if (isStoreTypeWithSettings(s)) { + type = s.name + settings = s.settings + } else { + type = s + } + + switch (type) { + case StoreType.Cookie: + return new CookieStorage(settings) + case StoreType.LocalStorage: + return new LocalStorage() + case StoreType.Memory: + return new MemoryStorage() + default: + throw new Error(`Unknown Store Type: ${s}`) + } + }) + return storages +} + +/** + * Injects the CookieOptions into a the arguments for initializeStorage + * @param storeTypes list of storeType + * @param cookieOptions cookie Options + * @returns arguments for initializeStorage + */ +export function applyCookieOptions( + storeTypes: StoreType[], + cookieOptions?: CookieOptions +): InitializeStorageArgs { + return storeTypes.map((s) => { + if (cookieOptions && s === StoreType.Cookie) { + return { + name: s, + settings: cookieOptions, + } + } + return s + }) +} diff --git a/packages/browser/src/core/storage/localStorage.ts b/packages/browser/src/core/storage/localStorage.ts new file mode 100644 index 000000000..f5385f965 --- /dev/null +++ b/packages/browser/src/core/storage/localStorage.ts @@ -0,0 +1,60 @@ +import { BaseStorage, StorageObject, StoreType } from './types' + +/** + * Data storage using browser's localStorage + */ +export class LocalStorage< + Data extends StorageObject = StorageObject +> extends BaseStorage { + private localStorageWarning(key: keyof Data, state: 'full' | 'unavailable') { + console.warn(`Unable to access ${key}, localStorage may be ${state}`) + } + + get type() { + return StoreType.LocalStorage + } + + get available(): boolean { + const test = 'test' + try { + localStorage.setItem(test, test) + localStorage.removeItem(test) + return true + } catch (e) { + return false + } + } + + get<K extends keyof Data>(key: K): Data[K] | null { + try { + const val = localStorage.getItem(key) + if (val === null) { + return null + } + try { + return JSON.parse(val) ?? null + } catch (e) { + return (val ?? null) as unknown as Data[K] | null + } + } catch (err) { + this.localStorageWarning(key, 'unavailable') + return null + } + } + + set<K extends keyof Data>(key: K, value: Data[K] | null): void { + try { + localStorage.setItem(key, JSON.stringify(value)) + } catch { + this.localStorageWarning(key, 'full') + } + } + + clear<K extends keyof Data>(key: K): void { + try { + return localStorage.removeItem(key) + } catch (err) { + this.localStorageWarning(key, 'unavailable') + } + } +} diff --git a/packages/browser/src/core/storage/memoryStorage.ts b/packages/browser/src/core/storage/memoryStorage.ts new file mode 100644 index 000000000..fa9a3d580 --- /dev/null +++ b/packages/browser/src/core/storage/memoryStorage.ts @@ -0,0 +1,30 @@ +import { BaseStorage, StorageObject, StoreType } from './types' + +/** + * Data Storage using in memory object + */ +export class MemoryStorage< + Data extends StorageObject = StorageObject +> extends BaseStorage<Data> { + private cache: Record<string, unknown> = {} + + get type() { + return StoreType.Memory + } + + get available(): boolean { + return true + } + + get<K extends keyof Data>(key: K): Data[K] | null { + return (this.cache[key] ?? null) as Data[K] | null + } + + set<K extends keyof Data>(key: K, value: Data[K] | null): void { + this.cache[key] = value + } + + clear<K extends keyof Data>(key: K): void { + delete this.cache[key] + } +} diff --git a/packages/browser/src/core/storage/settings.ts b/packages/browser/src/core/storage/settings.ts new file mode 100644 index 000000000..2eab09876 --- /dev/null +++ b/packages/browser/src/core/storage/settings.ts @@ -0,0 +1,28 @@ +import { Storage, StoreType, StoreTypeWithSettings } from './types' + +export type StorageSettings = Storage | StoreType[] + +export function isArrayOfStoreType(s: StorageSettings): s is StoreType[] { + return ( + s !== undefined && + s !== null && + Array.isArray(s) && + s.every((e) => Object.values(StoreType).includes(e)) + ) +} + +export function isStorageObject(s: StorageSettings): s is Storage { + return ( + s !== undefined && + s !== null && + !Array.isArray(s) && + typeof s === 'object' && + s.get !== undefined + ) +} + +export function isStoreTypeWithSettings( + s: StoreTypeWithSettings | StoreType +): s is StoreTypeWithSettings { + return typeof s === 'object' && s.name !== undefined +} diff --git a/packages/browser/src/core/storage/types.ts b/packages/browser/src/core/storage/types.ts new file mode 100644 index 000000000..18f3b0f26 --- /dev/null +++ b/packages/browser/src/core/storage/types.ts @@ -0,0 +1,93 @@ +import { CookieOptions } from './cookieStorage' + +/** + * Known Storage Types + * + * Convenience settings for storage systems that AJS includes support for + */ +export enum StoreType { + Cookie = 'cookie', + LocalStorage = 'localStorage', + Memory = 'memory', +} + +export type StorageObject = Record<string, unknown> + +/** + * Defines a Storage object for use in AJS Client. + */ +export interface Storage<Data extends StorageObject = StorageObject> { + /** + * Returns the kind of storage. + * @example cookie, localStorage, custom + */ + get type(): StoreType | string + + /** + * Tests if the storage is available for use in the current environment + */ + get available(): boolean + /** + * get value for the key from the stores. it will return the first value found in the stores + * @param key key for the value to be retrieved + * @returns value for the key or null if not found + */ + get<K extends keyof Data>(key: K): Data[K] | null + /* + This is to support few scenarios where: + - value exist in one of the stores ( as a result of other stores being cleared from browser ) and we want to resync them + - read values in AJS 1.0 format ( for customers after 1.0 --> 2.0 migration ) and then re-write them in AJS 2.0 format + */ + + /** + * get value for the key from the stores. it will pick the first value found in the stores, and then sync the value to all the stores + * if the found value is a number, it will be converted to a string. this is to support legacy behavior that existed in AJS 1.0 + * @param key key for the value to be retrieved + * @returns value for the key or null if not found + */ + getAndSync<K extends keyof Data>(key: K): Data[K] | null + /** + * it will set the value for the key in all the stores + * @param key key for the value to be stored + * @param value value to be stored + * @returns value that was stored + */ + set<K extends keyof Data>(key: K, value: Data[K] | null): void + /** + * remove the value for the key from all the stores + * @param key key for the value to be removed + * @param storeTypes optional array of store types to be used for removing the value + */ + clear<K extends keyof Data>(key: K): void +} + +/** + * Abstract class for creating basic storage systems + */ +export abstract class BaseStorage<Data extends StorageObject = StorageObject> + implements Storage<Data> +{ + abstract get type(): StoreType | string + abstract get available(): boolean + abstract get<K extends keyof Data>(key: K): Data[K] | null + abstract set<K extends keyof Data>(key: K, value: Data[K] | null): void + abstract clear<K extends keyof Data>(key: K): void + /** + * By default a storage getAndSync will handle calls exactly as the + */ + getAndSync<K extends keyof Data>(key: K): Data[K] | null { + const val = this.get(key) + // legacy behavior, getAndSync can change the type of a value from number to string (AJS 1.0 stores numerical values as a number) + const coercedValue = (typeof val === 'number' ? val.toString() : val) as + | Data[K] + | null + return coercedValue + } +} + +export interface StoreTypeWithSettings<T extends StoreType = StoreType> { + name: T + settings?: T extends StoreType.Cookie ? CookieOptions : never +} + +export type InitializeStorageArgs = (StoreTypeWithSettings | StoreType)[] diff --git a/packages/browser/src/core/storage/universalStorage.ts b/packages/browser/src/core/storage/universalStorage.ts new file mode 100644 index 000000000..9e16bac38 --- /dev/null +++ b/packages/browser/src/core/storage/universalStorage.ts @@ -0,0 +1,55 @@ +import { Storage, StorageObject } from './types' + +/** + * Uses multiple storages in a priority list to get/set values in the order they are specified. + */ +export class UniversalStorage<Data extends StorageObject = StorageObject> + implements Storage<Data> +{ + private stores: Storage[] + + constructor(stores: Storage[]) { + this.stores = stores.filter((s) => s.available) + } + + get available(): boolean { + return this.stores.some((s) => s.available) + } + + get type(): string { + return 'PriorityListStorage' + } + + get<K extends keyof Data>(key: K): Data[K] | null { + let val: Data[K] | null = null + + for (const store of this.stores) { + val = store.get(key) as Data[K] | null + if (val !== undefined && val !== null) { + return val + } + } + return null + } + + set<K extends keyof Data>(key: K, value: Data[K] | null): void { + this.stores.forEach((s) => s.set(key, value)) + } + + clear<K extends keyof Data>(key: K): void { + this.stores.forEach((s) => s.clear(key)) + } + + getAndSync<K extends keyof Data>(key: K): Data[K] | null { + const val = this.get(key) + + // legacy behavior, getAndSync can change the type of a value from number to string (AJS 1.0 stores numerical values as a number) + const coercedValue = (typeof val === 'number' ? val.toString() : val) as + | Data[K] + | null + + this.set(key, coercedValue) + + return coercedValue + } +} diff --git a/packages/browser/src/core/user/__tests__/index.test.ts b/packages/browser/src/core/user/__tests__/index.test.ts index 7f78deab7..045c3cedd 100644 --- a/packages/browser/src/core/user/__tests__/index.test.ts +++ b/packages/browser/src/core/user/__tests__/index.test.ts @@ -1,14 +1,12 @@ -import { - User, - LocalStorage, - Cookie, - Group, - UniversalStorage, - StoreType, - getAvailableStorageOptions, -} from '..' -import jar from 'js-cookie' import assert from 'assert' +import jar from 'js-cookie' +import { Group, User } from '..' +import { LocalStorage, StoreType, Storage } from '../../storage' +import { + disableCookies, + disableLocalStorage, +} from '../../storage/__tests__/test-helpers' +import { MemoryStorage } from '../../storage/memoryStorage' function clear(): void { document.cookie.split(';').forEach(function (c) { @@ -23,10 +21,11 @@ let store: LocalStorage beforeEach(function () { store = new LocalStorage() clear() + // Restore any cookie, localstorage disable + jest.restoreAllMocks() + jest.spyOn(console, 'warn').mockImplementation(() => {}) // silence console spam. }) -jest.spyOn(console, 'warn').mockImplementation(() => {}) // silence console spam. - describe('user', () => { const cookieKey = User.defaults.cookie.key const localStorageKey = User.defaults.localStorage.key @@ -76,7 +75,7 @@ describe('user', () => { describe('when cookies are disabled', () => { beforeEach(() => { - jest.spyOn(Cookie, 'available').mockReturnValueOnce(false) + disableCookies() user = new User() clear() @@ -140,8 +139,8 @@ describe('user', () => { describe('when cookies and localStorage are disabled', () => { beforeEach(() => { - jest.spyOn(Cookie, 'available').mockReturnValueOnce(false) - jest.spyOn(LocalStorage, 'available').mockReturnValueOnce(false) + disableCookies() + disableLocalStorage() user = new User() clear() @@ -161,10 +160,6 @@ describe('user', () => { assert(user.id() === 'id') }) - it('should be null by default', () => { - assert(user.id() === null) - }) - it('should not reset anonymousId if the user didnt have previous id', () => { const prev = user.anonymousId() user.id('foo') @@ -308,7 +303,7 @@ describe('user', () => { describe('when cookies are disabled', () => { beforeEach(() => { - jest.spyOn(Cookie, 'available').mockReturnValueOnce(false) + disableCookies() user = new User() }) @@ -336,8 +331,8 @@ describe('user', () => { describe('when cookies and localStorage are disabled', () => { beforeEach(() => { - jest.spyOn(LocalStorage, 'available').mockReturnValueOnce(false) - jest.spyOn(Cookie, 'available').mockReturnValueOnce(false) + disableCookies() + disableLocalStorage() user = new User() }) @@ -504,40 +499,6 @@ describe('user', () => { }) }) - describe('#options', () => { - it('should have default cookie options', () => { - const cookie = new Cookie() - expect(cookie['options'].domain).toBe(undefined) - expect(cookie['options'].maxage).toBe(365) - expect(cookie['options'].path).toBe('/') - expect(cookie['options'].sameSite).toBe('Lax') - expect(cookie['options'].secure).toBe(undefined) - }) - - it('should set options properly', () => { - const cookie = new Cookie({ domain: 'foo', secure: true, path: '/test' }) - expect(cookie['options'].domain).toBe('foo') - expect(cookie['options'].secure).toBe(true) - expect(cookie['options'].path).toBe('/test') - expect(cookie['options'].secure).toBe(true) - }) - - it('should pass options when creating cookie', () => { - const jarSpy = jest.spyOn(jar, 'set') - const cookie = new Cookie({ domain: 'foo', secure: true, path: '/test' }) - - cookie.set('foo', 'bar') - - expect(jarSpy).toHaveBeenCalledWith('foo', 'bar', { - domain: 'foo', - expires: 365, - path: '/test', - sameSite: 'Lax', - secure: true, - }) - }) - }) - describe('#save', () => { let user: User @@ -762,6 +723,79 @@ describe('user', () => { ) }) }) + + describe('storage', () => { + it('allows custom storage priority', () => { + const expected = 'CookieValue' + // Set a cookie first + jar.set('ajs_anonymous_id', expected) + store.set('ajs_anonymous_id', 'localStorageValue') + const user = new User({ + storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + }) + expect(user.anonymousId()).toEqual(expected) + }) + + it('custom storage priority respects availability', () => { + const expected = 'localStorageValue' + // Set a cookie first + jar.set('ajs_anonymous_id', 'CookieValue') + disableCookies() + store.set('ajs_anonymous_id', expected) + const user = new User({ + storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + }) + expect(user.anonymousId()).toEqual(expected) + }) + + it('persist option overrides any custom storage', () => { + const setCookieSpy = jest.spyOn(jar, 'set') + const user = new User({ + storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + persist: false, + }) + user.id('id') + + expect(user.id()).toBe('id') + expect(jar.get('ajs_user_id')).toBeFalsy() + expect(store.get('ajs_user_id')).toBeFalsy() + expect(setCookieSpy.mock.calls.length).toBe(0) + }) + + it('disable option overrides any custom storage', () => { + const setCookieSpy = jest.spyOn(jar, 'set') + const user = new User({ + storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + disable: true, + }) + user.id('id') + + expect(user.id()).toBe(null) + expect(jar.get('ajs_user_id')).toBeFalsy() + expect(store.get('ajs_user_id')).toBeFalsy() + expect(setCookieSpy.mock.calls.length).toBe(0) + }) + + it('can use a fully custom storage object', () => { + const customStore: Storage = { + get type() { + return 'something' + }, + get available() { + return true + }, + get: jest.fn().mockReturnValue('custom'), + set: jest.fn(), + clear: jest.fn(), + getAndSync: jest.fn().mockReturnValue('custom'), + } + + const user = new User({ storage: customStore }) + user.id('id') + expect(customStore.set).toHaveBeenCalled() + expect(user.id()).toBe('custom') + }) + }) }) describe('group', () => { @@ -872,64 +906,6 @@ describe('group', () => { }) }) -describe('store', function () { - describe('#get', function () { - it('should return null if localStorage throws an error (or does not exist)', function () { - const getItemSpy = jest - .spyOn(global.Storage.prototype, 'getItem') - .mockImplementationOnce(() => { - throw new Error('getItem fail.') - }) - store.set('foo', 'some value') - expect(store.get('foo')).toBeNull() - expect(getItemSpy).toBeCalledTimes(1) - }) - - it('should not get an empty record', function () { - expect(store.get('abc')).toBe(null) - }) - - it('should get an existing record', function () { - store.set('x', { a: 'b' }) - store.set('a', 'hello world') - store.set('b', '') - store.set('c', false) - store.set('d', null) - store.set('e', undefined) - - expect(store.get('x')).toStrictEqual({ a: 'b' }) - expect(store.get('a')).toBe('hello world') - expect(store.get('b')).toBe('') - expect(store.get('c')).toBe(false) - expect(store.get('d')).toBe(null) - expect(store.get('e')).toBe('undefined') - }) - }) - - describe('#set', function () { - it('should be able to set a record', function () { - store.set('x', { a: 'b' }) - expect(store.get('x')).toStrictEqual({ a: 'b' }) - }) - - it('should catch localStorage quota exceeded errors', () => { - const val = 'x'.repeat(10 * 1024 * 1024) - store.set('foo', val) - - expect(store.get('foo')).toBe(null) - }) - }) - - describe('#remove', function () { - it('should be able to remove a record', function () { - store.set('x', { a: 'b' }) - expect(store.get('x')).toStrictEqual({ a: 'b' }) - store.remove('x') - expect(store.get('x')).toBe(null) - }) - }) -}) - describe('Custom cookie params', () => { it('allows for overriding keys', () => { const customUser = new User( @@ -947,157 +923,3 @@ describe('Custom cookie params', () => { expect(customUser.traits()).toEqual({ trait: true }) }) }) - -describe('universal storage', function () { - const defaultTargets = ['cookie', 'localStorage', 'memory'] as StoreType[] - const getFromLS = (key: string) => JSON.parse(localStorage.getItem(key) ?? '') - beforeEach(function () { - clear() - }) - - describe('#get', function () { - it('picks data from cookies first', function () { - jar.set('ajs_test_key', 'ðŠ') - localStorage.setItem('ajs_test_key', 'ðū') - const us = new UniversalStorage( - defaultTargets, - getAvailableStorageOptions() - ) - expect(us.get('ajs_test_key')).toEqual('ðŠ') - }) - - it('picks data from localStorage if there is no cookie target', function () { - jar.set('ajs_test_key', 'ðŠ') - localStorage.setItem('ajs_test_key', 'ðū') - const us = new UniversalStorage( - ['localStorage', 'memory'], - getAvailableStorageOptions() - ) - expect(us.get('ajs_test_key')).toEqual('ðū') - }) - - it('get data from memory', function () { - jar.set('ajs_test_key', 'ðŠ') - localStorage.setItem('ajs_test_key', 'ðū') - const us = new UniversalStorage(['memory'], getAvailableStorageOptions()) - expect(us.get('ajs_test_key')).toBeNull() - }) - - it('order of default targets matters!', function () { - jar.set('ajs_test_key', 'ðŠ') - localStorage.setItem('ajs_test_key', 'ðū') - const us = new UniversalStorage( - ['cookie', 'localStorage', 'memory'], - getAvailableStorageOptions() - ) - expect(us.get('ajs_test_key')).toEqual('ðŠ') - }) - - it('returns null if there are no storage targets', function () { - jar.set('ajs_test_key', 'ðŠ') - localStorage.setItem('ajs_test_key', 'ðū') - const us = new UniversalStorage([], getAvailableStorageOptions()) - expect(us.get('ajs_test_key')).toBeNull() - }) - - it('can override the default targets', function () { - jar.set('ajs_test_key', 'ðŠ') - localStorage.setItem('ajs_test_key', 'ðū') - const us = new UniversalStorage( - defaultTargets, - getAvailableStorageOptions() - ) - expect(us.get('ajs_test_key', ['localStorage'])).toEqual('ðū') - expect(us.get('ajs_test_key', ['localStorage', 'memory'])).toEqual('ðū') - expect(us.get('ajs_test_key', ['cookie', 'memory'])).toEqual('ðŠ') - expect(us.get('ajs_test_key', ['cookie', 'localStorage'])).toEqual('ðŠ') - expect(us.get('ajs_test_key', ['cookie'])).toEqual('ðŠ') - expect(us.get('ajs_test_key', ['memory'])).toEqual(null) - }) - }) - - describe('#set', function () { - it('set the data in all storage types', function () { - const us = new UniversalStorage<{ ajs_test_key: string }>( - defaultTargets, - getAvailableStorageOptions() - ) - us.set('ajs_test_key', 'ð°') - expect(jar.get('ajs_test_key')).toEqual('ð°') - expect(getFromLS('ajs_test_key')).toEqual('ð°') - }) - - it('skip saving data to localStorage', function () { - const us = new UniversalStorage( - ['cookie', 'memory'], - getAvailableStorageOptions() - ) - us.set('ajs_test_key', 'ð°') - expect(jar.get('ajs_test_key')).toEqual('ð°') - expect(localStorage.getItem('ajs_test_key')).toEqual(null) - }) - - it('skip saving data to cookie', function () { - const us = new UniversalStorage( - ['localStorage', 'memory'], - getAvailableStorageOptions() - ) - us.set('ajs_test_key', 'ð°') - expect(jar.get('ajs_test_key')).toEqual(undefined) - expect(getFromLS('ajs_test_key')).toEqual('ð°') - }) - - it('can save and retrieve from memory when there is no other storage', function () { - const us = new UniversalStorage(['memory'], getAvailableStorageOptions()) - us.set('ajs_test_key', 'ð°') - expect(jar.get('ajs_test_key')).toEqual(undefined) - expect(localStorage.getItem('ajs_test_key')).toEqual(null) - expect(us.get('ajs_test_key')).toEqual('ð°') - }) - - it('does not write to cookies when cookies are not available', function () { - jest.spyOn(Cookie, 'available').mockReturnValueOnce(false) - const us = new UniversalStorage( - ['localStorage', 'cookie', 'memory'], - getAvailableStorageOptions() - ) - us.set('ajs_test_key', 'ð°') - expect(jar.get('ajs_test_key')).toEqual(undefined) - expect(getFromLS('ajs_test_key')).toEqual('ð°') - expect(us.get('ajs_test_key')).toEqual('ð°') - }) - - it('does not write to LS when LS is not available', function () { - jest.spyOn(LocalStorage, 'available').mockReturnValueOnce(false) - const us = new UniversalStorage( - ['localStorage', 'cookie', 'memory'], - getAvailableStorageOptions() - ) - us.set('ajs_test_key', 'ð°') - expect(jar.get('ajs_test_key')).toEqual('ð°') - expect(localStorage.getItem('ajs_test_key')).toEqual(null) - expect(us.get('ajs_test_key')).toEqual('ð°') - }) - - it('can override the default targets', function () { - const us = new UniversalStorage( - defaultTargets, - getAvailableStorageOptions() - ) - us.set('ajs_test_key', 'ð°', ['localStorage']) - expect(jar.get('ajs_test_key')).toEqual(undefined) - expect(getFromLS('ajs_test_key')).toEqual('ð°') - expect(us.get('ajs_test_key')).toEqual('ð°') - - us.set('ajs_test_key_2', 'ðĶī', ['cookie']) - expect(jar.get('ajs_test_key_2')).toEqual('ðĶī') - expect(localStorage.getItem('ajs_test_key_2')).toEqual(null) - expect(us.get('ajs_test_key_2')).toEqual('ðĶī') - - us.set('ajs_test_key_3', 'ðŧ', []) - expect(jar.get('ajs_test_key_3')).toEqual(undefined) - expect(localStorage.getItem('ajs_test_key_3')).toEqual(null) - expect(us.get('ajs_test_key_3')).toEqual(null) - }) - }) -}) diff --git a/packages/browser/src/core/user/index.ts b/packages/browser/src/core/user/index.ts index a778ecd21..f62607dec 100644 --- a/packages/browser/src/core/user/index.ts +++ b/packages/browser/src/core/user/index.ts @@ -1,8 +1,20 @@ import { v4 as uuid } from '@lukeed/uuid' -import jar from 'js-cookie' -import { Traits } from '../events' -import { tld } from './tld' import autoBind from '../../lib/bind-all' +import { Traits } from '../events' +import { + CookieOptions, + UniversalStorage, + Storage, + StorageObject, + StorageSettings, + StoreType, + applyCookieOptions, + initializeStorages, + isArrayOfStoreType, + isStorageObject, +} from '../storage' +import { MemoryStorage } from '../storage/memoryStorage' +import {} from '../storage/settings' export type ID = string | null | undefined @@ -22,6 +34,12 @@ export interface UserOptions { localStorage?: { key: string } + + /** + * Storage system to use + * @example new MemoryStorage, [StoreType.Cookie, StoreType.Memory] + */ + storage?: StorageSettings } const defaults = { @@ -35,290 +53,6 @@ const defaults = { }, } -export type StoreType = 'cookie' | 'localStorage' | 'memory' - -type StorageObject = Record<string, unknown> - -class Store { - private cache: Record<string, unknown> = {} - - get<T>(key: string): T | null { - return this.cache[key] as T | null - } - - set<T>(key: string, value: T | null): void { - this.cache[key] = value - } - - remove(key: string): void { - delete this.cache[key] - } - get type(): StoreType { - return 'memory' - } -} - -const ONE_YEAR = 365 - -export class Cookie extends Store { - static available(): boolean { - let cookieEnabled = window.navigator.cookieEnabled - - if (!cookieEnabled) { - jar.set('ajs:cookies', 'test') - cookieEnabled = document.cookie.includes('ajs:cookies') - jar.remove('ajs:cookies') - } - - return cookieEnabled - } - - static get defaults(): CookieOptions { - return { - maxage: ONE_YEAR, - domain: tld(window.location.href), - path: '/', - sameSite: 'Lax', - } - } - - private options: Required<CookieOptions> - - constructor(options: CookieOptions = Cookie.defaults) { - super() - this.options = { - ...Cookie.defaults, - ...options, - } as Required<CookieOptions> - } - - private opts(): jar.CookieAttributes { - return { - sameSite: this.options.sameSite as jar.CookieAttributes['sameSite'], - expires: this.options.maxage, - domain: this.options.domain, - path: this.options.path, - secure: this.options.secure, - } - } - - get<T>(key: string): T | null { - try { - const value = jar.get(key) - - if (!value) { - return null - } - - try { - return JSON.parse(value) - } catch (e) { - return value as unknown as T - } - } catch (e) { - return null - } - } - - set<T>(key: string, value: T): void { - if (typeof value === 'string') { - jar.set(key, value, this.opts()) - } else if (value === null) { - jar.remove(key, this.opts()) - } else { - jar.set(key, JSON.stringify(value), this.opts()) - } - } - - remove(key: string): void { - return jar.remove(key, this.opts()) - } - - get type(): StoreType { - return 'cookie' - } -} - -const localStorageWarning = (key: string, state: 'full' | 'unavailable') => { - console.warn(`Unable to access ${key}, localStorage may be ${state}`) -} - -export class LocalStorage extends Store { - static available(): boolean { - const test = 'test' - try { - localStorage.setItem(test, test) - localStorage.removeItem(test) - return true - } catch (e) { - return false - } - } - - get<T>(key: string): T | null { - try { - const val = localStorage.getItem(key) - if (val === null) { - return null - } - try { - return JSON.parse(val) - } catch (e) { - return val as any as T - } - } catch (err) { - localStorageWarning(key, 'unavailable') - return null - } - } - - set<T>(key: string, value: T): void { - try { - localStorage.setItem(key, JSON.stringify(value)) - } catch { - localStorageWarning(key, 'full') - } - } - - remove(key: string): void { - try { - return localStorage.removeItem(key) - } catch (err) { - localStorageWarning(key, 'unavailable') - } - } - - get type(): StoreType { - return 'localStorage' - } -} - -export interface CookieOptions { - maxage?: number - domain?: string - path?: string - secure?: boolean - sameSite?: string -} - -export class UniversalStorage<Data extends StorageObject = StorageObject> { - private enabledStores: StoreType[] - private storageOptions: StorageOptions - - constructor(stores: StoreType[], storageOptions: StorageOptions) { - this.storageOptions = storageOptions - this.enabledStores = stores - } - - private getStores(storeTypes: StoreType[] | undefined): Store[] { - const stores: Store[] = [] - this.enabledStores - .filter((i) => !storeTypes || storeTypes?.includes(i)) - .forEach((storeType) => { - const storage = this.storageOptions[storeType] - if (storage !== undefined) { - stores.push(storage) - } - }) - - return stores - } - - /* - This is to support few scenarios where: - - value exist in one of the stores ( as a result of other stores being cleared from browser ) and we want to resync them - - read values in AJS 1.0 format ( for customers after 1.0 --> 2.0 migration ) and then re-write them in AJS 2.0 format - */ - - /** - * get value for the key from the stores. it will pick the first value found in the stores, and then sync the value to all the stores - * if the found value is a number, it will be converted to a string. this is to support legacy behavior that existed in AJS 1.0 - * @param key key for the value to be retrieved - * @param storeTypes optional array of store types to be used for performing get and sync - * @returns value for the key or null if not found - */ - public getAndSync<K extends keyof Data>( - key: K, - storeTypes?: StoreType[] - ): Data[K] | null { - const val = this.get(key, storeTypes) - - // legacy behavior, getAndSync can change the type of a value from number to string (AJS 1.0 stores numerical values as a number) - const coercedValue = (typeof val === 'number' ? val.toString() : val) as - | Data[K] - | null - - this.set(key, coercedValue, storeTypes) - - return coercedValue - } - - /** - * get value for the key from the stores. it will return the first value found in the stores - * @param key key for the value to be retrieved - * @param storeTypes optional array of store types to be used for retrieving the value - * @returns value for the key or null if not found - */ - public get<K extends keyof Data>( - key: K, - storeTypes?: StoreType[] - ): Data[K] | null { - let val = null - - for (const store of this.getStores(storeTypes)) { - val = store.get<Data[K]>(key) - if (val) { - return val - } - } - return null - } - - /** - * it will set the value for the key in all the stores - * @param key key for the value to be stored - * @param value value to be stored - * @param storeTypes optional array of store types to be used for storing the value - * @returns value that was stored - */ - public set<K extends keyof Data>( - key: K, - value: Data[K] | null, - storeTypes?: StoreType[] - ): void { - for (const store of this.getStores(storeTypes)) { - store.set(key, value) - } - } - - /** - * remove the value for the key from all the stores - * @param key key for the value to be removed - * @param storeTypes optional array of store types to be used for removing the value - */ - public clear<K extends keyof Data>(key: K, storeTypes?: StoreType[]): void { - for (const store of this.getStores(storeTypes)) { - store.remove(key) - } - } -} - -type StorageOptions = { - cookie: Cookie | undefined - localStorage: LocalStorage | undefined - memory: Store -} - -export function getAvailableStorageOptions( - cookieOptions?: CookieOptions -): StorageOptions { - return { - cookie: Cookie.available() ? new Cookie(cookieOptions) : undefined, - localStorage: LocalStorage.available() ? new LocalStorage() : undefined, - memory: new Store(), - } -} - export class User { static defaults = defaults @@ -327,7 +61,7 @@ export class User { private anonKey: string private cookieOptions?: CookieOptions - private legacyUserStore: UniversalStorage<{ + private legacyUserStore: Storage<{ [k: string]: | { id?: string @@ -335,58 +69,38 @@ export class User { } | string }> - private traitsStore: UniversalStorage<{ + private traitsStore: Storage<{ [k: string]: Traits }> - private identityStore: UniversalStorage<{ + private identityStore: Storage<{ [k: string]: string }> options: UserOptions = {} constructor(options: UserOptions = defaults, cookieOptions?: CookieOptions) { - this.options = options + this.options = { ...defaults, ...options } this.cookieOptions = cookieOptions this.idKey = options.cookie?.key ?? defaults.cookie.key this.traitsKey = options.localStorage?.key ?? defaults.localStorage.key this.anonKey = 'ajs_anonymous_id' - const isDisabled = options.disable === true - const shouldPersist = options.persist !== false - - let defaultStorageTargets: StoreType[] = isDisabled - ? [] - : shouldPersist - ? ['localStorage', 'cookie', 'memory'] - : ['memory'] - - const storageOptions = getAvailableStorageOptions(cookieOptions) - - if (options.localStorageFallbackDisabled) { - defaultStorageTargets = defaultStorageTargets.filter( - (t) => t !== 'localStorage' - ) - } - - this.identityStore = new UniversalStorage( - defaultStorageTargets, - storageOptions - ) + this.identityStore = this.createStorage(this.options, cookieOptions) // using only cookies for legacy user store - this.legacyUserStore = new UniversalStorage( - defaultStorageTargets.filter( - (t) => t !== 'localStorage' && t !== 'memory' - ), - storageOptions + this.legacyUserStore = this.createStorage( + this.options, + cookieOptions, + (s) => s === StoreType.Cookie ) // using only localStorage / memory for traits store - this.traitsStore = new UniversalStorage( - defaultStorageTargets.filter((t) => t !== 'cookie'), - storageOptions + this.traitsStore = this.createStorage( + this.options, + cookieOptions, + (s) => s !== StoreType.Cookie ) const legacyUser = this.legacyUserStore.get(defaults.cookie.oldKey) @@ -510,6 +224,59 @@ export class User { save(): boolean { return true } + + /** + * Creates the right storage system applying all the user options, cookie options and particular filters + * @param options UserOptions + * @param cookieOpts CookieOptions + * @param filterStores filter function to apply to any StoreTypes (skipped if options specify using a custom storage) + * @returns a Storage object + */ + private createStorage<T extends StorageObject = StorageObject>( + options: UserOptions, + cookieOpts?: CookieOptions, + filterStores?: (value: StoreType) => boolean + ): Storage<T> { + let stores: StoreType[] = [ + StoreType.LocalStorage, + StoreType.Cookie, + StoreType.Memory, + ] + + // If disabled we won't have any storage functionality + if (options.disable) { + return new UniversalStorage<T>([]) + } + + // If persistance is disabled we will always fallback to Memory Storage + if (!options.persist) { + return new MemoryStorage<T>() + } + + if (options.storage !== undefined && options.storage !== null) { + // If the user is sending its own storage implementation we will use that without any modifications + if (isStorageObject(options.storage)) { + return options.storage as Storage<T> + } else if (isArrayOfStoreType(options.storage)) { + // If the user only specified order of stores we will still apply filters and transformations e.g. not using localStorage if localStorageFallbackDisabled + stores = options.storage + } + } + + // Disable LocalStorage + if (options.localStorageFallbackDisabled) { + stores = stores.filter((s) => s !== StoreType.LocalStorage) + } + + // Apply Additional filters + if (filterStores) { + stores = stores.filter(filterStores) + } + + return new UniversalStorage( + initializeStorages(applyCookieOptions(stores, cookieOpts)) + ) + } } const groupDefaults: UserOptions = { @@ -524,7 +291,7 @@ const groupDefaults: UserOptions = { export class Group extends User { constructor(options: UserOptions = groupDefaults, cookie?: CookieOptions) { - super(options, cookie) + super({ ...groupDefaults, ...options }, cookie) autoBind(this) } diff --git a/packages/browser/src/plugins/segmentio/normalize.ts b/packages/browser/src/plugins/segmentio/normalize.ts index eb1b6ceed..288aa27de 100644 --- a/packages/browser/src/plugins/segmentio/normalize.ts +++ b/packages/browser/src/plugins/segmentio/normalize.ts @@ -7,7 +7,7 @@ import { tld } from '../../core/user/tld' import { SegmentFacade } from '../../lib/to-facade' import { SegmentioSettings } from './index' import { version } from '../../generated/version' -import { getAvailableStorageOptions, UniversalStorage } from '../../core/user' +import { CookieStorage, UniversalStorage } from '../../core/storage' let cookieOptions: jar.CookieAttributes | undefined function getCookieOptions(): jar.CookieAttributes { @@ -97,10 +97,7 @@ function referrerId( ): void { const storage = new UniversalStorage<{ 's:context.referrer': Ad - }>( - disablePersistance ? [] : ['cookie'], - getAvailableStorageOptions(getCookieOptions()) - ) + }>(disablePersistance ? [] : [new CookieStorage(getCookieOptions())]) const stored = storage.get('s:context.referrer') let ad: Ad | undefined | null = ads(query) From ac0076e55743fbb40a59b0c102e4b76c78d76539 Mon Sep 17 00:00:00 2001 From: Oscar Bazaldua <511911+oscb@users.noreply.github.com> Date: Mon, 24 Jul 2023 12:10:56 -0700 Subject: [PATCH 2/5] chore: address pr comments --- .../analytics/__tests__/integration.test.ts | 22 +++++++ packages/browser/src/core/analytics/index.ts | 2 +- ...orage.test.ts => universalStorage.test.ts} | 65 ++++++------------- packages/browser/src/core/storage/settings.ts | 11 +--- packages/browser/src/core/storage/types.ts | 3 +- packages/browser/src/core/user/index.ts | 3 +- 6 files changed, 47 insertions(+), 59 deletions(-) rename packages/browser/src/core/storage/__tests__/{prioritizedListStorage.test.ts => universalStorage.test.ts} (64%) diff --git a/packages/browser/src/core/analytics/__tests__/integration.test.ts b/packages/browser/src/core/analytics/__tests__/integration.test.ts index 5e2045b15..eb1c6beda 100644 --- a/packages/browser/src/core/analytics/__tests__/integration.test.ts +++ b/packages/browser/src/core/analytics/__tests__/integration.test.ts @@ -298,5 +298,27 @@ describe('Analytics', () => { expect(analytics.user().id()).toEqual('known-user') expect(setCookieSpy).toHaveBeenCalled() }) + + it('handles disabling storage', async () => { + const setCookieSpy = jest.spyOn(jar, 'set') + const expected = 'CookieValue' + jar.set('ajs_anonymous_id', expected) + localStorage.setItem('ajs_anonymous_id', 'localStorageValue') + + const analytics = new Analytics( + { writeKey: '' }, + { + storage: [StoreType.Cookie, StoreType.Memory], + } + ) + + expect(analytics.user().anonymousId()).toEqual(expected) + + analytics.user().id('known-user') + expect(analytics.user().id()).toEqual('known-user') + expect(setCookieSpy).toHaveBeenCalled() + // Local storage shouldn't change + expect(localStorage.getItem('ajs_anonymous_id')).toBe('localStorageValue') + }) }) }) diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts index d958e974f..22ea0d97a 100644 --- a/packages/browser/src/core/analytics/index.ts +++ b/packages/browser/src/core/analytics/index.ts @@ -218,7 +218,7 @@ export class Analytics if (disablePersistance) { return new MemoryStorage() } else { - if (storageSetting !== undefined && storageSetting !== null) { + if (storageSetting) { if (isArrayOfStoreType(storageSetting)) { // We will create the store with the priority for customer settings return new UniversalStorage( diff --git a/packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts similarity index 64% rename from packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts rename to packages/browser/src/core/storage/__tests__/universalStorage.test.ts index f05f5ad9d..e147cb5a7 100644 --- a/packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts +++ b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts @@ -3,7 +3,9 @@ import { CookieStorage } from '../cookieStorage' import { LocalStorage } from '../localStorage' import { MemoryStorage } from '../memoryStorage' import { UniversalStorage } from '../universalStorage' -describe('prioritizedListStorage', function () { +import { disableCookies, disableLocalStorage } from './test-helpers' + +describe('UniversalStorage', function () { const defaultTargets = [ new CookieStorage(), new LocalStorage(), @@ -14,6 +16,10 @@ describe('prioritizedListStorage', function () { clear() }) + afterEach(() => { + jest.restoreAllMocks() + }) + function clear(): void { document.cookie.split(';').forEach(function (c) { document.cookie = c @@ -48,8 +54,12 @@ describe('prioritizedListStorage', function () { it('order of default targets matters!', function () { jar.set('ajs_test_key', 'ðŠ') localStorage.setItem('ajs_test_key', 'ðū') - const us = new UniversalStorage(defaultTargets) - expect(us.get('ajs_test_key')).toEqual('ðŠ') + const us = new UniversalStorage([ + new LocalStorage(), + new CookieStorage(), + new MemoryStorage(), + ]) + expect(us.get('ajs_test_key')).toEqual('ðū') }) it('returns null if there are no storage targets', function () { @@ -58,24 +68,10 @@ describe('prioritizedListStorage', function () { const us = new UniversalStorage([]) expect(us.get('ajs_test_key')).toBeNull() }) - - // it('can override the default targets', function () { - // jar.set('ajs_test_key', 'ðŠ') - // localStorage.setItem('ajs_test_key', 'ðū') - // const us = new PrioritizedListStorage( - // defaultTargets - // ) - // expect(us.get('ajs_test_key', ['localStorage'])).toEqual('ðū') - // expect(us.get('ajs_test_key', ['localStorage', 'memory'])).toEqual('ðū') - // expect(us.get('ajs_test_key', ['cookie', 'memory'])).toEqual('ðŠ') - // expect(us.get('ajs_test_key', ['cookie', 'localStorage'])).toEqual('ðŠ') - // expect(us.get('ajs_test_key', ['cookie'])).toEqual('ðŠ') - // expect(us.get('ajs_test_key', ['memory'])).toEqual(null) - // }) }) describe('#set', function () { - it('set the data in all storage types', function () { + it('sets the data in all storage types', function () { const us = new UniversalStorage<{ ajs_test_key: string }>(defaultTargets) us.set('ajs_test_key', 'ð°') expect(jar.get('ajs_test_key')).toEqual('ð°') @@ -108,11 +104,10 @@ describe('prioritizedListStorage', function () { }) it('does not write to cookies when cookies are not available', function () { - const cookieStore = new CookieStorage() - jest.spyOn(cookieStore, 'available', 'get').mockReturnValueOnce(false) + disableCookies() const us = new UniversalStorage([ new LocalStorage(), - cookieStore, + new CookieStorage(), new MemoryStorage(), ]) us.set('ajs_test_key', 'ð°') @@ -122,38 +117,16 @@ describe('prioritizedListStorage', function () { }) it('does not write to LS when LS is not available', function () { - const localStorage = new LocalStorage() - jest.spyOn(localStorage, 'available', 'get').mockReturnValueOnce(false) + disableLocalStorage() const us = new UniversalStorage([ - localStorage, + new LocalStorage(), new CookieStorage(), new MemoryStorage(), ]) us.set('ajs_test_key', 'ð°') expect(jar.get('ajs_test_key')).toEqual('ð°') - expect(localStorage.get('ajs_test_key')).toEqual(null) + expect(localStorage.getItem('ajs_test_key')).toEqual(null) expect(us.get('ajs_test_key')).toEqual('ð°') }) - - // it('can override the default targets', function () { - // const us = new UniversalStorage( - // defaultTargets, - // getAvailableStorageOptions() - // ) - // us.set('ajs_test_key', 'ð°', ['localStorage']) - // expect(jar.get('ajs_test_key')).toEqual(undefined) - // expect(getFromLS('ajs_test_key')).toEqual('ð°') - // expect(us.get('ajs_test_key')).toEqual('ð°') - - // us.set('ajs_test_key_2', 'ðĶī', ['cookie']) - // expect(jar.get('ajs_test_key_2')).toEqual('ðĶī') - // expect(localStorage.getItem('ajs_test_key_2')).toEqual(null) - // expect(us.get('ajs_test_key_2')).toEqual('ðĶī') - - // us.set('ajs_test_key_3', 'ðŧ', []) - // expect(jar.get('ajs_test_key_3')).toEqual(undefined) - // expect(localStorage.getItem('ajs_test_key_3')).toEqual(null) - // expect(us.get('ajs_test_key_3')).toEqual(null) - // }) }) }) diff --git a/packages/browser/src/core/storage/settings.ts b/packages/browser/src/core/storage/settings.ts index 2eab09876..1503bcb1c 100644 --- a/packages/browser/src/core/storage/settings.ts +++ b/packages/browser/src/core/storage/settings.ts @@ -4,21 +4,14 @@ export type StorageSettings = Storage | StoreType[] export function isArrayOfStoreType(s: StorageSettings): s is StoreType[] { return ( - s !== undefined && - s !== null && + s && Array.isArray(s) && s.every((e) => Object.values(StoreType).includes(e)) ) } export function isStorageObject(s: StorageSettings): s is Storage { - return ( - s !== undefined && - s !== null && - !Array.isArray(s) && - typeof s === 'object' && - s.get !== undefined - ) + return s && !Array.isArray(s) && typeof s === 'object' && s.get !== undefined } export function isStoreTypeWithSettings( diff --git a/packages/browser/src/core/storage/types.ts b/packages/browser/src/core/storage/types.ts index 18f3b0f26..eaa0c192f 100644 --- a/packages/browser/src/core/storage/types.ts +++ b/packages/browser/src/core/storage/types.ts @@ -73,7 +73,8 @@ export abstract class BaseStorage<Data extends StorageObject = StorageObject> abstract set<K extends keyof Data>(key: K, value: Data[K] | null): void abstract clear<K extends keyof Data>(key: K): void /** - * By default a storage getAndSync will handle calls exactly as the + * By default a storage getAndSync will handle calls exactly as a normal get. + * getAndSync needs to be implemented for more complex storage types that might wrap several base storage systems (see UniversalStorage) */ getAndSync<K extends keyof Data>(key: K): Data[K] | null { const val = this.get(key) diff --git a/packages/browser/src/core/user/index.ts b/packages/browser/src/core/user/index.ts index f62607dec..b958ff350 100644 --- a/packages/browser/src/core/user/index.ts +++ b/packages/browser/src/core/user/index.ts @@ -4,6 +4,7 @@ import { Traits } from '../events' import { CookieOptions, UniversalStorage, + MemoryStorage, Storage, StorageObject, StorageSettings, @@ -13,8 +14,6 @@ import { isArrayOfStoreType, isStorageObject, } from '../storage' -import { MemoryStorage } from '../storage/memoryStorage' -import {} from '../storage/settings' export type ID = string | null | undefined From eff7dc197d212e459f08d6d451ce8107a5096869 Mon Sep 17 00:00:00 2001 From: Oscar Bazaldua <511911+oscb@users.noreply.github.com> Date: Fri, 28 Jul 2023 17:29:43 -0700 Subject: [PATCH 3/5] fix: remove custom storage option and interface --- .changeset/smooth-seahorses-unite.md | 2 +- .../analytics/__tests__/integration.test.ts | 12 ++++- packages/browser/src/core/analytics/index.ts | 15 ++---- .../storage/__tests__/cookieStorage.test.ts | 18 ------- .../storage/__tests__/localStorage.test.ts | 2 +- .../core/storage/__tests__/test-helpers.ts | 20 +++++--- .../__tests__/universalStorage.test.ts | 8 ++- .../browser/src/core/storage/cookieStorage.ts | 27 ++-------- packages/browser/src/core/storage/index.ts | 4 +- .../browser/src/core/storage/localStorage.ts | 25 ++------- .../browser/src/core/storage/memoryStorage.ts | 18 ++----- packages/browser/src/core/storage/settings.ts | 20 ++++---- packages/browser/src/core/storage/types.ts | 51 +------------------ .../src/core/storage/universalStorage.ts | 51 +++++++++++-------- .../src/core/user/__tests__/index.test.ts | 46 +++++------------ packages/browser/src/core/user/index.ts | 23 ++++----- 16 files changed, 117 insertions(+), 225 deletions(-) diff --git a/.changeset/smooth-seahorses-unite.md b/.changeset/smooth-seahorses-unite.md index ae8fa2dc9..ef526d79c 100644 --- a/.changeset/smooth-seahorses-unite.md +++ b/.changeset/smooth-seahorses-unite.md @@ -2,4 +2,4 @@ '@segment/analytics-next': minor --- -Adds storage option in analytics client to specify priority of storage (e.g use cookies over localstorage) or use a custom implementation +Adds storage option in analytics client to specify priority of storage (e.g use cookies over localstorage) diff --git a/packages/browser/src/core/analytics/__tests__/integration.test.ts b/packages/browser/src/core/analytics/__tests__/integration.test.ts index eb1c6beda..73337107d 100644 --- a/packages/browser/src/core/analytics/__tests__/integration.test.ts +++ b/packages/browser/src/core/analytics/__tests__/integration.test.ts @@ -288,7 +288,13 @@ describe('Analytics', () => { const analytics = new Analytics( { writeKey: '' }, { - storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + storage: { + stores: [ + StoreType.Cookie, + StoreType.LocalStorage, + StoreType.Memory, + ], + }, } ) @@ -308,7 +314,9 @@ describe('Analytics', () => { const analytics = new Analytics( { writeKey: '' }, { - storage: [StoreType.Cookie, StoreType.Memory], + storage: { + stores: [StoreType.Cookie, StoreType.Memory], + }, } ) diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts index 22ea0d97a..ef88ed131 100644 --- a/packages/browser/src/core/analytics/index.ts +++ b/packages/browser/src/core/analytics/index.ts @@ -46,13 +46,11 @@ import { CookieOptions, MemoryStorage, UniversalStorage, - Storage, StorageSettings, StoreType, applyCookieOptions, initializeStorages, isArrayOfStoreType, - isStorageObject, } from '../storage' const deprecationWarning = @@ -138,7 +136,7 @@ export class Analytics private _group: Group private eventFactory: EventFactory private _debug = false - private _universalStorage: Storage + private _universalStorage: UniversalStorage initialized = false integrations: Integrations @@ -213,22 +211,19 @@ export class Analytics disablePersistance: boolean, storageSetting: InitOptions['storage'], cookieOptions?: CookieOptions | undefined - ): Storage { + ): UniversalStorage { // DisablePersistance option overrides all, no storage will be used outside of memory even if specified if (disablePersistance) { - return new MemoryStorage() + return new UniversalStorage([new MemoryStorage()]) } else { if (storageSetting) { if (isArrayOfStoreType(storageSetting)) { // We will create the store with the priority for customer settings return new UniversalStorage( initializeStorages( - applyCookieOptions(storageSetting, cookieOptions) + applyCookieOptions(storageSetting.stores, cookieOptions) ) ) - } else if (isStorageObject(storageSetting)) { - // If it is an object we will use the customer provided storage - return storageSetting } } } @@ -245,7 +240,7 @@ export class Analytics ) } - get storage(): Storage { + get storage(): UniversalStorage { return this._universalStorage } diff --git a/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts b/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts index da02beccc..3ac9dd34f 100644 --- a/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts +++ b/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts @@ -1,6 +1,5 @@ import { CookieStorage } from '../cookieStorage' import jar from 'js-cookie' -import { disableCookies } from './test-helpers' describe('cookieStorage', () => { function clearCookies() { @@ -15,23 +14,6 @@ describe('cookieStorage', () => { clearCookies() }) - describe('#available', () => { - afterEach(() => { - jest.restoreAllMocks() - }) - - it('is available', () => { - const cookie = new CookieStorage() - expect(cookie.available).toBe(true) - }) - - it("is unavailable if can't write cookies", () => { - disableCookies() - const cookie = new CookieStorage() - expect(cookie.available).toBe(false) - }) - }) - describe('cookie options', () => { it('should have default cookie options', () => { const cookie = new CookieStorage() diff --git a/packages/browser/src/core/storage/__tests__/localStorage.test.ts b/packages/browser/src/core/storage/__tests__/localStorage.test.ts index 30c73a121..09c3b9f2d 100644 --- a/packages/browser/src/core/storage/__tests__/localStorage.test.ts +++ b/packages/browser/src/core/storage/__tests__/localStorage.test.ts @@ -63,7 +63,7 @@ describe('LocalStorage', function () { it('should be able to remove a record', function () { store.set('x', { a: 'b' }) expect(store.get('x')).toStrictEqual({ a: 'b' }) - store.clear('x') + store.remove('x') expect(store.get('x')).toBe(null) }) }) diff --git a/packages/browser/src/core/storage/__tests__/test-helpers.ts b/packages/browser/src/core/storage/__tests__/test-helpers.ts index 7bd5af18b..20faf069a 100644 --- a/packages/browser/src/core/storage/__tests__/test-helpers.ts +++ b/packages/browser/src/core/storage/__tests__/test-helpers.ts @@ -1,19 +1,27 @@ +import jar from 'js-cookie' /** * Disables Cookies * @returns jest spy */ -export function disableCookies(): jest.SpyInstance { - return jest - .spyOn(window.navigator, 'cookieEnabled', 'get') - .mockReturnValue(false) +export function disableCookies(): void { + jest.spyOn(window.navigator, 'cookieEnabled', 'get').mockReturnValue(false) + jest.spyOn(jar, 'set').mockImplementation(() => { + throw new Error() + }) + jest.spyOn(jar, 'get').mockImplementation(() => { + throw new Error() + }) } /** * Disables LocalStorage * @returns jest spy */ -export function disableLocalStorage(): jest.SpyInstance { - return jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { +export function disableLocalStorage(): void { + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error() + }) + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error() }) } diff --git a/packages/browser/src/core/storage/__tests__/universalStorage.test.ts b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts index e147cb5a7..93bdaffc9 100644 --- a/packages/browser/src/core/storage/__tests__/universalStorage.test.ts +++ b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts @@ -103,21 +103,20 @@ describe('UniversalStorage', function () { expect(us.get('ajs_test_key')).toEqual('ð°') }) - it('does not write to cookies when cookies are not available', function () { - disableCookies() + it('handles cookie errors gracefully', function () { + disableCookies() // Cookies is going to throw exceptions now const us = new UniversalStorage([ new LocalStorage(), new CookieStorage(), new MemoryStorage(), ]) us.set('ajs_test_key', 'ð°') - expect(jar.get('ajs_test_key')).toEqual(undefined) expect(getFromLS('ajs_test_key')).toEqual('ð°') expect(us.get('ajs_test_key')).toEqual('ð°') }) it('does not write to LS when LS is not available', function () { - disableLocalStorage() + disableLocalStorage() // Localstorage will throw exceptions const us = new UniversalStorage([ new LocalStorage(), new CookieStorage(), @@ -125,7 +124,6 @@ describe('UniversalStorage', function () { ]) us.set('ajs_test_key', 'ð°') expect(jar.get('ajs_test_key')).toEqual('ð°') - expect(localStorage.getItem('ajs_test_key')).toEqual(null) expect(us.get('ajs_test_key')).toEqual('ð°') }) }) diff --git a/packages/browser/src/core/storage/cookieStorage.ts b/packages/browser/src/core/storage/cookieStorage.ts index 02f5eb50a..9450d55d9 100644 --- a/packages/browser/src/core/storage/cookieStorage.ts +++ b/packages/browser/src/core/storage/cookieStorage.ts @@ -1,4 +1,4 @@ -import { BaseStorage, StorageObject, StoreType } from './types' +import { Store, StorageObject } from './types' import jar from 'js-cookie' import { tld } from '../user/tld' @@ -15,25 +15,9 @@ export interface CookieOptions { /** * Data storage using browser cookies */ -export class CookieStorage< - Data extends StorageObject = StorageObject -> extends BaseStorage { - get available(): boolean { - let cookieEnabled = window.navigator.cookieEnabled - - if (!cookieEnabled) { - jar.set('ajs:cookies', 'test') - cookieEnabled = document.cookie.includes('ajs:cookies') - jar.remove('ajs:cookies') - } - - return cookieEnabled - } - - get type() { - return StoreType.Cookie - } - +export class CookieStorage<Data extends StorageObject = StorageObject> + implements Store<Data> +{ static get defaults(): CookieOptions { return { maxage: ONE_YEAR, @@ -46,7 +30,6 @@ export class CookieStorage< private options: Required<CookieOptions> constructor(options: CookieOptions = CookieStorage.defaults) { - super() this.options = { ...CookieStorage.defaults, ...options, @@ -91,7 +74,7 @@ export class CookieStorage< } } - clear<K extends keyof Data>(key: K): void { + remove<K extends keyof Data>(key: K): void { return jar.remove(key, this.opts()) } } diff --git a/packages/browser/src/core/storage/index.ts b/packages/browser/src/core/storage/index.ts index 5793795b0..aeb9a3b84 100644 --- a/packages/browser/src/core/storage/index.ts +++ b/packages/browser/src/core/storage/index.ts @@ -2,7 +2,7 @@ import { CookieOptions, CookieStorage } from './cookieStorage' import { LocalStorage } from './localStorage' import { MemoryStorage } from './memoryStorage' import { isStoreTypeWithSettings } from './settings' -import { StoreType, Storage, InitializeStorageArgs } from './types' +import { StoreType, Store, InitializeStorageArgs } from './types' export * from './types' export * from './localStorage' @@ -16,7 +16,7 @@ export * from './settings' * @param args StoreType and options * @returns Storage array */ -export function initializeStorages(args: InitializeStorageArgs): Storage[] { +export function initializeStorages(args: InitializeStorageArgs): Store[] { const storages = args.map((s) => { let type: StoreType let settings diff --git a/packages/browser/src/core/storage/localStorage.ts b/packages/browser/src/core/storage/localStorage.ts index f5385f965..9eb369d77 100644 --- a/packages/browser/src/core/storage/localStorage.ts +++ b/packages/browser/src/core/storage/localStorage.ts @@ -1,30 +1,15 @@ -import { BaseStorage, StorageObject, StoreType } from './types' +import { StorageObject, Store } from './types' /** * Data storage using browser's localStorage */ -export class LocalStorage< - Data extends StorageObject = StorageObject -> extends BaseStorage { +export class LocalStorage<Data extends StorageObject = StorageObject> + implements Store<Data> +{ private localStorageWarning(key: keyof Data, state: 'full' | 'unavailable') { console.warn(`Unable to access ${key}, localStorage may be ${state}`) } - get type() { - return StoreType.LocalStorage - } - - get available(): boolean { - const test = 'test' - try { - localStorage.setItem(test, test) - localStorage.removeItem(test) - return true - } catch (e) { - return false - } - } - get<K extends keyof Data>(key: K): Data[K] | null { try { const val = localStorage.getItem(key) @@ -50,7 +35,7 @@ export class LocalStorage< } } - clear<K extends keyof Data>(key: K): void { + remove<K extends keyof Data>(key: K): void { try { return localStorage.removeItem(key) } catch (err) { diff --git a/packages/browser/src/core/storage/memoryStorage.ts b/packages/browser/src/core/storage/memoryStorage.ts index fa9a3d580..7840e7ba7 100644 --- a/packages/browser/src/core/storage/memoryStorage.ts +++ b/packages/browser/src/core/storage/memoryStorage.ts @@ -1,21 +1,13 @@ -import { BaseStorage, StorageObject, StoreType } from './types' +import { Store, StorageObject } from './types' /** * Data Storage using in memory object */ -export class MemoryStorage< - Data extends StorageObject = StorageObject -> extends BaseStorage<Data> { +export class MemoryStorage<Data extends StorageObject = StorageObject> + implements Store<Data> +{ private cache: Record<string, unknown> = {} - get type() { - return StoreType.Memory - } - - get available(): boolean { - return true - } - get<K extends keyof Data>(key: K): Data[K] | null { return (this.cache[key] ?? null) as Data[K] | null } @@ -24,7 +16,7 @@ export class MemoryStorage< this.cache[key] = value } - clear<K extends keyof Data>(key: K): void { + remove<K extends keyof Data>(key: K): void { delete this.cache[key] } } diff --git a/packages/browser/src/core/storage/settings.ts b/packages/browser/src/core/storage/settings.ts index 1503bcb1c..cc11a58fb 100644 --- a/packages/browser/src/core/storage/settings.ts +++ b/packages/browser/src/core/storage/settings.ts @@ -1,19 +1,21 @@ -import { Storage, StoreType, StoreTypeWithSettings } from './types' +import { StoreType, StoreTypeWithSettings } from './types' -export type StorageSettings = Storage | StoreType[] +export type UniversalStorageSettings = { stores: StoreType[] } -export function isArrayOfStoreType(s: StorageSettings): s is StoreType[] { +// This is setup this way to permit eventually a different set of settings for custom storage +export type StorageSettings = UniversalStorageSettings + +export function isArrayOfStoreType( + s: StorageSettings +): s is UniversalStorageSettings { return ( s && - Array.isArray(s) && - s.every((e) => Object.values(StoreType).includes(e)) + s.stores && + Array.isArray(s.stores) && + s.stores.every((e) => Object.values(StoreType).includes(e)) ) } -export function isStorageObject(s: StorageSettings): s is Storage { - return s && !Array.isArray(s) && typeof s === 'object' && s.get !== undefined -} - export function isStoreTypeWithSettings( s: StoreTypeWithSettings | StoreType ): s is StoreTypeWithSettings { diff --git a/packages/browser/src/core/storage/types.ts b/packages/browser/src/core/storage/types.ts index eaa0c192f..c2943ebe8 100644 --- a/packages/browser/src/core/storage/types.ts +++ b/packages/browser/src/core/storage/types.ts @@ -16,36 +16,14 @@ export type StorageObject = Record<string, unknown> /** * Defines a Storage object for use in AJS Client. */ -export interface Storage<Data extends StorageObject = StorageObject> { - /** - * Returns the kind of storage. - * @example cookie, localStorage, custom - */ - get type(): StoreType | string - - /** - * Tests if the storage is available for use in the current environment - */ - get available(): boolean +export interface Store<Data extends StorageObject = StorageObject> { /** * get value for the key from the stores. it will return the first value found in the stores * @param key key for the value to be retrieved * @returns value for the key or null if not found */ get<K extends keyof Data>(key: K): Data[K] | null - /* - This is to support few scenarios where: - - value exist in one of the stores ( as a result of other stores being cleared from browser ) and we want to resync them - - read values in AJS 1.0 format ( for customers after 1.0 --> 2.0 migration ) and then re-write them in AJS 2.0 format - */ - /** - * get value for the key from the stores. it will pick the first value found in the stores, and then sync the value to all the stores - * if the found value is a number, it will be converted to a string. this is to support legacy behavior that existed in AJS 1.0 - * @param key key for the value to be retrieved - * @returns value for the key or null if not found - */ - getAndSync<K extends keyof Data>(key: K): Data[K] | null /** * it will set the value for the key in all the stores * @param key key for the value to be stored @@ -58,32 +36,7 @@ export interface Storage<Data extends StorageObject = StorageObject> { * @param key key for the value to be removed * @param storeTypes optional array of store types to be used for removing the value */ - clear<K extends keyof Data>(key: K): void -} - -/** - * Abstract class for creating basic storage systems - */ -export abstract class BaseStorage<Data extends StorageObject = StorageObject> - implements Storage<Data> -{ - abstract get type(): StoreType | string - abstract get available(): boolean - abstract get<K extends keyof Data>(key: K): Data[K] | null - abstract set<K extends keyof Data>(key: K, value: Data[K] | null): void - abstract clear<K extends keyof Data>(key: K): void - /** - * By default a storage getAndSync will handle calls exactly as a normal get. - * getAndSync needs to be implemented for more complex storage types that might wrap several base storage systems (see UniversalStorage) - */ - getAndSync<K extends keyof Data>(key: K): Data[K] | null { - const val = this.get(key) - // legacy behavior, getAndSync can change the type of a value from number to string (AJS 1.0 stores numerical values as a number) - const coercedValue = (typeof val === 'number' ? val.toString() : val) as - | Data[K] - | null - return coercedValue - } + remove<K extends keyof Data>(key: K): void } export interface StoreTypeWithSettings<T extends StoreType = StoreType> { diff --git a/packages/browser/src/core/storage/universalStorage.ts b/packages/browser/src/core/storage/universalStorage.ts index 9e16bac38..41cebf926 100644 --- a/packages/browser/src/core/storage/universalStorage.ts +++ b/packages/browser/src/core/storage/universalStorage.ts @@ -1,45 +1,56 @@ -import { Storage, StorageObject } from './types' +import { Store, StorageObject } from './types' /** * Uses multiple storages in a priority list to get/set values in the order they are specified. */ -export class UniversalStorage<Data extends StorageObject = StorageObject> - implements Storage<Data> -{ - private stores: Storage[] +export class UniversalStorage<Data extends StorageObject = StorageObject> { + private stores: Store[] - constructor(stores: Storage[]) { - this.stores = stores.filter((s) => s.available) - } - - get available(): boolean { - return this.stores.some((s) => s.available) - } - - get type(): string { - return 'PriorityListStorage' + constructor(stores: Store[]) { + this.stores = stores } get<K extends keyof Data>(key: K): Data[K] | null { let val: Data[K] | null = null for (const store of this.stores) { - val = store.get(key) as Data[K] | null - if (val !== undefined && val !== null) { - return val + try { + val = store.get(key) as Data[K] | null + if (val !== undefined && val !== null) { + return val + } + } catch (e) { + console.warn(`Can't access ${key}: ${e}`) } } return null } set<K extends keyof Data>(key: K, value: Data[K] | null): void { - this.stores.forEach((s) => s.set(key, value)) + this.stores.forEach((s) => { + try { + s.set(key, value) + } catch (e) { + console.warn(`Can't set ${key}: ${e}`) + } + }) } clear<K extends keyof Data>(key: K): void { - this.stores.forEach((s) => s.clear(key)) + this.stores.forEach((s) => { + try { + s.remove(key) + } catch (e) { + console.warn(`Can't remove ${key}: ${e}`) + } + }) } + /* + This is to support few scenarios where: + - value exist in one of the stores ( as a result of other stores being cleared from browser ) and we want to resync them + - read values in AJS 1.0 format ( for customers after 1.0 --> 2.0 migration ) and then re-write them in AJS 2.0 format + */ getAndSync<K extends keyof Data>(key: K): Data[K] | null { const val = this.get(key) diff --git a/packages/browser/src/core/user/__tests__/index.test.ts b/packages/browser/src/core/user/__tests__/index.test.ts index 045c3cedd..f047ef4d6 100644 --- a/packages/browser/src/core/user/__tests__/index.test.ts +++ b/packages/browser/src/core/user/__tests__/index.test.ts @@ -1,12 +1,11 @@ import assert from 'assert' import jar from 'js-cookie' import { Group, User } from '..' -import { LocalStorage, StoreType, Storage } from '../../storage' +import { LocalStorage, StoreType, Store } from '../../storage' import { disableCookies, disableLocalStorage, } from '../../storage/__tests__/test-helpers' -import { MemoryStorage } from '../../storage/memoryStorage' function clear(): void { document.cookie.split(';').forEach(function (c) { @@ -149,9 +148,6 @@ describe('user', () => { it('should get an id from memory', () => { user.id('id') assert(user.id() === 'id') - - expect(jar.get(cookieKey)).toBeFalsy() - expect(store.get(cookieKey)).toBeFalsy() }) it('should get an id when not persisting', () => { @@ -197,9 +193,6 @@ describe('user', () => { it('should get an id from memory', () => { user.id('id') assert(user.id() === 'id') - - expect(jar.get(cookieKey)).toBeFalsy() - expect(store.get(cookieKey)).toBeFalsy() }) it('should be null by default', () => { @@ -340,7 +333,6 @@ describe('user', () => { it('should get an id from memory', () => { user.anonymousId('anon-id') assert(user.anonymousId() === 'anon-id') - expect(jar.get('ajs_anonymous_id')).toBeFalsy() }) }) @@ -731,7 +723,9 @@ describe('user', () => { jar.set('ajs_anonymous_id', expected) store.set('ajs_anonymous_id', 'localStorageValue') const user = new User({ - storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + storage: { + stores: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + }, }) expect(user.anonymousId()).toEqual(expected) }) @@ -743,7 +737,9 @@ describe('user', () => { disableCookies() store.set('ajs_anonymous_id', expected) const user = new User({ - storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + storage: { + stores: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + }, }) expect(user.anonymousId()).toEqual(expected) }) @@ -751,7 +747,9 @@ describe('user', () => { it('persist option overrides any custom storage', () => { const setCookieSpy = jest.spyOn(jar, 'set') const user = new User({ - storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + storage: { + stores: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + }, persist: false, }) user.id('id') @@ -765,7 +763,9 @@ describe('user', () => { it('disable option overrides any custom storage', () => { const setCookieSpy = jest.spyOn(jar, 'set') const user = new User({ - storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + storage: { + stores: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + }, disable: true, }) user.id('id') @@ -775,26 +775,6 @@ describe('user', () => { expect(store.get('ajs_user_id')).toBeFalsy() expect(setCookieSpy.mock.calls.length).toBe(0) }) - - it('can use a fully custom storage object', () => { - const customStore: Storage = { - get type() { - return 'something' - }, - get available() { - return true - }, - get: jest.fn().mockReturnValue('custom'), - set: jest.fn(), - clear: jest.fn(), - getAndSync: jest.fn().mockReturnValue('custom'), - } - - const user = new User({ storage: customStore }) - user.id('id') - expect(customStore.set).toHaveBeenCalled() - expect(user.id()).toBe('custom') - }) }) }) diff --git a/packages/browser/src/core/user/index.ts b/packages/browser/src/core/user/index.ts index b958ff350..cd9d86dc6 100644 --- a/packages/browser/src/core/user/index.ts +++ b/packages/browser/src/core/user/index.ts @@ -5,14 +5,12 @@ import { CookieOptions, UniversalStorage, MemoryStorage, - Storage, StorageObject, StorageSettings, StoreType, applyCookieOptions, initializeStorages, isArrayOfStoreType, - isStorageObject, } from '../storage' export type ID = string | null | undefined @@ -35,8 +33,8 @@ export interface UserOptions { } /** - * Storage system to use - * @example new MemoryStorage, [StoreType.Cookie, StoreType.Memory] + * Store priority + * @example stores: [StoreType.Cookie, StoreType.Memory] */ storage?: StorageSettings } @@ -60,7 +58,7 @@ export class User { private anonKey: string private cookieOptions?: CookieOptions - private legacyUserStore: Storage<{ + private legacyUserStore: UniversalStorage<{ [k: string]: | { id?: string @@ -68,11 +66,11 @@ export class User { } | string }> - private traitsStore: Storage<{ + private traitsStore: UniversalStorage<{ [k: string]: Traits }> - private identityStore: Storage<{ + private identityStore: UniversalStorage<{ [k: string]: string }> @@ -235,7 +233,7 @@ export class User { options: UserOptions, cookieOpts?: CookieOptions, filterStores?: (value: StoreType) => boolean - ): Storage<T> { + ): UniversalStorage<T> { let stores: StoreType[] = [ StoreType.LocalStorage, StoreType.Cookie, @@ -249,16 +247,13 @@ export class User { // If persistance is disabled we will always fallback to Memory Storage if (!options.persist) { - return new MemoryStorage<T>() + return new UniversalStorage<T>([new MemoryStorage<T>()]) } if (options.storage !== undefined && options.storage !== null) { - // If the user is sending its own storage implementation we will use that without any modifications - if (isStorageObject(options.storage)) { - return options.storage as Storage<T> - } else if (isArrayOfStoreType(options.storage)) { + if (isArrayOfStoreType(options.storage)) { // If the user only specified order of stores we will still apply filters and transformations e.g. not using localStorage if localStorageFallbackDisabled - stores = options.storage + stores = options.storage.stores } } From aa14d570a5402eb8c19e3b7ecd358666b125875b Mon Sep 17 00:00:00 2001 From: Oscar Bazaldua <511911+oscb@users.noreply.github.com> Date: Mon, 7 Aug 2023 10:37:37 -0700 Subject: [PATCH 4/5] chore: replace enum with const type --- packages/browser/src/core/storage/types.ts | 14 ++++++++------ .../browser/src/core/user/__tests__/index.test.ts | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/browser/src/core/storage/types.ts b/packages/browser/src/core/storage/types.ts index c2943ebe8..e64bc0f1a 100644 --- a/packages/browser/src/core/storage/types.ts +++ b/packages/browser/src/core/storage/types.ts @@ -1,15 +1,17 @@ import { CookieOptions } from './cookieStorage' +export const StoreType = { + Cookie: 'cookie', + LocalStorage: 'localStorage', + Memory: 'memory', +} as const + /** * Known Storage Types * * Convenience settings for storage systems that AJS includes support for */ -export enum StoreType { - Cookie = 'cookie', - LocalStorage = 'localStorage', - Memory = 'memory', -} +export type StoreType = typeof StoreType[keyof typeof StoreType] export type StorageObject = Record<string, unknown> @@ -41,7 +43,7 @@ export interface Store<Data extends StorageObject = StorageObject> { export interface StoreTypeWithSettings<T extends StoreType = StoreType> { name: T - settings?: T extends StoreType.Cookie ? CookieOptions : never + settings?: T extends 'cookie' ? CookieOptions : never } export type InitializeStorageArgs = (StoreTypeWithSettings | StoreType)[] diff --git a/packages/browser/src/core/user/__tests__/index.test.ts b/packages/browser/src/core/user/__tests__/index.test.ts index f047ef4d6..46048c1b2 100644 --- a/packages/browser/src/core/user/__tests__/index.test.ts +++ b/packages/browser/src/core/user/__tests__/index.test.ts @@ -1,7 +1,7 @@ import assert from 'assert' import jar from 'js-cookie' import { Group, User } from '..' -import { LocalStorage, StoreType, Store } from '../../storage' +import { LocalStorage, StoreType } from '../../storage' import { disableCookies, disableLocalStorage, From 8332060f49da5a7b69887d4a8fe5ea3a00970723 Mon Sep 17 00:00:00 2001 From: Oscar Bazaldua <511911+oscb@users.noreply.github.com> Date: Tue, 8 Aug 2023 10:27:31 -0700 Subject: [PATCH 5/5] fix: adding test for cookie getter override --- .../storage/__tests__/universalStorage.test.ts | 14 ++++++++++++++ .../src/core/user/__tests__/storage.test.ts | 15 --------------- 2 files changed, 14 insertions(+), 15 deletions(-) delete mode 100644 packages/browser/src/core/user/__tests__/storage.test.ts diff --git a/packages/browser/src/core/storage/__tests__/universalStorage.test.ts b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts index 93bdaffc9..3c813ebb9 100644 --- a/packages/browser/src/core/storage/__tests__/universalStorage.test.ts +++ b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts @@ -126,5 +126,19 @@ describe('UniversalStorage', function () { expect(jar.get('ajs_test_key')).toEqual('ð°') expect(us.get('ajs_test_key')).toEqual('ð°') }) + + it('handles cookie getter overrides gracefully', function () { + ;(document as any).__defineGetter__('cookie', function () { + return '' + }) + const us = new UniversalStorage([ + new LocalStorage(), + new CookieStorage(), + new MemoryStorage(), + ]) + us.set('ajs_test_key', 'ð°') + expect(getFromLS('ajs_test_key')).toEqual('ð°') + expect(us.get('ajs_test_key')).toEqual('ð°') + }) }) }) diff --git a/packages/browser/src/core/user/__tests__/storage.test.ts b/packages/browser/src/core/user/__tests__/storage.test.ts deleted file mode 100644 index 30100a30c..000000000 --- a/packages/browser/src/core/user/__tests__/storage.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Cookie } from '..' - -describe('Cookie storage', () => { - it('should report cookie storage available when cookies are accessible', () => { - expect(Cookie.available()).toBe(true) - }) - - it('should report cookie storage unavailable when cookies are not accessible', () => { - ;(document as any).__defineGetter__('cookie', function () { - return '' - }) - - expect(Cookie.available()).toBe(false) - }) -})