diff --git a/currencies/rate_converter.go b/currencies/rate_converter.go new file mode 100644 index 00000000000..6d3f8ee0fe6 --- /dev/null +++ b/currencies/rate_converter.go @@ -0,0 +1,162 @@ +package currencies + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "sync/atomic" + "time" + + "github.com/golang/glog" +) + +// RateConverter holds the currencies conversion rates dictionnary +type RateConverter struct { + httpClient httpClient + done chan bool + updateNotifier chan<- int + fetchingInterval time.Duration + syncSourceURL string + rates atomic.Value // Should only hold Rates struct + lastUpdated atomic.Value // Should only hold time.Time +} + +// NewRateConverter returns a new RateConverter +func NewRateConverter( + httpClient httpClient, + syncSourceURL string, + fetchingInterval time.Duration, +) *RateConverter { + return NewRateConverterWithNotifier( + httpClient, + syncSourceURL, + fetchingInterval, + nil, // no notifier channel specified, won't send any notifications + ) +} + +// NewRateConverterWithNotifier returns a new RateConverter +// it allow to pass an update chan in which the number of ticks will be passed after each tick +// allowing clients to listen on updates +// Do not use this method +func NewRateConverterWithNotifier( + httpClient httpClient, + syncSourceURL string, + fetchingInterval time.Duration, + updateNotifier chan<- int, +) *RateConverter { + rc := &RateConverter{ + httpClient: httpClient, + done: make(chan bool), + updateNotifier: updateNotifier, + fetchingInterval: fetchingInterval, + syncSourceURL: syncSourceURL, + rates: atomic.Value{}, + lastUpdated: atomic.Value{}, + } + + // In case host do not want to support currency lookup + // we just stop here and do nothing + if rc.fetchingInterval == time.Duration(0) { + return rc + } + + rc.Update() // Make sure to populate data before to return the converter + go rc.startPeriodicFetching() // Start periodic ticking + + return rc +} + +// fetch allows to retrieve the currencies rates from the syncSourceURL provided +func (rc *RateConverter) fetch() (*Rates, error) { + request, err := http.NewRequest("GET", rc.syncSourceURL, nil) + if err != nil { + return nil, err + } + + response, err := rc.httpClient.Do(request) + if err != nil { + return nil, err + } + + defer response.Body.Close() + + bytesJSON, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, err + } + + updatedRates := &Rates{} + err = json.Unmarshal(bytesJSON, updatedRates) + if err != nil { + return nil, err + } + + return updatedRates, err +} + +// Update updates the internal currencies rates from remote sources +func (rc *RateConverter) Update() error { + rates, err := rc.fetch() + if err == nil { + rc.rates.Store(rates) + rc.lastUpdated.Store(time.Now()) + } else { + glog.Errorf("Error updating conversion rates: %v", err) + } + + return err +} + +// startPeriodicFetching starts the periodic fetching at the given interval +// triggers a first fetch when called before the first tick happen in order to initialize currencies rates map +// returns a chan in which the number of data updates everytime a new update was done +func (rc *RateConverter) startPeriodicFetching() { + + ticker := time.NewTicker(rc.fetchingInterval) + updatesTicksCount := 0 + + for { + select { + case <-ticker.C: + // Retries are handled by clients directly. + rc.Update() + updatesTicksCount++ + if rc.updateNotifier != nil { + rc.updateNotifier <- updatesTicksCount + } + case <-rc.done: + if ticker != nil { + ticker.Stop() + ticker = nil + } + return + } + } +} + +// StopPeriodicFetching stops the periodic fetching while keeping the latest currencies rates map +func (rc *RateConverter) StopPeriodicFetching() { + rc.done <- true + close(rc.done) +} + +// LastUpdated returns time when currencies rates were updated +func (rc *RateConverter) LastUpdated() time.Time { + if lastUpdated := rc.lastUpdated.Load(); lastUpdated != nil { + return lastUpdated.(time.Time) + } + return time.Time{} +} + +// Rates returns current rates +func (rc *RateConverter) Rates() *Rates { + if rates := rc.rates.Load(); rates != nil { + return rates.(*Rates) + } + return nil +} + +type httpClient interface { + Do(req *http.Request) (*http.Response, error) +} diff --git a/currencies/rate_converter_test.go b/currencies/rate_converter_test.go new file mode 100644 index 00000000000..7a4b26ee1df --- /dev/null +++ b/currencies/rate_converter_test.go @@ -0,0 +1,543 @@ +package currencies_test + +import ( + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/prebid/prebid-server/currencies" + "github.com/stretchr/testify/assert" +) + +func TestFetch_Success(t *testing.T) { + + // Setup: + calledURLs := []string{} + mockedHttpServer := httptest.NewServer(http.HandlerFunc( + func(rw http.ResponseWriter, req *http.Request) { + calledURLs = append(calledURLs, req.RequestURI) + rw.WriteHeader(http.StatusOK) + rw.Write([]byte( + `{ + "dataAsOf":"2018-09-12", + "conversions":{ + "USD":{ + "GBP":0.77208 + }, + "GBP":{ + "USD":1.2952 + } + } + }`, + )) + }), + ) + + defer mockedHttpServer.Close() + + expectedRates := currencies.Rates{ + DataAsOf: time.Date(2018, time.September, 12, 0, 0, 0, 0, time.UTC), + Conversions: map[string]map[string]float64{ + "USD": { + "GBP": 0.77208, + }, + "GBP": { + "USD": 1.2952, + }, + }, + } + + // Execute: + rateConverter := currencies.NewRateConverter( + &http.Client{}, + mockedHttpServer.URL, + time.Duration(0), + ) + beforeExecution := time.Now() + err := rateConverter.Update() + + // Verify: + assert.Equal(t, 1, len(calledURLs), "sync URL should have been called %d times but was %d", 1, len(calledURLs)) + assert.Nil(t, err, "err should be nil") + assert.NotEqual(t, rateConverter.LastUpdated(), (time.Time{}), "LastUpdated() should return a time set") + assert.True(t, rateConverter.LastUpdated().After(beforeExecution), "LastUpdated() should be after last update") + rates := rateConverter.Rates() + assert.NotNil(t, rates, "Rates() should not return nil") + assert.Equal(t, expectedRates, *rates, "Rates() doesn't return expected rates") +} + +func TestFetch_Fail404(t *testing.T) { + + // Setup: + calledURLs := []string{} + mockedHttpServer := httptest.NewServer(http.HandlerFunc( + func(rw http.ResponseWriter, req *http.Request) { + calledURLs = append(calledURLs, req.RequestURI) + rw.WriteHeader(http.StatusNotFound) + }), + ) + + defer mockedHttpServer.Close() + + // Execute: + rateConverter := currencies.NewRateConverter( + &http.Client{}, + mockedHttpServer.URL, + time.Duration(0), + ) + err := rateConverter.Update() + + // Verify: + assert.Equal(t, 1, len(calledURLs), "sync URL should have been called %d times but was %d", 1, len(calledURLs)) + assert.NotNil(t, err, "err shouldn't be nil") + assert.Equal(t, rateConverter.LastUpdated(), (time.Time{}), "LastUpdated() shouldn't return a time set") + assert.Nil(t, rateConverter.Rates(), "Rates() should return nil") +} + +func TestFetch_FailErrorHttpClient(t *testing.T) { + + // Setup: + calledURLs := []string{} + mockedHttpServer := httptest.NewServer(http.HandlerFunc( + func(rw http.ResponseWriter, req *http.Request) { + calledURLs = append(calledURLs, req.RequestURI) + rw.WriteHeader(http.StatusBadRequest) + }), + ) + + defer mockedHttpServer.Close() + + // Execute: + rateConverter := currencies.NewRateConverter( + &http.Client{}, + mockedHttpServer.URL, + time.Duration(0), + ) + err := rateConverter.Update() + + // Verify: + assert.Equal(t, 1, len(calledURLs), "sync URL should have been called %d times but was %d", 1, len(calledURLs)) + assert.NotNil(t, err, "err shouldn't be nil") + assert.Equal(t, rateConverter.LastUpdated(), (time.Time{}), "LastUpdated() shouldn't return a time set") + assert.Nil(t, rateConverter.Rates(), "Rates() should return nil") +} + +func TestFetch_FailBadSyncURL(t *testing.T) { + + // Setup: + + // Execute: + rateConverter := currencies.NewRateConverter( + &http.Client{}, + "justaweirdurl", + time.Duration(0), + ) + err := rateConverter.Update() + + // Verify: + assert.NotNil(t, err, "err shouldn't be nil") + assert.Equal(t, rateConverter.LastUpdated(), (time.Time{}), "LastUpdated() shouldn't return a time set") + assert.Nil(t, rateConverter.Rates(), "Rates() should return nil") +} + +func TestFetch_FailBadJSON(t *testing.T) { + + // Setup: + calledURLs := []string{} + mockedHttpServer := httptest.NewServer(http.HandlerFunc( + func(rw http.ResponseWriter, req *http.Request) { + calledURLs = append(calledURLs, req.RequestURI) + rw.WriteHeader(http.StatusOK) + rw.Write([]byte( + `{ + "dataAsOf":"2018-09-12", + "conversions":{ + "USD":{ + "GBP":0.77208 + }, + "GBP":{ + "USD":1.2952 + }, + "badJsonHere" + } + }`, + )) + }), + ) + + defer mockedHttpServer.Close() + + // Execute: + rateConverter := currencies.NewRateConverter( + &http.Client{}, + mockedHttpServer.URL, + time.Duration(0), + ) + err := rateConverter.Update() + + // Verify: + assert.Equal(t, 1, len(calledURLs), "sync URL should have been called %d times but was %d", 1, len(calledURLs)) + assert.NotNil(t, err, "err shouldn't be nil") + assert.Equal(t, rateConverter.LastUpdated(), (time.Time{}), "LastUpdated() shouldn't return a time set") + assert.Nil(t, rateConverter.Rates(), "Rates() should return nil") +} + +func TestFetch_InvalidRemoteResponseContent(t *testing.T) { + + // Setup: + calledURLs := []string{} + mockedHttpServer := httptest.NewServer(http.HandlerFunc( + func(rw http.ResponseWriter, req *http.Request) { + calledURLs = append(calledURLs, req.RequestURI) + rw.WriteHeader(http.StatusOK) + rw.Write(nil) + }), + ) + + defer mockedHttpServer.Close() + + // Execute: + rateConverter := currencies.NewRateConverter( + &http.Client{}, + mockedHttpServer.URL, + time.Duration(0), + ) + err := rateConverter.Update() + + // Verify: + assert.Equal(t, 1, len(calledURLs), "sync URL should have been called %d times but was %d", 1, len(calledURLs)) + assert.NotNil(t, err, "err shouldn't be nil") + assert.Equal(t, rateConverter.LastUpdated(), (time.Time{}), "LastUpdated() shouldn't return a time set") + assert.Nil(t, rateConverter.Rates(), "Rates() should return nil") +} + +func TestInit(t *testing.T) { + + // Setup: + mockedHttpServer := httptest.NewServer(http.HandlerFunc( + func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte( + `{ + "dataAsOf":"2018-09-12", + "conversions":{ + "USD":{ + "GBP":0.77208 + }, + "GBP":{ + "USD":1.2952 + } + } + }`, + )) + }), + ) + + // Execute: + expectedTicks := 5 + ticksTimes := []time.Time{} + ticks := make(chan int) + rateConverter := currencies.NewRateConverterWithNotifier( + &http.Client{}, + mockedHttpServer.URL, + time.Duration(100)*time.Millisecond, + ticks, + ) + + // Verify: + expectedIntervalDuration := time.Duration(100) * time.Millisecond + errorMargin := 0.1 // 10% error margin + expectedRates := ¤cies.Rates{ + DataAsOf: time.Date(2018, time.September, 12, 0, 0, 0, 0, time.UTC), + Conversions: map[string]map[string]float64{ + "USD": { + "GBP": 0.77208, + }, + "GBP": { + "USD": 1.2952, + }, + }, + } + + // At each ticks, do couple checks + for ticksCount := range ticks { + ticksTimes = append(ticksTimes, time.Now()) + if len(ticksTimes) > 1 { + intervalDuration := ticksTimes[len(ticksTimes)-1].Truncate(time.Millisecond).Sub(ticksTimes[len(ticksTimes)-2].Truncate(time.Millisecond)) + intervalDiff := float64(float64(intervalDuration.Nanoseconds()) / float64(expectedIntervalDuration.Nanoseconds())) + assert.False(t, intervalDiff > float64(errorMargin*100), "Interval between ticks should be: %d but was: %d", expectedIntervalDuration, intervalDuration) + } + + assert.NotNil(t, rateConverter.Rates(), "Rates shouldn't be nil") + assert.NotEqual(t, rateConverter.LastUpdated(), (time.Time{}), "LastUpdated should be set") + rates := rateConverter.Rates() + assert.Equal(t, expectedRates, rates, "Conversions.Rates weren't the expected ones") + + if ticksCount == expectedTicks { + rateConverter.StopPeriodicFetching() + return + } + } +} + +func TestStop(t *testing.T) { + + // Setup: + calledURLs := []string{} + mockedHttpServer := httptest.NewServer(http.HandlerFunc( + func(rw http.ResponseWriter, req *http.Request) { + calledURLs = append(calledURLs, req.RequestURI) + rw.WriteHeader(http.StatusOK) + rw.Write([]byte( + `{ + "dataAsOf":"2018-09-12", + "conversions":{ + "USD":{ + "GBP":0.77208 + }, + "GBP":{ + "USD":1.2952 + } + } + }`, + )) + }), + ) + + // Execute: + expectedTicks := 2 + ticks := make(chan int) + rateConverter := currencies.NewRateConverterWithNotifier( + &http.Client{}, + mockedHttpServer.URL, + time.Duration(100)*time.Millisecond, + ticks, + ) + + // Let the currency converter fetch 5 times before stopping it + for ticksCount := range ticks { + if ticksCount == expectedTicks { + rateConverter.StopPeriodicFetching() + break + } + } + lastFetched := time.Now() + + // Verify: + // Check for the next 1 second that no fetch was triggered + time.Sleep(1 * time.Second) + + assert.False(t, rateConverter.LastUpdated().After(lastFetched), "LastUpdated() shouldn't be after `lastFetched` since the periodic fetching is stopped") +} + +func TestInitWithZeroDuration(t *testing.T) { + + // Setup: + calledURLs := []string{} + mockedHttpServer := httptest.NewServer(http.HandlerFunc( + func(rw http.ResponseWriter, req *http.Request) { + calledURLs = append(calledURLs, req.RequestURI) + rw.WriteHeader(http.StatusOK) + rw.Write([]byte( + `{ + "dataAsOf":"2018-09-12", + "conversions":{ + "USD":{ + "GBP":0.77208 + }, + "GBP":{ + "USD":1.2952 + } + } + }`, + )) + }), + ) + + // Execute: + rateConverter := currencies.NewRateConverter( + &http.Client{}, + mockedHttpServer.URL, + time.Duration(0)*time.Millisecond, + ) + + // Verify: + // Check for the next 1 second that no fetch was triggered + time.Sleep(1 * time.Second) + + assert.Equal(t, 0, len(calledURLs), "sync URL shouldn't have been called but was called %d times", 0, len(calledURLs)) + assert.Equal(t, (time.Time{}), rateConverter.LastUpdated(), "LastUpdated() shouldn't be set") + assert.Nil(t, rateConverter.Rates(), "Rates should be nil") +} + +func TestRates(t *testing.T) { + + // Setup: + testCases := []struct { + from string + to string + expectedRate float64 + hasError bool + }{ + {from: "USD", to: "GBP", expectedRate: 0.77208, hasError: false}, + {from: "GBP", to: "USD", expectedRate: 1.2952, hasError: false}, + {from: "GBP", to: "EUR", expectedRate: 0, hasError: true}, + {from: "CNY", to: "EUR", expectedRate: 0, hasError: true}, + {from: "", to: "EUR", expectedRate: 0, hasError: true}, + {from: "CNY", to: "", expectedRate: 0, hasError: true}, + {from: "", to: "", expectedRate: 0, hasError: true}, + } + + mockedHttpServer := httptest.NewServer(http.HandlerFunc( + func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte( + `{ + "dataAsOf":"2018-09-12", + "conversions":{ + "USD":{ + "GBP":0.77208 + }, + "GBP":{ + "USD":1.2952 + } + } + }`, + )) + }), + ) + + // Execute: + ticks := make(chan int) + rateConverter := currencies.NewRateConverterWithNotifier( + &http.Client{}, + mockedHttpServer.URL, + time.Duration(100)*time.Millisecond, + ticks, + ) + rates := rateConverter.Rates() + + // Let the currency converter ticks 1 time before to stop it + select { + case <-ticks: + rateConverter.StopPeriodicFetching() + } + + // Verify: + assert.NotNil(t, rates, "rates shouldn't be nil") + for _, tc := range testCases { + rate, err := rates.GetRate(tc.from, tc.to) + + if tc.hasError { + assert.NotNil(t, err, "err shouldn't be nil") + assert.Equal(t, float64(0), rate, "rate should be 0") + } else { + assert.Nil(t, err, "err should be nil") + assert.Equal(t, tc.expectedRate, rate, "rate doesn't match the expected one") + } + } +} + +func TestRates_EmptyRates(t *testing.T) { + + // Setup: + mockedHttpServer := httptest.NewServer(http.HandlerFunc( + func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte("")) + }), + ) + + // Execute: + // Will try to fetch directly on method call but will fail + rateConverter := currencies.NewRateConverter( + &http.Client{}, + mockedHttpServer.URL, + time.Duration(100)*time.Millisecond, + ) + defer rateConverter.StopPeriodicFetching() + rates := rateConverter.Rates() + + // Verify: + assert.Nil(t, rates, "rates should be nil") +} + +func TestRace(t *testing.T) { + + // This test is checking that no race conditions appear in rate converter. + // It simulate multiple clients (in different goroutines) asking for updates + // and rates while the rate converter is also updating periodically. + + // Setup: + mockedHttpServer := httptest.NewServer(http.HandlerFunc( + func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte( + `{ + "dataAsOf":"2018-09-12", + "conversions":{ + "USD":{ + "GBP":0.77208 + }, + "GBP":{ + "USD":1.2952 + } + } + }`, + )) + }), + ) + + // Execute: + + // Create a rate converter which will be fetching new values every 10 ms + rateConverter := currencies.NewRateConverter( + &http.Client{}, + mockedHttpServer.URL, + time.Duration(10)*time.Millisecond, + ) + defer rateConverter.StopPeriodicFetching() + + // Create 50 clients asking for updates and rates conversion at random intervals + // from 1ms to 50ms for 10 seconds + var wg sync.WaitGroup + clientsCount := 50 + wg.Add(clientsCount) + dones := make([]chan bool, clientsCount) + + for c := 0; c < clientsCount; c++ { + dones[c] = make(chan bool) + go func(done chan bool, clientNum int) { + randomTickInterval := time.Duration(clientNum+1) * time.Millisecond + clientTicker := time.NewTicker(randomTickInterval) + for { + select { + case tickTime := <-clientTicker.C: + // Either ask for an Update() or for GetRate() + // based on the tick ms + tickMs := tickTime.UnixNano() / int64(time.Millisecond) + if tickMs%2 == 0 { + err := rateConverter.Update() + assert.Nil(t, err) + } else { + rate, err := rateConverter.Rates().GetRate("USD", "GBP") + assert.Nil(t, err) + assert.Equal(t, float64(0.77208), rate) + } + case <-done: + wg.Done() + return + } + } + }(dones[c], c) + } + + time.Sleep(10 * time.Second) + // Sending stop signals to all clients + for i := range dones { + dones[i] <- true + } + wg.Wait() +} diff --git a/currencies/rates.go b/currencies/rates.go new file mode 100644 index 00000000000..5e740e2081a --- /dev/null +++ b/currencies/rates.go @@ -0,0 +1,54 @@ +package currencies + +import ( + "encoding/json" + "errors" + "fmt" + "time" +) + +// Rates holds data as represented on http://currency.prebid.org/latest.json +// note that `DataAsOfRaw` field is needed when parsing remote JSON as the date format if not standard and requires +// custom parsing to be properly set as Golang time.Time +type Rates struct { + DataAsOf time.Time `json:"dataAsOf"` + Conversions map[string]map[string]float64 `json:"conversions"` +} + +func NewRates(dataAsOf time.Time, conversions map[string]map[string]float64) *Rates { + return &Rates{ + DataAsOf: dataAsOf, + Conversions: conversions, + } +} + +func (r *Rates) UnmarshalJSON(b []byte) error { + c := &struct { + DataAsOf string `json:"dataAsOf"` + Conversions map[string]map[string]float64 `json:"conversions"` + }{} + if err := json.Unmarshal(b, &c); err != nil { + return err + } + + r.Conversions = c.Conversions + + layout := "2006-01-02" + if date, err := time.Parse(layout, c.DataAsOf); err == nil { + r.DataAsOf = date + } + + return nil +} + +// GetRate returns the conversion rate between two currencies +// returns an error in case the conversion rate between the two given currencies is not in the currencies rates map +func (r *Rates) GetRate(from string, to string) (float64, error) { + if r.Conversions != nil { + if conversion, present := r.Conversions[from][to]; present == true { + return conversion, nil + } + return 0, fmt.Errorf("conversion %s->%s not present in rates dictionnary", from, to) + } + return 0, errors.New("rates are nil") +} diff --git a/currencies/rates_test.go b/currencies/rates_test.go new file mode 100644 index 00000000000..75408c4345d --- /dev/null +++ b/currencies/rates_test.go @@ -0,0 +1,176 @@ +package currencies_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/prebid/prebid-server/currencies" +) + +func TestUnMarshallRates(t *testing.T) { + + // Setup: + testCases := []struct { + ratesJSON string + expectedRates currencies.Rates + expectsError bool + }{ + { + ratesJSON: `{ + "dataAsOf":"2018-09-12", + "conversions":{ + "USD":{ + "GBP":0.7662523901 + }, + "GBP":{ + "USD":1.3050530256 + } + } + }`, + expectedRates: currencies.Rates{ + DataAsOf: time.Date(2018, time.September, 12, 0, 0, 0, 0, time.UTC), + Conversions: map[string]map[string]float64{ + "USD": { + "GBP": 0.7662523901, + }, + "GBP": { + "USD": 1.3050530256, + }, + }, + }, + expectsError: false, + }, + { + ratesJSON: `{ + "dataAsOf":"", + "conversions":{ + "USD":{ + "GBP":0.7662523901 + }, + "GBP":{ + "USD":1.3050530256 + } + } + }`, + expectedRates: currencies.Rates{ + DataAsOf: time.Time{}, + Conversions: map[string]map[string]float64{ + "USD": { + "GBP": 0.7662523901, + }, + "GBP": { + "USD": 1.3050530256, + }, + }, + }, + expectsError: false, + }, + { + ratesJSON: `{ + "dataAsOf":"blabla", + "conversions":{ + "USD":{ + "GBP":0.7662523901 + }, + "GBP":{ + "USD":1.3050530256 + } + } + }`, + expectedRates: currencies.Rates{ + DataAsOf: time.Time{}, + Conversions: map[string]map[string]float64{ + "USD": { + "GBP": 0.7662523901, + }, + "GBP": { + "USD": 1.3050530256, + }, + }, + }, + expectsError: false, + }, + { + ratesJSON: `{ + "dataAsOf":"blabla", + "conversions":{ + "USD":{ + "GBP":0.7662523901, + }, + "GBP":{ + "USD":1.3050530256, + } + } + }`, + expectedRates: currencies.Rates{}, + expectsError: true, + }, + } + + for _, tc := range testCases { + + // Execute: + updatedRates := currencies.Rates{} + err := json.Unmarshal([]byte(tc.ratesJSON), &updatedRates) + + // Verify: + assert.Equal(t, err != nil, tc.expectsError) + assert.Equal(t, tc.expectedRates, updatedRates, "Rates weren't the expected ones") + } +} + +func TestGetRate(t *testing.T) { + + // Setup: + rates := currencies.NewRates(time.Now(), map[string]map[string]float64{ + "USD": { + "GBP": 0.77208, + }, + "GBP": { + "USD": 1.2952, + }, + }) + + testCases := []struct { + from string + to string + expectedRate float64 + hasError bool + }{ + {from: "USD", to: "GBP", expectedRate: 0.77208, hasError: false}, + {from: "GBP", to: "USD", expectedRate: 1.2952, hasError: false}, + {from: "GBP", to: "EUR", expectedRate: 0, hasError: true}, + {from: "CNY", to: "EUR", expectedRate: 0, hasError: true}, + {from: "", to: "EUR", expectedRate: 0, hasError: true}, + {from: "CNY", to: "", expectedRate: 0, hasError: true}, + {from: "", to: "", expectedRate: 0, hasError: true}, + } + + // Verify: + for _, tc := range testCases { + rate, err := rates.GetRate(tc.from, tc.to) + + if tc.hasError { + assert.NotNil(t, err, "err shouldn't be nil") + assert.Equal(t, float64(0), rate, "rate should be 0") + } else { + assert.Nil(t, err, "err should be nil") + assert.Equal(t, tc.expectedRate, rate, "rate doesn't match the expected one") + } + } +} + +func TestGetRate_EmptyRates(t *testing.T) { + + // Setup: + rates := currencies.NewRates(time.Time{}, nil) + + // Verify: + rate, err := rates.GetRate("USD", "EUR") + + assert.NotNil(t, err, "err shouldn't be nil") + assert.Equal(t, float64(0), rate, "rate should be 0") +}