diff --git a/tests/api/api.go b/tests/api/api.go index dd62109f..76e245d2 100644 --- a/tests/api/api.go +++ b/tests/api/api.go @@ -46,6 +46,9 @@ var commonTests = map[string]func(t *testing.T, th *TestHandler){ "GetAddress": testGetAddress, "GetAddressTxids": testGetAddressTxids, "GetAddressTxs": testGetAddressTxs, + "GetCurrentFiatRates": testGetCurrentFiatRates, + "GetTickersList": testGetTickersList, + "GetMultiTickers": testGetMultiTickers, } var utxoOnlyTests = map[string]func(t *testing.T, th *TestHandler){ @@ -106,6 +109,9 @@ type TestHandler struct { sampleBlockHash string sampleContractResolved bool sampleContract string + sampleFiatResolved bool + sampleFiatAvailable bool + sampleFiatTicker fiatTickerResponse capabilitiesResolved bool supportsUTXO bool @@ -120,7 +126,9 @@ type statusEnvelope struct { } type statusBlockbook struct { - BestHeight int `json:"bestHeight"` + BestHeight int `json:"bestHeight"` + HasFiatRates bool `json:"hasFiatRates"` + CurrentFiatRatesTime *time.Time `json:"currentFiatRatesTime"` } type blockIndexResponse struct { @@ -180,6 +188,16 @@ type utxoResponse struct { Height int `json:"height"` } +type fiatTickerResponse struct { + Timestamp int64 `json:"ts"` + Rates map[string]float32 `json:"rates"` +} + +type availableVsCurrenciesResponse struct { + Timestamp int64 `json:"ts"` + Tickers []string `json:"available_currencies"` +} + type wsRequest struct { ID string `json:"id"` Method string `json:"method"` diff --git a/tests/api/http_tests.go b/tests/api/http_tests.go index 6ed69ead..b9daf2d8 100644 --- a/tests/api/http_tests.go +++ b/tests/api/http_tests.go @@ -122,6 +122,81 @@ func testGetAddress(t *testing.T, h *TestHandler) { } } +func testGetCurrentFiatRates(t *testing.T, h *TestHandler) { + ticker := h.sampleFiatTickerOrSkip(t) + assertFiatTickerPayload(t, &ticker, "GetCurrentFiatRates") + + rate, ok := ticker.Rates["usd"] + if !ok { + t.Fatalf("GetCurrentFiatRates missing requested usd rate") + } + if rate == 0 { + t.Fatalf("GetCurrentFiatRates usd rate must not be zero") + } +} + +func testGetTickersList(t *testing.T, h *TestHandler) { + ticker := h.sampleFiatTickerOrSkip(t) + + path := fmt.Sprintf("/api/v2/tickers-list?timestamp=%d", ticker.Timestamp) + var list availableVsCurrenciesResponse + h.mustGetFiatJSONOrSkip(t, path, &list) + + if list.Timestamp <= 0 { + t.Fatalf("GetTickersList invalid timestamp: %d", list.Timestamp) + } + if len(list.Tickers) == 0 { + t.Fatalf("GetTickersList returned no currencies") + } + for i := range list.Tickers { + assertNonEmptyString(t, list.Tickers[i], "GetTickersList.available_currencies") + } +} + +func testGetMultiTickers(t *testing.T, h *TestHandler) { + ticker := h.sampleFiatTickerOrSkip(t) + + listPath := fmt.Sprintf("/api/v2/tickers-list?timestamp=%d", ticker.Timestamp) + var list availableVsCurrenciesResponse + h.mustGetFiatJSONOrSkip(t, listPath, &list) + if len(list.Tickers) == 0 { + t.Skipf("Skipping test, no available fiat currencies for timestamp %d", ticker.Timestamp) + } + + currency := strings.ToLower(strings.TrimSpace(list.Tickers[0])) + if currency == "" { + t.Fatalf("GetMultiTickers invalid empty currency from tickers-list") + } + + var single fiatTickerResponse + singlePath := fmt.Sprintf("/api/v2/tickers?timestamp=%d¤cy=%s", ticker.Timestamp, url.QueryEscape(currency)) + h.mustGetFiatJSONOrSkip(t, singlePath, &single) + assertFiatTickerPayload(t, &single, "GetMultiTickers.single") + + var multi []fiatTickerResponse + multiPath := fmt.Sprintf("/api/v2/multi-tickers?timestamp=%d¤cy=%s", ticker.Timestamp, url.QueryEscape(currency)) + h.mustGetFiatJSONOrSkip(t, multiPath, &multi) + if len(multi) != 1 { + t.Fatalf("GetMultiTickers expected exactly 1 entry, got %d", len(multi)) + } + assertFiatTickerPayload(t, &multi[0], "GetMultiTickers.multi[0]") + + if multi[0].Timestamp != single.Timestamp { + t.Fatalf("GetMultiTickers timestamp mismatch: single=%d multi=%d", single.Timestamp, multi[0].Timestamp) + } + singleRate, ok := single.Rates[currency] + if !ok { + t.Fatalf("GetMultiTickers single missing rate for %s", currency) + } + multiRate, ok := multi[0].Rates[currency] + if !ok { + t.Fatalf("GetMultiTickers multi missing rate for %s", currency) + } + if singleRate != multiRate { + t.Fatalf("GetMultiTickers rate mismatch for %s: single=%v multi=%v", currency, singleRate, multiRate) + } +} + func testGetAddressTxids(t *testing.T, h *TestHandler) { address := h.sampleAddressOrSkip(t) txid := h.sampleTxIDOrSkip(t) @@ -203,6 +278,29 @@ func (h *TestHandler) mustGetJSON(t *testing.T, path string, out interface{}) { } } +func (h *TestHandler) mustGetFiatJSONOrSkip(t *testing.T, path string, out interface{}) { + t.Helper() + + const maxAttempts = 2 + for attempt := 1; attempt <= maxAttempts; attempt++ { + status, body := h.getHTTP(t, path) + if status == http.StatusOK { + if err := json.Unmarshal(body, out); err != nil { + t.Fatalf("decode %s: %v", path, err) + } + return + } + if isFiatDataUnavailable(status, body) { + if attempt < maxAttempts { + time.Sleep(time.Duration(attempt) * 300 * time.Millisecond) + continue + } + t.Skipf("Skipping test, fiat data unavailable for %s (HTTP %d: %s)", path, status, preview(body)) + } + t.Fatalf("GET %s returned HTTP %d: %s", path, status, preview(body)) + } +} + func (h *TestHandler) getHTTP(t *testing.T, path string) (int, []byte) { t.Helper() @@ -314,3 +412,11 @@ func isRetryableHTTPStatus(status int) bool { return false } } + +func isFiatDataUnavailable(status int, body []byte) bool { + if status != http.StatusBadRequest && status != http.StatusInternalServerError { + return false + } + msg := strings.ToLower(preview(body)) + return strings.Contains(msg, "no tickers found") || strings.Contains(msg, "error finding ticker") +} diff --git a/tests/api/sample_data.go b/tests/api/sample_data.go index 654dc5ee..eb704f9e 100644 --- a/tests/api/sample_data.go +++ b/tests/api/sample_data.go @@ -436,6 +436,47 @@ func (h *TestHandler) sampleAddressOrSkip(t *testing.T) string { return address } +func (h *TestHandler) getSampleFiatTicker(t *testing.T) (fiatTickerResponse, bool) { + if h.sampleFiatResolved { + return h.sampleFiatTicker, h.sampleFiatAvailable + } + h.sampleFiatResolved = true + + path := "/api/v2/tickers?currency=usd" + status, body := h.getHTTP(t, path) + if isFiatDataUnavailable(status, body) { + return fiatTickerResponse{}, false + } + if status != http.StatusOK { + t.Fatalf("GET %s returned HTTP %d: %s", path, status, preview(body)) + } + + var ticker fiatTickerResponse + if err := json.Unmarshal(body, &ticker); err != nil { + t.Fatalf("decode %s: %v", path, err) + } + if ticker.Timestamp <= 0 || len(ticker.Rates) == 0 { + return fiatTickerResponse{}, false + } + + h.sampleFiatAvailable = true + h.sampleFiatTicker = ticker + return h.sampleFiatTicker, true +} + +func (h *TestHandler) sampleFiatTickerOrSkip(t *testing.T) fiatTickerResponse { + t.Helper() + ticker, found := h.getSampleFiatTicker(t) + if !found { + status := h.getStatus(t) + if !status.HasFiatRates { + t.Skipf("Skipping test, endpoint reports hasFiatRates=false") + } + t.Skipf("Skipping test, fiat ticker data currently unavailable") + } + return ticker +} + func (h *TestHandler) requireCapabilities(t *testing.T, required testCapability, group, test string) bool { t.Helper() if required == capabilityNone { diff --git a/tests/api/test_helpers.go b/tests/api/test_helpers.go index 59e2f709..3e9edea8 100644 --- a/tests/api/test_helpers.go +++ b/tests/api/test_helpers.go @@ -155,6 +155,22 @@ func assertUTXOListNonNegativeConfirmations(t *testing.T, utxos []utxoResponse, } } +func assertFiatTickerPayload(t *testing.T, payload *fiatTickerResponse, context string) { + t.Helper() + if payload.Timestamp <= 0 { + t.Fatalf("%s invalid timestamp: %d", context, payload.Timestamp) + } + if len(payload.Rates) == 0 { + t.Fatalf("%s returned no rates", context) + } + for currency, rate := range payload.Rates { + assertNonEmptyString(t, currency, context+".rates.currency") + if rate == 0 { + t.Fatalf("%s returned zero rate for currency %s", context, currency) + } + } +} + func assertUTXOSetsEqualByOutpoint(t *testing.T, got, want []utxoResponse, context string) { t.Helper() gotSet := utxoSetByOutpoint(t, got, context+".got") diff --git a/tests/tests.json b/tests/tests.json index ea4d26f7..c5de8fe9 100644 --- a/tests/tests.json +++ b/tests/tests.json @@ -2,14 +2,14 @@ "avalanche": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetAddressBasicEVM", "GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetAddressBasicEVM", "GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] }, "bcash": { "connectivity": ["http"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "GetTransactionForMempool", "MempoolSync", "EstimateFee", "GetBestBlockHash", "GetBestBlockHeight", "GetBlockHeader"], "sync": ["ConnectBlocksParallel", "ConnectBlocks", "HandleFork"] @@ -30,7 +30,7 @@ "bitcoin": { "connectivity": ["http"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfo", "WsGetAccountUtxo", "WsPing"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "GetTransactionForMempool", "MempoolSync", "EstimateSmartFee", "EstimateFee", "GetBestBlockHash", "GetBestBlockHeight", "GetBlockHeader"], @@ -39,7 +39,7 @@ "bitcoin_testnet": { "connectivity": ["http"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "GetTransactionForMempool", "MempoolSync", "EstimateSmartFee", "EstimateFee", "GetBestBlockHash", "GetBestBlockHeight", "GetBlockHeader"], "sync": ["ConnectBlocksParallel", "ConnectBlocks", "HandleFork"] @@ -47,7 +47,7 @@ "bitcoin_testnet4": { "connectivity": ["http"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "GetTransactionForMempool", "MempoolSync", "EstimateSmartFee", "EstimateFee", "GetBestBlockHash", "GetBestBlockHeight", "GetBlockHeader"], "sync": ["ConnectBlocksParallel", "ConnectBlocks", "HandleFork"] @@ -70,7 +70,7 @@ "bsc": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetAddressBasicEVM", "GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetAddressBasicEVM", "GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] }, @@ -119,7 +119,7 @@ "dogecoin": { "connectivity": ["http"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "MempoolSync"], "sync": ["ConnectBlocksParallel", "ConnectBlocks"] }, @@ -165,7 +165,7 @@ "litecoin": { "connectivity": ["http"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "GetTransactionForMempool", "MempoolSync", "EstimateSmartFee", "EstimateFee"], "sync": ["ConnectBlocksParallel", "ConnectBlocks", "HandleFork"] @@ -191,7 +191,7 @@ "zcash": { "connectivity": ["http"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "GetTransactionForMempool", "MempoolSync", "GetBestBlockHash", "GetBestBlockHeight", "GetBlockHeader"], "sync": ["ConnectBlocksParallel", "ConnectBlocks", "HandleFork"] @@ -283,35 +283,35 @@ "arbitrum": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetAddressBasicEVM", "GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetAddressBasicEVM", "GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] }, "base": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetAddressBasicEVM", "GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetAddressBasicEVM", "GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] }, "ethereum": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetAddressBasicEVM", "GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetAddressBasicEVM", "GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] }, "optimism": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetAddressBasicEVM", "GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetAddressBasicEVM", "GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] }, "polygon": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetAddressBasicEVM", "GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetAddressBasicEVM", "GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] }