diff --git a/packages/pricing/package.json b/packages/pricing/package.json index 39e1ff3..e8cd5b6 100644 --- a/packages/pricing/package.json +++ b/packages/pricing/package.json @@ -30,5 +30,8 @@ "dependencies": { "@grants-stack-indexer/shared": "workspace:0.0.1", "axios": "1.7.7" + }, + "devDependencies": { + "axios-mock-adapter": "2.0.0" } } diff --git a/packages/pricing/src/external.ts b/packages/pricing/src/external.ts index cdc716a..0585585 100644 --- a/packages/pricing/src/external.ts +++ b/packages/pricing/src/external.ts @@ -1,4 +1,4 @@ -export type { TokenPrice } from "./internal.js"; +export type { TokenPrice, IPricingProvider } from "./internal.js"; export { CoingeckoProvider } from "./internal.js"; export { diff --git a/packages/pricing/src/providers/coingecko.provider.ts b/packages/pricing/src/providers/coingecko.provider.ts index a7e2beb..087ed06 100644 --- a/packages/pricing/src/providers/coingecko.provider.ts +++ b/packages/pricing/src/providers/coingecko.provider.ts @@ -53,6 +53,9 @@ const nativeTokens: { [key in CoingeckoSupportedChainId]: CoingeckoTokenId } = { 1088: "metis-token" as CoingeckoTokenId, }; +/** + * The Coingecko provider is a pricing provider that uses the Coingecko API to get the price of a token. + */ export class CoingeckoProvider implements IPricingProvider { private readonly axios: AxiosInstance; @@ -112,13 +115,13 @@ export class CoingeckoProvider implements IPricingProvider { return undefined; } - if (error.status! >= 500) { + if (error.status! >= 500 || error.message === "Network Error") { throw new NetworkException(error.message, error.status!); } } console.error(error); throw new UnknownPricingException( - JSON.stringify(error), + isNativeError(error) ? error.message : JSON.stringify(error), isNativeError(error) ? error.stack : undefined, ); } diff --git a/packages/pricing/test/providers/coingecko.provider.spec.ts b/packages/pricing/test/providers/coingecko.provider.spec.ts new file mode 100644 index 0000000..b660467 --- /dev/null +++ b/packages/pricing/test/providers/coingecko.provider.spec.ts @@ -0,0 +1,144 @@ +import MockAdapter from "axios-mock-adapter"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { Address, NATIVE_TOKEN_ADDRESS } from "@grants-stack-indexer/shared"; + +import type { TokenPrice } from "../../src/external.js"; +import { + CoingeckoProvider, + NetworkException, + UnsupportedChainException, +} from "../../src/external.js"; + +describe("CoingeckoProvider", () => { + let provider: CoingeckoProvider; + let mock: MockAdapter; + + beforeEach(() => { + provider = new CoingeckoProvider({ + apiKey: "test-api-key", + apiType: "demo", + }); + mock = new MockAdapter(provider["axios"]); + }); + + afterEach(() => { + mock.reset(); + }); + + describe("getTokenPrice", () => { + it("return token price for a supported chain and valid token", async () => { + const mockResponse = { + prices: [[1609459200000, 100]], + }; + mock.onGet().reply(200, mockResponse); + + const result = await provider.getTokenPrice( + 1, + "0x1234567890123456789012345678901234567890" as Address, + 1609459200000, + 1609545600000, + ); + + const expectedPrice: TokenPrice = { + timestampMs: 1609459200000, + priceUsd: 100, + }; + + expect(result).toEqual(expectedPrice); + expect(mock.history.get[0].url).toContain( + "/coins/ethereum/contract/0x1234567890123456789012345678901234567890/market_chart/range?vs_currency=usd&from=1609459200&to=1609545600&precision=full", + ); + }); + + it("return token price for a supported chain and native token", async () => { + const mockResponse = { + prices: [[1609459200000, 100]], + }; + mock.onGet().reply(200, mockResponse); + + const result = await provider.getTokenPrice( + 10, + NATIVE_TOKEN_ADDRESS, + 1609459200000, + 1609545600000, + ); + + const expectedPrice: TokenPrice = { + timestampMs: 1609459200000, + priceUsd: 100, + }; + + expect(result).toEqual(expectedPrice); + expect(mock.history.get[0].url).toContain( + "/coins/ethereum/market_chart/range?vs_currency=usd&from=1609459200&to=1609545600&precision=full", + ); + }); + + it("return undefined if no price data is available for timerange", async () => { + const mockResponse = { + prices: [], + }; + mock.onGet().reply(200, mockResponse); + + const result = await provider.getTokenPrice( + 1, + "0x1234567890123456789012345678901234567890" as Address, + 1609459200000, + 1609545600000, + ); + + expect(result).toBeUndefined(); + }); + + it("return undefined if 400 family error", async () => { + mock.onGet().replyOnce(400, "Bad Request"); + + const result = await provider.getTokenPrice( + 1, + "0x1234567890123456789012345678901234567890" as Address, + 1609459200000, + 1609545600000, + ); + + expect(result).toBeUndefined(); + }); + + it("throw UnsupportedChainException for unsupported chain", async () => { + await expect(() => + provider.getTokenPrice( + 999999, // Unsupported chain ID + "0x1234567890123456789012345678901234567890" as Address, + 1609459200000, + 1609545600000, + ), + ).rejects.toThrow(UnsupportedChainException); + }); + + it("should throw NetworkException for 500 family errors", async () => { + mock.onGet().reply(500, "Internal Server Error"); + + await expect( + provider.getTokenPrice( + 1, + "0x1234567890123456789012345678901234567890" as Address, + 1609459200000, + 1609545600000, + ), + ).rejects.toThrow(NetworkException); + }); + + it("throw NetworkException for network errors", async () => { + mock.onGet().networkErrorOnce(); + + await expect( + provider.getTokenPrice( + 1, + "0x1234567890123456789012345678901234567890" as Address, + 1609459200000, + 1609545600000, + ), + ).rejects.toThrow(NetworkException); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 359271f..194e1d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,10 @@ importers: axios: specifier: 1.7.7 version: 1.7.7 + devDependencies: + axios-mock-adapter: + specifier: 2.0.0 + version: 2.0.0(axios@1.7.7) packages/shared: dependencies: @@ -1291,6 +1295,14 @@ packages: integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==, } + axios-mock-adapter@2.0.0: + resolution: + { + integrity: sha512-D/K0J5Zm6KvaMTnsWrBQZWLzKN9GxUFZEa0mx2qeEHXDeTugCoplWehy8y36dj5vuSjhe1u/Dol8cZ8lzzmDew==, + } + peerDependencies: + axios: ">= 0.17.0" + axios@1.7.7: resolution: { @@ -2299,6 +2311,13 @@ packages: } engines: { node: ">=8" } + is-buffer@2.0.5: + resolution: + { + integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==, + } + engines: { node: ">=4" } + is-extglob@2.1.1: resolution: { @@ -4603,6 +4622,12 @@ snapshots: asynckit@0.4.0: {} + axios-mock-adapter@2.0.0(axios@1.7.7): + dependencies: + axios: 1.7.7 + fast-deep-equal: 3.1.3 + is-buffer: 2.0.5 + axios@1.7.7: dependencies: follow-redirects: 1.15.9 @@ -5211,6 +5236,8 @@ snapshots: dependencies: binary-extensions: 2.3.0 + is-buffer@2.0.5: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {}