github.com/prebid/prebid-server/v2@v2.18.0/currency/rate_converter_test.go (about) 1 package currency 2 3 import ( 4 "io" 5 "net/http" 6 "net/http/httptest" 7 "strings" 8 "sync" 9 "testing" 10 "time" 11 12 "github.com/prebid/prebid-server/v2/util/task" 13 "github.com/stretchr/testify/assert" 14 ) 15 16 func getMockRates() []byte { 17 return []byte(`{ 18 "dataAsOf":"2018-09-12", 19 "conversions":{ 20 "USD":{ 21 "GBP":0.77208 22 }, 23 "GBP":{ 24 "USD":1.2952 25 } 26 } 27 }`) 28 } 29 30 // FakeTime implements the Time interface 31 type FakeTime struct { 32 time time.Time 33 } 34 35 func (mc *FakeTime) Now() time.Time { 36 return mc.time 37 } 38 39 func TestReadWriteRates(t *testing.T) { 40 // Setup 41 mockServerHandler := func(mockResponse []byte, mockStatus int) http.Handler { 42 return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 43 rw.WriteHeader(mockStatus) 44 rw.Write([]byte(mockResponse)) 45 }) 46 } 47 48 tests := []struct { 49 description string 50 giveFakeTime time.Time 51 giveMockUrl string 52 giveMockResponse []byte 53 giveMockStatus int 54 wantUpdateErr bool 55 wantConstantRates bool 56 wantLastUpdated time.Time 57 wantConversions map[string]map[string]float64 58 }{ 59 { 60 description: "Fetching currency rates successfully", 61 giveFakeTime: time.Date(2018, time.September, 12, 30, 0, 0, 0, time.UTC), 62 giveMockResponse: getMockRates(), 63 giveMockStatus: 200, 64 wantLastUpdated: time.Date(2018, time.September, 12, 30, 0, 0, 0, time.UTC), 65 wantConversions: map[string]map[string]float64{"USD": {"GBP": 0.77208}, "GBP": {"USD": 1.2952}}, 66 }, 67 { 68 description: "Currency rates endpoint returns empty response", 69 giveFakeTime: time.Date(2018, time.September, 12, 30, 0, 0, 0, time.UTC), 70 giveMockResponse: []byte("{}"), 71 giveMockStatus: 200, 72 wantLastUpdated: time.Date(2018, time.September, 12, 30, 0, 0, 0, time.UTC), 73 wantConversions: nil, 74 }, 75 { 76 description: "Currency rates endpoint returns nil response", 77 giveFakeTime: time.Date(2018, time.September, 12, 30, 0, 0, 0, time.UTC), 78 giveMockResponse: nil, 79 giveMockStatus: 200, 80 wantUpdateErr: true, 81 wantConstantRates: true, 82 wantLastUpdated: time.Time{}, 83 }, 84 { 85 description: "Currency rates endpoint returns non-2xx status code", 86 giveFakeTime: time.Date(2018, time.September, 12, 30, 0, 0, 0, time.UTC), 87 giveMockResponse: []byte(`{"message": "Not Found"}`), 88 giveMockStatus: 404, 89 wantUpdateErr: true, 90 wantConstantRates: true, 91 wantLastUpdated: time.Time{}, 92 }, 93 { 94 description: "Currency rates endpoint returns invalid json response", 95 giveFakeTime: time.Date(2018, time.September, 12, 30, 0, 0, 0, time.UTC), 96 giveMockResponse: []byte(`{"message": Invalid-JSON-No-Surrounding-Quotes}`), 97 giveMockStatus: 200, 98 wantUpdateErr: true, 99 wantConstantRates: true, 100 wantLastUpdated: time.Time{}, 101 }, 102 { 103 description: "Currency rates endpoint url is invalid", 104 giveFakeTime: time.Date(2018, time.September, 12, 30, 0, 0, 0, time.UTC), 105 giveMockUrl: "invalidurl", 106 giveMockResponse: getMockRates(), 107 giveMockStatus: 200, 108 wantUpdateErr: true, 109 wantConstantRates: true, 110 wantLastUpdated: time.Time{}, 111 }, 112 } 113 114 for _, tt := range tests { 115 mockedHttpServer := httptest.NewServer(mockServerHandler(tt.giveMockResponse, tt.giveMockStatus)) 116 defer mockedHttpServer.Close() 117 118 var url string 119 if len(tt.giveMockUrl) > 0 { 120 url = tt.giveMockUrl 121 } else { 122 url = mockedHttpServer.URL 123 } 124 currencyConverter := NewRateConverter( 125 &http.Client{}, 126 url, 127 24*time.Hour, 128 ) 129 currencyConverter.time = &FakeTime{time: tt.giveFakeTime} 130 err := currencyConverter.Run() 131 132 if tt.wantUpdateErr { 133 assert.NotNil(t, err) 134 } else { 135 assert.Nil(t, err) 136 } 137 138 if tt.wantConstantRates { 139 assert.Equal(t, currencyConverter.Rates(), &ConstantRates{}, tt.description) 140 } else { 141 rates := currencyConverter.Rates().(*Rates) 142 assert.Equal(t, tt.wantConversions, (*rates).Conversions, tt.description) 143 } 144 145 lastUpdated := currencyConverter.LastUpdated() 146 assert.Equal(t, tt.wantLastUpdated, lastUpdated, tt.description) 147 } 148 } 149 150 func TestRateStaleness(t *testing.T) { 151 callCount := 0 152 mockedHttpServer := httptest.NewServer(http.HandlerFunc( 153 func(rw http.ResponseWriter, req *http.Request) { 154 callCount++ 155 if callCount == 2 || callCount >= 5 { 156 rw.WriteHeader(http.StatusOK) 157 rw.Write([]byte(getMockRates())) 158 } else { 159 rw.WriteHeader(http.StatusNotFound) 160 rw.Write([]byte(`{"message": "Not Found"}`)) 161 } 162 }), 163 ) 164 165 defer mockedHttpServer.Close() 166 167 expectedRates := &Rates{ 168 Conversions: map[string]map[string]float64{ 169 "USD": { 170 "GBP": 0.77208, 171 }, 172 "GBP": { 173 "USD": 1.2952, 174 }, 175 }, 176 } 177 178 initialFakeTime := time.Date(2018, time.September, 12, 30, 0, 0, 0, time.UTC) 179 fakeTime := &FakeTime{time: initialFakeTime} 180 181 // Execute: 182 currencyConverter := NewRateConverter( 183 &http.Client{}, 184 mockedHttpServer.URL, 185 30*time.Second, // stale rates threshold 186 ) 187 currencyConverter.time = fakeTime 188 189 // First Update call results in error 190 err1 := currencyConverter.Run() 191 assert.NotNil(t, err1) 192 193 // Verify constant rates are used and last update ts is not set 194 assert.Equal(t, &ConstantRates{}, currencyConverter.Rates(), "Rates should return constant rates") 195 assert.Equal(t, time.Time{}, currencyConverter.LastUpdated(), "LastUpdated return is incorrect") 196 197 // Second Update call is successful and yields valid rates 198 err2 := currencyConverter.Run() 199 assert.Nil(t, err2) 200 201 // Verify rates are valid and last update timestamp is set 202 assert.Equal(t, expectedRates, currencyConverter.Rates(), "Conversions.Rates weren't the expected ones") 203 assert.Equal(t, initialFakeTime, currencyConverter.LastUpdated(), "LastUpdated should be set") 204 205 // Advance time so the rates fall just short of being considered stale 206 fakeTime.time = fakeTime.time.Add(29 * time.Second) 207 208 // Third Update call results in error but stale rate threshold has not been exceeded 209 err3 := currencyConverter.Run() 210 assert.NotNil(t, err3) 211 212 // Verify rates are valid and last update ts has not changed 213 assert.Equal(t, expectedRates, currencyConverter.Rates(), "Conversions.Rates weren't the expected ones") 214 assert.Equal(t, initialFakeTime, currencyConverter.LastUpdated(), "LastUpdated should be set") 215 216 // Advance time just past the threshold so the rates are considered stale 217 fakeTime.time = fakeTime.time.Add(2 * time.Second) 218 219 // Fourth Update call results in error and stale rate threshold has been exceeded 220 err4 := currencyConverter.Run() 221 assert.NotNil(t, err4) 222 223 // Verify constant rates are used and last update ts has not changed 224 assert.Equal(t, &ConstantRates{}, currencyConverter.Rates(), "Rates should return constant rates") 225 assert.Equal(t, initialFakeTime, currencyConverter.LastUpdated(), "LastUpdated return is incorrect") 226 227 // Fifth Update call is successful and yields valid rates 228 err5 := currencyConverter.Run() 229 assert.Nil(t, err5) 230 231 // Verify rates are valid and last update ts has changed 232 thirtyOneSec := 31 * time.Second 233 assert.Equal(t, expectedRates, currencyConverter.Rates(), "Conversions.Rates weren't the expected ones") 234 assert.Equal(t, (initialFakeTime.Add(thirtyOneSec)), currencyConverter.LastUpdated(), "LastUpdated should be set") 235 } 236 237 func TestRatesAreNeverConsideredStale(t *testing.T) { 238 callCount := 0 239 mockedHttpServer := httptest.NewServer(http.HandlerFunc( 240 func(rw http.ResponseWriter, req *http.Request) { 241 callCount++ 242 if callCount == 1 { 243 rw.WriteHeader(http.StatusOK) 244 rw.Write([]byte(getMockRates())) 245 } else { 246 rw.WriteHeader(http.StatusNotFound) 247 rw.Write([]byte(`{"message": "Not Found"}`)) 248 } 249 }), 250 ) 251 252 defer mockedHttpServer.Close() 253 254 expectedRates := &Rates{ 255 Conversions: map[string]map[string]float64{ 256 "USD": { 257 "GBP": 0.77208, 258 }, 259 "GBP": { 260 "USD": 1.2952, 261 }, 262 }, 263 } 264 265 initialFakeTime := time.Date(2018, time.September, 12, 30, 0, 0, 0, time.UTC) 266 fakeTime := &FakeTime{time: initialFakeTime} 267 268 // Execute: 269 currencyConverter := NewRateConverter( 270 &http.Client{}, 271 mockedHttpServer.URL, 272 0*time.Millisecond, // stale rates threshold 273 ) 274 currencyConverter.time = fakeTime 275 276 // First Update call is successful and yields valid rates 277 err1 := currencyConverter.Run() 278 assert.Nil(t, err1) 279 280 // Verify rates are valid and last update timestamp is correct 281 assert.Equal(t, expectedRates, currencyConverter.Rates(), "Conversions.Rates weren't the expected ones") 282 assert.Equal(t, fakeTime.time, currencyConverter.LastUpdated(), "LastUpdated should be set") 283 284 // Advance time so the current time is well past the the time the rates were last updated 285 fakeTime.time = initialFakeTime.Add(24 * time.Hour) 286 287 // Second Update call results in error but rates from a day ago are still valid 288 err2 := currencyConverter.Run() 289 assert.NotNil(t, err2) 290 291 // Verify rates are valid and last update ts is correct 292 assert.Equal(t, expectedRates, currencyConverter.Rates(), "Conversions.Rates weren't the expected ones") 293 assert.Equal(t, initialFakeTime, currencyConverter.LastUpdated(), "LastUpdated should be set") 294 } 295 296 func TestRace(t *testing.T) { 297 // This test is checking that no race conditions appear in rate converter. 298 // It simulate multiple clients (in different goroutines) asking for updates 299 // and rates while the rate converter is also updating periodically. 300 301 // Setup: 302 // Using an HTTP client mock preventing any http client overload while using 303 // very small update intervals (less than 50ms) in this test. 304 // See #722 305 mockedHttpClient := &mockHttpClient{ 306 responseBody: `{ 307 "dataAsOf":"2018-09-12", 308 "conversions":{ 309 "USD":{ 310 "GBP":0.77208 311 }, 312 "GBP":{ 313 "USD":1.2952 314 } 315 } 316 }`, 317 } 318 319 // Execute: 320 // Create a rate converter which will be fetching new values every 1 ms 321 interval := 1 * time.Millisecond 322 currencyConverter := NewRateConverter( 323 mockedHttpClient, 324 "currency.fake.com", 325 24*time.Hour, 326 ) 327 ticker := task.NewTickerTask(interval, currencyConverter) 328 ticker.Start() 329 defer ticker.Stop() 330 331 var wg sync.WaitGroup 332 clientsCount := 10 333 wg.Add(clientsCount) 334 dones := make([]chan bool, clientsCount) 335 336 for c := 0; c < clientsCount; c++ { 337 dones[c] = make(chan bool) 338 go func(done chan bool, clientNum int) { 339 randomTickInterval := time.Duration(clientNum+1) * time.Millisecond 340 clientTicker := time.NewTicker(randomTickInterval) 341 for { 342 select { 343 case <-clientTicker.C: 344 if clientNum < 5 { 345 err := currencyConverter.Run() 346 assert.Nil(t, err) 347 } else { 348 rate, err := currencyConverter.Rates().GetRate("USD", "GBP") 349 assert.Nil(t, err) 350 assert.Equal(t, float64(0.77208), rate) 351 } 352 case <-done: 353 wg.Done() 354 return 355 } 356 } 357 }(dones[c], c) 358 } 359 360 time.Sleep(100 * time.Millisecond) 361 // Sending stop signals to all clients 362 for i := range dones { 363 dones[i] <- true 364 } 365 wg.Wait() 366 } 367 368 // mockHttpClient is a simple http client mock returning a constant response body 369 type mockHttpClient struct { 370 responseBody string 371 } 372 373 func (m *mockHttpClient) Do(req *http.Request) (*http.Response, error) { 374 return &http.Response{ 375 Status: "200 OK", 376 StatusCode: http.StatusOK, 377 Body: io.NopCloser(strings.NewReader(m.responseBody)), 378 }, nil 379 }