diff --git a/CLAUDE.md b/CLAUDE.md index a4d44abb70..7cdc8047dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,22 +214,46 @@ The `mainsail` CLI binary. Commands in `source/commands/` cover: `core:run`, `co Tests use `describe` from `@mainsail/test-runner` (wraps uvu suites): ```typescript +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; import { describe } from "@mainsail/test-runner"; +import { Handler } from "./handler"; -describe("ComponentName", ({ it, beforeEach, assert, stub, spy, clock }) => { +describe<{ + app: Application; + handler: Handler; + myService: any; +}>("Handler", ({ it, beforeEach, assert, stub, spy, clock }) => { beforeEach((context) => { // Set up stubs for injected dependencies context.myService = { method: () => {} }; - // Build container, bind stubs, resolve class under test + + // Use Application (from @mainsail/kernel), not the raw Container — it auto-binds itself + // as Identifiers.Application.Instance and exposes resolve() (which applies autobind). + context.app = new Application(); + context.app.bind(Identifiers.SomeService).toConstantValue(context.myService); + + // Resolve the class under test once, here — never inline inside an it(). + context.handler = context.app.resolve(Handler); }); - it("does something", async (context) => { - // arrange, act, assert - assert.equal(result, expected); + // Destructure the context in the it() callback rather than threading `context.` through. + it("does something", async ({ handler, myService }) => { + const method = spy(myService, "method"); + + await handler.handle(); + + method.calledOnce(); }); }); ``` +Conventions for IoC-injected classes under test: + +- Bind stubs to their `Identifiers` on an `Application` instance and resolve the class with `app.resolve(Class)`. `Application.get(id)` takes only the identifier; use `resolve()` for autobinding the class under test. +- Resolve the tested class **in `beforeEach`** and store it on the context (e.g. `context.handler`). Don't resolve inline inside an `it()`. +- In `it()` callbacks, **destructure the context** — `async ({ handler, myService }) => {}` — instead of referencing `context.x`. Mutating a destructured stub (e.g. `myService.method = …`) still works since it's the same object the handler holds. + Helpers available: `assert` (custom assertions), `stub()` / `spy()` (sinon wrappers), `clock()` (sinon fake timers), `nock` (HTTP mocking), `each()` (data-driven tests), `schema` (zod). Test factories for common entities (blocks, wallets, transactions, commits) are in `@mainsail/test-factories`. Transaction builders for tests are in `@mainsail/test-transaction-builders`. diff --git a/packages/transaction-pool-worker/package.json b/packages/transaction-pool-worker/package.json index c8d5ab66d5..1d42842f43 100644 --- a/packages/transaction-pool-worker/package.json +++ b/packages/transaction-pool-worker/package.json @@ -25,12 +25,12 @@ "@mainsail/container": "workspace:*", "@mainsail/kernel": "workspace:*", "@mainsail/utils": "workspace:*", - "dayjs": "1.11.20", - "joi": "18.2.1" + "dayjs": "1.11.20" }, "devDependencies": { "@mainsail/contracts": "workspace:*", "@mainsail/test-runner": "workspace:*", + "esmock": "2.7.5", "uvu": "0.5.6" }, "engines": { diff --git a/packages/transaction-pool-worker/source/defaults.ts b/packages/transaction-pool-worker/source/defaults.ts deleted file mode 100644 index 0e40a8a2f0..0000000000 --- a/packages/transaction-pool-worker/source/defaults.ts +++ /dev/null @@ -1 +0,0 @@ -export const defaults = {}; diff --git a/packages/transaction-pool-worker/source/handlers/commit.test.ts b/packages/transaction-pool-worker/source/handlers/commit.test.ts new file mode 100644 index 0000000000..55fff0c860 --- /dev/null +++ b/packages/transaction-pool-worker/source/handlers/commit.test.ts @@ -0,0 +1,88 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { CommitHandler } from "./commit"; + +describe<{ + app: Application; + handler: CommitHandler; + stateStore: any; + configuration: any; + transactionPoolService: any; + selector: any; +}>("CommitHandler", ({ assert, beforeEach, it, spy }) => { + beforeEach((context) => { + context.stateStore = { setBlockNumber: () => {} }; + context.configuration = { isNewMilestone: () => false }; + context.transactionPoolService = { commit: async () => {}, reAddTransactions: async () => {} }; + context.selector = { clear: () => {} }; + + context.app = new Application(); + context.app.bind(Identifiers.State.Store).toConstantValue(context.stateStore); + context.app.bind(Identifiers.Cryptography.Configuration).toConstantValue(context.configuration); + context.app.bind(Identifiers.TransactionPool.Service).toConstantValue(context.transactionPoolService); + context.app.bind(Identifiers.TransactionPool.Selector).toConstantValue(context.selector); + + context.handler = context.app.resolve(CommitHandler); + }); + + it("sets the block number and clears the selector", async ({ handler, stateStore, selector }) => { + const setBlockNumber = spy(stateStore, "setBlockNumber"); + const clear = spy(selector, "clear"); + + await handler.handle(10, ["address-1"], 1000, false); + + setBlockNumber.calledOnce(); + setBlockNumber.calledWith(10); + clear.calledOnce(); + }); + + it("commits the senders, gas and syncing flag when not a new milestone", async ({ + handler, + transactionPoolService, + }) => { + const commit = spy(transactionPoolService, "commit"); + const reAdd = spy(transactionPoolService, "reAddTransactions"); + + await handler.handle(10, ["address-1", "address-2"], 5000, true); + + commit.calledOnce(); + commit.calledWith(["address-1", "address-2"], 5000, true); + reAdd.neverCalled(); + }); + + it("re-adds transactions instead of committing on a new milestone", async ({ + handler, + configuration, + transactionPoolService, + }) => { + configuration.isNewMilestone = () => true; + const commit = spy(transactionPoolService, "commit"); + const reAdd = spy(transactionPoolService, "reAddTransactions"); + + await handler.handle(10, ["address-1"], 1000, false); + + reAdd.calledOnce(); + commit.neverCalled(); + }); + + it("wraps a thrown error with a 'Failed to commit block' message", async ({ handler, transactionPoolService }) => { + transactionPoolService.commit = async () => { + throw new Error("boom"); + }; + + await assert.rejects(() => handler.handle(10, ["address-1"], 1000, false), "Failed to commit block: boom"); + }); + + it("normalizes a non-Error throw into the wrapped message", async ({ handler, selector }) => { + selector.clear = () => { + throw "string failure"; + }; + + await assert.rejects( + () => handler.handle(10, ["address-1"], 1000, false), + "Failed to commit block: string failure", + ); + }); +}); diff --git a/packages/transaction-pool-worker/source/handlers/commit.ts b/packages/transaction-pool-worker/source/handlers/commit.ts index e7085d9ce5..bc58fdfd18 100644 --- a/packages/transaction-pool-worker/source/handlers/commit.ts +++ b/packages/transaction-pool-worker/source/handlers/commit.ts @@ -18,9 +18,6 @@ export class CommitHandler { @inject(Identifiers.TransactionPool.Selector) private readonly selector!: Contracts.TransactionPool.Selector; - @inject(Identifiers.Services.Log.Service) - protected readonly logger!: Contracts.Kernel.Logger; - public async handle( blockNumber: number, sendersAddresses: string[], diff --git a/packages/transaction-pool-worker/source/handlers/forget-peer.test.ts b/packages/transaction-pool-worker/source/handlers/forget-peer.test.ts new file mode 100644 index 0000000000..2343eca299 --- /dev/null +++ b/packages/transaction-pool-worker/source/handlers/forget-peer.test.ts @@ -0,0 +1,29 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { ForgetPeerHandler } from "./forget-peer"; + +describe<{ + app: Application; + handler: ForgetPeerHandler; + peerRepository: any; +}>("ForgetPeerHandler", ({ beforeEach, it, spy }) => { + beforeEach((context) => { + context.peerRepository = { forgetPeer: () => {} }; + + context.app = new Application(); + context.app.bind(Identifiers.TransactionPool.Peer.Repository).toConstantValue(context.peerRepository); + + context.handler = context.app.resolve(ForgetPeerHandler); + }); + + it("forgets the peer by ip", async ({ handler, peerRepository }) => { + const forgetPeer = spy(peerRepository, "forgetPeer"); + + await handler.handle("127.0.0.1"); + + forgetPeer.calledOnce(); + forgetPeer.calledWith("127.0.0.1"); + }); +}); diff --git a/packages/transaction-pool-worker/source/handlers/get-transactions.test.ts b/packages/transaction-pool-worker/source/handlers/get-transactions.test.ts new file mode 100644 index 0000000000..afcd744e4a --- /dev/null +++ b/packages/transaction-pool-worker/source/handlers/get-transactions.test.ts @@ -0,0 +1,33 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { GetTransactionsHandler } from "./get-transactions"; + +describe<{ + app: Application; + handler: GetTransactionsHandler; + selector: any; +}>("GetTransactionsHandler", ({ assert, beforeEach, it, spy }) => { + beforeEach((context) => { + context.selector = { getBatch: async () => ({ remaining: 0, transactions: [] }) }; + + context.app = new Application(); + context.app.bind(Identifiers.TransactionPool.Selector).toConstantValue(context.selector); + + context.handler = context.app.resolve(GetTransactionsHandler); + }); + + it("delegates to the selector and returns its batch", async ({ handler, selector }) => { + const batch = { remaining: 2, transactions: [] }; + selector.getBatch = async () => batch; + const getBatch = spy(selector, "getBatch"); + + const options = { blockRound: "0", maxBytes: 1024, maxSize: 100 }; + const result = await handler.handle(options); + + getBatch.calledOnce(); + getBatch.calledWith(options); + assert.equal(result, batch); + }); +}); diff --git a/packages/transaction-pool-worker/source/handlers/reload-webhooks.test.ts b/packages/transaction-pool-worker/source/handlers/reload-webhooks.test.ts new file mode 100644 index 0000000000..c533f50c34 --- /dev/null +++ b/packages/transaction-pool-worker/source/handlers/reload-webhooks.test.ts @@ -0,0 +1,28 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { ReloadWebhooksHandler } from "./reload-webhooks"; + +describe<{ + app: Application; + handler: ReloadWebhooksHandler; + database: any; +}>("ReloadWebhooksHandler", ({ beforeEach, it, spy }) => { + beforeEach((context) => { + context.database = { restore: () => {} }; + + context.app = new Application(); + context.app.bind(Identifiers.Webhooks.Database).toConstantValue(context.database); + + context.handler = context.app.resolve(ReloadWebhooksHandler); + }); + + it("restores the webhooks database", async ({ handler, database }) => { + const restore = spy(database, "restore"); + + await handler.handle(); + + restore.calledOnce(); + }); +}); diff --git a/packages/transaction-pool-worker/source/handlers/remove-transaction.test.ts b/packages/transaction-pool-worker/source/handlers/remove-transaction.test.ts new file mode 100644 index 0000000000..2e0f71c71a --- /dev/null +++ b/packages/transaction-pool-worker/source/handlers/remove-transaction.test.ts @@ -0,0 +1,35 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { RemoveTransactionHandler } from "./remove-transaction"; + +describe<{ + app: Application; + handler: RemoveTransactionHandler; + mempool: any; + storage: any; +}>("RemoveTransactionHandler", ({ beforeEach, it, spy }) => { + beforeEach((context) => { + context.mempool = { removeTransaction: async () => {} }; + context.storage = { removeTransaction: () => {} }; + + context.app = new Application(); + context.app.bind(Identifiers.TransactionPool.Mempool).toConstantValue(context.mempool); + context.app.bind(Identifiers.TransactionPool.Storage).toConstantValue(context.storage); + + context.handler = context.app.resolve(RemoveTransactionHandler); + }); + + it("removes the transaction from the mempool and the storage", async ({ handler, mempool, storage }) => { + const fromMempool = spy(mempool, "removeTransaction"); + const fromStorage = spy(storage, "removeTransaction"); + + await handler.handle("address-1", "hash-1"); + + fromMempool.calledOnce(); + fromMempool.calledWith("address-1", "hash-1"); + fromStorage.calledOnce(); + fromStorage.calledWith("hash-1"); + }); +}); diff --git a/packages/transaction-pool-worker/source/handlers/set-peer.test.ts b/packages/transaction-pool-worker/source/handlers/set-peer.test.ts new file mode 100644 index 0000000000..97f3cae7bd --- /dev/null +++ b/packages/transaction-pool-worker/source/handlers/set-peer.test.ts @@ -0,0 +1,29 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { SetPeerHandler } from "./set-peer"; + +describe<{ + app: Application; + handler: SetPeerHandler; + peerRepository: any; +}>("SetPeerHandler", ({ beforeEach, it, spy }) => { + beforeEach((context) => { + context.peerRepository = { setPeer: () => {} }; + + context.app = new Application(); + context.app.bind(Identifiers.TransactionPool.Peer.Repository).toConstantValue(context.peerRepository); + + context.handler = context.app.resolve(SetPeerHandler); + }); + + it("sets the peer by ip", async ({ handler, peerRepository }) => { + const setPeer = spy(peerRepository, "setPeer"); + + await handler.handle("127.0.0.1"); + + setPeer.calledOnce(); + setPeer.calledWith("127.0.0.1"); + }); +}); diff --git a/packages/transaction-pool-worker/source/handlers/start.test.ts b/packages/transaction-pool-worker/source/handlers/start.test.ts new file mode 100644 index 0000000000..9ae860a7ee --- /dev/null +++ b/packages/transaction-pool-worker/source/handlers/start.test.ts @@ -0,0 +1,99 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { StartHandler } from "./start"; + +describe<{ + app: Application; + handler: StartHandler; + store: any; + transactionPoolService: any; + httpServer: any; + httpsServer: any; + enabled: { http: boolean; https: boolean }; +}>("StartHandler", ({ beforeEach, it, spy }) => { + beforeEach((context) => { + context.store = { setBlockNumber: () => {} }; + context.transactionPoolService = { reAddTransactions: async () => {} }; + context.httpServer = { boot: async () => {} }; + context.httpsServer = { boot: async () => {} }; + context.enabled = { http: false, https: false }; + + const configuration = { + getRequired: (key: string) => + key === "server.http.enabled" ? context.enabled.http : context.enabled.https, + }; + + context.app = new Application(); + context.app.bind(Identifiers.State.Store).toConstantValue(context.store); + context.app.bind(Identifiers.TransactionPool.Service).toConstantValue(context.transactionPoolService); + context.app.bind(Identifiers.TransactionPool.API.HTTP).toConstantValue(context.httpServer); + context.app.bind(Identifiers.TransactionPool.API.HTTPS).toConstantValue(context.httpsServer); + context.app + .bind(Identifiers.ServiceProvider.Configuration) + .toConstantValue(configuration) + .whenTagged("plugin", "api-transaction-pool"); + + context.handler = context.app.resolve(StartHandler); + }); + + it("sets the block number and re-adds the transactions", async ({ handler, store, transactionPoolService }) => { + const setBlockNumber = spy(store, "setBlockNumber"); + const reAdd = spy(transactionPoolService, "reAddTransactions"); + + await handler.handle(42); + + setBlockNumber.calledOnce(); + setBlockNumber.calledWith(42); + reAdd.calledOnce(); + }); + + it("does not boot any server when neither http nor https is enabled", async ({ + handler, + httpServer, + httpsServer, + }) => { + const http = spy(httpServer, "boot"); + const https = spy(httpsServer, "boot"); + + await handler.handle(42); + + http.neverCalled(); + https.neverCalled(); + }); + + it("boots only the http server when http is enabled", async ({ handler, enabled, httpServer, httpsServer }) => { + enabled.http = true; + const http = spy(httpServer, "boot"); + const https = spy(httpsServer, "boot"); + + await handler.handle(42); + + http.calledOnce(); + https.neverCalled(); + }); + + it("boots only the https server when https is enabled", async ({ handler, enabled, httpServer, httpsServer }) => { + enabled.https = true; + const http = spy(httpServer, "boot"); + const https = spy(httpsServer, "boot"); + + await handler.handle(42); + + http.neverCalled(); + https.calledOnce(); + }); + + it("boots both servers when http and https are enabled", async ({ handler, enabled, httpServer, httpsServer }) => { + enabled.http = true; + enabled.https = true; + const http = spy(httpServer, "boot"); + const https = spy(httpsServer, "boot"); + + await handler.handle(42); + + http.calledOnce(); + https.calledOnce(); + }); +}); diff --git a/packages/transaction-pool-worker/source/service-provider.test.ts b/packages/transaction-pool-worker/source/service-provider.test.ts new file mode 100644 index 0000000000..5623dc897d --- /dev/null +++ b/packages/transaction-pool-worker/source/service-provider.test.ts @@ -0,0 +1,107 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application, Ipc } from "@mainsail/kernel"; +import { EventEmitter } from "events"; +import esmock from "esmock"; +import { PassThrough } from "stream"; + +import { describe } from "@mainsail/test-runner"; + +// Records every `new Worker(...)` so the factory test can assert how the thread is spawned. +const constructions: any[][] = []; + +// Stand-in for worker_threads.Worker: an EventEmitter exposing the stdout/stderr streams and +// threadId that Ipc.Subprocess reads, so the real Subprocess wraps it without a real thread. +class FakeWorker extends EventEmitter { + public threadId = 1; + public readonly stdout = new PassThrough(); + public readonly stderr = new PassThrough(); + + public constructor(...arguments_: any[]) { + super(); + constructions.push(arguments_); + } + + public postMessage(): void {} + public async terminate(): Promise { + return 0; + } +} + +// Load the provider with worker_threads.Worker swapped for the fake; the real Ipc.Subprocess +// and ./worker.js stay in place. +const { ServiceProvider } = await esmock("./service-provider", { + worker_threads: { Worker: FakeWorker }, +}); + +describe<{ + app: Application; + serviceProvider: any; + worker: any; + flags: any; +}>("ServiceProvider", ({ assert, beforeEach, it, spy, stub }) => { + beforeEach((context) => { + constructions.length = 0; + context.flags = { network: "testnet" }; + context.worker = { boot: async () => {}, dispose: async () => {} }; + + context.app = new Application(); + context.app.bind(Identifiers.Config.Flags).toConstantValue(context.flags); + // Ipc.Subprocess resolves the logger from the container when the factory runs. + context.app.bind(Identifiers.Services.Log.Service).toConstantValue({ debug: () => {}, error: () => {} }); + + // Resolve the provider before stubbing resolve, so its own injection still works. + context.serviceProvider = context.app.resolve(ServiceProvider); + + // register() resolves the WorkerInstance, whose @postConstruct invokes the factory. + // Intercept that resolution so only the explicit factory call below spawns one. + stub(context.app, "resolve").returnValue(context.worker); + }); + + it("register binds the worker subprocess factory and the worker", async (context) => { + assert.false(context.app.isBound(Identifiers.TransactionPool.WorkerSubprocess.Factory)); + assert.false(context.app.isBound(Identifiers.TransactionPool.Worker)); + + await context.serviceProvider.register(); + + assert.true(context.app.isBound(Identifiers.TransactionPool.WorkerSubprocess.Factory)); + assert.true(context.app.isBound(Identifiers.TransactionPool.Worker)); + assert.function(context.app.get(Identifiers.TransactionPool.WorkerSubprocess.Factory)); + assert.equal(context.app.get(Identifiers.TransactionPool.Worker), context.worker); + }); + + it("the factory spawns the worker script with piped stdio and wraps it in an Ipc.Subprocess", async (context) => { + await context.serviceProvider.register(); + + const factory = context.app.get(Identifiers.TransactionPool.WorkerSubprocess.Factory) as () => Ipc.Subprocess; + const subprocess = factory(); + + assert.length(constructions, 1); + const [scriptPath, options] = constructions[0]; + assert.true(scriptPath.endsWith("worker-script.js")); + assert.equal(options, { stderr: true, stdout: true }); + assert.instance(subprocess, Ipc.Subprocess); + }); + + it("boot delegates to the worker with the flags and the thread name", async (context) => { + await context.serviceProvider.register(); + const boot = spy(context.worker, "boot"); + + await context.serviceProvider.boot(); + + boot.calledOnce(); + boot.calledWith({ network: "testnet", thread: "transaction-pool" }); + }); + + it("dispose delegates to the worker", async (context) => { + await context.serviceProvider.register(); + const dispose = spy(context.worker, "dispose"); + + await context.serviceProvider.dispose(); + + dispose.calledOnce(); + }); + + it("is required", async (context) => { + assert.true(await context.serviceProvider.required()); + }); +}); diff --git a/packages/transaction-pool-worker/source/service-provider.ts b/packages/transaction-pool-worker/source/service-provider.ts index d56031bd99..8e315bae5d 100644 --- a/packages/transaction-pool-worker/source/service-provider.ts +++ b/packages/transaction-pool-worker/source/service-provider.ts @@ -3,7 +3,6 @@ import type { Contracts } from "@mainsail/contracts"; import { Identifiers } from "@mainsail/constants"; import { inject, injectable } from "@mainsail/container"; import { Ipc, Providers } from "@mainsail/kernel"; -import Joi from "joi"; import { fileURLToPath } from "url"; import { Worker } from "worker_threads"; @@ -42,8 +41,4 @@ export class ServiceProvider extends Providers.ServiceProvider { public async required(): Promise { return true; } - - public configSchema(): Joi.AnySchema { - return Joi.object({}).required().unknown(true); - } } diff --git a/packages/transaction-pool-worker/source/worker-handler.test.ts b/packages/transaction-pool-worker/source/worker-handler.test.ts new file mode 100644 index 0000000000..b85a080031 --- /dev/null +++ b/packages/transaction-pool-worker/source/worker-handler.test.ts @@ -0,0 +1,121 @@ +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { + CommitHandler, + ForgetPeerHandler, + GetTransactionsHandler, + ReloadWebhooksHandler, + RemoveTransactionHandler, + SetPeerHandler, + StartHandler, +} from "./handlers/index.js"; +import { WorkerScriptHandler } from "./worker-handler"; + +describe<{ + subject: WorkerScriptHandler; + handler: any; + resolve: any; +}>("WorkerScriptHandler", ({ assert, beforeEach, it, spy, stub }) => { + beforeEach((context) => { + // WorkerScriptHandler owns a private `new Application()`; stub the prototype so the + // handler resolutions and lifecycle calls stay in-process. + context.handler = { handle: async () => {} }; + context.resolve = stub(Application.prototype, "resolve").returnValue(context.handler); + + context.subject = new WorkerScriptHandler(); + }); + + it("boot bootstraps the app with the flags and boots it", async ({ subject }) => { + const bootstrap = stub(Application.prototype, "bootstrap").resolvedValue(undefined); + const boot = stub(Application.prototype, "boot").resolvedValue(undefined); + const flags = { network: "testnet" } as any; + + await subject.boot(flags); + + bootstrap.calledWith({ flags }); + boot.calledOnce(); + }); + + it("dispose terminates the app", async ({ subject }) => { + const terminate = stub(Application.prototype, "terminate").resolvedValue(undefined); + + await subject.dispose(); + + terminate.calledOnce(); + }); + + it("start resolves the StartHandler and forwards the height", async ({ subject, handler, resolve }) => { + const handle = spy(handler, "handle"); + + await subject.start(42); + + resolve.calledWith(StartHandler); + handle.calledWith(42); + }); + + it("commit resolves the CommitHandler and forwards all arguments", async ({ subject, handler, resolve }) => { + const handle = spy(handler, "handle"); + + await subject.commit(10, ["alice"], 5000, true); + + resolve.calledWith(CommitHandler); + handle.calledWith(10, ["alice"], 5000, true); + }); + + it("getTransactions resolves the GetTransactionsHandler and returns its result", async ({ + subject, + handler, + resolve, + }) => { + const batch = { remaining: 0, transactions: [] }; + const handle = stub(handler, "handle").resolvedValue(batch); + const options = { blockRound: "0", maxBytes: 1024, maxSize: 100 }; + + const result = await subject.getTransactions(options); + + resolve.calledWith(GetTransactionsHandler); + handle.calledWith(options); + assert.equal(result, batch); + }); + + it("removeTransaction resolves the RemoveTransactionHandler and forwards address and id", async ({ + subject, + handler, + resolve, + }) => { + const handle = spy(handler, "handle"); + + await subject.removeTransaction("address-1", "hash-1"); + + resolve.calledWith(RemoveTransactionHandler); + handle.calledWith("address-1", "hash-1"); + }); + + it("setPeer resolves the SetPeerHandler and forwards the ip", async ({ subject, handler, resolve }) => { + const handle = spy(handler, "handle"); + + await subject.setPeer("127.0.0.1"); + + resolve.calledWith(SetPeerHandler); + handle.calledWith("127.0.0.1"); + }); + + it("forgetPeer resolves the ForgetPeerHandler and forwards the ip", async ({ subject, handler, resolve }) => { + const handle = spy(handler, "handle"); + + await subject.forgetPeer("127.0.0.1"); + + resolve.calledWith(ForgetPeerHandler); + handle.calledWith("127.0.0.1"); + }); + + it("reloadWebhooks resolves the ReloadWebhooksHandler", async ({ subject, handler, resolve }) => { + const handle = spy(handler, "handle"); + + await subject.reloadWebhooks(); + + resolve.calledWith(ReloadWebhooksHandler); + handle.calledOnce(); + }); +}); diff --git a/packages/transaction-pool-worker/source/worker-script.test.ts b/packages/transaction-pool-worker/source/worker-script.test.ts new file mode 100644 index 0000000000..f7cf7777bf --- /dev/null +++ b/packages/transaction-pool-worker/source/worker-script.test.ts @@ -0,0 +1,10 @@ +import { describe } from "@mainsail/test-runner"; + +// worker-script.ts is the worker thread entrypoint: importing it wires an Ipc.Handler to a +// WorkerScriptHandler. On the main thread parentPort is null, so the handler registers no +// listener — the import should simply complete without throwing. +describe("WorkerScript", ({ assert, it }) => { + it("loads without throwing", async () => { + await assert.resolves(() => import("./worker-script.js")); + }); +}); diff --git a/packages/transaction-pool-worker/source/worker.test.ts b/packages/transaction-pool-worker/source/worker.test.ts new file mode 100644 index 0000000000..d7f64d44ba --- /dev/null +++ b/packages/transaction-pool-worker/source/worker.test.ts @@ -0,0 +1,209 @@ +import { Events, Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { Worker } from "./worker"; + +describe<{ + app: Application; + worker: Worker; + ipc: any; + configuration: any; + eventDispatcher: any; +}>("Worker", ({ assert, beforeEach, it, spy, stub, clock }) => { + beforeEach((context) => { + context.ipc = { + dispose: async () => 0, + drain: async () => {}, + getQueueSize: () => 3, + kill: async () => 7, + registerEventHandler: () => {}, + sendRequest: async () => {}, + }; + context.configuration = { getMilestone: () => ({ timeouts: { blockTime: 8000 } }) }; + context.eventDispatcher = { listen: () => {} }; + + context.app = new Application(); + // The injected factory hands back our fake subprocess instead of spawning a thread. + context.app.bind(Identifiers.TransactionPool.WorkerSubprocess.Factory).toConstantValue(() => context.ipc); + context.app.bind(Identifiers.Cryptography.Configuration).toConstantValue(context.configuration); + context.app.bind(Identifiers.Services.EventDispatcher.Service).toConstantValue(context.eventDispatcher); + + context.worker = context.app.resolve(Worker); + }); + + it("initialize subscribes to the webhook events", ({ app, eventDispatcher }) => { + // initialize() ran during resolve() in beforeEach; assert its side effects on a fresh resolve. + const listen = spy(eventDispatcher, "listen"); + const fresh = app.resolve(Worker); + + listen.calledTimes(3); + listen.calledNthWith(0, Events.WebhookEvent.Created, fresh); + listen.calledNthWith(1, Events.WebhookEvent.Updated, fresh); + listen.calledNthWith(2, Events.WebhookEvent.Removed, fresh); + }); + + it("boot sends a single boot request and memoizes it", async ({ worker, ipc }) => { + const sendRequest = spy(ipc, "sendRequest"); + const flags = { thread: "transaction-pool" } as any; + + await worker.boot(flags); + await worker.boot(flags); + + sendRequest.calledOnce(); + sendRequest.calledWith("boot", flags); + }); + + it("dispose drains, requests an inner dispose, then terminates the subprocess", async ({ worker, ipc }) => { + const drain = spy(ipc, "drain"); + const sendRequest = spy(ipc, "sendRequest"); + const dispose = spy(ipc, "dispose"); + + await worker.dispose(); + + drain.calledOnce(); + sendRequest.calledWith("dispose"); + dispose.calledOnce(); + }); + + it("dispose still terminates the subprocess when the inner dispose request fails", async ({ worker, ipc }) => { + ipc.sendRequest = async () => { + throw new Error("worker already gone"); + }; + const dispose = spy(ipc, "dispose"); + + await assert.resolves(() => worker.dispose()); + + dispose.calledOnce(); + }); + + it("dispose is memoized across calls", async ({ worker, ipc }) => { + const drain = spy(ipc, "drain"); + + await worker.dispose(); + await worker.dispose(); + + drain.calledOnce(); + }); + + it("kill terminates the subprocess and returns its exit code", async ({ worker, ipc }) => { + const kill = spy(ipc, "kill"); + + assert.equal(await worker.kill(), 7); + kill.calledOnce(); + }); + + it("getQueueSize reports the subprocess queue size", ({ worker }) => { + assert.equal(worker.getQueueSize(), 3); + }); + + it("registerEventHandler forwards to the subprocess", ({ worker, ipc }) => { + const register = spy(ipc, "registerEventHandler"); + const callback = () => {}; + + worker.registerEventHandler("some-event", callback); + + register.calledWith("some-event", callback); + }); + + it("start requests start with the block number", async ({ worker, ipc }) => { + const sendRequest = spy(ipc, "sendRequest"); + + await worker.start(42); + + sendRequest.calledWith("start", 42); + }); + + it("getTransactions requests and returns the batch", async ({ worker, ipc }) => { + const batch = { remaining: 0, transactions: [] }; + const sendRequest = stub(ipc, "sendRequest").resolvedValue(batch); + const options = { blockRound: "0", maxBytes: 1024, maxSize: 100 }; + + const result = await worker.getTransactions(options); + + sendRequest.calledWith("getTransactions", options); + assert.equal(result, batch); + }); + + it("removeTransaction requests removal by address and id", async ({ worker, ipc }) => { + const sendRequest = spy(ipc, "sendRequest"); + + await worker.removeTransaction("address-1", "hash-1"); + + sendRequest.calledWith("removeTransaction", "address-1", "hash-1"); + }); + + it("setPeer requests the peer by ip", async ({ worker, ipc }) => { + const sendRequest = spy(ipc, "sendRequest"); + + await worker.setPeer("127.0.0.1"); + + sendRequest.calledWith("setPeer", "127.0.0.1"); + }); + + it("forgetPeer requests forgetting the peer by ip", async ({ worker, ipc }) => { + const sendRequest = spy(ipc, "sendRequest"); + + await worker.forgetPeer("127.0.0.1"); + + sendRequest.calledWith("forgetPeer", "127.0.0.1"); + }); + + it("reloadWebhooks requests a webhook reload", async ({ worker, ipc }) => { + const sendRequest = spy(ipc, "sendRequest"); + + await worker.reloadWebhooks(); + + sendRequest.calledWith("reloadWebhooks"); + }); + + it("handle reloads webhooks", async ({ worker, ipc }) => { + const sendRequest = spy(ipc, "sendRequest"); + + await worker.handle({ data: {}, name: "webhooks.created" }); + + sendRequest.calledWith("reloadWebhooks"); + }); + + it("onCommit commits sender addresses, gas used and a not-syncing flag for a recent block", async ({ + worker, + ipc, + }) => { + const now = 1_700_000_000_000; + clock(now); + const sendRequest = spy(ipc, "sendRequest"); + + const unit = { + blockNumber: 100, + getBlock: () => ({ + gasUsed: 21_000, + timestamp: now, // recent → not syncing + transactions: [{ from: "alice" }, { from: "bob" }, { from: "alice" }], + }), + } as any; + + await worker.onCommit(unit); + + // Duplicate senders collapse via the Set. + sendRequest.calledWith("commit", 100, ["alice", "bob"], 21_000, false); + }); + + it("onCommit flags syncing when the block is older than three block times", async ({ worker, ipc }) => { + const now = 1_700_000_000_000; + clock(now); + const sendRequest = spy(ipc, "sendRequest"); + + const unit = { + blockNumber: 100, + getBlock: () => ({ + gasUsed: 0, + timestamp: now - 8000 * 3 - 1, // older than 3 * blockTime → syncing + transactions: [{ from: "alice" }], + }), + } as any; + + await worker.onCommit(unit); + + sendRequest.calledWith("commit", 100, ["alice"], 0, true); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07fc34c979..2181414ed5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3167,9 +3167,6 @@ importers: dayjs: specifier: 1.11.20 version: 1.11.20 - joi: - specifier: 18.2.1 - version: 18.2.1 devDependencies: '@mainsail/contracts': specifier: workspace:* @@ -3177,6 +3174,9 @@ importers: '@mainsail/test-runner': specifier: workspace:* version: link:../test-runner + esmock: + specifier: 2.7.5 + version: 2.7.5 uvu: specifier: 0.5.6 version: 0.5.6