From 3c11e0eb21e72a991d533bd9816ec6db77b3033b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20Kozma?= Date: Mon, 10 Feb 2025 19:15:50 +0100 Subject: [PATCH] test(wallet): add functional tests for /blocks and /transactions refs #719 --- .../src/services/db/transactionsService.ts | 2 +- apps/api/test/functional/blocks.spec.ts | 144 +++++++++++++++++ apps/api/test/functional/transactions.spec.ts | 153 ++++++++++++++++++ apps/api/test/seeders/block.seeder.ts | 43 +++++ apps/api/test/seeders/day.seeder.ts | 27 ++++ apps/api/test/seeders/message.seeder.ts | 40 +++++ apps/api/test/seeders/transaction.seeder.ts | 39 +++++ apps/api/test/seeders/validator.seeder.ts | 43 +++++ apps/api/test/setup-functional-tests.ts | 32 ++++ apps/api/test/tsconfig.json | 2 +- apps/api/test/types/jest.d.ts | 8 + packages/net/src/generated/netConfigData.ts | 4 +- 12 files changed, 533 insertions(+), 4 deletions(-) create mode 100644 apps/api/test/functional/blocks.spec.ts create mode 100644 apps/api/test/functional/transactions.spec.ts create mode 100644 apps/api/test/seeders/block.seeder.ts create mode 100644 apps/api/test/seeders/day.seeder.ts create mode 100644 apps/api/test/seeders/message.seeder.ts create mode 100644 apps/api/test/seeders/transaction.seeder.ts create mode 100644 apps/api/test/seeders/validator.seeder.ts create mode 100644 apps/api/test/types/jest.d.ts diff --git a/apps/api/src/services/db/transactionsService.ts b/apps/api/src/services/db/transactionsService.ts index ec68d66ef..3e039fbd0 100644 --- a/apps/api/src/services/db/transactionsService.ts +++ b/apps/api/src/services/db/transactionsService.ts @@ -46,7 +46,7 @@ export async function getTransactions(limit: number) { export async function getTransaction(hash: string): Promise { const tx = await Transaction.findOne({ where: { - hash: hash + hash }, include: [ { diff --git a/apps/api/test/functional/blocks.spec.ts b/apps/api/test/functional/blocks.spec.ts new file mode 100644 index 000000000..d4e91bf65 --- /dev/null +++ b/apps/api/test/functional/blocks.spec.ts @@ -0,0 +1,144 @@ +import { AkashBlock, AkashMessage } from "@akashnetwork/database/dbSchemas/akash"; +import { Day, Transaction, Validator } from "@akashnetwork/database/dbSchemas/base"; + +import { app } from "@src/app"; + +import { BlockSeeder } from "@test/seeders/block.seeder"; +import { MessageSeeder } from "@test/seeders/message.seeder"; +import { TransactionSeeder } from "@test/seeders/transaction.seeder"; +import { ValidatorSeeder } from "@test/seeders/validator.seeder"; + +jest.setTimeout(20000); + +const getMaxHeight = async () => { + const height = await AkashBlock.max("height"); + + return (height as number) ?? 0; +}; + +describe("Blocks", () => { + describe("GET /v1/blocks", () => { + it("resolves list of most recent blocks", async () => { + const response = await app.request("/v1/blocks?limit=2", { + method: "GET", + headers: new Headers({ "Content-Type": "application/json" }) + }); + + expect(response.status).toBe(200); + const blocks = await response.json(); + expect(blocks.length).toBe(2); + blocks.forEach((block: unknown) => { + expect(block).toMatchObject({ + height: expect.any(Number), + proposer: { + address: expect.any(String), + operatorAddress: expect.any(String), + moniker: expect.any(String), + avatarUrl: expect.toBeTypeOrNull(String) + }, + transactionCount: expect.any(Number), + totalTransactionCount: expect.any(Number), + datetime: expect.dateTimeZ() + }); + }); + }); + + it("will not resolve more than 100 blocks", async () => { + const response = await app.request("/v1/blocks?limit=101", { + method: "GET", + headers: new Headers({ "Content-Type": "application/json" }) + }); + + expect(response.status).toBe(200); + const blocks = await response.json(); + expect(blocks.length).toBe(100); + }); + }); + + describe("GET /v1/blocks/{height}", () => { + it("resolves block by height", async () => { + const maxHeight = await getMaxHeight(); + const nextHeight = maxHeight + 1; + + const day = await Day.findOne({ order: [["date", "DESC"]] }); + + const validator = ValidatorSeeder.create(); + await Validator.create(validator); + + const block = BlockSeeder.create({ + height: nextHeight, + proposer: validator.hexAddress, + dayId: day.id + }); + await AkashBlock.create(block); + + const transaction = TransactionSeeder.create({ + height: nextHeight, + hasProcessingError: false + }); + await Transaction.create(transaction); + + const message = MessageSeeder.create({ + txId: transaction.id, + height: nextHeight, + amount: "1000" + }); + await AkashMessage.create(message); + + const response = await app.request(`/v1/blocks/${block.height}`, { + method: "GET", + headers: new Headers({ "Content-Type": "application/json" }) + }); + + expect(response.status).toBe(200); + const blockLoaded = await response.json(); + expect(blockLoaded).toEqual({ + height: block.height, + datetime: block.datetime, + proposer: { + address: validator.accountAddress, + operatorAddress: validator.operatorAddress, + moniker: validator.moniker, + avatarUrl: validator.keybaseAvatarUrl + }, + hash: blockLoaded.hash, + gasUsed: blockLoaded.gasUsed, + gasWanted: blockLoaded.gasWanted, + transactions: [ + { + hash: transaction.hash, + isSuccess: true, + error: null, + fee: parseInt(transaction.fee), + datetime: block.datetime, + messages: [ + { + id: message.id, + type: message.type, + amount: parseInt(message.amount) + } + ] + } + ] + }); + }); + + it("responds 400 for invalid height", async () => { + const response = await app.request("/v1/blocks/a", { + method: "GET", + headers: new Headers({ "Content-Type": "application/json" }) + }); + + expect(response.status).toBe(400); + }); + + it("responds 404 for a block not found", async () => { + const response = await app.request("/v1/blocks/0", { + method: "GET", + headers: new Headers({ "Content-Type": "application/json" }) + }); + + expect(response.status).toBe(404); + }); + }); +}); diff --git a/apps/api/test/functional/transactions.spec.ts b/apps/api/test/functional/transactions.spec.ts new file mode 100644 index 000000000..408c37199 --- /dev/null +++ b/apps/api/test/functional/transactions.spec.ts @@ -0,0 +1,153 @@ +import { AkashBlock, AkashMessage } from "@akashnetwork/database/dbSchemas/akash"; +import { Day, Transaction, Validator } from "@akashnetwork/database/dbSchemas/base"; +import { get } from "lodash"; + +import { app } from "@src/app"; + +import { BlockSeeder } from "@test/seeders/block.seeder"; +import { MessageSeeder } from "@test/seeders/message.seeder"; +import { TransactionSeeder } from "@test/seeders/transaction.seeder"; +import { ValidatorSeeder } from "@test/seeders/validator.seeder"; + +jest.setTimeout(20000); + +const getMaxHeight = async () => { + const height = await AkashBlock.max("height"); + + return (height as number) ?? 0; +}; + +describe("Transactions", () => { + describe("GET /v1/transactions", () => { + it("resolves list of most recent transactions", async () => { + const response = await app.request("/v1/transactions?limit=2", { + method: "GET", + headers: new Headers({ "Content-Type": "application/json" }) + }); + + expect(response.status).toBe(200); + const transactions = await response.json(); + expect(transactions.length).toBe(2); + transactions.forEach((transaction: unknown) => { + expect(transaction).toMatchObject({ + height: expect.any(Number), + datetime: expect.any(String), + hash: expect.any(String), + isSuccess: expect.any(Boolean), + error: expect.toBeTypeOrNull(String), + gasUsed: expect.any(Number), + gasWanted: expect.any(Number), + fee: expect.any(Number), + memo: expect.any(String) + }); + + get(transaction, "messages", []).forEach((message: unknown) => { + expect(message).toMatchObject({ + id: expect.any(String), + type: expect.any(String), + amount: expect.any(Number) + }); + }); + }); + }); + + it("will not resolve more than 100 transactions", async () => { + const response = await app.request("/v1/transactions?limit=101", { + method: "GET", + headers: new Headers({ "Content-Type": "application/json" }) + }); + + expect(response.status).toBe(200); + const blocks = await response.json(); + expect(blocks.length).toBe(100); + }); + + it("responds 400 when limit is not set", async () => { + const response = await app.request("/v1/transactions", { + method: "GET", + headers: new Headers({ "Content-Type": "application/json" }) + }); + + expect(response.status).toBe(400); + }); + }); + + describe("GET /v1/transactions/{hash}", () => { + it("resolves trancation by hash", async () => { + const maxHeight = await getMaxHeight(); + const nextHeight = maxHeight + 1; + + const day = await Day.findOne({ order: [["date", "DESC"]] }); + + const validator = ValidatorSeeder.create(); + await Validator.create(validator); + + const block = BlockSeeder.create({ + height: nextHeight, + proposer: validator.hexAddress, + dayId: day.id + }); + await AkashBlock.create(block); + + const transaction = TransactionSeeder.create({ + height: nextHeight, + hasProcessingError: false + }); + await Transaction.create(transaction); + + const message = MessageSeeder.create({ + txId: transaction.id, + height: nextHeight, + amount: "1000" + }); + await AkashMessage.create(message); + + const response = await app.request(`/v1/transactions/${transaction.hash}`, { + method: "GET", + headers: new Headers({ "Content-Type": "application/json" }) + }); + + expect(response.status).toBe(200); + const transactionLoaded = await response.json(); + expect(transactionLoaded).toEqual({ + height: block.height, + datetime: block.datetime, + hash: transaction.hash, + isSuccess: !transaction.hasProcessingError, + multisigThreshold: null, + signers: [], + error: transaction.hasProcessingError ? transaction.log : null, + gasUsed: transaction.gasUsed, + gasWanted: transaction.gasWanted, + fee: parseInt(transaction.fee), + memo: transaction.memo, + messages: [ + { + id: message.id, + type: message.type, + data: { + amount: [ + { + amount: "10000", + denom: "uakt" + } + ], + fromAddress: "akash10ml4dz5npgyhzx3xq0myl44dzycmkgytmc9rhe", + toAddress: "akash1gxglu3ny085vnwearp3kf6tvhqagadyawy05gq" + }, + relatedDeploymentId: null + } + ] + }); + }); + + it("responds 404 for an unknown hash", async () => { + const response = await app.request("/v1/transactions/unknown-hash", { + method: "GET", + headers: new Headers({ "Content-Type": "application/json" }) + }); + + expect(response.status).toBe(404); + }); + }); +}); diff --git a/apps/api/test/seeders/block.seeder.ts b/apps/api/test/seeders/block.seeder.ts new file mode 100644 index 000000000..cac5b1ffe --- /dev/null +++ b/apps/api/test/seeders/block.seeder.ts @@ -0,0 +1,43 @@ +import { faker } from "@faker-js/faker"; +import { merge } from "lodash"; + +import { AkashAddressSeeder } from "./akash-address.seeder"; + +type Block = { + height: number; + datetime: string; + hash: string; + proposer: string; + dayId: string; + txCount: number; + isProcessed: boolean; + totalUAktSpent?: number; + totalUUsdcSpent?: number; + totalUUsdSpent?: number; + activeLeaseCount?: number; + totalLeaseCount?: number; + activeCPU?: number; + activeGPU?: number; + activeMemory?: number; + activeEphemeralStorage?: number; + activePersistentStorage?: number; + activeProviderCount?: number; +}; + +export class BlockSeeder { + static create(input: Partial = {}): Block { + return merge( + { + height: faker.number.int({ min: 0, max: 10000000 }), + datetime: faker.date.past().toISOString(), + hash: AkashAddressSeeder.create(), + proposer: AkashAddressSeeder.create(), + dayId: faker.string.uuid(), + txCount: faker.number.int({ min: 0, max: 10000000 }), + isProcessed: faker.datatype.boolean(), + totalTxCount: faker.number.int({ min: 0, max: 10000000 }) + }, + input + ); + } +} diff --git a/apps/api/test/seeders/day.seeder.ts b/apps/api/test/seeders/day.seeder.ts new file mode 100644 index 000000000..50102630c --- /dev/null +++ b/apps/api/test/seeders/day.seeder.ts @@ -0,0 +1,27 @@ +import { faker } from "@faker-js/faker"; +import { merge } from "lodash"; + +type Day = { + id: string; + date: Date; + aktPrice?: number; + firstBlockHeight: number; + lastBlockHeight?: number; + lastBlockHeightYet: number; + aktPriceChanged: boolean; +}; + +export class DaySeeder { + static create(input: Partial = {}): Day { + return merge( + { + id: faker.string.uuid(), + date: faker.date.past(), + firstBlockHeight: faker.number.int({ min: 0, max: 10000000 }), + lastBlockHeightYet: faker.number.int({ min: 0, max: 10000000 }), + aktPriceChanged: faker.datatype.boolean() + }, + input + ); + } +} diff --git a/apps/api/test/seeders/message.seeder.ts b/apps/api/test/seeders/message.seeder.ts new file mode 100644 index 000000000..0fbed283e --- /dev/null +++ b/apps/api/test/seeders/message.seeder.ts @@ -0,0 +1,40 @@ +import { faker } from "@faker-js/faker"; +import { merge } from "lodash"; + +export type Message = { + id: string; + txId: string; + height: number; + type: string; + typeCategory: string; + index: number; + indexInBlock: number; + isProcessed: boolean; + isNotificationProcessed: boolean; + amount?: string; + data: Uint8Array; + relatedDeploymentId?: string; +}; + +export class MessageSeeder { + static create(input: Partial = {}): Message { + return merge( + { + id: faker.string.uuid(), + txId: faker.string.uuid(), + height: faker.number.int({ min: 0, max: 10000000 }), + type: "/cosmos.bank.v1beta1.MsgSend", + typeCategory: "cosmos", + index: faker.number.int({ min: 0, max: 10000000 }), + indexInBlock: faker.number.int({ min: 0, max: 10000000 }), + isProcessed: faker.datatype.boolean(), + isNotificationProcessed: faker.datatype.boolean(), + data: Buffer.from( + "0a2c616b61736831306d6c34647a356e706779687a78337871306d796c3434647a79636d6b6779746d6339726865122c616b617368316778676c75336e79303835766e7765617270336b6636747668716167616479617779303567711a0d0a0475616b7412053130303030", + "hex" + ) + }, + input + ); + } +} diff --git a/apps/api/test/seeders/transaction.seeder.ts b/apps/api/test/seeders/transaction.seeder.ts new file mode 100644 index 000000000..eba1de98b --- /dev/null +++ b/apps/api/test/seeders/transaction.seeder.ts @@ -0,0 +1,39 @@ +import { faker } from "@faker-js/faker"; +import { merge } from "lodash"; + +export type Transaction = { + id: string; + hash: string; + index: number; + height: number; + msgCount: number; + multisigThreshold?: number; + gasUsed: number; + gasWanted: number; + fee: string; + memo: string; + isProcessed: boolean; + hasProcessingError: boolean; + log?: string; +}; + +export class TransactionSeeder { + static create(input: Partial = {}): Transaction { + return merge( + { + id: faker.string.uuid(), + hash: faker.string.hexadecimal({ length: 64 }), + index: faker.number.int({ min: 0, max: 10000000 }), + height: faker.number.int({ min: 0, max: 10000000 }), + msgCount: faker.number.int({ min: 0, max: 10000000 }), + gasUsed: faker.number.int({ min: 0, max: 10000000 }), + gasWanted: faker.number.int({ min: 0, max: 10000000 }), + fee: faker.string.numeric({ length: 5 }), + memo: faker.word.noun(), + isProcessed: faker.datatype.boolean(), + hasProcessingError: faker.datatype.boolean() + }, + input + ); + } +} diff --git a/apps/api/test/seeders/validator.seeder.ts b/apps/api/test/seeders/validator.seeder.ts new file mode 100644 index 000000000..59c5cd5cc --- /dev/null +++ b/apps/api/test/seeders/validator.seeder.ts @@ -0,0 +1,43 @@ +import { faker } from "@faker-js/faker"; +import { merge } from "lodash"; + +import { AkashAddressSeeder } from "./akash-address.seeder"; + +export type Validator = { + id: string; + operatorAddress: string; + accountAddress: string; + hexAddress: string; + createdMsgId?: string; + moniker: string; + identity?: string; + website?: string; + description?: string; + securityContact?: string; + rate: number; + maxRate: number; + maxChangeRate: number; + minSelfDelegation: number; + keybaseUsername?: string; + keybaseAvatarUrl?: string; +}; + +export class ValidatorSeeder { + static create(input: Partial = {}): Validator { + return merge( + { + id: faker.string.uuid(), + operatorAddress: AkashAddressSeeder.create(), + accountAddress: AkashAddressSeeder.create(), + hexAddress: AkashAddressSeeder.create(), + moniker: faker.company.name(), + rate: faker.number.int({ min: 0, max: 10000000 }), + maxRate: faker.number.int({ min: 0, max: 10000000 }), + maxChangeRate: faker.number.int({ min: 0, max: 10000000 }), + minSelfDelegation: faker.number.int({ min: 0, max: 10000000 }), + keybaseAvatarUrl: faker.image.avatar() + }, + input + ); + } +} diff --git a/apps/api/test/setup-functional-tests.ts b/apps/api/test/setup-functional-tests.ts index 47f8c0a8a..a802603a1 100644 --- a/apps/api/test/setup-functional-tests.ts +++ b/apps/api/test/setup-functional-tests.ts @@ -9,3 +9,35 @@ beforeAll(async () => { afterAll(async () => { await closeConnections(); }); + +expect.extend({ + toBeTypeOrNull(received: unknown, type: StringConstructor) { + try { + expect(received).toEqual(expect.any(type)); + return { + message: () => `Ok`, + pass: true + }; + } catch (error) { + return received === null + ? { + message: () => `Ok`, + pass: true + } + : { + message: () => `expected ${received} to be ${type} type or null`, + pass: false + }; + } + }, + + dateTimeZ(received: string) { + const pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})Z$/; + const pass = pattern.test(received); + + return { + pass, + message: () => `expected ${received} to be a UTC datetime string with milliseconds` + }; + } +}); diff --git a/apps/api/test/tsconfig.json b/apps/api/test/tsconfig.json index bc4f18bd3..84df2572a 100644 --- a/apps/api/test/tsconfig.json +++ b/apps/api/test/tsconfig.json @@ -7,6 +7,6 @@ "@test/*": ["*"] } }, - "include": ["../src/**/*", "**/*", "../../packages/logging/src/types/pino-fluentd.d.ts"], + "include": ["../src/**/*", "**/*", "../../packages/logging/src/types/pino-fluentd.d.ts", "types/*.d.ts"], "exclude": ["../node_modules", "../dist"] } diff --git a/apps/api/test/types/jest.d.ts b/apps/api/test/types/jest.d.ts new file mode 100644 index 000000000..1d6ff6c66 --- /dev/null +++ b/apps/api/test/types/jest.d.ts @@ -0,0 +1,8 @@ +/// + +declare namespace jest { + interface Expect { + toBeTypeOrNull(type: StringConstructor): R; + dateTimeZ(): R; + } +} diff --git a/packages/net/src/generated/netConfigData.ts b/packages/net/src/generated/netConfigData.ts index 44936070d..61dc953cd 100644 --- a/packages/net/src/generated/netConfigData.ts +++ b/packages/net/src/generated/netConfigData.ts @@ -9,10 +9,10 @@ export const netConfigData = { ], rpcUrls: [ "https://rpc.akashnet.net:443", - "https://rpc.akash.forbole.com:443", "https://rpc-akash.ecostake.com:443", "https://akash-rpc.polkachu.com:443", - "https://akash.c29r3.xyz:443/rpc" + "https://akash.c29r3.xyz:443/rpc", + "https://akash-rpc.europlots.com:443" ] }, sandbox: {