1
+ /**
2
+ * @jest -environment jsdom
3
+ */
4
+
5
+ import * as LZString from 'lz-string' ;
6
+
1
7
import { LocalStorageEngine } from './local-storage-engine' ;
2
8
import { StorageFullUnableToWrite , LocalStorageUnknownFailure } from './string-valued.store' ;
3
9
4
10
describe ( 'LocalStorageEngine' , ( ) => {
5
- let mockLocalStorage : Storage ;
11
+ let mockLocalStorage : Storage & { _length : number } ;
6
12
let engine : LocalStorageEngine ;
7
13
8
14
beforeEach ( ( ) => {
@@ -17,16 +23,27 @@ describe('LocalStorageEngine', () => {
17
23
removeItem : jest . fn ( ) ,
18
24
setItem : jest . fn ( ) ,
19
25
} as Storage & { _length : number } ;
26
+
27
+ // Setup: migration already completed to avoid interference with basic functionality tests
28
+ ( mockLocalStorage . getItem as jest . Mock ) . mockImplementation ( ( key ) => {
29
+ if ( key === 'eppo-meta' ) return JSON . stringify ( { version : 1 , migratedAt : Date . now ( ) } ) ;
30
+ return null ;
31
+ } ) ;
32
+
20
33
engine = new LocalStorageEngine ( mockLocalStorage , 'test' ) ;
21
34
} ) ;
22
35
23
- describe ( 'setContentsJsonString' , ( ) => {
36
+ afterEach ( ( ) => {
37
+ jest . clearAllMocks ( ) ;
38
+ } ) ;
39
+
40
+ describe ( 'Basic Functionality' , ( ) => {
24
41
it ( 'should set item successfully when no error occurs' , async ( ) => {
25
42
await engine . setContentsJsonString ( 'test-config' ) ;
26
43
27
44
expect ( mockLocalStorage . setItem ) . toHaveBeenCalledWith (
28
45
'eppo-configuration-test' ,
29
- 'test-config' ,
46
+ LZString . compressToBase64 ( 'test-config' ) ,
30
47
) ;
31
48
} ) ;
32
49
@@ -59,7 +76,7 @@ describe('LocalStorageEngine', () => {
59
76
expect ( mockLocalStorage . setItem ) . toHaveBeenCalledTimes ( 2 ) ;
60
77
expect ( mockLocalStorage . setItem ) . toHaveBeenLastCalledWith (
61
78
'eppo-configuration-test' ,
62
- 'test-config' ,
79
+ LZString . compressToBase64 ( 'test-config' ) ,
63
80
) ;
64
81
} ) ;
65
82
@@ -156,4 +173,234 @@ describe('LocalStorageEngine', () => {
156
173
expect ( mockLocalStorage . removeItem ) . toHaveBeenCalledTimes ( 3 ) ;
157
174
} ) ;
158
175
} ) ;
176
+
177
+ describe ( 'Compression Migration' , ( ) => {
178
+ let migrationEngine : LocalStorageEngine ;
179
+
180
+ beforeEach ( ( ) => {
181
+ // Reset mocks for migration tests
182
+ jest . clearAllMocks ( ) ;
183
+ mockLocalStorage = {
184
+ get length ( ) {
185
+ return this . _length || 0 ;
186
+ } ,
187
+ _length : 0 ,
188
+ clear : jest . fn ( ) ,
189
+ getItem : jest . fn ( ) ,
190
+ key : jest . fn ( ) ,
191
+ removeItem : jest . fn ( ) ,
192
+ setItem : jest . fn ( ) ,
193
+ } as Storage & { _length : number } ;
194
+ } ) ;
195
+
196
+ describe ( 'Migration' , ( ) => {
197
+ it ( 'should run migration on first construction' , ( ) => {
198
+ // Setup: no global meta exists (first time)
199
+ ( mockLocalStorage . getItem as jest . Mock ) . mockImplementation ( ( key ) => {
200
+ if ( key === 'eppo-meta' ) return null ;
201
+ return null ;
202
+ } ) ;
203
+
204
+ // Mock localStorage.length and key() for iteration
205
+ ( mockLocalStorage as Storage & { _length : number } ) . _length = 3 ;
206
+ ( mockLocalStorage . key as jest . Mock ) . mockImplementation ( ( index ) => {
207
+ const keys = [ 'eppo-configuration-abc123' , 'eppo-configuration-meta-def456' , 'other-key' ] ;
208
+ return keys [ index ] || null ;
209
+ } ) ;
210
+
211
+ migrationEngine = new LocalStorageEngine ( mockLocalStorage , 'test' ) ;
212
+
213
+ // Should have removed configuration keys
214
+ expect ( mockLocalStorage . removeItem ) . toHaveBeenCalledWith ( 'eppo-configuration-abc123' ) ;
215
+ expect ( mockLocalStorage . removeItem ) . toHaveBeenCalledWith ( 'eppo-configuration-meta-def456' ) ;
216
+ expect ( mockLocalStorage . removeItem ) . not . toHaveBeenCalledWith ( 'other-key' ) ;
217
+
218
+ // Should have set global meta
219
+ expect ( mockLocalStorage . setItem ) . toHaveBeenCalledWith (
220
+ 'eppo-meta' ,
221
+ expect . stringContaining ( '"version":1' ) ,
222
+ ) ;
223
+ } ) ;
224
+
225
+ it ( 'should skip migration if already completed' , ( ) => {
226
+ // Setup: migration already done
227
+ ( mockLocalStorage . getItem as jest . Mock ) . mockImplementation ( ( key ) => {
228
+ if ( key === 'eppo-meta' ) return JSON . stringify ( { version : 1 , migratedAt : Date . now ( ) } ) ;
229
+ return null ;
230
+ } ) ;
231
+
232
+ migrationEngine = new LocalStorageEngine ( mockLocalStorage , 'test' ) ;
233
+
234
+ // Should not have removed any keys
235
+ expect ( mockLocalStorage . removeItem ) . not . toHaveBeenCalled ( ) ;
236
+ } ) ;
237
+
238
+ it ( 'should handle migration errors gracefully' , ( ) => {
239
+ // Setup: no global meta, but error during migration
240
+ ( mockLocalStorage . getItem as jest . Mock ) . mockImplementation ( ( key ) => {
241
+ if ( key === 'eppo-meta' ) return null ;
242
+ return null ;
243
+ } ) ;
244
+
245
+ // Make removeItem throw an error
246
+ ( mockLocalStorage . removeItem as jest . Mock ) . mockImplementation ( ( ) => {
247
+ throw new Error ( 'Storage error' ) ;
248
+ } ) ;
249
+
250
+ ( mockLocalStorage as Storage & { _length : number } ) . _length = 1 ;
251
+ ( mockLocalStorage . key as jest . Mock ) . mockReturnValue ( 'eppo-configuration-test' ) ;
252
+
253
+ // Should not throw error, just continue silently
254
+ expect ( ( ) => new LocalStorageEngine ( mockLocalStorage , 'test' ) ) . not . toThrow ( ) ;
255
+ } ) ;
256
+ } ) ;
257
+
258
+ describe ( 'Compression' , ( ) => {
259
+ beforeEach ( ( ) => {
260
+ // Setup: migration already completed
261
+ ( mockLocalStorage . getItem as jest . Mock ) . mockImplementation ( ( key ) => {
262
+ if ( key === 'eppo-meta' ) return JSON . stringify ( { version : 1 , migratedAt : Date . now ( ) } ) ;
263
+ return null ;
264
+ } ) ;
265
+
266
+ migrationEngine = new LocalStorageEngine ( mockLocalStorage , 'test' ) ;
267
+ } ) ;
268
+
269
+ it ( 'should compress data when storing' , async ( ) => {
270
+ const testData = JSON . stringify ( { flag : 'test-flag' , value : 'test-value' } ) ;
271
+
272
+ await migrationEngine . setContentsJsonString ( testData ) ;
273
+
274
+ expect ( mockLocalStorage . setItem ) . toHaveBeenCalledWith (
275
+ 'eppo-configuration-test' ,
276
+ LZString . compressToBase64 ( testData ) ,
277
+ ) ;
278
+ } ) ;
279
+
280
+ it ( 'should decompress data when reading' , async ( ) => {
281
+ const testData = JSON . stringify ( { flag : 'test-flag' , value : 'test-value' } ) ;
282
+ const compressedData = LZString . compressToBase64 ( testData ) ;
283
+
284
+ ( mockLocalStorage . getItem as jest . Mock ) . mockImplementation ( ( key ) => {
285
+ if ( key === 'eppo-meta' ) return JSON . stringify ( { version : 1 , migratedAt : Date . now ( ) } ) ;
286
+ if ( key === 'eppo-configuration-test' ) return compressedData ;
287
+ return null ;
288
+ } ) ;
289
+
290
+ const result = await migrationEngine . getContentsJsonString ( ) ;
291
+
292
+ expect ( result ) . toBe ( testData ) ;
293
+ } ) ;
294
+
295
+ it ( 'should handle decompression errors gracefully' , async ( ) => {
296
+ // Mock LZString.decompress to throw an error
297
+ const decompressSpy = jest
298
+ . spyOn ( LZString , 'decompressFromBase64' )
299
+ . mockImplementation ( ( ) => {
300
+ throw new Error ( 'Decompression failed' ) ;
301
+ } ) ;
302
+
303
+ ( mockLocalStorage . getItem as jest . Mock ) . mockImplementation ( ( key ) => {
304
+ if ( key === 'eppo-meta' ) return JSON . stringify ( { version : 1 , migratedAt : Date . now ( ) } ) ;
305
+ if ( key === 'eppo-configuration-test' ) return 'corrupted-data' ;
306
+ return null ;
307
+ } ) ;
308
+
309
+ const result = await migrationEngine . getContentsJsonString ( ) ;
310
+
311
+ expect ( result ) . toBe ( null ) ;
312
+ expect ( mockLocalStorage . removeItem ) . toHaveBeenCalledWith ( 'eppo-configuration-test' ) ;
313
+
314
+ decompressSpy . mockRestore ( ) ;
315
+ } ) ;
316
+
317
+ it ( 'should store and retrieve meta data without compression' , async ( ) => {
318
+ const metaData = JSON . stringify ( { lastUpdated : Date . now ( ) } ) ;
319
+
320
+ await migrationEngine . setMetaJsonString ( metaData ) ;
321
+
322
+ // Meta data should be stored uncompressed
323
+ expect ( mockLocalStorage . setItem ) . toHaveBeenCalledWith (
324
+ 'eppo-configuration-meta-test' ,
325
+ metaData ,
326
+ ) ;
327
+
328
+ // Test reading back
329
+ ( mockLocalStorage . getItem as jest . Mock ) . mockImplementation ( ( key ) => {
330
+ if ( key === 'eppo-meta' ) return JSON . stringify ( { version : 1 , migratedAt : Date . now ( ) } ) ;
331
+ if ( key === 'eppo-configuration-meta-test' ) return metaData ;
332
+ return null ;
333
+ } ) ;
334
+
335
+ const result = await migrationEngine . getMetaJsonString ( ) ;
336
+ expect ( result ) . toBe ( metaData ) ;
337
+ } ) ;
338
+
339
+ it ( 'should return null for non-existent data' , async ( ) => {
340
+ ( mockLocalStorage . getItem as jest . Mock ) . mockImplementation ( ( key ) => {
341
+ if ( key === 'eppo-meta' ) return JSON . stringify ( { version : 1 , migratedAt : Date . now ( ) } ) ;
342
+ return null ;
343
+ } ) ;
344
+
345
+ const contentsResult = await migrationEngine . getContentsJsonString ( ) ;
346
+ const metaResult = await migrationEngine . getMetaJsonString ( ) ;
347
+
348
+ expect ( contentsResult ) . toBe ( null ) ;
349
+ expect ( metaResult ) . toBe ( null ) ;
350
+ } ) ;
351
+ } ) ;
352
+
353
+ describe ( 'Global Meta Management' , ( ) => {
354
+ it ( 'should parse valid global meta' , ( ) => {
355
+ const validMeta = { version : 1 , migratedAt : Date . now ( ) } ;
356
+ ( mockLocalStorage . getItem as jest . Mock ) . mockImplementation ( ( key ) => {
357
+ if ( key === 'eppo-meta' ) return JSON . stringify ( validMeta ) ;
358
+ return null ;
359
+ } ) ;
360
+
361
+ new LocalStorageEngine ( mockLocalStorage , 'test' ) ;
362
+
363
+ expect ( mockLocalStorage . getItem ) . toHaveBeenCalledWith ( 'eppo-meta' ) ;
364
+ } ) ;
365
+
366
+ it ( 'should handle corrupted global meta' , ( ) => {
367
+ ( mockLocalStorage . getItem as jest . Mock ) . mockImplementation ( ( key ) => {
368
+ if ( key === 'eppo-meta' ) return 'invalid-json' ;
369
+ return null ;
370
+ } ) ;
371
+
372
+ ( mockLocalStorage as Storage & { _length : number } ) . _length = 0 ;
373
+
374
+ // Should not throw error, just continue silently with default version
375
+ expect ( ( ) => new LocalStorageEngine ( mockLocalStorage , 'test' ) ) . not . toThrow ( ) ;
376
+ } ) ;
377
+ } ) ;
378
+
379
+ describe ( 'Space Optimization' , ( ) => {
380
+ it ( 'should actually compress large configuration data' , ( ) => {
381
+ // Create a large configuration object with repetitive data
382
+ const largeConfig = {
383
+ flags : Array . from ( { length : 100 } , ( _ , i ) => ( {
384
+ flagKey : `test-flag-${ i } ` ,
385
+ variationType : 'STRING' ,
386
+ allocations : [
387
+ { key : 'control' , value : 'control-value' } ,
388
+ { key : 'treatment' , value : 'treatment-value' } ,
389
+ ] ,
390
+ rules : [ { conditions : [ { attribute : 'userId' , operator : 'MATCHES' , value : '.*' } ] } ] ,
391
+ } ) ) ,
392
+ } ;
393
+
394
+ const originalJson = JSON . stringify ( largeConfig ) ;
395
+ const compressedData = LZString . compressToBase64 ( originalJson ) ;
396
+
397
+ // Verify compression actually reduces size
398
+ expect ( compressedData . length ) . toBeLessThan ( originalJson . length ) ;
399
+
400
+ // Verify compression ratio is reasonable (should be significant for repetitive JSON)
401
+ const compressionRatio = compressedData . length / originalJson . length ;
402
+ expect ( compressionRatio ) . toBeLessThan ( 0.5 ) ; // At least 50% compression
403
+ } ) ;
404
+ } ) ;
405
+ } ) ;
159
406
} ) ;
0 commit comments