diff --git a/packages/api/src/api/api.controller.spec.ts b/packages/api/src/api/api.controller.spec.ts index d2dad27554..f1688725a0 100644 --- a/packages/api/src/api/api.controller.spec.ts +++ b/packages/api/src/api/api.controller.spec.ts @@ -237,4 +237,18 @@ describe("ApiController", () => { expect(result).toBe(null); }); }); + + describe("tokenInfo", () => { + it("returns null as it is defined only to appear in docs and cannot be called", async () => { + const result = await controller.tokenInfo(); + expect(result).toBe(null); + }); + }); + + describe("ethPrice", () => { + it("returns null as it is defined only to appear in docs and cannot be called", async () => { + const result = await controller.ethPrice(); + expect(result).toBe(null); + }); + }); }); diff --git a/packages/api/src/api/api.controller.ts b/packages/api/src/api/api.controller.ts index d0e20087ff..4bb82d2d4b 100644 --- a/packages/api/src/api/api.controller.ts +++ b/packages/api/src/api/api.controller.ts @@ -43,6 +43,8 @@ import { ParseModulePipe } from "./pipes/parseModule.pipe"; import { ParseActionPipe } from "./pipes/parseAction.pipe"; import { ApiExceptionFilter } from "./exceptionFilter"; import { LogsResponseDto, LogApiDto } from "./dtos/log/logs.dto"; +import { TokenInfoResponseDto, TokenInfoDto } from "./dtos/token/tokenInfo.dto"; +import { EthPriceResponseDto, EthPriceDto } from "./dtos/stats/ethPrice.dto"; import { constants } from "../config/docs"; @Controller("") @@ -604,4 +606,34 @@ export class ApiController { ): Promise { return null; } + + @ApiTags("Token API") + @Get("api?module=token&action=tokeninfo") + @ApiOperation({ summary: "Returns token information" }) + @ApiQuery({ + name: "contractaddress", + description: "The contract address of the ERC-20/ERC-721 token to retrieve token info", + example: constants.tokenAddress, + required: true, + }) + @ApiExtraModels(TokenInfoDto) + @ApiOkResponse({ + description: "Token information", + type: TokenInfoResponseDto, + }) + public async tokenInfo(): Promise { + return null; + } + + @ApiTags("Stats API") + @Get("api?module=stats&action=ethprice") + @ApiOperation({ summary: "Returns price of 1 ETH" }) + @ApiExtraModels(EthPriceDto) + @ApiOkResponse({ + description: "ETH price", + type: EthPriceResponseDto, + }) + public async ethPrice(): Promise { + return null; + } } diff --git a/packages/api/src/api/dtos/stats/ethPrice.dto.ts b/packages/api/src/api/dtos/stats/ethPrice.dto.ts new file mode 100644 index 0000000000..6680b7c4a7 --- /dev/null +++ b/packages/api/src/api/dtos/stats/ethPrice.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { ResponseBaseDto } from "../common/responseBase.dto"; + +export class EthPriceDto { + @ApiProperty({ + type: String, + description: "ETH price in USD", + example: "1823.567", + }) + public readonly ethusd: string; + + @ApiProperty({ + type: String, + description: "ETH price timestamp", + example: "1624961308", + }) + public readonly ethusd_timestamp: string; +} + +export class EthPriceResponseDto extends ResponseBaseDto { + @ApiProperty({ + description: "ETH price", + type: EthPriceDto, + }) + public readonly result: EthPriceDto; +} diff --git a/packages/api/src/api/dtos/token/tokenInfo.dto.ts b/packages/api/src/api/dtos/token/tokenInfo.dto.ts new file mode 100644 index 0000000000..7a30de84bf --- /dev/null +++ b/packages/api/src/api/dtos/token/tokenInfo.dto.ts @@ -0,0 +1,69 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { ResponseBaseDto } from "../common/responseBase.dto"; + +export class TokenInfoDto { + @ApiProperty({ + type: String, + description: "Token contract address", + example: "0x000000000000000000000000000000000000800A", + }) + public readonly contractAddress: string; + + @ApiProperty({ + type: String, + description: "Token name", + example: "Ether", + }) + public readonly tokenName: string; + + @ApiProperty({ + type: String, + description: "Token symbol", + example: "ETH", + }) + public readonly symbol: string; + + @ApiProperty({ + type: String, + description: "Token decimals", + example: "18", + }) + public readonly tokenDecimal: string; + + @ApiProperty({ + type: String, + description: "Token price in USD", + example: "1823.567", + }) + public readonly tokenPriceUSD: string; + + @ApiProperty({ + type: String, + description: "Token liquidity in USD", + example: "220000000000", + }) + public readonly liquidity: string; + + @ApiProperty({ + type: String, + description: "Token L1 address", + example: "0x0000000000000000000000000000000000000000", + }) + public readonly l1Address: string; + + @ApiProperty({ + type: String, + description: "Token icon URL", + example: "https://assets.coingecko.com/coins/images/279/large/ethereum.png?1698873266", + }) + public readonly iconURL: string; +} + +export class TokenInfoResponseDto extends ResponseBaseDto { + @ApiProperty({ + description: "Token info", + type: TokenInfoDto, + isArray: true, + }) + public readonly result: TokenInfoDto[]; +} diff --git a/packages/api/src/api/stats/stats.controller.spec.ts b/packages/api/src/api/stats/stats.controller.spec.ts new file mode 100644 index 0000000000..f9851dde0b --- /dev/null +++ b/packages/api/src/api/stats/stats.controller.spec.ts @@ -0,0 +1,72 @@ +import { Test } from "@nestjs/testing"; +import { mock } from "jest-mock-extended"; +import { Logger } from "@nestjs/common"; +import { TokenService } from "../../token/token.service"; +import { Token, ETH_TOKEN } from "../../token/token.entity"; +import { StatsController } from "./stats.controller"; + +describe("StatsController", () => { + let controller: StatsController; + let tokenServiceMock: TokenService; + + beforeEach(async () => { + tokenServiceMock = mock({ + findOne: jest.fn().mockResolvedValue(null), + }); + + const module = await Test.createTestingModule({ + controllers: [StatsController], + providers: [ + { + provide: TokenService, + useValue: tokenServiceMock, + }, + ], + }).compile(); + module.useLogger(mock()); + + controller = module.get(StatsController); + }); + + describe("ethPrice", () => { + it("returns ok response and ETH price when ETH token is found", async () => { + jest.spyOn(tokenServiceMock, "findOne").mockResolvedValueOnce({ + usdPrice: ETH_TOKEN.usdPrice, + offChainDataUpdatedAt: new Date("2023-03-03"), + } as Token); + + const response = await controller.ethPrice(); + expect(response).toEqual({ + status: "1", + message: "OK", + result: { + ethusd: ETH_TOKEN.usdPrice.toString(), + ethusd_timestamp: Math.floor(new Date("2023-03-03").getTime() / 1000).toString(), + }, + }); + }); + + it("returns ok response and ETH price with default values when ETH token doesn't have price details", async () => { + jest.spyOn(tokenServiceMock, "findOne").mockResolvedValueOnce({} as Token); + + const response = await controller.ethPrice(); + expect(response).toEqual({ + status: "1", + message: "OK", + result: { + ethusd: "", + ethusd_timestamp: "", + }, + }); + }); + + it("returns not ok response and no ETH price info when ETH token is not found", async () => { + const response = await controller.ethPrice(); + expect(response).toEqual({ + status: "0", + message: "No data found", + result: null, + }); + }); + }); +}); diff --git a/packages/api/src/api/stats/stats.controller.ts b/packages/api/src/api/stats/stats.controller.ts new file mode 100644 index 0000000000..972edb3680 --- /dev/null +++ b/packages/api/src/api/stats/stats.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, UseFilters } from "@nestjs/common"; +import { ApiTags, ApiExcludeController } from "@nestjs/swagger"; +import { ResponseStatus, ResponseMessage } from "../dtos/common/responseBase.dto"; +import { ApiExceptionFilter } from "../exceptionFilter"; +import { EthPriceResponseDto } from "../dtos/stats/ethPrice.dto"; +import { TokenService } from "../../token/token.service"; +import { ETH_TOKEN } from "../../token/token.entity"; +import { dateToTimestamp } from "../../common/utils"; + +const entityName = "stats"; + +@ApiExcludeController() +@ApiTags(entityName) +@Controller(`api/${entityName}`) +@UseFilters(ApiExceptionFilter) +export class StatsController { + constructor(private readonly tokenService: TokenService) {} + + @Get("/ethprice") + public async ethPrice(): Promise { + const token = await this.tokenService.findOne(ETH_TOKEN.l2Address, { usdPrice: true, offChainDataUpdatedAt: true }); + return { + status: token ? ResponseStatus.OK : ResponseStatus.NOTOK, + message: token ? ResponseMessage.OK : ResponseMessage.NO_DATA_FOUND, + result: token + ? { + ethusd: token.usdPrice?.toString() || "", + ethusd_timestamp: token.offChainDataUpdatedAt + ? dateToTimestamp(token.offChainDataUpdatedAt).toString() + : "", + } + : null, + }; + } +} diff --git a/packages/api/src/api/stats/stats.module.ts b/packages/api/src/api/stats/stats.module.ts new file mode 100644 index 0000000000..e5c1c3a712 --- /dev/null +++ b/packages/api/src/api/stats/stats.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { StatsController } from "./stats.controller"; +import { TokenModule } from "../../token/token.module"; + +@Module({ + imports: [TokenModule], + controllers: [StatsController], +}) +export class ApiStatsModule {} diff --git a/packages/api/src/api/token/token.controller.spec.ts b/packages/api/src/api/token/token.controller.spec.ts new file mode 100644 index 0000000000..e3d44eee71 --- /dev/null +++ b/packages/api/src/api/token/token.controller.spec.ts @@ -0,0 +1,91 @@ +import { Test } from "@nestjs/testing"; +import { mock } from "jest-mock-extended"; +import { Logger } from "@nestjs/common"; +import { TokenService } from "../../token/token.service"; +import { Token, ETH_TOKEN } from "../../token/token.entity"; +import { TokenController } from "./token.controller"; + +describe("TokenController", () => { + let controller: TokenController; + let tokenServiceMock: TokenService; + + const contractAddress = "address"; + + beforeEach(async () => { + tokenServiceMock = mock({ + findOne: jest.fn().mockResolvedValue(null), + }); + + const module = await Test.createTestingModule({ + controllers: [TokenController], + providers: [ + { + provide: TokenService, + useValue: tokenServiceMock, + }, + ], + }).compile(); + module.useLogger(mock()); + + controller = module.get(TokenController); + }); + + describe("tokenInfo", () => { + it("returns ok response and token info when token is found", async () => { + jest.spyOn(tokenServiceMock, "findOne").mockResolvedValueOnce(ETH_TOKEN); + + const response = await controller.tokenInfo(contractAddress); + expect(response).toEqual({ + status: "1", + message: "OK", + result: [ + { + contractAddress: ETH_TOKEN.l2Address, + iconURL: ETH_TOKEN.iconURL, + l1Address: ETH_TOKEN.l1Address, + liquidity: ETH_TOKEN.liquidity.toString(), + symbol: ETH_TOKEN.symbol, + tokenDecimal: ETH_TOKEN.decimals.toString(), + tokenName: ETH_TOKEN.name, + tokenPriceUSD: ETH_TOKEN.usdPrice.toString(), + }, + ], + }); + }); + + it("returns ok response and token info with default values when token doesn't have all details", async () => { + jest.spyOn(tokenServiceMock, "findOne").mockResolvedValueOnce({ + l2Address: "0x000000000000000000000000000000000000800A", + symbol: "", + decimals: 6, + } as Token); + + const response = await controller.tokenInfo(contractAddress); + expect(response).toEqual({ + status: "1", + message: "OK", + result: [ + { + contractAddress: "0x000000000000000000000000000000000000800A", + iconURL: "", + l1Address: "", + liquidity: "", + symbol: "", + tokenDecimal: "6", + tokenName: "", + tokenPriceUSD: "", + }, + ], + }); + }); + + it("returns not ok response and no token info when token is not found", async () => { + const response = await controller.tokenInfo(contractAddress); + expect(response).toEqual({ + status: "0", + message: "No data found", + result: [], + }); + }); + }); +}); diff --git a/packages/api/src/api/token/token.controller.ts b/packages/api/src/api/token/token.controller.ts new file mode 100644 index 0000000000..95c559696c --- /dev/null +++ b/packages/api/src/api/token/token.controller.ts @@ -0,0 +1,43 @@ +import { Controller, Get, Query, UseFilters } from "@nestjs/common"; +import { ApiTags, ApiExcludeController } from "@nestjs/swagger"; +import { ParseAddressPipe } from "../../common/pipes/parseAddress.pipe"; +import { ResponseStatus, ResponseMessage } from "../dtos/common/responseBase.dto"; +import { ApiExceptionFilter } from "../exceptionFilter"; +import { TokenInfoResponseDto } from "../dtos/token/tokenInfo.dto"; +import { TokenService } from "../../token/token.service"; + +const entityName = "token"; + +@ApiExcludeController() +@ApiTags(entityName) +@Controller(`api/${entityName}`) +@UseFilters(ApiExceptionFilter) +export class TokenController { + constructor(private readonly tokenService: TokenService) {} + + @Get("/tokeninfo") + public async tokenInfo( + @Query("contractaddress", new ParseAddressPipe({ errorMessage: "Error! Invalid contract address format" })) + contractAddress: string + ): Promise { + const token = await this.tokenService.findOne(contractAddress); + return { + status: token ? ResponseStatus.OK : ResponseStatus.NOTOK, + message: token ? ResponseMessage.OK : ResponseMessage.NO_DATA_FOUND, + result: token + ? [ + { + contractAddress: token.l2Address, + tokenName: token.name || "", + symbol: token.symbol, + tokenDecimal: token.decimals.toString(), + tokenPriceUSD: token.usdPrice?.toString() || "", + liquidity: token.liquidity?.toString() || "", + l1Address: token.l1Address || "", + iconURL: token.iconURL || "", + }, + ] + : [], + }; + } +} diff --git a/packages/api/src/api/token/token.module.ts b/packages/api/src/api/token/token.module.ts new file mode 100644 index 0000000000..5677bf8625 --- /dev/null +++ b/packages/api/src/api/token/token.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { TokenController } from "./token.controller"; +import { TokenModule } from "../../token/token.module"; + +@Module({ + imports: [TokenModule], + controllers: [TokenController], +}) +export class ApiTokenModule {} diff --git a/packages/api/src/api/types.ts b/packages/api/src/api/types.ts index 31dd9a91b1..3c819c9561 100644 --- a/packages/api/src/api/types.ts +++ b/packages/api/src/api/types.ts @@ -10,6 +10,8 @@ export enum ApiModule { Transaction = "transaction", Block = "block", Logs = "logs", + Token = "token", + Stats = "stats", } export enum ApiAccountAction { @@ -46,12 +48,22 @@ export enum ApiLogsAction { getLogs = "getLogs", } +export enum ApiTokenAction { + tokenInfo = "tokeninfo", +} + +export enum ApiStatsAction { + ethPrice = "ethprice", +} + export const apiActionsMap = { [ApiModule.Account]: Object.values(ApiAccountAction) as string[], [ApiModule.Contract]: Object.values(ApiContractAction) as string[], [ApiModule.Transaction]: Object.values(ApiTransactionAction) as string[], [ApiModule.Block]: Object.values(ApiBlockAction) as string[], [ApiModule.Logs]: Object.values(ApiLogsAction) as string[], + [ApiModule.Token]: Object.values(ApiTokenAction) as string[], + [ApiModule.Stats]: Object.values(ApiStatsAction) as string[], }; type ContractFunctionInput = { diff --git a/packages/api/src/app.module.ts b/packages/api/src/app.module.ts index cce4f45ef6..d0c104ed09 100644 --- a/packages/api/src/app.module.ts +++ b/packages/api/src/app.module.ts @@ -8,6 +8,8 @@ import { ApiAccountModule } from "./api/account/account.module"; import { ApiContractModule } from "./api/contract/contract.module"; import { ApiTransactionModule } from "./api/transaction/transaction.module"; import { ApiLogModule } from "./api/log/log.module"; +import { ApiTokenModule } from "./api/token/token.module"; +import { ApiStatsModule } from "./api/stats/stats.module"; import { TokenModule } from "./token/token.module"; import { BatchModule } from "./batch/batch.module"; import { BlockModule } from "./block/block.module"; @@ -34,7 +36,9 @@ import config from "./config"; ApiModule, ApiContractModule, // TMP: disable external API until release - ...(disableExternalAPI ? [] : [ApiBlockModule, ApiAccountModule, ApiTransactionModule, ApiLogModule]), + ...(disableExternalAPI + ? [] + : [ApiBlockModule, ApiAccountModule, ApiTransactionModule, ApiLogModule, ApiTokenModule, ApiStatsModule]), TokenModule, AddressModule, BalanceModule, diff --git a/packages/api/src/token/token.service.spec.ts b/packages/api/src/token/token.service.spec.ts index d418934524..1a57c06cf5 100644 --- a/packages/api/src/token/token.service.spec.ts +++ b/packages/api/src/token/token.service.spec.ts @@ -41,13 +41,13 @@ describe("TokenService", () => { token = { l2Address: tokenAddress, }; - (repositoryMock.findOneBy as jest.Mock).mockResolvedValue(token); + (repositoryMock.findOne as jest.Mock).mockResolvedValue(token); }); it("queries tokens by specified token address", async () => { await service.findOne(tokenAddress); - expect(repositoryMock.findOneBy).toHaveBeenCalledTimes(1); - expect(repositoryMock.findOneBy).toHaveBeenCalledWith({ l2Address: tokenAddress }); + expect(repositoryMock.findOne).toHaveBeenCalledTimes(1); + expect(repositoryMock.findOne).toHaveBeenCalledWith({ where: { l2Address: tokenAddress } }); }); it("returns token by address", async () => { @@ -55,9 +55,20 @@ describe("TokenService", () => { expect(result).toBe(token); }); + describe("when called with fields", () => { + it("queries only specified fields", async () => { + await service.findOne(tokenAddress, { l2Address: true }); + expect(repositoryMock.findOne).toHaveBeenCalledTimes(1); + expect(repositoryMock.findOne).toHaveBeenCalledWith({ + where: { l2Address: tokenAddress }, + select: { l2Address: true }, + }); + }); + }); + describe("when requested token does not exist", () => { beforeEach(() => { - (repositoryMock.findOneBy as jest.Mock).mockResolvedValue(null); + (repositoryMock.findOne as jest.Mock).mockResolvedValue(null); }); it("returns ETH token for ETH address", async () => { diff --git a/packages/api/src/token/token.service.ts b/packages/api/src/token/token.service.ts index bb97dee9c5..8e4ff874a9 100644 --- a/packages/api/src/token/token.service.ts +++ b/packages/api/src/token/token.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { Repository, FindOptionsSelect } from "typeorm"; import { Pagination, IPaginationOptions } from "nestjs-typeorm-paginate"; import { paginate } from "../common/utils"; import { Token, ETH_TOKEN } from "./token.entity"; @@ -12,9 +12,14 @@ export class TokenService { private readonly tokenRepository: Repository ) {} - public async findOne(address: string): Promise { - const token = await this.tokenRepository.findOneBy({ l2Address: address }); - if (!token && address === ETH_TOKEN.l2Address.toLowerCase()) { + public async findOne(address: string, fields?: FindOptionsSelect): Promise { + const token = await this.tokenRepository.findOne({ + where: { + l2Address: address, + }, + select: fields, + }); + if (!token && address.toLowerCase() === ETH_TOKEN.l2Address.toLowerCase()) { return ETH_TOKEN; } return token; diff --git a/packages/api/test/stats-api.e2e-spec.ts b/packages/api/test/stats-api.e2e-spec.ts new file mode 100644 index 0000000000..d9fec2f445 --- /dev/null +++ b/packages/api/test/stats-api.e2e-spec.ts @@ -0,0 +1,96 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import * as request from "supertest"; +import { Repository } from "typeorm"; +import { BatchDetails } from "../src/batch/batchDetails.entity"; +import { BlockDetail } from "../src/block/blockDetail.entity"; +import { Token, ETH_TOKEN } from "../src/token/token.entity"; +import { AppModule } from "../src/app.module"; +import { configureApp } from "../src/configureApp"; + +describe("Stats API (e2e)", () => { + let app: INestApplication; + let blockRepository: Repository; + let batchRepository: Repository; + let tokenRepository: Repository; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication({ logger: false }); + configureApp(app); + await app.init(); + + blockRepository = app.get>(getRepositoryToken(BlockDetail)); + batchRepository = app.get>(getRepositoryToken(BatchDetails)); + tokenRepository = app.get>(getRepositoryToken(Token)); + + await batchRepository.insert({ + number: 0, + timestamp: new Date(), + l1TxCount: 10, + l2TxCount: 20, + l1GasPrice: "10000000", + l2FairGasPrice: "20000000", + commitTxHash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e21", + proveTxHash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e22", + executeTxHash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e23", + }); + + await blockRepository.insert({ + number: 0, + hash: "0x4f86d6647711915ac90e5ef69c29845946f0a55b3feaa0488aece4a359f79cb1", + timestamp: new Date(), + gasLimit: "0", + gasUsed: "0", + baseFeePerGas: "100000000", + extraData: "0x", + l1TxCount: 1, + l2TxCount: 1, + l1BatchNumber: 0, + miner: "0x0000000000000000000000000000000000000000", + }); + + await tokenRepository.insert({ + l2Address: ETH_TOKEN.l2Address, + l1Address: ETH_TOKEN.l1Address, + symbol: ETH_TOKEN.symbol, + name: ETH_TOKEN.name, + decimals: ETH_TOKEN.decimals, + blockNumber: 0, + logIndex: 0, + usdPrice: ETH_TOKEN.usdPrice, + liquidity: ETH_TOKEN.liquidity, + iconURL: ETH_TOKEN.iconURL, + offChainDataUpdatedAt: new Date("2023-03-03"), + }); + }); + + afterAll(async () => { + await tokenRepository.delete({}); + await blockRepository.delete({}); + await batchRepository.delete({}); + await app.close(); + }); + + describe("/api?module=stats&action=ethprice GET", () => { + it("returns HTTP 200 and ETH price", () => { + return request(app.getHttpServer()) + .get(`/api?module=stats&action=ethprice`) + .expect(200) + .expect((res) => + expect(res.body).toStrictEqual({ + message: "OK", + result: { + ethusd: ETH_TOKEN.usdPrice.toString(), + ethusd_timestamp: Math.floor(new Date("2023-03-03").getTime() / 1000).toString(), + }, + status: "1", + }) + ); + }); + }); +}); diff --git a/packages/api/test/token-api.e2e-spec.ts b/packages/api/test/token-api.e2e-spec.ts new file mode 100644 index 0000000000..ce825b8535 --- /dev/null +++ b/packages/api/test/token-api.e2e-spec.ts @@ -0,0 +1,153 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import * as request from "supertest"; +import { Repository } from "typeorm"; +import { BatchDetails } from "../src/batch/batchDetails.entity"; +import { BlockDetail } from "../src/block/blockDetail.entity"; +import { Token, ETH_TOKEN } from "../src/token/token.entity"; +import { AppModule } from "../src/app.module"; +import { configureApp } from "../src/configureApp"; + +describe("Token API (e2e)", () => { + let app: INestApplication; + let blockRepository: Repository; + let batchRepository: Repository; + let tokenRepository: Repository; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication({ logger: false }); + configureApp(app); + await app.init(); + + blockRepository = app.get>(getRepositoryToken(BlockDetail)); + batchRepository = app.get>(getRepositoryToken(BatchDetails)); + tokenRepository = app.get>(getRepositoryToken(Token)); + + await batchRepository.insert({ + number: 0, + timestamp: new Date(), + l1TxCount: 10, + l2TxCount: 20, + l1GasPrice: "10000000", + l2FairGasPrice: "20000000", + commitTxHash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e21", + proveTxHash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e22", + executeTxHash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e23", + }); + + await blockRepository.insert({ + number: 0, + hash: "0x4f86d6647711915ac90e5ef69c29845946f0a55b3feaa0488aece4a359f79cb1", + timestamp: new Date(), + gasLimit: "0", + gasUsed: "0", + baseFeePerGas: "100000000", + extraData: "0x", + l1TxCount: 1, + l2TxCount: 1, + l1BatchNumber: 0, + miner: "0x0000000000000000000000000000000000000000", + }); + + await tokenRepository.insert({ + l2Address: ETH_TOKEN.l2Address, + l1Address: ETH_TOKEN.l1Address, + symbol: ETH_TOKEN.symbol, + name: ETH_TOKEN.name, + decimals: ETH_TOKEN.decimals, + blockNumber: 0, + logIndex: 0, + usdPrice: ETH_TOKEN.usdPrice, + liquidity: ETH_TOKEN.liquidity, + iconURL: ETH_TOKEN.iconURL, + }); + + await tokenRepository.insert({ + l2Address: "0x91d0a23f34e535e44df8ba84c53a0945cf0eeb67", + l1Address: "0x91d0a23f34e535e44df8ba84c53a0945cf0eeb68", + symbol: "TKN", + name: "Token", + decimals: 6, + blockNumber: 0, + logIndex: 1, + usdPrice: 123.456, + liquidity: 1000000, + iconURL: "http://token.url", + }); + }); + + afterAll(async () => { + await tokenRepository.delete({}); + await blockRepository.delete({}); + await batchRepository.delete({}); + await app.close(); + }); + + describe("/api?module=token&action=tokenInfo GET", () => { + it("returns HTTP 200 and no data found response when no token is found", () => { + return request(app.getHttpServer()) + .get(`/api?module=token&action=tokeninfo&contractaddress=0x91d0a23f34e535e44df8ba84c53a0945cf0eeb66`) + .expect(200) + .expect((res) => + expect(res.body).toStrictEqual({ + status: "0", + message: "No data found", + result: [], + }) + ); + }); + + it("returns HTTP 200 and token info when token is found", () => { + return request(app.getHttpServer()) + .get(`/api?module=token&action=tokeninfo&contractaddress=0x91d0a23f34e535e44df8ba84c53a0945cf0eeb67`) + .expect(200) + .expect((res) => + expect(res.body).toStrictEqual({ + message: "OK", + result: [ + { + contractAddress: "0x91D0a23f34E535E44dF8ba84c53A0945CF0EEb67", + iconURL: "http://token.url", + l1Address: "0x91d0A23f34E535E44Df8Ba84c53a0945Cf0eEb68", + liquidity: "1000000", + symbol: "TKN", + tokenDecimal: "6", + tokenName: "Token", + tokenPriceUSD: "123.456", + }, + ], + status: "1", + }) + ); + }); + + it("returns HTTP 200 and ETH token info for ETH token", () => { + return request(app.getHttpServer()) + .get(`/api?module=token&action=tokeninfo&contractaddress=${ETH_TOKEN.l2Address}`) + .expect(200) + .expect((res) => + expect(res.body).toStrictEqual({ + message: "OK", + result: [ + { + contractAddress: ETH_TOKEN.l2Address, + iconURL: ETH_TOKEN.iconURL, + l1Address: ETH_TOKEN.l1Address, + liquidity: ETH_TOKEN.liquidity.toString(), + symbol: ETH_TOKEN.symbol, + tokenDecimal: ETH_TOKEN.decimals.toString(), + tokenName: ETH_TOKEN.name, + tokenPriceUSD: ETH_TOKEN.usdPrice.toString(), + }, + ], + status: "1", + }) + ); + }); + }); +});