Skip to content

Commit 2d65bc6

Browse files
authored
Update persistence of generated keys for transient contexts. (#60)
1 parent ef919f0 commit 2d65bc6

7 files changed

+255
-134
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@babel/preset-env": "^7.6.3",
2828
"@babel/runtime": "7.6.3",
2929
"@rollup/plugin-replace": "^2.2.0",
30+
"@types/jest": "^27.4.1",
3031
"babel-eslint": "^10.1.0",
3132
"babel-jest": "^25.1.0",
3233
"cross-env": "^5.1.4",

src/TransientContextProcessor.js

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
const { v1: uuidv1 } = require('uuid');
2+
const { getContextKinds } = require('./context');
3+
4+
const errors = require('./errors');
5+
const messages = require('./messages');
6+
const utils = require('./utils');
7+
8+
const ldUserIdKey = 'ld:$anonUserId';
9+
10+
/**
11+
* Create an object which can process a context and populate any required keys
12+
* for transient objects.
13+
*
14+
* @param {Object} persistentStorage The persistent storage from which to store
15+
* and access persisted transient context keys.
16+
* @returns A TransientContextProcessor.
17+
*/
18+
function TransientContextProcessor(persistentStorage) {
19+
function getContextKeyIdString(kind) {
20+
if (kind === undefined || kind === null || kind === 'user') {
21+
return ldUserIdKey;
22+
}
23+
return `ld:$contextKey:${kind}`;
24+
}
25+
26+
function getCachedContextKey(kind) {
27+
return persistentStorage.get(getContextKeyIdString(kind));
28+
}
29+
30+
function setCachedContextKey(id, kind) {
31+
return persistentStorage.set(getContextKeyIdString(kind), id);
32+
}
33+
34+
/**
35+
* Process a single kind context, or a single context within a multi-kind context.
36+
* @param {string} kind The kind of the context. Independent because the kind is not prevent
37+
* within a context in a multi-kind context.
38+
* @param {Object} context
39+
* @returns {Promise} a promise that resolves to a processed contexts, or rejects
40+
* a context which cannot be processed.
41+
*/
42+
function processSingleKindContext(kind, context) {
43+
// We are working on a copy of an original context, so we want to re-assign
44+
// versus duplicating it again.
45+
46+
/* eslint-disable no-param-reassign */
47+
if (context.key !== null && context.key !== undefined) {
48+
context.key = context.key.toString();
49+
return Promise.resolve(context);
50+
}
51+
52+
const transient = ((kind === undefined || kind === null) && context.anonymous) || (kind && context.transient);
53+
// If it has no kind, then it is a legacy style user and is transient if 'anonymous' is set.
54+
// If it has a kind, then the attribute would be 'transient'.
55+
56+
// The context did not have a key, so the context needs to be transient, if it
57+
// is not transient, then it is not valid.
58+
if (transient) {
59+
// If the key doesn't exist, then the persistent storage will resolve
60+
// with undefined.
61+
return getCachedContextKey(kind).then(cachedId => {
62+
if (cachedId) {
63+
context.key = cachedId;
64+
return context;
65+
} else {
66+
const id = uuidv1();
67+
context.key = id;
68+
return setCachedContextKey(id, kind).then(() => context);
69+
}
70+
});
71+
} else {
72+
return Promise.reject(new errors.LDInvalidUserError(messages.invalidUser()));
73+
}
74+
/* eslint-enable no-param-reassign */
75+
}
76+
77+
/**
78+
* Process the context, returning a Promise that resolves to the processed context, or rejects if there is an error.
79+
* @param {Object} context
80+
* @returns {Promise} A promise which resolves to a processed context, or a rejection if the context cannot be
81+
* processed. The context should still be checked for overall validity after being processed.
82+
*/
83+
this.processContext = context => {
84+
if (!context) {
85+
return Promise.reject(new errors.LDInvalidUserError(messages.userNotSpecified()));
86+
}
87+
88+
const processedContext = utils.clone(context);
89+
90+
if (context.kind === 'multi') {
91+
const kinds = getContextKinds(processedContext);
92+
93+
return Promise.all(kinds.map(kind => processSingleKindContext(kind, processedContext[kind]))).then(
94+
() => processedContext
95+
);
96+
}
97+
return processSingleKindContext(context.kind, processedContext);
98+
};
99+
}
100+
101+
module.exports = TransientContextProcessor;

src/UserValidator.js

-56
This file was deleted.

src/__tests__/LDClient-test.js

+6-3
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,11 @@ describe('LDClient', () => {
498498
});
499499
});
500500

501-
it('returns an error and does not update flags when identify is called with invalid user', async () => {
501+
it.each([
502+
{ country: 'US' }, // Legacy user with no key, and not anonymous.
503+
{ kind: 'user' }, // A single kind that is not transient and has no key.
504+
{ kind: 'multi', app: { transient: true }, org: {}, user: { key: 'yes' } }, // Multi kind with 1 non-transient context without a key.
505+
])('returns an error and does not update flags when identify is called with invalid contexts', async badContext => {
502506
const flags0 = { 'enable-foo': { value: false } };
503507
const flags1 = { 'enable-foo': { value: true } };
504508
await withServers(async (baseConfig, pollServer) => {
@@ -516,8 +520,7 @@ describe('LDClient', () => {
516520
expect(client.variation('enable-foo')).toBe(false);
517521
expect(pollServer.requests.length()).toEqual(1);
518522

519-
const userWithNoKey = { country: 'US' };
520-
await expect(client.identify(userWithNoKey)).rejects.toThrow();
523+
await expect(client.identify(badContext)).rejects.toThrow();
521524

522525
expect(client.variation('enable-foo')).toBe(false);
523526
expect(pollServer.requests.length()).toEqual(1);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import TransientContextProcessor from '../TransientContextProcessor';
2+
3+
describe('TransientContextProcessor', () => {
4+
let localStorage;
5+
let logger;
6+
let uv;
7+
8+
beforeEach(() => {
9+
localStorage = {};
10+
logger = {
11+
warn: jest.fn(),
12+
};
13+
uv = new TransientContextProcessor(localStorage, logger);
14+
});
15+
16+
it('rejects null user', async () => {
17+
await expect(uv.processContext(null)).rejects.toThrow();
18+
});
19+
20+
it('leaves user with string key unchanged', async () => {
21+
const u = { key: 'someone', name: 'me' };
22+
expect(await uv.processContext(u)).toEqual(u);
23+
});
24+
25+
it('stringifies non-string key', async () => {
26+
const u0 = { key: 123, name: 'me' };
27+
const u1 = { key: '123', name: 'me' };
28+
expect(await uv.processContext(u0)).toEqual(u1);
29+
});
30+
31+
it('uses cached key for anonymous user', async () => {
32+
const cachedKey = 'thing';
33+
let storageKey;
34+
localStorage.get = async key => {
35+
storageKey = key;
36+
return cachedKey;
37+
};
38+
const u = { anonymous: true };
39+
expect(await uv.processContext(u)).toEqual({ key: cachedKey, anonymous: true });
40+
expect(storageKey).toEqual('ld:$anonUserId');
41+
});
42+
43+
it('generates and stores key for anonymous user', async () => {
44+
let storageKey;
45+
let storedValue;
46+
localStorage.get = async () => null;
47+
localStorage.set = async (key, value) => {
48+
storageKey = key;
49+
storedValue = value;
50+
};
51+
const u0 = { anonymous: true };
52+
const u1 = await uv.processContext(u0);
53+
expect(storedValue).toEqual(expect.anything());
54+
expect(u1).toEqual({ key: storedValue, anonymous: true });
55+
expect(storageKey).toEqual('ld:$anonUserId');
56+
});
57+
58+
it('generates and stores a key for each transient context in a multi-kind context', async () => {
59+
const context = {
60+
kind: 'multi',
61+
user: { transient: true },
62+
org: { transient: true },
63+
app: { key: 'app' },
64+
};
65+
66+
const storage = {};
67+
localStorage.get = async key => storage[key];
68+
localStorage.set = async (key, value) => {
69+
storage[key] = value;
70+
};
71+
72+
const processed = await uv.processContext(context);
73+
expect(processed.user.key).toBeDefined();
74+
expect(processed.user.key).not.toEqual(processed.org.key);
75+
expect(processed.org.key).toBeDefined();
76+
expect(processed.app.key).toEqual('app');
77+
});
78+
79+
it('uses cached keys for context kinds that have already been generated', async () => {
80+
const context = {
81+
kind: 'multi',
82+
user: { transient: true },
83+
org: { transient: true },
84+
another: { transient: true },
85+
app: { key: 'app' },
86+
};
87+
88+
const storage = {
89+
'ld:$contextKey:org': 'cachedOrgKey',
90+
'ld:$anonUserId': 'cachedUserKey',
91+
};
92+
localStorage.get = async key => storage[key];
93+
localStorage.set = async (key, value) => {
94+
storage[key] = value;
95+
};
96+
97+
const processed = await uv.processContext(context);
98+
expect(processed.user.key).toEqual('cachedUserKey');
99+
expect(processed.org.key).toEqual('cachedOrgKey');
100+
expect(processed.another.key).toBeDefined();
101+
expect(processed.app.key).toEqual('app');
102+
});
103+
104+
it.each([{ anonymous: true }, { kind: 'user', transient: true }, { kind: 'multi', user: { transient: true } }])(
105+
'uses the same key to store any user context (legacy, single, multi)',
106+
async context => {
107+
const storage = {};
108+
localStorage.get = async key => expect(key).toEqual('ld:$anonUserId');
109+
localStorage.set = async (key, value) => {
110+
storage[key] = value;
111+
};
112+
await uv.processContext(context);
113+
}
114+
);
115+
});

src/__tests__/UserValidator-test.js

-57
This file was deleted.

0 commit comments

Comments
 (0)