Skip to content

Commit 35a20b7

Browse files
committed
Freshness accounts for all attributes of contexts.
1 parent 2e0a662 commit 35a20b7

6 files changed

Lines changed: 267 additions & 285 deletions

File tree

packages/shared/sdk-client/__tests__/datasource/FreshnessTracker.test.ts

Lines changed: 73 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,7 @@
1-
import { Context, Crypto, Hasher, Storage } from '@launchdarkly/js-sdk-common';
1+
import { Context, Crypto, Storage } from '@launchdarkly/js-sdk-common';
22

33
import { createFreshnessTracker } from '../../src/datasource/FreshnessTracker';
4-
5-
function makeMemoryStorage(): Storage {
6-
const data = new Map<string, string>();
7-
return {
8-
get: async (key: string) => {
9-
const value = data.get(key);
10-
return value !== undefined ? value : null;
11-
},
12-
set: async (key: string, value: string) => {
13-
data.set(key, value);
14-
},
15-
clear: async (key: string) => {
16-
data.delete(key);
17-
},
18-
};
19-
}
20-
21-
function makeMockCrypto(): Crypto {
22-
let lastInput = '';
23-
const hasher: Hasher = {
24-
update: jest.fn((input) => {
25-
lastInput = input;
26-
return hasher;
27-
}),
28-
digest: jest.fn(() => `${lastInput}Hashed`),
29-
};
30-
31-
return {
32-
createHash: jest.fn(() => hasher),
33-
createHmac: jest.fn(),
34-
randomUUID: jest.fn(() => 'test-uuid'),
35-
};
36-
}
4+
import { makeMemoryStorage, makeMockCrypto } from '../flag-manager/flagManagerTestHelpers';
375

386
const TEST_NAMESPACE = 'TestNamespace';
397

@@ -124,9 +92,9 @@ describe('FreshnessTracker', () => {
12492
});
12593

12694
it('handles corrupt freshness data gracefully', async () => {
127-
// Storage that always returns a non-numeric string
95+
// Storage that always returns non-JSON data
12896
const corruptStorage: Storage = {
129-
get: async () => 'not-a-number',
97+
get: async () => 'not valid json!!!',
13098
set: async () => {},
13199
clear: async () => {},
132100
};
@@ -161,7 +129,7 @@ describe('FreshnessTracker', () => {
161129
expect(delay).toBe(700);
162130
});
163131

164-
it('tracks freshness per context independently', async () => {
132+
it('tracks freshness per context key independently', async () => {
165133
const storage = makeMemoryStorage();
166134
let time = 1000;
167135
const tracker = createFreshnessTracker(storage, crypto, TEST_NAMESPACE, () => time);
@@ -186,4 +154,72 @@ describe('FreshnessTracker', () => {
186154
const delay = await tracker.getNextPollDelayMs(context, 60000);
187155
expect(delay).toBe(0);
188156
});
157+
158+
it('returns undefined when context attributes change for same key', async () => {
159+
const storage = makeMemoryStorage();
160+
const tracker = createFreshnessTracker(storage, crypto, TEST_NAMESPACE, () => 1000);
161+
162+
// Record freshness for a context with specific attributes
163+
const contextV1 = Context.fromLDContext({
164+
kind: 'user',
165+
key: 'test-user',
166+
name: 'Alice',
167+
email: 'alice@example.com',
168+
});
169+
await tracker.recordFreshness(contextV1);
170+
171+
// Same key, different attributes — should be treated as stale
172+
const contextV2 = Context.fromLDContext({
173+
kind: 'user',
174+
key: 'test-user',
175+
name: 'Alice Updated',
176+
email: 'alice-new@example.com',
177+
});
178+
const freshness = await tracker.getFreshness(contextV2);
179+
expect(freshness).toBeUndefined();
180+
});
181+
182+
it('returns freshness when context attributes match exactly', async () => {
183+
const storage = makeMemoryStorage();
184+
const tracker = createFreshnessTracker(storage, crypto, TEST_NAMESPACE, () => 5000);
185+
186+
const contextA = Context.fromLDContext({
187+
kind: 'user',
188+
key: 'test-user',
189+
name: 'Alice',
190+
});
191+
await tracker.recordFreshness(contextA);
192+
193+
// Identical context — freshness should be valid
194+
const contextB = Context.fromLDContext({
195+
kind: 'user',
196+
key: 'test-user',
197+
name: 'Alice',
198+
});
199+
const freshness = await tracker.getFreshness(contextB);
200+
expect(freshness).toBe(5000);
201+
});
202+
203+
it('returns 0 delay when attributes change (poll immediately)', async () => {
204+
const storage = makeMemoryStorage();
205+
let time = 1000;
206+
const tracker = createFreshnessTracker(storage, crypto, TEST_NAMESPACE, () => time);
207+
208+
const contextV1 = Context.fromLDContext({
209+
kind: 'user',
210+
key: 'test-user',
211+
country: 'US',
212+
});
213+
await tracker.recordFreshness(contextV1);
214+
215+
time = 1100;
216+
const contextV2 = Context.fromLDContext({
217+
kind: 'user',
218+
key: 'test-user',
219+
country: 'UK',
220+
});
221+
// Attributes changed → stale → poll immediately
222+
const delay = await tracker.getNextPollDelayMs(contextV2, 60000);
223+
expect(delay).toBe(0);
224+
});
189225
});

packages/shared/sdk-client/__tests__/datasource/fdv2/CacheInitializer.test.ts

Lines changed: 10 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,15 @@
1-
import { Context, Crypto, Hasher, Storage } from '@launchdarkly/js-sdk-common';
1+
import { Context, Crypto, Storage } from '@launchdarkly/js-sdk-common';
22

33
import { createCacheInitializerFactory } from '../../../src/datasource/fdv2/CacheInitializer';
44
import { Initializer } from '../../../src/datasource/fdv2/Initializer';
55
import { namespaceForContextData } from '../../../src/storage/namespaceUtils';
66
import { Flag, Flags } from '../../../src/types';
7-
8-
function makeMemoryStorage(): Storage {
9-
const data = new Map<string, string>();
10-
return {
11-
get: async (key: string) => {
12-
const value = data.get(key);
13-
return value !== undefined ? value : null;
14-
},
15-
set: async (key: string, value: string) => {
16-
data.set(key, value);
17-
},
18-
clear: async (key: string) => {
19-
data.delete(key);
20-
},
21-
};
22-
}
23-
24-
function makeMockCrypto(): Crypto {
25-
let lastInput = '';
26-
const hasher: Hasher = {
27-
update: jest.fn((input) => {
28-
lastInput = input;
29-
return hasher;
30-
}),
31-
digest: jest.fn(() => `${lastInput}Hashed`),
32-
};
33-
34-
return {
35-
createHash: jest.fn(() => hasher),
36-
createHmac: jest.fn(),
37-
randomUUID: jest.fn(() => 'test-uuid'),
38-
};
39-
}
40-
41-
function makeLogger() {
42-
return {
43-
error: jest.fn(),
44-
warn: jest.fn(),
45-
info: jest.fn(),
46-
debug: jest.fn(),
47-
};
48-
}
49-
50-
function makeMockFlag(version: number = 1, value: any = true): Flag {
51-
return {
52-
version,
53-
flagVersion: version,
54-
value,
55-
variation: 0,
56-
trackEvents: false,
57-
};
58-
}
7+
import {
8+
makeMemoryStorage,
9+
makeMockCrypto,
10+
makeMockFlag,
11+
makeMockLogger,
12+
} from '../../flag-manager/flagManagerTestHelpers';
5913

6014
const TEST_NAMESPACE = 'TestNamespace';
6115
const noSelector = () => undefined;
@@ -74,7 +28,7 @@ function createInitializer(
7428
storage: Storage | undefined,
7529
crypto: Crypto,
7630
context: Context,
77-
logger?: ReturnType<typeof makeLogger>,
31+
logger?: ReturnType<typeof makeMockLogger>,
7832
): Initializer {
7933
const factory = createCacheInitializerFactory({
8034
storage,
@@ -192,7 +146,7 @@ describe('CacheInitializer', () => {
192146
});
193147

194148
it('returns interrupted when storage is undefined', async () => {
195-
const logger = makeLogger();
149+
const logger = makeMockLogger();
196150
const initializer = createInitializer(undefined, crypto, context, logger);
197151
const result = await initializer.run();
198152

@@ -208,7 +162,7 @@ describe('CacheInitializer', () => {
208162
const storageKey = await namespaceForContextData(crypto, TEST_NAMESPACE, context);
209163
await storage.set(storageKey, 'not valid json!!!');
210164

211-
const logger = makeLogger();
165+
const logger = makeMockLogger();
212166
const initializer = createInitializer(storage, crypto, context, logger);
213167
const result = await initializer.run();
214168

packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts

Lines changed: 8 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,18 @@
1-
import { Context, Crypto, Hasher, LDLogger, Platform, Storage } from '@launchdarkly/js-sdk-common';
1+
import { Context, LDLogger, Platform } from '@launchdarkly/js-sdk-common';
22

33
import DefaultFlagManager from '../../src/flag-manager/FlagManager';
44
import { FlagsChangeCallback } from '../../src/flag-manager/FlagUpdater';
5-
import { ItemDescriptor } from '../../src/flag-manager/ItemDescriptor';
6-
import { Flag } from '../../src/types';
5+
import {
6+
makeMemoryStorage,
7+
makeMockCrypto,
8+
makeMockItemDescriptor,
9+
makeMockLogger,
10+
makeMockPlatform,
11+
} from './flagManagerTestHelpers';
712

813
const TEST_SDK_KEY = 'test-sdk-key';
914
const TEST_MAX_CACHED_CONTEXTS = 5;
1015

11-
function makeMockPlatform(storage: Storage, crypto: Crypto): Platform {
12-
return {
13-
storage,
14-
crypto,
15-
info: {
16-
platformData: jest.fn(),
17-
sdkData: jest.fn(),
18-
},
19-
requests: {
20-
fetch: jest.fn(),
21-
createEventSource: jest.fn(),
22-
getEventSourceCapabilities: jest.fn(),
23-
},
24-
};
25-
}
26-
27-
function makeMemoryStorage(): Storage {
28-
const data = new Map<string, string>();
29-
return {
30-
get: async (key: string) => {
31-
const value = data.get(key);
32-
return value !== undefined ? value : null;
33-
},
34-
set: async (key: string, value: string) => {
35-
data.set(key, value);
36-
},
37-
clear: async (key: string) => {
38-
data.delete(key);
39-
},
40-
};
41-
}
42-
43-
function makeMockCrypto() {
44-
let counter = 0;
45-
let lastInput = '';
46-
const hasher: Hasher = {
47-
update: jest.fn((input) => {
48-
lastInput = input;
49-
return hasher;
50-
}),
51-
digest: jest.fn(() => `${lastInput}Hashed`),
52-
};
53-
54-
return {
55-
createHash: jest.fn(() => hasher),
56-
createHmac: jest.fn(),
57-
randomUUID: jest.fn(() => {
58-
counter += 1;
59-
return `${counter}`;
60-
}),
61-
};
62-
}
63-
64-
function makeMockLogger(): LDLogger {
65-
return {
66-
error: jest.fn(),
67-
warn: jest.fn(),
68-
info: jest.fn(),
69-
debug: jest.fn(),
70-
};
71-
}
72-
73-
function makeMockFlag(version: number = 1, value: any = 'test-value'): Flag {
74-
return {
75-
version,
76-
flagVersion: version,
77-
value,
78-
variation: 0,
79-
trackEvents: false,
80-
};
81-
}
82-
83-
function makeMockItemDescriptor(version: number = 1, value: any = 'test-value'): ItemDescriptor {
84-
return {
85-
version,
86-
flag: makeMockFlag(version, value),
87-
};
88-
}
89-
9016
describe('FlagManager override tests', () => {
9117
let flagManager: DefaultFlagManager;
9218
let mockPlatform: Platform;

0 commit comments

Comments
 (0)