Create utility endpoint for currency conversion

This commit is contained in:
Moxie Marlinspike 2020-12-17 10:27:54 -08:00 committed by Moxie Marlinspike
parent 47916ecb0f
commit 2dbab70c8c
10 changed files with 515 additions and 9 deletions

View File

@ -40,6 +40,7 @@ import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
import io.micrometer.wavefront.WavefrontConfig;
import io.micrometer.wavefront.WavefrontMeterRegistry;
import java.net.http.HttpClient;
import java.security.Security;
import java.time.Duration;
import java.util.ArrayList;
@ -87,6 +88,9 @@ import org.whispersystems.textsecuregcm.controllers.SecureBackupController;
import org.whispersystems.textsecuregcm.controllers.SecureStorageController;
import org.whispersystems.textsecuregcm.controllers.StickerController;
import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController;
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
import org.whispersystems.textsecuregcm.currency.FixerClient;
import org.whispersystems.textsecuregcm.currency.FtxClient;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;
import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter;
@ -364,6 +368,11 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
accountDatabaseCrawlerListeners.add(new AccountCleaner(accountsManager));
accountDatabaseCrawlerListeners.add(new RegistrationLockVersionCounter(metricsCluster, config.getMetricsFactory()));
HttpClient currencyClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).connectTimeout(Duration.ofSeconds(10)).build();
FixerClient fixerClient = new FixerClient(currencyClient, config.getPaymentsServiceConfiguration().getFixerApiKey());
FtxClient ftxClient = new FtxClient(currencyClient);
CurrencyConversionManager currencyManager = new CurrencyConversionManager(fixerClient, ftxClient, config.getPaymentsServiceConfiguration().getPaymentCurrencies());
AccountDatabaseCrawlerCache accountDatabaseCrawlerCache = new AccountDatabaseCrawlerCache(cacheCluster);
AccountDatabaseCrawler accountDatabaseCrawler = new AccountDatabaseCrawler(accountsManager, accountDatabaseCrawlerCache, accountDatabaseCrawlerListeners, config.getAccountDatabaseCrawlerConfiguration().getChunkSize(), config.getAccountDatabaseCrawlerConfiguration().getChunkIntervalMs());
@ -419,7 +428,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.jersey().register(new VoiceVerificationController(config.getVoiceVerificationConfiguration().getUrl(), config.getVoiceVerificationConfiguration().getLocales()));
environment.jersey().register(new SecureStorageController(storageCredentialsGenerator));
environment.jersey().register(new SecureBackupController(backupCredentialsGenerator));
environment.jersey().register(new PaymentsController(paymentsCredentialsGenerator));
environment.jersey().register(new PaymentsController(currencyManager, paymentsCredentialsGenerator));
environment.jersey().register(attachmentControllerV1);
environment.jersey().register(attachmentControllerV2);
environment.jersey().register(attachmentControllerV3);

View File

@ -9,7 +9,9 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty;
import java.util.List;
public class PaymentsServiceConfiguration {
@ -17,8 +19,23 @@ public class PaymentsServiceConfiguration {
@JsonProperty
private String userAuthenticationTokenSharedSecret;
@NotEmpty
@JsonProperty
private String fixerApiKey;
@NotEmpty
@JsonProperty
private List<String> paymentCurrencies;
public byte[] getUserAuthenticationTokenSharedSecret() throws DecoderException {
return Hex.decodeHex(userAuthenticationTokenSharedSecret.toCharArray());
}
public String getFixerApiKey() {
return fixerApiKey;
}
public List<String> getPaymentCurrencies() {
return paymentCurrencies;
}
}

View File

@ -6,22 +6,27 @@
package org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.annotation.Timed;
import io.dropwizard.auth.Auth;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import io.dropwizard.auth.Auth;
@Path("/v1/payments")
public class PaymentsController {
private final ExternalServiceCredentialGenerator paymentsServiceCredentialGenerator;
private final CurrencyConversionManager currencyManager;
public PaymentsController(ExternalServiceCredentialGenerator paymentsServiceCredentialGenerator) {
public PaymentsController(CurrencyConversionManager currencyManager, ExternalServiceCredentialGenerator paymentsServiceCredentialGenerator) {
this.currencyManager = currencyManager;
this.paymentsServiceCredentialGenerator = paymentsServiceCredentialGenerator;
}
@ -32,4 +37,12 @@ public class PaymentsController {
public ExternalServiceCredentials getAuth(@Auth Account account) {
return paymentsServiceCredentialGenerator.generateFor(account.getUuid().toString());
}
@Timed
@GET
@Path("/conversions")
@Produces(MediaType.APPLICATION_JSON)
public CurrencyConversionEntityList getConversions(@Auth Account account) {
return currencyManager.getCurrencyConversions().orElseThrow();
}
}

View File

@ -0,0 +1,117 @@
package org.whispersystems.textsecuregcm.currency;
import com.google.common.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntity;
import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;
import org.whispersystems.textsecuregcm.util.Util;
import java.io.IOException;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import io.dropwizard.lifecycle.Managed;
public class CurrencyConversionManager implements Managed {
private static final Logger logger = LoggerFactory.getLogger(CurrencyConversionManager.class);
private static final long FIXER_INTERVAL = TimeUnit.HOURS.toMillis(2);
private static final long FTX_INTERVAL = TimeUnit.MINUTES.toMillis(5);
private final FixerClient fixerClient;
private final FtxClient ftxClient;
private final List<String> currencies;
private AtomicReference<CurrencyConversionEntityList> cached = new AtomicReference<>(null);
private long fixerUpdatedTimestamp;
private long ftxUpdatedTimestamp;
private Map<String, Double> cachedFixerValues;
private Map<String, Double> cachedFtxValues;
public CurrencyConversionManager(FixerClient fixerClient, FtxClient ftxClient, List<String> currencies) {
this.fixerClient = fixerClient;
this.ftxClient = ftxClient;
this.currencies = currencies;
}
public Optional<CurrencyConversionEntityList> getCurrencyConversions() {
return Optional.ofNullable(cached.get());
}
@Override
public void start() throws Exception {
new Thread(() -> {
for (;;) {
try {
updateCacheIfNecessary();
} catch (Throwable t) {
logger.warn("Error updating currency conversions", t);
}
Util.sleep(15000);
}
}).start();
}
@Override
public void stop() throws Exception {
}
@VisibleForTesting
void updateCacheIfNecessary() throws IOException {
if (System.currentTimeMillis() - fixerUpdatedTimestamp > FIXER_INTERVAL || cachedFixerValues == null) {
this.cachedFixerValues = new HashMap<>(fixerClient.getConversionsForBase("USD"));
this.fixerUpdatedTimestamp = System.currentTimeMillis();
}
if (System.currentTimeMillis() - ftxUpdatedTimestamp > FTX_INTERVAL || cachedFtxValues == null) {
Map<String, Double> cachedFtxValues = new HashMap<>();
for (String currency : currencies) {
cachedFtxValues.put(currency, ftxClient.getSpotPrice(currency, "USD"));
}
this.cachedFtxValues = cachedFtxValues;
this.ftxUpdatedTimestamp = System.currentTimeMillis();
}
List<CurrencyConversionEntity> entities = new LinkedList<>();
for (Map.Entry<String, Double> currency : cachedFtxValues.entrySet()) {
double usdValue = currency.getValue();
Map<String, Double> values = new HashMap<>();
values.put("USD", usdValue);
for (Map.Entry<String, Double> conversion : cachedFixerValues.entrySet()) {
values.put(conversion.getKey(), conversion.getValue() * usdValue);
}
entities.add(new CurrencyConversionEntity(currency.getKey(), values));
}
this.cached.set(new CurrencyConversionEntityList(entities, Math.min(fixerUpdatedTimestamp, ftxUpdatedTimestamp)));
}
@VisibleForTesting
void setFixerUpdatedTimestamp(long timestamp) {
this.fixerUpdatedTimestamp = timestamp;
}
@VisibleForTesting
void setFtxUpdatedTimestamp(long timestamp) {
this.ftxUpdatedTimestamp = timestamp;
}
}

View File

@ -0,0 +1,75 @@
package org.whispersystems.textsecuregcm.currency;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
public class FixerClient {
private final String apiKey;
private final HttpClient client;
public FixerClient(HttpClient client, String apiKey) {
this.apiKey = apiKey;
this.client = client;
}
public Map<String, Double> getConversionsForBase(String base) throws FixerException {
try {
URI uri = URI.create("https://data.fixer.io/api/latest?access_key=" + apiKey + "&base=" + base);
HttpResponse<String> response = client.send(HttpRequest.newBuilder()
.GET()
.uri(uri)
.build(),
HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new FixerException("Bad response: " + response.statusCode() + " " + response.toString());
}
FixerResponse parsedResponse = SystemMapper.getMapper().readValue(response.body(), FixerResponse.class);
if (parsedResponse.success) return parsedResponse.rates;
else throw new FixerException("Got failed response!");
} catch (IOException | InterruptedException e) {
throw new FixerException(e);
}
}
private static class FixerResponse {
@JsonProperty
private boolean success;
@JsonProperty
private long timestamp;
@JsonProperty
private String base;
@JsonProperty
private String date;
@JsonProperty
private Map<String, Double> rates;
}
public static class FixerException extends IOException {
public FixerException(String message) {
super(message);
}
public FixerException(Exception exception) {
super(exception);
}
}
}

View File

@ -0,0 +1,68 @@
package org.whispersystems.textsecuregcm.currency;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class FtxClient {
private final HttpClient client;
public FtxClient(HttpClient client) {
this.client = client;
}
public double getSpotPrice(String currency, String base) throws FtxException{
try {
URI uri = URI.create("https://ftx.com/api/markets/" + currency + "/" + base);
HttpResponse<String> response = client.send(HttpRequest.newBuilder()
.GET()
.uri(uri)
.build(),
HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new FtxException("Bad response: " + response.statusCode() + " " + response.toString());
}
FtxResponse parsedResponse = SystemMapper.getMapper().readValue(response.body(), FtxResponse.class);
return parsedResponse.result.price;
} catch (IOException | InterruptedException e) {
throw new FtxException(e);
}
}
private static class FtxResponse {
@JsonProperty
private FtxResult result;
}
private static class FtxResult {
@JsonProperty
private double price;
}
public static class FtxException extends IOException {
public FtxException(String message) {
super(message);
}
public FtxException(Exception exception) {
super(exception);
}
}
}

View File

@ -0,0 +1,30 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Map;
public class CurrencyConversionEntity {
@JsonProperty
private String base;
@JsonProperty
private Map<String, Double> conversions;
public CurrencyConversionEntity(String base, Map<String, Double> conversions) {
this.base = base;
this.conversions = conversions;
}
public CurrencyConversionEntity() {}
public String getBase() {
return base;
}
public Map<String, Double> getConversions() {
return conversions;
}
}

View File

@ -0,0 +1,29 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public class CurrencyConversionEntityList {
@JsonProperty
private List<CurrencyConversionEntity> currencies;
@JsonProperty
private long timestamp;
public CurrencyConversionEntityList(List<CurrencyConversionEntity> currencies, long timestamp) {
this.currencies = currencies;
this.timestamp = timestamp;
}
public CurrencyConversionEntityList() {}
public List<CurrencyConversionEntity> getCurrencies() {
return currencies;
}
public long getTimestamp() {
return timestamp;
}
}

View File

@ -0,0 +1,126 @@
package org.whispersystems.textsecuregcm.currency;
import org.junit.Test;
import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class CurrencyConversionManagerTest {
@Test
public void testCurrencyCalculations() throws IOException {
FixerClient fixerClient = mock(FixerClient.class);
FtxClient ftxClient = mock(FtxClient.class);
when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(2.35);
when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of("EUR", 0.822876, "FJD", 2.0577,"FKP", 0.743446));
CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, ftxClient, List.of("FOO"));
manager.updateCacheIfNecessary();
CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow();
assertThat(conversions.getCurrencies().size()).isEqualTo(1);
assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo("FOO");
assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(4);
assertThat(conversions.getCurrencies().get(0).getConversions().get("USD")).isEqualTo(2.35);
assertThat(conversions.getCurrencies().get(0).getConversions().get("EUR")).isEqualTo(1.9337586000000002);
assertThat(conversions.getCurrencies().get(0).getConversions().get("FJD")).isEqualTo(4.8355950000000005);
assertThat(conversions.getCurrencies().get(0).getConversions().get("FKP")).isEqualTo(1.7470981);
}
@Test
public void testCurrencyCalculationsTimeoutNoRun() throws IOException {
FixerClient fixerClient = mock(FixerClient.class);
FtxClient ftxClient = mock(FtxClient.class);
when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(2.35);
when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of("EUR", 0.822876, "FJD", 2.0577,"FKP", 0.743446));
CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, ftxClient, List.of("FOO"));
manager.updateCacheIfNecessary();
when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(3.50);
manager.updateCacheIfNecessary();
CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow();
assertThat(conversions.getCurrencies().size()).isEqualTo(1);
assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo("FOO");
assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(4);
assertThat(conversions.getCurrencies().get(0).getConversions().get("USD")).isEqualTo(2.35);
assertThat(conversions.getCurrencies().get(0).getConversions().get("EUR")).isEqualTo(1.9337586000000002);
assertThat(conversions.getCurrencies().get(0).getConversions().get("FJD")).isEqualTo(4.8355950000000005);
assertThat(conversions.getCurrencies().get(0).getConversions().get("FKP")).isEqualTo(1.7470981);
}
@Test
public void testCurrencyCalculationsFtxTimeoutWithRun() throws IOException {
FixerClient fixerClient = mock(FixerClient.class);
FtxClient ftxClient = mock(FtxClient.class);
when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(2.35);
when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of("EUR", 0.822876, "FJD", 2.0577,"FKP", 0.743446));
CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, ftxClient, List.of("FOO"));
manager.updateCacheIfNecessary();
when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(3.50);
manager.setFtxUpdatedTimestamp(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2) - TimeUnit.SECONDS.toMillis(1));
manager.updateCacheIfNecessary();
CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow();
assertThat(conversions.getCurrencies().size()).isEqualTo(1);
assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo("FOO");
assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(4);
assertThat(conversions.getCurrencies().get(0).getConversions().get("USD")).isEqualTo(3.5);
assertThat(conversions.getCurrencies().get(0).getConversions().get("EUR")).isEqualTo(2.8800660000000002);
assertThat(conversions.getCurrencies().get(0).getConversions().get("FJD")).isEqualTo(7.20195);
assertThat(conversions.getCurrencies().get(0).getConversions().get("FKP")).isEqualTo(2.602061);
}
@Test
public void testCurrencyCalculationsFixerTimeoutWithRun() throws IOException {
FixerClient fixerClient = mock(FixerClient.class);
FtxClient ftxClient = mock(FtxClient.class);
when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(2.35);
when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of("EUR", 0.822876, "FJD", 2.0577,"FKP", 0.743446));
CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, ftxClient, List.of("FOO"));
manager.updateCacheIfNecessary();
when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(3.50);
when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of("EUR", 0.922876, "FJD", 2.0577,"FKP", 0.743446));
manager.setFixerUpdatedTimestamp(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2) - TimeUnit.SECONDS.toMillis(1));
manager.updateCacheIfNecessary();
CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow();
assertThat(conversions.getCurrencies().size()).isEqualTo(1);
assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo("FOO");
assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(4);
assertThat(conversions.getCurrencies().get(0).getConversions().get("USD")).isEqualTo(2.35);
assertThat(conversions.getCurrencies().get(0).getConversions().get("EUR")).isEqualTo(2.1687586000000003);
assertThat(conversions.getCurrencies().get(0).getConversions().get("FJD")).isEqualTo(4.8355950000000005);
assertThat(conversions.getCurrencies().get(0).getConversions().get("FKP")).isEqualTo(1.7470981);
}
}

View File

@ -6,9 +6,6 @@
package org.whispersystems.textsecuregcm.tests.controllers;
import com.google.common.collect.ImmutableSet;
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.testing.junit.ResourceTestRule;
import org.assertj.core.api.Assertions;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.Before;
import org.junit.ClassRule;
@ -17,19 +14,28 @@ import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccount;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.controllers.PaymentsController;
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntity;
import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import javax.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.testing.junit.ResourceTestRule;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class PaymentsControllerTest {
private static final ExternalServiceCredentialGenerator paymentsCredentialGenerator = mock(ExternalServiceCredentialGenerator.class);
private static final CurrencyConversionManager currencyManager = mock(CurrencyConversionManager.class);
private final ExternalServiceCredentials validCredentials = new ExternalServiceCredentials("username", "password");
@ -38,13 +44,14 @@ public class PaymentsControllerTest {
.addProvider(AuthHelper.getAuthFilter())
.addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(ImmutableSet.of(Account.class, DisabledPermittedAccount.class)))
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new PaymentsController(paymentsCredentialGenerator))
.addResource(new PaymentsController(currencyManager, paymentsCredentialGenerator))
.build();
@Before
public void setup() {
when(paymentsCredentialGenerator.generateFor(eq(AuthHelper.VALID_UUID.toString()))).thenReturn(validCredentials);
when(currencyManager.getCurrencyConversions()).thenReturn(Optional.of(new CurrencyConversionEntityList(List.of(new CurrencyConversionEntity("FOO", Map.of("USD", 2.35, "EUR", 1.89)), new CurrencyConversionEntity("BAR", Map.of("USD", 1.50, "EUR", 0.98))), System.currentTimeMillis())));
}
@Test
@ -83,4 +90,19 @@ public class PaymentsControllerTest {
assertThat(response.getStatus()).isEqualTo(401);
}
@Test
public void testGetCurrencyConversions() {
CurrencyConversionEntityList conversions =
resources.getJerseyTest()
.target("/v1/payments/conversions")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD))
.get(CurrencyConversionEntityList.class);
assertThat(conversions.getCurrencies().size()).isEqualTo(2);
assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo("FOO");
assertThat(conversions.getCurrencies().get(0).getConversions().get("USD")).isEqualTo(2.35);
}
}