@savvagent/angular 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +484 -0
  4. package/coverage/base.css +224 -0
  5. package/coverage/block-navigation.js +87 -0
  6. package/coverage/favicon.png +0 -0
  7. package/coverage/index.html +131 -0
  8. package/coverage/lcov-report/base.css +224 -0
  9. package/coverage/lcov-report/block-navigation.js +87 -0
  10. package/coverage/lcov-report/favicon.png +0 -0
  11. package/coverage/lcov-report/index.html +131 -0
  12. package/coverage/lcov-report/module.ts.html +289 -0
  13. package/coverage/lcov-report/prettify.css +1 -0
  14. package/coverage/lcov-report/prettify.js +2 -0
  15. package/coverage/lcov-report/service.ts.html +1846 -0
  16. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  17. package/coverage/lcov-report/sorter.js +210 -0
  18. package/coverage/lcov.info +242 -0
  19. package/coverage/module.ts.html +289 -0
  20. package/coverage/prettify.css +1 -0
  21. package/coverage/prettify.js +2 -0
  22. package/coverage/service.ts.html +1846 -0
  23. package/coverage/sort-arrow-sprite.png +0 -0
  24. package/coverage/sorter.js +210 -0
  25. package/dist/README.md +484 -0
  26. package/dist/esm2022/index.mjs +15 -0
  27. package/dist/esm2022/module.mjs +75 -0
  28. package/dist/esm2022/savvagent-angular.mjs +5 -0
  29. package/dist/esm2022/service.mjs +473 -0
  30. package/dist/fesm2022/savvagent-angular.mjs +563 -0
  31. package/dist/fesm2022/savvagent-angular.mjs.map +1 -0
  32. package/dist/index.d.ts +13 -0
  33. package/dist/module.d.ts +57 -0
  34. package/dist/service.d.ts +319 -0
  35. package/jest.config.js +40 -0
  36. package/ng-package.json +8 -0
  37. package/package.json +73 -0
  38. package/setup-jest.ts +2 -0
  39. package/src/index.spec.ts +144 -0
  40. package/src/index.ts +38 -0
  41. package/src/module.spec.ts +283 -0
  42. package/src/module.ts +68 -0
  43. package/src/service.spec.ts +945 -0
  44. package/src/service.ts +587 -0
  45. package/test-utils/angular-core-mock.ts +28 -0
  46. package/test-utils/angular-testing-mock.ts +87 -0
  47. package/tsconfig.json +33 -0
  48. package/tsconfig.spec.json +11 -0
@@ -0,0 +1,945 @@
1
+ import { TestBed } from '@angular/core/testing';
2
+ import { SavvagentService, SAVVAGENT_CONFIG, SavvagentConfig } from './service';
3
+ import { FlagClient, FlagEvaluationResult } from '@savvagent/sdk';
4
+ import { firstValueFrom, take, toArray } from 'rxjs';
5
+
6
+ // Mock FlagClient
7
+ jest.mock('@savvagent/sdk', () => ({
8
+ FlagClient: jest.fn().mockImplementation(() => ({
9
+ evaluate: jest.fn(),
10
+ isEnabled: jest.fn(),
11
+ withFlag: jest.fn(),
12
+ trackError: jest.fn(),
13
+ setUserId: jest.fn(),
14
+ getUserId: jest.fn(),
15
+ getAnonymousId: jest.fn(),
16
+ setAnonymousId: jest.fn(),
17
+ setOverride: jest.fn(),
18
+ clearOverride: jest.fn(),
19
+ clearAllOverrides: jest.fn(),
20
+ hasOverride: jest.fn(),
21
+ getOverride: jest.fn(),
22
+ getOverrides: jest.fn(),
23
+ setOverrides: jest.fn(),
24
+ getAllFlags: jest.fn(),
25
+ getEnterpriseFlags: jest.fn(),
26
+ clearCache: jest.fn(),
27
+ isRealtimeConnected: jest.fn(),
28
+ close: jest.fn(),
29
+ subscribe: jest.fn().mockReturnValue(() => {}), // Always return unsubscribe function
30
+ onOverrideChange: jest.fn().mockReturnValue(() => {}), // Always return unsubscribe function
31
+ })),
32
+ }));
33
+
34
+ describe('SavvagentService', () => {
35
+ let service: SavvagentService;
36
+ let mockClient: jest.Mocked<FlagClient>;
37
+
38
+ const mockConfig: SavvagentConfig = {
39
+ config: {
40
+ apiKey: 'test_api_key',
41
+ baseUrl: 'https://api.test.com',
42
+ },
43
+ defaultContext: {
44
+ applicationId: 'test-app',
45
+ environment: 'test',
46
+ userId: 'user-123',
47
+ },
48
+ };
49
+
50
+ beforeEach(() => {
51
+ jest.clearAllMocks();
52
+ TestBed.configureTestingModule({
53
+ providers: [
54
+ SavvagentService,
55
+ {
56
+ provide: SAVVAGENT_CONFIG,
57
+ useValue: mockConfig,
58
+ },
59
+ ],
60
+ });
61
+ });
62
+
63
+ afterEach(() => {
64
+ service?.ngOnDestroy();
65
+ });
66
+
67
+ describe('Initialization', () => {
68
+ it('should be created', () => {
69
+ service = TestBed.inject(SavvagentService);
70
+ expect(service).toBeTruthy();
71
+ });
72
+
73
+ it('should initialize with config from injection token', () => {
74
+ service = TestBed.inject(SavvagentService);
75
+ expect(FlagClient).toHaveBeenCalledWith(mockConfig.config);
76
+ expect(service.isReady).toBe(true);
77
+ });
78
+
79
+ it('should not initialize without config', () => {
80
+ TestBed.resetTestingModule();
81
+ TestBed.configureTestingModule({
82
+ providers: [SavvagentService],
83
+ });
84
+ service = TestBed.inject(SavvagentService);
85
+ expect(service.isReady).toBe(false);
86
+ });
87
+
88
+ it('should allow manual initialization', () => {
89
+ TestBed.resetTestingModule();
90
+ TestBed.configureTestingModule({
91
+ providers: [SavvagentService],
92
+ });
93
+ service = TestBed.inject(SavvagentService);
94
+ service.initialize(mockConfig);
95
+ expect(service.isReady).toBe(true);
96
+ });
97
+
98
+ it('should warn when reinitializing', () => {
99
+ const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
100
+ service = TestBed.inject(SavvagentService);
101
+ service.initialize(mockConfig);
102
+ expect(consoleSpy).toHaveBeenCalledWith(
103
+ '[Savvagent] Client already initialized. Call close() first to reinitialize.'
104
+ );
105
+ consoleSpy.mockRestore();
106
+ });
107
+
108
+ it('should handle initialization errors', () => {
109
+ const error = new Error('Init error');
110
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
111
+ const onError = jest.fn();
112
+ (FlagClient as jest.Mock).mockImplementationOnce(() => {
113
+ throw error;
114
+ });
115
+
116
+ TestBed.resetTestingModule();
117
+ TestBed.configureTestingModule({
118
+ providers: [
119
+ SavvagentService,
120
+ {
121
+ provide: SAVVAGENT_CONFIG,
122
+ useValue: {
123
+ config: { apiKey: 'test', onError },
124
+ },
125
+ },
126
+ ],
127
+ });
128
+ service = TestBed.inject(SavvagentService);
129
+
130
+ expect(consoleSpy).toHaveBeenCalledWith('[Savvagent] Failed to initialize client:', error);
131
+ expect(onError).toHaveBeenCalledWith(error);
132
+ consoleSpy.mockRestore();
133
+ });
134
+
135
+ it('should set up override change listener', () => {
136
+ service = TestBed.inject(SavvagentService);
137
+ mockClient = (service as any).client;
138
+ expect(mockClient.onOverrideChange).toHaveBeenCalled();
139
+ });
140
+ });
141
+
142
+ describe('Ready State', () => {
143
+ beforeEach(() => {
144
+ service = TestBed.inject(SavvagentService);
145
+ mockClient = (service as any).client;
146
+ });
147
+
148
+ it('should emit ready$ observable', async () => {
149
+ const ready = await firstValueFrom(service.ready$);
150
+ expect(ready).toBe(true);
151
+ });
152
+
153
+ it('should return isReady getter', () => {
154
+ expect(service.isReady).toBe(true);
155
+ });
156
+
157
+ it('should provide flagClient getter', () => {
158
+ expect(service.flagClient).toBe(mockClient);
159
+ });
160
+ });
161
+
162
+ describe('Context Merging', () => {
163
+ beforeEach(() => {
164
+ service = TestBed.inject(SavvagentService);
165
+ mockClient = (service as any).client;
166
+ });
167
+
168
+ it('should convert camelCase defaultContext to snake_case', async () => {
169
+ mockClient.evaluate.mockResolvedValue({
170
+ key: 'test-flag',
171
+ value: true,
172
+ reason: 'evaluated',
173
+ });
174
+
175
+ await service.evaluate('test-flag');
176
+
177
+ expect(mockClient.evaluate).toHaveBeenCalledWith('test-flag', expect.objectContaining({
178
+ application_id: 'test-app',
179
+ environment: 'test',
180
+ user_id: 'user-123',
181
+ }));
182
+ });
183
+
184
+ it('should merge per-call context with default context', async () => {
185
+ mockClient.evaluate.mockResolvedValue({
186
+ key: 'test-flag',
187
+ value: true,
188
+ reason: 'evaluated',
189
+ });
190
+
191
+ await service.evaluate('test-flag', {
192
+ user_id: 'user-456',
193
+ attributes: { plan: 'pro' },
194
+ });
195
+
196
+ expect(mockClient.evaluate).toHaveBeenCalledWith('test-flag', {
197
+ application_id: 'test-app',
198
+ environment: 'test',
199
+ user_id: 'user-456',
200
+ organization_id: undefined,
201
+ anonymous_id: undefined,
202
+ session_id: undefined,
203
+ language: undefined,
204
+ attributes: { plan: 'pro' },
205
+ });
206
+ });
207
+
208
+ it('should merge attributes correctly', async () => {
209
+ TestBed.resetTestingModule();
210
+ TestBed.configureTestingModule({
211
+ providers: [
212
+ SavvagentService,
213
+ {
214
+ provide: SAVVAGENT_CONFIG,
215
+ useValue: {
216
+ config: { apiKey: 'test' },
217
+ defaultContext: {
218
+ attributes: { tier: 'basic', region: 'us' },
219
+ },
220
+ },
221
+ },
222
+ ],
223
+ });
224
+ service = TestBed.inject(SavvagentService);
225
+ mockClient = (service as any).client;
226
+ mockClient.evaluate.mockResolvedValue({
227
+ key: 'test-flag',
228
+ value: true,
229
+ reason: 'evaluated',
230
+ });
231
+
232
+ await service.evaluate('test-flag', {
233
+ attributes: { plan: 'pro', region: 'eu' },
234
+ });
235
+
236
+ expect(mockClient.evaluate).toHaveBeenCalledWith('test-flag', {
237
+ application_id: undefined,
238
+ environment: undefined,
239
+ user_id: undefined,
240
+ organization_id: undefined,
241
+ anonymous_id: undefined,
242
+ session_id: undefined,
243
+ language: undefined,
244
+ attributes: {
245
+ tier: 'basic',
246
+ region: 'eu', // should override
247
+ plan: 'pro',
248
+ },
249
+ });
250
+ });
251
+ });
252
+
253
+ describe('flag$ - Reactive Flag Evaluation', () => {
254
+ beforeEach(() => {
255
+ service = TestBed.inject(SavvagentService);
256
+ mockClient = (service as any).client;
257
+ });
258
+
259
+ it('should return observable with initial loading state', async () => {
260
+ mockClient.evaluate.mockImplementation(
261
+ () => new Promise((resolve) => setTimeout(resolve, 100))
262
+ );
263
+
264
+ const flag$ = service.flag$('test-flag');
265
+ const initialValue = await firstValueFrom(flag$);
266
+
267
+ expect(initialValue).toEqual({
268
+ value: false,
269
+ loading: true,
270
+ error: null,
271
+ result: null,
272
+ });
273
+ });
274
+
275
+ it('should evaluate flag and emit result', async () => {
276
+ const mockResult: FlagEvaluationResult = {
277
+ key: 'test-flag',
278
+ value: true,
279
+ reason: 'evaluated',
280
+ };
281
+ mockClient.evaluate.mockResolvedValue(mockResult);
282
+
283
+ const flag$ = service.flag$('test-flag');
284
+ const values = await firstValueFrom(flag$.pipe(take(2), toArray()));
285
+
286
+ expect(values[0].loading).toBe(true);
287
+ expect(values[1]).toEqual({
288
+ value: true,
289
+ loading: false,
290
+ error: null,
291
+ result: mockResult,
292
+ });
293
+ });
294
+
295
+ it('should use default value on error', async () => {
296
+ const error = new Error('Evaluation failed');
297
+ mockClient.evaluate.mockRejectedValue(error);
298
+
299
+ const flag$ = service.flag$('test-flag', { defaultValue: true });
300
+ const values = await firstValueFrom(flag$.pipe(take(2), toArray()));
301
+
302
+ expect(values[1]).toEqual({
303
+ value: true,
304
+ loading: false,
305
+ error,
306
+ result: null,
307
+ });
308
+ });
309
+
310
+ it('should return error when client not initialized', async () => {
311
+ TestBed.resetTestingModule();
312
+ TestBed.configureTestingModule({
313
+ providers: [SavvagentService],
314
+ });
315
+ service = TestBed.inject(SavvagentService);
316
+
317
+ const flag$ = service.flag$('test-flag');
318
+ const value = await firstValueFrom(flag$);
319
+
320
+ expect(value.error?.message).toBe('Savvagent client not initialized');
321
+ expect(value.value).toBe(false);
322
+ }, 10000);
323
+
324
+ it('should subscribe to real-time updates when realtime: true', async () => {
325
+ const unsubscribe = jest.fn();
326
+ mockClient.subscribe.mockReturnValue(unsubscribe);
327
+ mockClient.evaluate.mockResolvedValue({
328
+ key: 'test-flag',
329
+ value: true,
330
+ reason: 'evaluated',
331
+ });
332
+
333
+ const flag$ = service.flag$('test-flag', { realtime: true });
334
+ await firstValueFrom(flag$.pipe(take(2)));
335
+
336
+ expect(mockClient.subscribe).toHaveBeenCalledWith('test-flag', expect.any(Function));
337
+ });
338
+
339
+ it('should not subscribe to real-time updates when realtime: false', async () => {
340
+ mockClient.evaluate.mockResolvedValue({
341
+ key: 'test-flag',
342
+ value: true,
343
+ reason: 'evaluated',
344
+ });
345
+
346
+ const flag$ = service.flag$('test-flag', { realtime: false });
347
+ await firstValueFrom(flag$.pipe(take(2)));
348
+
349
+ expect(mockClient.subscribe).not.toHaveBeenCalled();
350
+ });
351
+
352
+ it('should deduplicate identical flag results', async () => {
353
+ mockClient.evaluate.mockResolvedValue({
354
+ key: 'test-flag',
355
+ value: true,
356
+ reason: 'evaluated',
357
+ });
358
+
359
+ const flag$ = service.flag$('test-flag');
360
+ const values: any[] = [];
361
+
362
+ const subscription = flag$.subscribe((value) => values.push(value));
363
+
364
+ // Wait for initial emission
365
+ await new Promise((resolve) => setTimeout(resolve, 50));
366
+
367
+ subscription.unsubscribe();
368
+
369
+ // Should only emit once after loading (not duplicate true values)
370
+ const nonLoadingValues = values.filter((v) => !v.loading);
371
+ expect(nonLoadingValues.length).toBe(1);
372
+ });
373
+
374
+ it('should reuse subject for same flag+context combination', () => {
375
+ mockClient.evaluate.mockResolvedValue({
376
+ key: 'test-flag',
377
+ value: true,
378
+ reason: 'evaluated',
379
+ });
380
+
381
+ service.flag$('test-flag', { context: { user_id: 'user-1' } });
382
+ service.flag$('test-flag', { context: { user_id: 'user-1' } });
383
+
384
+ expect(mockClient.evaluate).toHaveBeenCalledTimes(1);
385
+ });
386
+
387
+ it('should create separate subjects for different contexts', () => {
388
+ mockClient.evaluate.mockResolvedValue({
389
+ key: 'test-flag',
390
+ value: true,
391
+ reason: 'evaluated',
392
+ });
393
+
394
+ service.flag$('test-flag', { context: { user_id: 'user-1' } });
395
+ service.flag$('test-flag', { context: { user_id: 'user-2' } });
396
+
397
+ expect(mockClient.evaluate).toHaveBeenCalledTimes(2);
398
+ });
399
+ });
400
+
401
+ describe('flagValue$ - Simple Boolean Observable', () => {
402
+ beforeEach(() => {
403
+ service = TestBed.inject(SavvagentService);
404
+ mockClient = (service as any).client;
405
+ });
406
+
407
+ it('should return boolean observable', async () => {
408
+ mockClient.evaluate.mockResolvedValue({
409
+ key: 'test-flag',
410
+ value: true,
411
+ reason: 'evaluated',
412
+ });
413
+
414
+ const flagValue$ = service.flagValue$('test-flag');
415
+ const values = await firstValueFrom(flagValue$.pipe(take(2), toArray()));
416
+
417
+ expect(values).toEqual([false, true]);
418
+ });
419
+
420
+ it('should deduplicate boolean values', async () => {
421
+ mockClient.evaluate.mockResolvedValue({
422
+ key: 'test-flag',
423
+ value: true,
424
+ reason: 'evaluated',
425
+ });
426
+
427
+ const flagValue$ = service.flagValue$('test-flag');
428
+ const values: boolean[] = [];
429
+
430
+ const subscription = flagValue$.subscribe((value) => values.push(value));
431
+
432
+ await new Promise((resolve) => setTimeout(resolve, 50));
433
+
434
+ subscription.unsubscribe();
435
+
436
+ expect(values).toEqual([false, true]);
437
+ });
438
+ });
439
+
440
+ describe('evaluate - One-time Evaluation', () => {
441
+ beforeEach(() => {
442
+ service = TestBed.inject(SavvagentService);
443
+ mockClient = (service as any).client;
444
+ });
445
+
446
+ it('should evaluate flag and return result', async () => {
447
+ const mockResult: FlagEvaluationResult = {
448
+ key: 'test-flag',
449
+ value: true,
450
+ reason: 'evaluated',
451
+ };
452
+ mockClient.evaluate.mockResolvedValue(mockResult);
453
+
454
+ const result = await service.evaluate('test-flag');
455
+ expect(result).toEqual(mockResult);
456
+ });
457
+
458
+ it('should throw when client not initialized', async () => {
459
+ TestBed.resetTestingModule();
460
+ TestBed.configureTestingModule({
461
+ providers: [SavvagentService],
462
+ });
463
+ service = TestBed.inject(SavvagentService);
464
+
465
+ await expect(service.evaluate('test-flag')).rejects.toThrow(
466
+ 'Savvagent client not initialized'
467
+ );
468
+ });
469
+
470
+ it('should pass merged context to client', async () => {
471
+ mockClient.evaluate.mockResolvedValue({
472
+ key: 'test-flag',
473
+ value: true,
474
+ reason: 'evaluated',
475
+ });
476
+
477
+ await service.evaluate('test-flag', { user_id: 'custom-user' });
478
+
479
+ expect(mockClient.evaluate).toHaveBeenCalledWith('test-flag', expect.objectContaining({
480
+ application_id: 'test-app',
481
+ environment: 'test',
482
+ user_id: 'custom-user',
483
+ }));
484
+ });
485
+ });
486
+
487
+ describe('isEnabled - Boolean Check', () => {
488
+ beforeEach(() => {
489
+ service = TestBed.inject(SavvagentService);
490
+ mockClient = (service as any).client;
491
+ });
492
+
493
+ it('should return true when flag is enabled', async () => {
494
+ mockClient.isEnabled.mockResolvedValue(true);
495
+ const result = await service.isEnabled('test-flag');
496
+ expect(result).toBe(true);
497
+ });
498
+
499
+ it('should return false when flag is disabled', async () => {
500
+ mockClient.isEnabled.mockResolvedValue(false);
501
+ const result = await service.isEnabled('test-flag');
502
+ expect(result).toBe(false);
503
+ });
504
+
505
+ it('should return false when client not initialized', async () => {
506
+ TestBed.resetTestingModule();
507
+ TestBed.configureTestingModule({
508
+ providers: [SavvagentService],
509
+ });
510
+ service = TestBed.inject(SavvagentService);
511
+
512
+ const result = await service.isEnabled('test-flag');
513
+ expect(result).toBe(false);
514
+ });
515
+ });
516
+
517
+ describe('withFlag - Conditional Execution', () => {
518
+ beforeEach(() => {
519
+ service = TestBed.inject(SavvagentService);
520
+ mockClient = (service as any).client;
521
+ });
522
+
523
+ it('should execute callback when flag is enabled', async () => {
524
+ const callback = jest.fn().mockReturnValue('result');
525
+ mockClient.withFlag.mockResolvedValue('result');
526
+
527
+ const result = await service.withFlag('test-flag', callback);
528
+ expect(result).toBe('result');
529
+ });
530
+
531
+ it('should return null when client not initialized', async () => {
532
+ TestBed.resetTestingModule();
533
+ TestBed.configureTestingModule({
534
+ providers: [SavvagentService],
535
+ });
536
+ service = TestBed.inject(SavvagentService);
537
+
538
+ const callback = jest.fn();
539
+ const result = await service.withFlag('test-flag', callback);
540
+ expect(result).toBeNull();
541
+ expect(callback).not.toHaveBeenCalled();
542
+ });
543
+
544
+ it('should pass merged context to client', async () => {
545
+ const callback = jest.fn().mockReturnValue('result');
546
+ mockClient.withFlag.mockResolvedValue('result');
547
+
548
+ await service.withFlag('test-flag', callback, { user_id: 'custom' });
549
+
550
+ expect(mockClient.withFlag).toHaveBeenCalledWith(
551
+ 'test-flag',
552
+ callback,
553
+ expect.objectContaining({ user_id: 'custom' })
554
+ );
555
+ });
556
+ });
557
+
558
+ describe('User ID Management', () => {
559
+ beforeEach(() => {
560
+ service = TestBed.inject(SavvagentService);
561
+ mockClient = (service as any).client;
562
+ });
563
+
564
+ it('should set user ID', () => {
565
+ service.setUserId('new-user');
566
+ expect(mockClient.setUserId).toHaveBeenCalledWith('new-user');
567
+ });
568
+
569
+ it('should get user ID', () => {
570
+ mockClient.getUserId.mockReturnValue('user-123');
571
+ const userId = service.getUserId();
572
+ expect(userId).toBe('user-123');
573
+ });
574
+
575
+ it('should return null when client not initialized', () => {
576
+ TestBed.resetTestingModule();
577
+ TestBed.configureTestingModule({
578
+ providers: [SavvagentService],
579
+ });
580
+ service = TestBed.inject(SavvagentService);
581
+
582
+ expect(service.getUserId()).toBeNull();
583
+ });
584
+ });
585
+
586
+ describe('Anonymous ID Management', () => {
587
+ beforeEach(() => {
588
+ service = TestBed.inject(SavvagentService);
589
+ mockClient = (service as any).client;
590
+ });
591
+
592
+ it('should get anonymous ID', () => {
593
+ mockClient.getAnonymousId.mockReturnValue('anon-123');
594
+ const anonId = service.getAnonymousId();
595
+ expect(anonId).toBe('anon-123');
596
+ });
597
+
598
+ it('should set anonymous ID', () => {
599
+ service.setAnonymousId('custom-anon');
600
+ expect(mockClient.setAnonymousId).toHaveBeenCalledWith('custom-anon');
601
+ });
602
+
603
+ it('should return null when client not initialized', () => {
604
+ TestBed.resetTestingModule();
605
+ TestBed.configureTestingModule({
606
+ providers: [SavvagentService],
607
+ });
608
+ service = TestBed.inject(SavvagentService);
609
+
610
+ expect(service.getAnonymousId()).toBeNull();
611
+ });
612
+ });
613
+
614
+ describe('Override Management', () => {
615
+ beforeEach(() => {
616
+ service = TestBed.inject(SavvagentService);
617
+ mockClient = (service as any).client;
618
+ });
619
+
620
+ it('should set override', () => {
621
+ service.setOverride('test-flag', true);
622
+ expect(mockClient.setOverride).toHaveBeenCalledWith('test-flag', true);
623
+ });
624
+
625
+ it('should clear override', () => {
626
+ service.clearOverride('test-flag');
627
+ expect(mockClient.clearOverride).toHaveBeenCalledWith('test-flag');
628
+ });
629
+
630
+ it('should clear all overrides', () => {
631
+ service.clearAllOverrides();
632
+ expect(mockClient.clearAllOverrides).toHaveBeenCalled();
633
+ });
634
+
635
+ it('should check if override exists', () => {
636
+ mockClient.hasOverride.mockReturnValue(true);
637
+ const hasOverride = service.hasOverride('test-flag');
638
+ expect(hasOverride).toBe(true);
639
+ });
640
+
641
+ it('should get override value', () => {
642
+ mockClient.getOverride.mockReturnValue(true);
643
+ const override = service.getOverride('test-flag');
644
+ expect(override).toBe(true);
645
+ });
646
+
647
+ it('should get all overrides', () => {
648
+ const overrides = { 'flag-1': true, 'flag-2': false };
649
+ mockClient.getOverrides.mockReturnValue(overrides);
650
+ const result = service.getOverrides();
651
+ expect(result).toEqual(overrides);
652
+ });
653
+
654
+ it('should set multiple overrides', () => {
655
+ const overrides = { 'flag-1': true, 'flag-2': false };
656
+ service.setOverrides(overrides);
657
+ expect(mockClient.setOverrides).toHaveBeenCalledWith(overrides);
658
+ });
659
+
660
+ it('should return false/empty when client not initialized', () => {
661
+ TestBed.resetTestingModule();
662
+ TestBed.configureTestingModule({
663
+ providers: [SavvagentService],
664
+ });
665
+ service = TestBed.inject(SavvagentService);
666
+
667
+ expect(service.hasOverride('test')).toBe(false);
668
+ expect(service.getOverride('test')).toBeUndefined();
669
+ expect(service.getOverrides()).toEqual({});
670
+ });
671
+
672
+ it('should re-evaluate flags when override changes', async () => {
673
+ let overrideCallback: (() => void) | undefined;
674
+ mockClient.onOverrideChange.mockImplementation((cb: () => void) => {
675
+ overrideCallback = cb;
676
+ return () => {}; // Return unsubscribe function
677
+ });
678
+ mockClient.evaluate.mockResolvedValue({
679
+ key: 'test-flag',
680
+ value: true,
681
+ reason: 'evaluated',
682
+ });
683
+
684
+ // Re-initialize to set up the override listener
685
+ service.close();
686
+ service.initialize(mockConfig);
687
+ mockClient = (service as any).client;
688
+
689
+ // Create a flag subscription
690
+ const flag$ = service.flag$('test-flag');
691
+ const values: any[] = [];
692
+ flag$.subscribe((val) => values.push(val));
693
+
694
+ await new Promise((resolve) => setTimeout(resolve, 50));
695
+
696
+ // Trigger override change
697
+ if (overrideCallback) {
698
+ overrideCallback();
699
+ }
700
+
701
+ await new Promise((resolve) => setTimeout(resolve, 50));
702
+
703
+ // Should have re-evaluated (initial call + re-evaluation)
704
+ expect(mockClient.evaluate).toHaveBeenCalled();
705
+ });
706
+ });
707
+
708
+ describe('Flag Discovery', () => {
709
+ beforeEach(() => {
710
+ service = TestBed.inject(SavvagentService);
711
+ mockClient = (service as any).client;
712
+ });
713
+
714
+ it('should get all flags as observable', async () => {
715
+ const mockFlags = [
716
+ { key: 'flag-1', name: 'Flag 1' },
717
+ { key: 'flag-2', name: 'Flag 2' },
718
+ ];
719
+ mockClient.getAllFlags.mockResolvedValue(mockFlags as any);
720
+
721
+ const flags = await firstValueFrom(service.getAllFlags$('production'));
722
+ expect(flags).toEqual(mockFlags);
723
+ });
724
+
725
+ it('should handle errors in getAllFlags$', async () => {
726
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
727
+ mockClient.getAllFlags.mockRejectedValue(new Error('API error'));
728
+
729
+ const flags = await firstValueFrom(service.getAllFlags$('production'));
730
+ expect(flags).toEqual([]);
731
+ expect(consoleSpy).toHaveBeenCalled();
732
+ consoleSpy.mockRestore();
733
+ });
734
+
735
+ it('should get all flags as promise', async () => {
736
+ const mockFlags = [{ key: 'flag-1', name: 'Flag 1' }];
737
+ mockClient.getAllFlags.mockResolvedValue(mockFlags as any);
738
+
739
+ const flags = await service.getAllFlags('production');
740
+ expect(flags).toEqual(mockFlags);
741
+ });
742
+
743
+ it('should get enterprise flags', async () => {
744
+ const mockFlags = [{ key: 'enterprise-flag', name: 'Enterprise Flag' }];
745
+ mockClient.getEnterpriseFlags.mockResolvedValue(mockFlags as any);
746
+
747
+ const flags = await service.getEnterpriseFlags('production');
748
+ expect(flags).toEqual(mockFlags);
749
+ });
750
+
751
+ it('should return empty array when client not initialized', async () => {
752
+ TestBed.resetTestingModule();
753
+ TestBed.configureTestingModule({
754
+ providers: [SavvagentService],
755
+ });
756
+ service = TestBed.inject(SavvagentService);
757
+
758
+ const flags1 = await firstValueFrom(service.getAllFlags$());
759
+ const flags2 = await service.getAllFlags();
760
+ const flags3 = await service.getEnterpriseFlags();
761
+
762
+ expect(flags1).toEqual([]);
763
+ expect(flags2).toEqual([]);
764
+ expect(flags3).toEqual([]);
765
+ });
766
+ });
767
+
768
+ describe('Cache & Connection', () => {
769
+ beforeEach(() => {
770
+ service = TestBed.inject(SavvagentService);
771
+ mockClient = (service as any).client;
772
+ });
773
+
774
+ it('should clear cache', () => {
775
+ service.clearCache();
776
+ expect(mockClient.clearCache).toHaveBeenCalled();
777
+ });
778
+
779
+ it('should check realtime connection status', () => {
780
+ mockClient.isRealtimeConnected.mockReturnValue(true);
781
+ const isConnected = service.isRealtimeConnected();
782
+ expect(isConnected).toBe(true);
783
+ });
784
+
785
+ it('should return false for realtime when client not initialized', () => {
786
+ TestBed.resetTestingModule();
787
+ TestBed.configureTestingModule({
788
+ providers: [SavvagentService],
789
+ });
790
+ service = TestBed.inject(SavvagentService);
791
+
792
+ expect(service.isRealtimeConnected()).toBe(false);
793
+ });
794
+ });
795
+
796
+ describe('Error Tracking', () => {
797
+ beforeEach(() => {
798
+ service = TestBed.inject(SavvagentService);
799
+ mockClient = (service as any).client;
800
+ });
801
+
802
+ it('should track error with merged context', () => {
803
+ const error = new Error('Test error');
804
+ service.trackError('test-flag', error, { user_id: 'custom' });
805
+
806
+ expect(mockClient.trackError).toHaveBeenCalledWith(
807
+ 'test-flag',
808
+ error,
809
+ expect.objectContaining({ user_id: 'custom' })
810
+ );
811
+ });
812
+ });
813
+
814
+ describe('Cleanup', () => {
815
+ beforeEach(() => {
816
+ service = TestBed.inject(SavvagentService);
817
+ mockClient = (service as any).client;
818
+ });
819
+
820
+ it('should close client and cleanup on close()', () => {
821
+ service.close();
822
+ expect(mockClient.close).toHaveBeenCalled();
823
+ expect(service.isReady).toBe(false);
824
+ expect(service.flagClient).toBeNull();
825
+ });
826
+
827
+ it('should cleanup on ngOnDestroy', () => {
828
+ service.ngOnDestroy();
829
+ expect(mockClient.close).toHaveBeenCalled();
830
+ expect(service.isReady).toBe(false);
831
+ });
832
+
833
+ it('should complete all active flag subscriptions on close', async () => {
834
+ mockClient.subscribe.mockReturnValue(() => {}); // Return proper unsubscribe function
835
+ mockClient.evaluate.mockResolvedValue({
836
+ key: 'test-flag',
837
+ value: true,
838
+ reason: 'evaluated',
839
+ });
840
+
841
+ const flag$ = service.flag$('test-flag');
842
+ let completed = false;
843
+
844
+ flag$.subscribe({
845
+ complete: () => {
846
+ completed = true;
847
+ },
848
+ });
849
+
850
+ service.close();
851
+ await new Promise((resolve) => setTimeout(resolve, 50));
852
+
853
+ expect(completed).toBe(true);
854
+ });
855
+
856
+ it('should handle multiple close calls gracefully', () => {
857
+ service.close();
858
+ expect(() => service.close()).not.toThrow();
859
+ });
860
+ });
861
+
862
+ describe('Edge Cases', () => {
863
+ beforeEach(() => {
864
+ service = TestBed.inject(SavvagentService);
865
+ mockClient = (service as any).client;
866
+ });
867
+
868
+ it('should handle null/undefined context values', async () => {
869
+ mockClient.evaluate.mockResolvedValue({
870
+ key: 'test-flag',
871
+ value: true,
872
+ reason: 'evaluated',
873
+ });
874
+
875
+ await service.evaluate('test-flag', {
876
+ user_id: null as any,
877
+ attributes: undefined,
878
+ });
879
+
880
+ expect(mockClient.evaluate).toHaveBeenCalledWith(
881
+ 'test-flag',
882
+ expect.objectContaining({
883
+ user_id: null,
884
+ })
885
+ );
886
+ });
887
+
888
+ it('should handle empty defaultContext', async () => {
889
+ TestBed.resetTestingModule();
890
+ TestBed.configureTestingModule({
891
+ providers: [
892
+ SavvagentService,
893
+ {
894
+ provide: SAVVAGENT_CONFIG,
895
+ useValue: {
896
+ config: { apiKey: 'test' },
897
+ defaultContext: {},
898
+ },
899
+ },
900
+ ],
901
+ });
902
+ service = TestBed.inject(SavvagentService);
903
+ mockClient = (service as any).client;
904
+ mockClient.evaluate.mockResolvedValue({
905
+ key: 'test-flag',
906
+ value: true,
907
+ reason: 'evaluated',
908
+ });
909
+
910
+ await service.evaluate('test-flag');
911
+
912
+ expect(mockClient.evaluate).toHaveBeenCalledWith('test-flag', expect.objectContaining({
913
+ application_id: undefined,
914
+ environment: undefined,
915
+ user_id: undefined,
916
+ }));
917
+ });
918
+
919
+ it('should handle no defaultContext', async () => {
920
+ TestBed.resetTestingModule();
921
+ TestBed.configureTestingModule({
922
+ providers: [
923
+ SavvagentService,
924
+ {
925
+ provide: SAVVAGENT_CONFIG,
926
+ useValue: {
927
+ config: { apiKey: 'test' },
928
+ },
929
+ },
930
+ ],
931
+ });
932
+ service = TestBed.inject(SavvagentService);
933
+ mockClient = (service as any).client;
934
+ mockClient.evaluate.mockResolvedValue({
935
+ key: 'test-flag',
936
+ value: true,
937
+ reason: 'evaluated',
938
+ });
939
+
940
+ await service.evaluate('test-flag');
941
+
942
+ expect(mockClient.evaluate).toHaveBeenCalledWith('test-flag', expect.objectContaining({}));
943
+ });
944
+ });
945
+ });