1 package growthbook
2
3 import (
4 "encoding/json"
5 "net/http"
6 "net/http/httptest"
7 "reflect"
8 "runtime"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "github.com/ian-ross/sse/v2"
15 )
16
17 type env struct {
18 sync.RWMutex
19 server *httptest.Server
20 sseServer *sse.Server
21 fetchFails bool
22 featureValue *string
23 callCount *int
24 urls map[string]int
25 }
26
27 func (e *env) checkCalls(t *testing.T, expected int) {
28 e.RLock()
29 defer e.RUnlock()
30 if *e.callCount != expected {
31 t.Errorf("Expected %d calls to API, got %d", expected, *e.callCount)
32 }
33 }
34
35 func (e *env) close() {
36 if e.sseServer != nil {
37 e.sseServer.Close()
38 }
39 e.server.Close()
40 }
41
42 func setupEncrypted(features string, provideSSE bool) *env {
43 return setupWithDelay(provideSSE, 50*time.Millisecond, features)
44 }
45
46 func setup(provideSSE bool) *env {
47 return setupWithDelay(provideSSE, 50*time.Millisecond, "")
48 }
49
50 func setupWithDelay(provideSSE bool, delay time.Duration, encryptedFeatures string) *env {
51 SetLogger(&testLog)
52 testLog.reset()
53
54 initialValue := "initial"
55 callCount := 0
56 urls := map[string]int{}
57 env := env{featureValue: &initialValue, callCount: &callCount, urls: urls}
58
59
60
61 mux := http.NewServeMux()
62
63
64 mux.HandleFunc("/api/features/", func(w http.ResponseWriter, r *http.Request) {
65 env.Lock()
66 defer env.Unlock()
67 callCount++
68 env.urls[r.URL.Path]++
69
70 time.Sleep(delay)
71
72 if env.fetchFails {
73 w.WriteHeader(http.StatusBadRequest)
74 w.Write([]byte("fetch failed"))
75 } else {
76 features := FeatureMap{}
77 if encryptedFeatures == "" {
78 features = FeatureMap{"foo": {DefaultValue: *env.featureValue}}
79 }
80 response := &FeatureAPIResponse{
81 Features: features,
82 DateUpdated: time.Now(),
83 EncryptedFeatures: encryptedFeatures,
84 }
85
86 if provideSSE {
87 w.Header().Set("X-SSE-Support", "enabled")
88 }
89 w.Header().Set("Content-Type", "application/json")
90 w.WriteHeader(http.StatusOK)
91 json.NewEncoder(w).Encode(response)
92 }
93 })
94
95
96 if provideSSE {
97 env.sseServer = sse.New()
98 env.sseServer.CreateStream("features")
99 mux.HandleFunc("/sub/", env.sseServer.ServeHTTP)
100 }
101
102 env.server = httptest.NewServer(mux)
103
104 return &env
105 }
106
107 func makeGB(apiHost string, clientKey string, ttl time.Duration) *GrowthBook {
108 context := NewContext().
109 WithAPIHost(apiHost).
110 WithClientKey(clientKey)
111 if ttl != 0 {
112 context = context.WithCacheTTL(ttl)
113 }
114 return New(context)
115 }
116
117 func checkFeature(t *testing.T, gb *GrowthBook, feature string, expected interface{}) {
118 value := gb.EvalFeature(feature).Value
119 if value != expected {
120 t.Errorf("feature value, expected %v, got %v", expected, value)
121 }
122 }
123
124 func checkLogs(t *testing.T) {
125 if len(testLog.errors) != 0 {
126 t.Errorf("test log has errors: %s", testLog.allErrors())
127 }
128 if len(testLog.warnings) != 0 {
129 bad := false
130 for _, e := range testLog.errors {
131 if !strings.Contains(e, "SSE event stream disconnected") {
132 bad = true
133 }
134 }
135 if bad {
136 t.Errorf("test log has warnings: %s", testLog.allWarnings())
137 }
138 }
139 }
140
141 func knownWarnings(t *testing.T, count int) {
142 if len(testLog.errors) != 0 {
143 t.Error("found errors when looking for known warnings: ", testLog.allErrors())
144 return
145 }
146 if len(testLog.warnings) == count {
147 testLog.reset()
148 return
149 }
150
151 t.Errorf("expected %d log warnings, got %d: %s", count,
152 len(testLog.warnings), testLog.allWarnings())
153 }
154
155 func knownErrors(t *testing.T, messages ...string) {
156 if len(testLog.errors) != len(messages) {
157 t.Errorf("expected %d log errors, got %d: %s", len(messages),
158 len(testLog.errors), testLog.allErrors())
159 return
160 }
161
162 for i, msg := range messages {
163 if !strings.HasPrefix(testLog.errors[i], msg) {
164 t.Errorf("expected error message %d '%s...', got '%s'", i+1, msg, testLog.errors[i])
165 }
166 }
167
168 testLog.reset()
169 }
170
171 func knownSSEErrors(t *testing.T) func() {
172 return func() {
173 for _, msg := range testLog.errors {
174 if !strings.HasPrefix(msg, "SSE error:") {
175 t.Errorf("unexpected error in log: '%s'", msg)
176 }
177 }
178 }
179 }
180
181 func checkReady(t *testing.T, gb *GrowthBook, expected bool) {
182 if gb.Ready() != expected {
183 t.Errorf("expected ready flag to be %v", expected)
184 }
185 }
186
187 func checkEmptyFeatures(t *testing.T, gb *GrowthBook) {
188 if len(gb.Features()) != 0 {
189 t.Error("expected feature map to be empty")
190 }
191 }
192
193 func TestRepoDebounceFetchRequests(t *testing.T) {
194 env := setup(false)
195 defer cache.Clear()
196 defer checkLogs(t)
197 defer env.close()
198
199 cache.Clear()
200
201 gb1 := makeGB(env.server.URL, "qwerty1234", 0)
202 gb2 := makeGB(env.server.URL, "other", 0)
203 gb3 := makeGB(env.server.URL, "qwerty1234", 0)
204
205 gb1.LoadFeatures(nil)
206 gb2.LoadFeatures(nil)
207 gb3.LoadFeatures(nil)
208
209 env.checkCalls(t, 2)
210 if env.urls["/api/features/other"] != 1 ||
211 env.urls["/api/features/qwerty1234"] != 1 {
212 t.Errorf("unexpected URL calls: %v", env.urls)
213 }
214
215 checkFeature(t, gb1, "foo", "initial")
216 checkFeature(t, gb2, "foo", "initial")
217 checkFeature(t, gb3, "foo", "initial")
218 }
219
220 func TestRepoUsesCacheAndCanRefreshManually(t *testing.T) {
221 env := setup(false)
222 defer cache.Clear()
223 defer checkLogs(t)
224 defer env.close()
225
226 cache.Clear()
227
228
229 gb := makeGB(env.server.URL, "qwerty1234", 100*time.Millisecond)
230 time.Sleep(20 * time.Millisecond)
231
232
233 checkFeature(t, gb, "foo", nil)
234 env.checkCalls(t, 1)
235 knownWarnings(t, 1)
236
237
238 gb.LoadFeatures(nil)
239 checkFeature(t, gb, "foo", "initial")
240 env.checkCalls(t, 1)
241
242
243 *env.featureValue = "changed"
244
245
246 gb2 := makeGB(env.server.URL, "qwerty1234", 100*time.Millisecond)
247 checkFeature(t, gb2, "foo", nil)
248 knownWarnings(t, 1)
249 gb2.LoadFeatures(&FeatureRepoOptions{AutoRefresh: true})
250 checkFeature(t, gb2, "foo", "initial")
251
252
253 gb3 := makeGB(env.server.URL, "qwerty1234", 100*time.Millisecond)
254 checkFeature(t, gb3, "foo", nil)
255 knownWarnings(t, 1)
256 gb3.LoadFeatures(nil)
257 checkFeature(t, gb3, "foo", "initial")
258
259 env.checkCalls(t, 1)
260
261
262 checkFeature(t, gb, "foo", "initial")
263
264
265
266 gb.RefreshFeatures(nil)
267 env.checkCalls(t, 1)
268
269
270 time.Sleep(100 * time.Millisecond)
271 gb.RefreshFeatures(nil)
272 env.checkCalls(t, 2)
273
274
275 checkFeature(t, gb, "foo", "changed")
276
277
278 checkFeature(t, gb2, "foo", "changed")
279
280
281
282 checkFeature(t, gb3, "foo", "initial")
283
284
285 gb4 := makeGB(env.server.URL, "qwerty1234", 100*time.Millisecond)
286 checkFeature(t, gb4, "foo", nil)
287 knownWarnings(t, 1)
288 gb4.LoadFeatures(nil)
289 checkFeature(t, gb4, "foo", "changed")
290
291 env.checkCalls(t, 2)
292 }
293
294 func TestRepoUpdatesFeaturesBasedOnSSE1(t *testing.T) {
295 env := setup(true)
296 defer cache.Clear()
297 defer checkLogs(t)
298 defer env.close()
299
300 cache.Clear()
301
302 gb := makeGB(env.server.URL, "qwerty1234", 0)
303
304
305
306
307 defer func() {
308 gb = nil
309 runtime.GC()
310 }()
311
312
313 gb.LoadFeatures(&FeatureRepoOptions{AutoRefresh: true})
314 env.checkCalls(t, 1)
315
316
317 checkFeature(t, gb, "foo", "initial")
318
319
320 featuresJson := `{"features": {"foo": {"defaultValue": "changed"}}}`
321 env.sseServer.Publish("features", &sse.Event{Data: []byte(featuresJson)})
322
323
324 time.Sleep(20 * time.Millisecond)
325
326
327 checkFeature(t, gb, "foo", "changed")
328 env.checkCalls(t, 1)
329 }
330
331 func TestRepoUpdatesFeaturesBasedOnSSE2(t *testing.T) {
332 env := setup(true)
333 defer cache.Clear()
334 defer checkLogs(t)
335 defer env.close()
336
337 cache.Clear()
338
339 gb := makeGB(env.server.URL, "qwerty1234", 0)
340 gb2 := makeGB(env.server.URL, "qwerty1234", 0)
341
342
343
344
345 defer func() {
346 gb = nil
347 gb2 = nil
348 runtime.GC()
349 }()
350
351
352 gb.LoadFeatures(nil)
353 gb2.LoadFeatures(&FeatureRepoOptions{AutoRefresh: true})
354 env.checkCalls(t, 1)
355
356
357 checkFeature(t, gb, "foo", "initial")
358 checkFeature(t, gb2, "foo", "initial")
359
360
361 featuresJson := `{"features": {"foo": {"defaultValue": "changed"}}}`
362 env.sseServer.Publish("features", &sse.Event{Data: []byte(featuresJson)})
363
364
365 time.Sleep(20 * time.Millisecond)
366
367
368 checkFeature(t, gb, "foo", "initial")
369 checkFeature(t, gb2, "foo", "changed")
370 env.checkCalls(t, 1)
371 }
372
373 func TestRepoExposesAReadyFlag(t *testing.T) {
374 env := setup(false)
375 defer cache.Clear()
376 defer checkLogs(t)
377 defer env.close()
378
379 cache.Clear()
380 *env.featureValue = "api"
381
382 gb := makeGB(env.server.URL, "qwerty1234", 0)
383
384 if gb.Ready() {
385 t.Error("expected ready flag to be false")
386 }
387 gb.LoadFeatures(nil)
388 env.checkCalls(t, 1)
389 if !gb.Ready() {
390 t.Error("expected ready flag to be true")
391 }
392
393 gb2 := makeGB(env.server.URL, "qwerty1234", 0)
394 if gb2.Ready() {
395 t.Error("expected ready flag to be false")
396 }
397 gb2.WithFeatures(FeatureMap{"foo": &Feature{DefaultValue: "manual"}})
398 if !gb2.Ready() {
399 t.Error("expected ready flag to be false")
400 }
401 }
402
403 func TestRepoHandlesBrokenFetchResponses(t *testing.T) {
404 env := setup(false)
405 defer cache.Clear()
406 defer checkLogs(t)
407 defer env.close()
408
409 cache.Clear()
410 env.fetchFails = true
411
412 gb := makeGB(env.server.URL, "qwerty1234", 0)
413 checkReady(t, gb, false)
414 gb.LoadFeatures(nil)
415
416
417 env.checkCalls(t, 1)
418 knownErrors(t, "Error fetching features")
419
420
421 checkReady(t, gb, true)
422 checkEmptyFeatures(t, gb)
423
424
425 gb.RefreshFeatures(nil)
426 checkEmptyFeatures(t, gb)
427 env.checkCalls(t, 2)
428 knownErrors(t, "Error fetching features")
429
430 checkLogs(t)
431 }
432
433 func TestRepoHandlesSuperLongAPIRequests(t *testing.T) {
434 env := setupWithDelay(false, 100*time.Millisecond, "")
435 defer cache.Clear()
436 defer checkLogs(t)
437 defer env.close()
438
439 cache.Clear()
440 *env.featureValue = "api"
441
442 gb := makeGB(env.server.URL, "qwerty1234", 0)
443 checkReady(t, gb, false)
444
445
446 gb.LoadFeatures(&FeatureRepoOptions{Timeout: 20 * time.Millisecond})
447 env.checkCalls(t, 1)
448 checkLogs(t)
449
450
451 checkReady(t, gb, false)
452 checkEmptyFeatures(t, gb)
453
454
455
456 time.Sleep(100 * time.Millisecond)
457 gb.RefreshFeatures(&FeatureRepoOptions{Timeout: 20 * time.Millisecond})
458 env.checkCalls(t, 1)
459 checkReady(t, gb, true)
460 checkFeature(t, gb, "foo", "api")
461 }
462
463 func TestRepoHandlesSSEErrors(t *testing.T) {
464 env := setup(true)
465 defer cache.Clear()
466 defer checkLogs(t)
467 defer env.close()
468
469 cache.Clear()
470
471 gb := makeGB(env.server.URL, "qwerty1234", 0)
472
473 gb.LoadFeatures(&FeatureRepoOptions{AutoRefresh: true})
474 env.checkCalls(t, 1)
475 checkFeature(t, gb, "foo", "initial")
476
477
478 env.sseServer.Publish("features", &sse.Event{Data: []byte("broken(response")})
479
480
481
482 time.Sleep(20 * time.Millisecond)
483 env.checkCalls(t, 1)
484 checkFeature(t, gb, "foo", "initial")
485 knownErrors(t, "SSE error")
486
487 cache.Clear()
488 }
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632 func TestRepoDoesntDoBackgroundSyncWhenDisabled(t *testing.T) {
633 env := setup(true)
634 defer cache.Clear()
635 defer checkLogs(t)
636 defer env.close()
637
638 cache.Clear()
639 ConfigureCacheBackgroundSync(false)
640 defer ConfigureCacheBackgroundSync(true)
641
642 gb := makeGB(env.server.URL, "qwerty1234", 0)
643 gb2 := makeGB(env.server.URL, "qwerty1234", 0)
644
645 gb.LoadFeatures(nil)
646 gb2.LoadFeatures(&FeatureRepoOptions{AutoRefresh: true})
647
648
649 env.checkCalls(t, 1)
650 checkFeature(t, gb, "foo", "initial")
651 checkFeature(t, gb2, "foo", "initial")
652
653
654 featuresJson := `{"features": {"foo": {"defaultValue": "changed"}}}`
655 env.sseServer.Publish("features", &sse.Event{Data: []byte(featuresJson)})
656
657
658 time.Sleep(100 * time.Millisecond)
659 checkFeature(t, gb, "foo", "initial")
660 checkFeature(t, gb2, "foo", "initial")
661 env.checkCalls(t, 1)
662 }
663
664 func TestRepoDecryptsFeatures(t *testing.T) {
665 encryptedFeatures := "vMSg2Bj/IurObDsWVmvkUg==.L6qtQkIzKDoE2Dix6IAKDcVel8PHUnzJ7JjmLjFZFQDqidRIoCxKmvxvUj2kTuHFTQ3/NJ3D6XhxhXXv2+dsXpw5woQf0eAgqrcxHrbtFORs18tRXRZza7zqgzwvcznx"
666
667 env := setupEncrypted(encryptedFeatures, false)
668 defer cache.Clear()
669 defer checkLogs(t)
670 defer env.close()
671
672 cache.Clear()
673
674 context := NewContext().
675 WithAPIHost(env.server.URL).
676 WithClientKey("qwerty1234").
677 WithDecryptionKey("Ns04T5n9+59rl2x3SlNHtQ==")
678 gb := New(context)
679
680 gb.LoadFeatures(nil)
681
682 env.checkCalls(t, 1)
683
684 expectedJson := `{
685 "testfeature1": {
686 "defaultValue": true,
687 "rules": [{"condition": { "id": "1234" }, "force": false}]
688 }
689 }`
690 expected := ParseFeatureMap([]byte(expectedJson))
691 actual := gb.Features()
692
693 if !reflect.DeepEqual(actual, expected) {
694 t.Error("unexpected features value: ", actual)
695 }
696 }
697
View as plain text