|
| 1 | +import * as fs from 'fs/promises'; |
| 2 | +import * as os from 'os'; |
| 3 | +import * as path from 'path'; |
| 4 | + |
| 5 | +import NodeCrypto from '../src/platform/NodeCrypto'; |
| 6 | +import NodeEncoding from '../src/platform/NodeEncoding'; |
| 7 | +import NodeInfo from '../src/platform/NodeInfo'; |
| 8 | +import { resetNodeStorage } from '../src/platform/NodeStorage'; |
| 9 | +import { createMockLogger } from './testHelpers'; |
| 10 | + |
| 11 | +jest.mock('../src/platform/NodePlatform', () => ({ |
| 12 | + __esModule: true, |
| 13 | + default: jest.fn(() => ({ |
| 14 | + crypto: new NodeCrypto(), |
| 15 | + info: new NodeInfo(), |
| 16 | + requests: { |
| 17 | + createEventSource: jest.fn(), |
| 18 | + fetch: jest.fn(), |
| 19 | + getEventSourceCapabilities: jest.fn(), |
| 20 | + }, |
| 21 | + encoding: new NodeEncoding(), |
| 22 | + storage: { |
| 23 | + clear: jest.fn(), |
| 24 | + get: jest.fn(), |
| 25 | + set: jest.fn(), |
| 26 | + }, |
| 27 | + })), |
| 28 | +})); |
| 29 | + |
| 30 | +// Tracks the mode last passed to the mock FDv2 data manager's setConnectionMode. |
| 31 | +// Seeded from opts.foregroundMode when createFDv2DataManagerBase is called. |
| 32 | +// Used only to verify delegation; NodeClient now tracks requested mode itself. |
| 33 | +let mockCurrentMode = 'streaming'; |
| 34 | + |
| 35 | +// Captures the opts passed to createFDv2DataManagerBase so tests can invoke |
| 36 | +// buildQueryParams directly. |
| 37 | +let capturedFDv2Opts: |
| 38 | + | { buildQueryParams: (opts?: { hash?: string }) => { key: string; value: string }[] } |
| 39 | + | undefined; |
| 40 | + |
| 41 | +const mockSetConnectionMode = jest.fn((mode: string) => { |
| 42 | + mockCurrentMode = mode; |
| 43 | +}); |
| 44 | + |
| 45 | +jest.mock('@launchdarkly/js-client-sdk-common', () => ({ |
| 46 | + ...jest.requireActual('@launchdarkly/js-client-sdk-common'), |
| 47 | + createFDv2DataManagerBase: jest.fn((opts: any) => { |
| 48 | + capturedFDv2Opts = opts; |
| 49 | + mockCurrentMode = opts.foregroundMode ?? 'streaming'; |
| 50 | + return { |
| 51 | + identify: jest.fn(), |
| 52 | + close: jest.fn(), |
| 53 | + setConnectionMode: mockSetConnectionMode, |
| 54 | + getCurrentMode: () => mockCurrentMode, |
| 55 | + }; |
| 56 | + }), |
| 57 | +})); |
| 58 | + |
| 59 | +import { createClient } from '../src'; |
| 60 | + |
| 61 | +const DEFAULT_INITIAL_CONTEXT = { kind: 'user' as const, key: 'bob' }; |
| 62 | +let tmpRoot: string; |
| 63 | +let logger: ReturnType<typeof createMockLogger>; |
| 64 | + |
| 65 | +beforeAll(() => { |
| 66 | + jest.useFakeTimers(); |
| 67 | +}); |
| 68 | + |
| 69 | +afterAll(() => { |
| 70 | + jest.useRealTimers(); |
| 71 | +}); |
| 72 | + |
| 73 | +beforeEach(async () => { |
| 74 | + jest.clearAllMocks(); |
| 75 | + capturedFDv2Opts = undefined; |
| 76 | + mockCurrentMode = 'streaming'; |
| 77 | + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'node-client-fdv2-test-')); |
| 78 | + resetNodeStorage(); |
| 79 | + logger = createMockLogger(); |
| 80 | +}); |
| 81 | + |
| 82 | +afterEach(async () => { |
| 83 | + await fs.rm(tmpRoot, { recursive: true, force: true }); |
| 84 | +}); |
| 85 | + |
| 86 | +it('setConnectionMode does not throw in FDv2 mode', async () => { |
| 87 | + const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { |
| 88 | + dataSystem: {}, |
| 89 | + diagnosticOptOut: true, |
| 90 | + sendEvents: false, |
| 91 | + logger, |
| 92 | + localStoragePath: tmpRoot, |
| 93 | + }); |
| 94 | + await expect(client.setConnectionMode('offline')).resolves.toBeUndefined(); |
| 95 | +}); |
| 96 | + |
| 97 | +it('getConnectionMode returns the mode set via setConnectionMode in FDv2 mode', async () => { |
| 98 | + const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { |
| 99 | + dataSystem: {}, |
| 100 | + diagnosticOptOut: true, |
| 101 | + sendEvents: false, |
| 102 | + logger, |
| 103 | + localStoragePath: tmpRoot, |
| 104 | + }); |
| 105 | + await client.setConnectionMode('offline'); |
| 106 | + expect(client.getConnectionMode()).toBe('offline'); |
| 107 | + expect(client.isOffline()).toBe(true); |
| 108 | +}); |
| 109 | + |
| 110 | +it('isOffline returns false after switching back to streaming in FDv2 mode', async () => { |
| 111 | + const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { |
| 112 | + dataSystem: {}, |
| 113 | + diagnosticOptOut: true, |
| 114 | + sendEvents: false, |
| 115 | + logger, |
| 116 | + localStoragePath: tmpRoot, |
| 117 | + }); |
| 118 | + await client.setConnectionMode('offline'); |
| 119 | + await client.setConnectionMode('streaming'); |
| 120 | + expect(client.getConnectionMode()).toBe('streaming'); |
| 121 | + expect(client.isOffline()).toBe(false); |
| 122 | +}); |
| 123 | + |
| 124 | +it('setConnectionMode delegates to the FDv2 data manager', async () => { |
| 125 | + const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { |
| 126 | + dataSystem: {}, |
| 127 | + diagnosticOptOut: true, |
| 128 | + sendEvents: false, |
| 129 | + logger, |
| 130 | + localStoragePath: tmpRoot, |
| 131 | + }); |
| 132 | + await client.setConnectionMode('offline'); |
| 133 | + expect(mockSetConnectionMode).toHaveBeenCalledWith('offline'); |
| 134 | +}); |
| 135 | + |
| 136 | +// ------ FDv2 initial mode reconciliation ------ |
| 137 | + |
| 138 | +it('isOffline returns true initially when FDv2 dataSystem configures offline mode', () => { |
| 139 | + const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { |
| 140 | + dataSystem: { automaticModeSwitching: { type: 'manual', initialConnectionMode: 'offline' } }, |
| 141 | + diagnosticOptOut: true, |
| 142 | + sendEvents: false, |
| 143 | + logger, |
| 144 | + localStoragePath: tmpRoot, |
| 145 | + }); |
| 146 | + expect(client.isOffline()).toBe(true); |
| 147 | + expect(client.getConnectionMode()).toBe('offline'); |
| 148 | +}); |
| 149 | + |
| 150 | +it('getConnectionMode reflects FDv2 manual polling mode at construction', () => { |
| 151 | + const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { |
| 152 | + dataSystem: { automaticModeSwitching: { type: 'manual', initialConnectionMode: 'polling' } }, |
| 153 | + diagnosticOptOut: true, |
| 154 | + sendEvents: false, |
| 155 | + logger, |
| 156 | + localStoragePath: tmpRoot, |
| 157 | + }); |
| 158 | + expect(client.getConnectionMode()).toBe('polling'); |
| 159 | + expect(client.isOffline()).toBe(false); |
| 160 | +}); |
| 161 | + |
| 162 | +// ------ buildQueryParams coverage ------ |
| 163 | + |
| 164 | +it('buildQueryParams returns [] in FDv2 mobile key mode', () => { |
| 165 | + createClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { |
| 166 | + useMobileKey: true, |
| 167 | + dataSystem: {}, |
| 168 | + diagnosticOptOut: true, |
| 169 | + sendEvents: false, |
| 170 | + logger, |
| 171 | + localStoragePath: tmpRoot, |
| 172 | + }); |
| 173 | + expect(capturedFDv2Opts).toBeDefined(); |
| 174 | + expect(capturedFDv2Opts!.buildQueryParams()).toEqual([]); |
| 175 | +}); |
| 176 | + |
| 177 | +it('buildQueryParams ignores and warns when per-identify hash is set in FDv2 mobile key mode', () => { |
| 178 | + createClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { |
| 179 | + useMobileKey: true, |
| 180 | + dataSystem: {}, |
| 181 | + diagnosticOptOut: true, |
| 182 | + sendEvents: false, |
| 183 | + logger, |
| 184 | + localStoragePath: tmpRoot, |
| 185 | + }); |
| 186 | + expect(capturedFDv2Opts).toBeDefined(); |
| 187 | + expect(capturedFDv2Opts!.buildQueryParams({ hash: 'should-not-leak' })).toEqual([]); |
| 188 | + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('hash')); |
| 189 | +}); |
| 190 | + |
| 191 | +it('buildQueryParams returns auth param in FDv2 client-side ID mode', () => { |
| 192 | + createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { |
| 193 | + dataSystem: {}, |
| 194 | + diagnosticOptOut: true, |
| 195 | + sendEvents: false, |
| 196 | + logger, |
| 197 | + localStoragePath: tmpRoot, |
| 198 | + }); |
| 199 | + expect(capturedFDv2Opts).toBeDefined(); |
| 200 | + expect(capturedFDv2Opts!.buildQueryParams()).toEqual([{ key: 'auth', value: 'client-side-id' }]); |
| 201 | +}); |
| 202 | + |
| 203 | +it('buildQueryParams includes hash param when hash is set in client-side ID mode', () => { |
| 204 | + createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { |
| 205 | + dataSystem: {}, |
| 206 | + hash: 'secure-hash-abc', |
| 207 | + diagnosticOptOut: true, |
| 208 | + sendEvents: false, |
| 209 | + logger, |
| 210 | + localStoragePath: tmpRoot, |
| 211 | + }); |
| 212 | + expect(capturedFDv2Opts).toBeDefined(); |
| 213 | + expect(capturedFDv2Opts!.buildQueryParams()).toEqual([ |
| 214 | + { key: 'auth', value: 'client-side-id' }, |
| 215 | + { key: 'h', value: 'secure-hash-abc' }, |
| 216 | + ]); |
| 217 | +}); |
| 218 | + |
| 219 | +it('buildQueryParams per-identify hash overrides construction-time hash in FDv2', () => { |
| 220 | + createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { |
| 221 | + dataSystem: {}, |
| 222 | + hash: 'construction-hash', |
| 223 | + diagnosticOptOut: true, |
| 224 | + sendEvents: false, |
| 225 | + logger, |
| 226 | + localStoragePath: tmpRoot, |
| 227 | + }); |
| 228 | + expect(capturedFDv2Opts).toBeDefined(); |
| 229 | + expect(capturedFDv2Opts!.buildQueryParams({ hash: 'per-identify-hash' })).toEqual([ |
| 230 | + { key: 'auth', value: 'client-side-id' }, |
| 231 | + { key: 'h', value: 'per-identify-hash' }, |
| 232 | + ]); |
| 233 | +}); |
| 234 | + |
| 235 | +it('buildQueryParams uses construction-time hash when no per-identify hash is provided in FDv2', () => { |
| 236 | + createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { |
| 237 | + dataSystem: {}, |
| 238 | + hash: 'construction-hash', |
| 239 | + diagnosticOptOut: true, |
| 240 | + sendEvents: false, |
| 241 | + logger, |
| 242 | + localStoragePath: tmpRoot, |
| 243 | + }); |
| 244 | + expect(capturedFDv2Opts).toBeDefined(); |
| 245 | + expect(capturedFDv2Opts!.buildQueryParams({})).toEqual([ |
| 246 | + { key: 'auth', value: 'client-side-id' }, |
| 247 | + { key: 'h', value: 'construction-hash' }, |
| 248 | + ]); |
| 249 | +}); |
| 250 | + |
| 251 | +// ------ Concurrent setConnectionMode serialization ------ |
| 252 | + |
| 253 | +it('concurrent setConnectionMode calls are serialized in call order', async () => { |
| 254 | + const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { |
| 255 | + dataSystem: {}, |
| 256 | + diagnosticOptOut: true, |
| 257 | + sendEvents: false, |
| 258 | + logger, |
| 259 | + localStoragePath: tmpRoot, |
| 260 | + }); |
| 261 | + // Fire offline then streaming concurrently. Without serialization the offline |
| 262 | + // branch's await flush() lets streaming complete first, reversing the order. |
| 263 | + const p1 = client.setConnectionMode('offline'); |
| 264 | + const p2 = client.setConnectionMode('streaming'); |
| 265 | + await Promise.all([p1, p2]); |
| 266 | + // Serialization ensures the calls execute in issue order: offline then streaming. |
| 267 | + expect(mockSetConnectionMode.mock.calls).toEqual([['offline'], ['streaming']]); |
| 268 | + expect(client.getConnectionMode()).toBe('streaming'); |
| 269 | + expect(client.isOffline()).toBe(false); |
| 270 | +}); |
| 271 | + |
| 272 | +// ------ Idempotency ------ |
| 273 | + |
| 274 | +it('setConnectionMode is a no-op when called with the current mode in FDv2', async () => { |
| 275 | + const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { |
| 276 | + dataSystem: {}, |
| 277 | + diagnosticOptOut: true, |
| 278 | + sendEvents: false, |
| 279 | + logger, |
| 280 | + localStoragePath: tmpRoot, |
| 281 | + }); |
| 282 | + // Initial mode is streaming; calling streaming again should be a no-op. |
| 283 | + await client.setConnectionMode('streaming'); |
| 284 | + expect(mockSetConnectionMode).not.toHaveBeenCalled(); |
| 285 | + expect(client.getConnectionMode()).toBe('streaming'); |
| 286 | +}); |
| 287 | + |
| 288 | +// ------ Post-close guard ------ |
| 289 | + |
| 290 | +it('setConnectionMode after close is a no-op in FDv2', async () => { |
| 291 | + const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { |
| 292 | + dataSystem: {}, |
| 293 | + diagnosticOptOut: true, |
| 294 | + sendEvents: false, |
| 295 | + logger, |
| 296 | + localStoragePath: tmpRoot, |
| 297 | + }); |
| 298 | + await client.close(); |
| 299 | + await client.setConnectionMode('offline'); |
| 300 | + expect(mockSetConnectionMode).not.toHaveBeenCalled(); |
| 301 | +}); |
| 302 | + |
| 303 | +// ------ Invalid mode validation ------ |
| 304 | + |
| 305 | +it('setConnectionMode with an invalid mode logs a warning and does not delegate in FDv2', async () => { |
| 306 | + const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { |
| 307 | + dataSystem: {}, |
| 308 | + diagnosticOptOut: true, |
| 309 | + sendEvents: false, |
| 310 | + logger, |
| 311 | + localStoragePath: tmpRoot, |
| 312 | + }); |
| 313 | + // @ts-ignore testing JS callers passing an invalid value |
| 314 | + await client.setConnectionMode('invalid-mode'); |
| 315 | + expect(mockSetConnectionMode).not.toHaveBeenCalled(); |
| 316 | + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('invalid-mode')); |
| 317 | +}); |
0 commit comments