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  }