From 75d38848387c63020c9d6baed7e242098fccd5c6 Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 29 May 2026 08:11:43 +0000 Subject: [PATCH 01/13] Test handlers --- .../source/handlers/commit.test.ts | 86 +++++++++++++++++ .../source/handlers/forget-peer.test.ts | 26 +++++ .../source/handlers/get-transactions.test.ts | 30 ++++++ .../source/handlers/reload-webhooks.test.ts | 25 +++++ .../handlers/remove-transaction.test.ts | 32 +++++++ .../source/handlers/set-peer.test.ts | 26 +++++ .../source/handlers/start.test.ts | 96 +++++++++++++++++++ 7 files changed, 321 insertions(+) create mode 100644 packages/transaction-pool-worker/source/handlers/commit.test.ts create mode 100644 packages/transaction-pool-worker/source/handlers/forget-peer.test.ts create mode 100644 packages/transaction-pool-worker/source/handlers/get-transactions.test.ts create mode 100644 packages/transaction-pool-worker/source/handlers/reload-webhooks.test.ts create mode 100644 packages/transaction-pool-worker/source/handlers/remove-transaction.test.ts create mode 100644 packages/transaction-pool-worker/source/handlers/set-peer.test.ts create mode 100644 packages/transaction-pool-worker/source/handlers/start.test.ts 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 000000000..d7d4563d5 --- /dev/null +++ b/packages/transaction-pool-worker/source/handlers/commit.test.ts @@ -0,0 +1,86 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { CommitHandler } from "./commit"; + +describe<{ + app: Application; + stateStore: any; + configuration: any; + transactionPoolService: any; + selector: any; + logger: 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.logger = { error: () => {} }; + + 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.app.bind(Identifiers.Services.Log.Service).toConstantValue(context.logger); + }); + + const resolve = (context) => context.app.resolve(CommitHandler); + + it("sets the block number and clears the selector", async (context) => { + const setBlockNumber = spy(context.stateStore, "setBlockNumber"); + const clear = spy(context.selector, "clear"); + + await resolve(context).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 (context) => { + const commit = spy(context.transactionPoolService, "commit"); + const reAdd = spy(context.transactionPoolService, "reAddTransactions"); + + await resolve(context).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 (context) => { + context.configuration.isNewMilestone = () => true; + const commit = spy(context.transactionPoolService, "commit"); + const reAdd = spy(context.transactionPoolService, "reAddTransactions"); + + await resolve(context).handle(10, ["address-1"], 1000, false); + + reAdd.calledOnce(); + commit.neverCalled(); + }); + + it("wraps a thrown error with a 'Failed to commit block' message", async (context) => { + context.transactionPoolService.commit = async () => { + throw new Error("boom"); + }; + + await assert.rejects( + () => resolve(context).handle(10, ["address-1"], 1000, false), + "Failed to commit block: boom", + ); + }); + + it("normalizes a non-Error throw into the wrapped message", async (context) => { + context.selector.clear = () => { + throw "string failure"; + }; + + await assert.rejects( + () => resolve(context).handle(10, ["address-1"], 1000, false), + "Failed to commit block: string failure", + ); + }); +}); 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 000000000..ca038a82e --- /dev/null +++ b/packages/transaction-pool-worker/source/handlers/forget-peer.test.ts @@ -0,0 +1,26 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { ForgetPeerHandler } from "./forget-peer"; + +describe<{ + app: Application; + 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); + }); + + it("forgets the peer by ip", async (context) => { + const forgetPeer = spy(context.peerRepository, "forgetPeer"); + + await context.app.resolve(ForgetPeerHandler).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 000000000..046d80108 --- /dev/null +++ b/packages/transaction-pool-worker/source/handlers/get-transactions.test.ts @@ -0,0 +1,30 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { GetTransactionsHandler } from "./get-transactions"; + +describe<{ + app: Application; + selector: any; +}>("GetTransactionsHandler", ({ assert, beforeEach, it, spy }) => { + beforeEach((context) => { + context.selector = { getBatch: async () => ({ transactions: [] }) }; + + context.app = new Application(); + context.app.bind(Identifiers.TransactionPool.Selector).toConstantValue(context.selector); + }); + + it("delegates to the selector and returns its batch", async (context) => { + const batch = { transactions: [Buffer.from("tx")] }; + context.selector.getBatch = async () => batch; + const getBatch = spy(context.selector, "getBatch"); + + const options = { limit: 5 }; + const result = await context.app.resolve(GetTransactionsHandler).handle(options as any); + + 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 000000000..8a7c34c0a --- /dev/null +++ b/packages/transaction-pool-worker/source/handlers/reload-webhooks.test.ts @@ -0,0 +1,25 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { ReloadWebhooksHandler } from "./reload-webhooks"; + +describe<{ + app: Application; + database: any; +}>("ReloadWebhooksHandler", ({ beforeEach, it, spy }) => { + beforeEach((context) => { + context.database = { restore: () => {} }; + + context.app = new Application(); + context.app.bind(Identifiers.Webhooks.Database).toConstantValue(context.database); + }); + + it("restores the webhooks database", async (context) => { + const restore = spy(context.database, "restore"); + + await context.app.resolve(ReloadWebhooksHandler).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 000000000..027c9faf6 --- /dev/null +++ b/packages/transaction-pool-worker/source/handlers/remove-transaction.test.ts @@ -0,0 +1,32 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { RemoveTransactionHandler } from "./remove-transaction"; + +describe<{ + app: Application; + 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); + }); + + it("removes the transaction from the mempool and the storage", async (context) => { + const fromMempool = spy(context.mempool, "removeTransaction"); + const fromStorage = spy(context.storage, "removeTransaction"); + + await context.app.resolve(RemoveTransactionHandler).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 000000000..eb7e88187 --- /dev/null +++ b/packages/transaction-pool-worker/source/handlers/set-peer.test.ts @@ -0,0 +1,26 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { SetPeerHandler } from "./set-peer"; + +describe<{ + app: Application; + 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); + }); + + it("sets the peer by ip", async (context) => { + const setPeer = spy(context.peerRepository, "setPeer"); + + await context.app.resolve(SetPeerHandler).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 000000000..f2de1e225 --- /dev/null +++ b/packages/transaction-pool-worker/source/handlers/start.test.ts @@ -0,0 +1,96 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { StartHandler } from "./start"; + +describe<{ + app: Application; + 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, + }; + + // Application binds itself as Application.Instance, so the handler resolves the servers + // off the same container the test binds them into. + 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"); + }); + + const resolve = (context) => context.app.resolve(StartHandler); + + it("sets the block number and re-adds the transactions", async (context) => { + const setBlockNumber = spy(context.store, "setBlockNumber"); + const reAdd = spy(context.transactionPoolService, "reAddTransactions"); + + await resolve(context).handle(42); + + setBlockNumber.calledOnce(); + setBlockNumber.calledWith(42); + reAdd.calledOnce(); + }); + + it("does not boot any server when neither http nor https is enabled", async (context) => { + const http = spy(context.httpServer, "boot"); + const https = spy(context.httpsServer, "boot"); + + await resolve(context).handle(42); + + http.neverCalled(); + https.neverCalled(); + }); + + it("boots only the http server when http is enabled", async (context) => { + context.enabled.http = true; + const http = spy(context.httpServer, "boot"); + const https = spy(context.httpsServer, "boot"); + + await resolve(context).handle(42); + + http.calledOnce(); + https.neverCalled(); + }); + + it("boots only the https server when https is enabled", async (context) => { + context.enabled.https = true; + const http = spy(context.httpServer, "boot"); + const https = spy(context.httpsServer, "boot"); + + await resolve(context).handle(42); + + http.neverCalled(); + https.calledOnce(); + }); + + it("boots both servers when http and https are enabled", async (context) => { + context.enabled.http = true; + context.enabled.https = true; + const http = spy(context.httpServer, "boot"); + const https = spy(context.httpsServer, "boot"); + + await resolve(context).handle(42); + + http.calledOnce(); + https.calledOnce(); + }); +}); From 7b431a545e613b5453ec2a25be4dfe2533f019e1 Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 29 May 2026 08:13:43 +0000 Subject: [PATCH 02/13] Resolve handler in beforeEach --- .../source/handlers/commit.test.ts | 15 ++++++++------- .../source/handlers/forget-peer.test.ts | 5 ++++- .../source/handlers/get-transactions.test.ts | 5 ++++- .../source/handlers/reload-webhooks.test.ts | 5 ++++- .../source/handlers/remove-transaction.test.ts | 5 ++++- .../source/handlers/set-peer.test.ts | 5 ++++- .../source/handlers/start.test.ts | 15 ++++++++------- 7 files changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/transaction-pool-worker/source/handlers/commit.test.ts b/packages/transaction-pool-worker/source/handlers/commit.test.ts index d7d4563d5..3028ea9f3 100644 --- a/packages/transaction-pool-worker/source/handlers/commit.test.ts +++ b/packages/transaction-pool-worker/source/handlers/commit.test.ts @@ -6,6 +6,7 @@ import { CommitHandler } from "./commit"; describe<{ app: Application; + handler: CommitHandler; stateStore: any; configuration: any; transactionPoolService: any; @@ -25,15 +26,15 @@ describe<{ context.app.bind(Identifiers.TransactionPool.Service).toConstantValue(context.transactionPoolService); context.app.bind(Identifiers.TransactionPool.Selector).toConstantValue(context.selector); context.app.bind(Identifiers.Services.Log.Service).toConstantValue(context.logger); - }); - const resolve = (context) => context.app.resolve(CommitHandler); + context.handler = context.app.resolve(CommitHandler); + }); it("sets the block number and clears the selector", async (context) => { const setBlockNumber = spy(context.stateStore, "setBlockNumber"); const clear = spy(context.selector, "clear"); - await resolve(context).handle(10, ["address-1"], 1000, false); + await context.handler.handle(10, ["address-1"], 1000, false); setBlockNumber.calledOnce(); setBlockNumber.calledWith(10); @@ -44,7 +45,7 @@ describe<{ const commit = spy(context.transactionPoolService, "commit"); const reAdd = spy(context.transactionPoolService, "reAddTransactions"); - await resolve(context).handle(10, ["address-1", "address-2"], 5000, true); + await context.handler.handle(10, ["address-1", "address-2"], 5000, true); commit.calledOnce(); commit.calledWith(["address-1", "address-2"], 5000, true); @@ -56,7 +57,7 @@ describe<{ const commit = spy(context.transactionPoolService, "commit"); const reAdd = spy(context.transactionPoolService, "reAddTransactions"); - await resolve(context).handle(10, ["address-1"], 1000, false); + await context.handler.handle(10, ["address-1"], 1000, false); reAdd.calledOnce(); commit.neverCalled(); @@ -68,7 +69,7 @@ describe<{ }; await assert.rejects( - () => resolve(context).handle(10, ["address-1"], 1000, false), + () => context.handler.handle(10, ["address-1"], 1000, false), "Failed to commit block: boom", ); }); @@ -79,7 +80,7 @@ describe<{ }; await assert.rejects( - () => resolve(context).handle(10, ["address-1"], 1000, false), + () => context.handler.handle(10, ["address-1"], 1000, false), "Failed to commit block: string failure", ); }); diff --git a/packages/transaction-pool-worker/source/handlers/forget-peer.test.ts b/packages/transaction-pool-worker/source/handlers/forget-peer.test.ts index ca038a82e..e6bb1a88e 100644 --- a/packages/transaction-pool-worker/source/handlers/forget-peer.test.ts +++ b/packages/transaction-pool-worker/source/handlers/forget-peer.test.ts @@ -6,6 +6,7 @@ import { ForgetPeerHandler } from "./forget-peer"; describe<{ app: Application; + handler: ForgetPeerHandler; peerRepository: any; }>("ForgetPeerHandler", ({ beforeEach, it, spy }) => { beforeEach((context) => { @@ -13,12 +14,14 @@ describe<{ 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 (context) => { const forgetPeer = spy(context.peerRepository, "forgetPeer"); - await context.app.resolve(ForgetPeerHandler).handle("127.0.0.1"); + await context.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 index 046d80108..765ef43ea 100644 --- a/packages/transaction-pool-worker/source/handlers/get-transactions.test.ts +++ b/packages/transaction-pool-worker/source/handlers/get-transactions.test.ts @@ -6,6 +6,7 @@ import { GetTransactionsHandler } from "./get-transactions"; describe<{ app: Application; + handler: GetTransactionsHandler; selector: any; }>("GetTransactionsHandler", ({ assert, beforeEach, it, spy }) => { beforeEach((context) => { @@ -13,6 +14,8 @@ describe<{ 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 (context) => { @@ -21,7 +24,7 @@ describe<{ const getBatch = spy(context.selector, "getBatch"); const options = { limit: 5 }; - const result = await context.app.resolve(GetTransactionsHandler).handle(options as any); + const result = await context.handler.handle(options as any); getBatch.calledOnce(); getBatch.calledWith(options); diff --git a/packages/transaction-pool-worker/source/handlers/reload-webhooks.test.ts b/packages/transaction-pool-worker/source/handlers/reload-webhooks.test.ts index 8a7c34c0a..80d7546e6 100644 --- a/packages/transaction-pool-worker/source/handlers/reload-webhooks.test.ts +++ b/packages/transaction-pool-worker/source/handlers/reload-webhooks.test.ts @@ -6,6 +6,7 @@ import { ReloadWebhooksHandler } from "./reload-webhooks"; describe<{ app: Application; + handler: ReloadWebhooksHandler; database: any; }>("ReloadWebhooksHandler", ({ beforeEach, it, spy }) => { beforeEach((context) => { @@ -13,12 +14,14 @@ describe<{ 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 (context) => { const restore = spy(context.database, "restore"); - await context.app.resolve(ReloadWebhooksHandler).handle(); + await context.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 index 027c9faf6..f66ba9c4d 100644 --- a/packages/transaction-pool-worker/source/handlers/remove-transaction.test.ts +++ b/packages/transaction-pool-worker/source/handlers/remove-transaction.test.ts @@ -6,6 +6,7 @@ import { RemoveTransactionHandler } from "./remove-transaction"; describe<{ app: Application; + handler: RemoveTransactionHandler; mempool: any; storage: any; }>("RemoveTransactionHandler", ({ beforeEach, it, spy }) => { @@ -16,13 +17,15 @@ describe<{ 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 (context) => { const fromMempool = spy(context.mempool, "removeTransaction"); const fromStorage = spy(context.storage, "removeTransaction"); - await context.app.resolve(RemoveTransactionHandler).handle("address-1", "hash-1"); + await context.handler.handle("address-1", "hash-1"); fromMempool.calledOnce(); fromMempool.calledWith("address-1", "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 index eb7e88187..4a6cfbb7f 100644 --- a/packages/transaction-pool-worker/source/handlers/set-peer.test.ts +++ b/packages/transaction-pool-worker/source/handlers/set-peer.test.ts @@ -6,6 +6,7 @@ import { SetPeerHandler } from "./set-peer"; describe<{ app: Application; + handler: SetPeerHandler; peerRepository: any; }>("SetPeerHandler", ({ beforeEach, it, spy }) => { beforeEach((context) => { @@ -13,12 +14,14 @@ describe<{ 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 (context) => { const setPeer = spy(context.peerRepository, "setPeer"); - await context.app.resolve(SetPeerHandler).handle("127.0.0.1"); + await context.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 index f2de1e225..14256186a 100644 --- a/packages/transaction-pool-worker/source/handlers/start.test.ts +++ b/packages/transaction-pool-worker/source/handlers/start.test.ts @@ -6,6 +6,7 @@ import { StartHandler } from "./start"; describe<{ app: Application; + handler: StartHandler; store: any; transactionPoolService: any; httpServer: any; @@ -35,15 +36,15 @@ describe<{ .bind(Identifiers.ServiceProvider.Configuration) .toConstantValue(configuration) .whenTagged("plugin", "api-transaction-pool"); - }); - const resolve = (context) => context.app.resolve(StartHandler); + context.handler = context.app.resolve(StartHandler); + }); it("sets the block number and re-adds the transactions", async (context) => { const setBlockNumber = spy(context.store, "setBlockNumber"); const reAdd = spy(context.transactionPoolService, "reAddTransactions"); - await resolve(context).handle(42); + await context.handler.handle(42); setBlockNumber.calledOnce(); setBlockNumber.calledWith(42); @@ -54,7 +55,7 @@ describe<{ const http = spy(context.httpServer, "boot"); const https = spy(context.httpsServer, "boot"); - await resolve(context).handle(42); + await context.handler.handle(42); http.neverCalled(); https.neverCalled(); @@ -65,7 +66,7 @@ describe<{ const http = spy(context.httpServer, "boot"); const https = spy(context.httpsServer, "boot"); - await resolve(context).handle(42); + await context.handler.handle(42); http.calledOnce(); https.neverCalled(); @@ -76,7 +77,7 @@ describe<{ const http = spy(context.httpServer, "boot"); const https = spy(context.httpsServer, "boot"); - await resolve(context).handle(42); + await context.handler.handle(42); http.neverCalled(); https.calledOnce(); @@ -88,7 +89,7 @@ describe<{ const http = spy(context.httpServer, "boot"); const https = spy(context.httpsServer, "boot"); - await resolve(context).handle(42); + await context.handler.handle(42); http.calledOnce(); https.calledOnce(); From b85ebc878d81da8ca330140e8898680fceb7c201 Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 29 May 2026 08:15:49 +0000 Subject: [PATCH 03/13] destruct context --- .../source/handlers/commit.test.ts | 48 +++++++++-------- .../source/handlers/forget-peer.test.ts | 6 +-- .../source/handlers/get-transactions.test.ts | 8 +-- .../source/handlers/reload-webhooks.test.ts | 6 +-- .../handlers/remove-transaction.test.ts | 8 +-- .../source/handlers/set-peer.test.ts | 6 +-- .../source/handlers/start.test.ts | 52 ++++++++++--------- 7 files changed, 71 insertions(+), 63 deletions(-) diff --git a/packages/transaction-pool-worker/source/handlers/commit.test.ts b/packages/transaction-pool-worker/source/handlers/commit.test.ts index 3028ea9f3..39d9b3161 100644 --- a/packages/transaction-pool-worker/source/handlers/commit.test.ts +++ b/packages/transaction-pool-worker/source/handlers/commit.test.ts @@ -30,57 +30,61 @@ describe<{ context.handler = context.app.resolve(CommitHandler); }); - it("sets the block number and clears the selector", async (context) => { - const setBlockNumber = spy(context.stateStore, "setBlockNumber"); - const clear = spy(context.selector, "clear"); + it("sets the block number and clears the selector", async ({ handler, stateStore, selector }) => { + const setBlockNumber = spy(stateStore, "setBlockNumber"); + const clear = spy(selector, "clear"); - await context.handler.handle(10, ["address-1"], 1000, false); + 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 (context) => { - const commit = spy(context.transactionPoolService, "commit"); - const reAdd = spy(context.transactionPoolService, "reAddTransactions"); + 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 context.handler.handle(10, ["address-1", "address-2"], 5000, true); + 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 (context) => { - context.configuration.isNewMilestone = () => true; - const commit = spy(context.transactionPoolService, "commit"); - const reAdd = spy(context.transactionPoolService, "reAddTransactions"); + 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 context.handler.handle(10, ["address-1"], 1000, false); + 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 (context) => { - context.transactionPoolService.commit = async () => { + 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( - () => context.handler.handle(10, ["address-1"], 1000, false), - "Failed to commit block: 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 (context) => { - context.selector.clear = () => { + it("normalizes a non-Error throw into the wrapped message", async ({ handler, selector }) => { + selector.clear = () => { throw "string failure"; }; await assert.rejects( - () => context.handler.handle(10, ["address-1"], 1000, false), + () => handler.handle(10, ["address-1"], 1000, false), "Failed to commit block: string failure", ); }); diff --git a/packages/transaction-pool-worker/source/handlers/forget-peer.test.ts b/packages/transaction-pool-worker/source/handlers/forget-peer.test.ts index e6bb1a88e..2343eca29 100644 --- a/packages/transaction-pool-worker/source/handlers/forget-peer.test.ts +++ b/packages/transaction-pool-worker/source/handlers/forget-peer.test.ts @@ -18,10 +18,10 @@ describe<{ context.handler = context.app.resolve(ForgetPeerHandler); }); - it("forgets the peer by ip", async (context) => { - const forgetPeer = spy(context.peerRepository, "forgetPeer"); + it("forgets the peer by ip", async ({ handler, peerRepository }) => { + const forgetPeer = spy(peerRepository, "forgetPeer"); - await context.handler.handle("127.0.0.1"); + 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 index 765ef43ea..8eb650ef3 100644 --- a/packages/transaction-pool-worker/source/handlers/get-transactions.test.ts +++ b/packages/transaction-pool-worker/source/handlers/get-transactions.test.ts @@ -18,13 +18,13 @@ describe<{ context.handler = context.app.resolve(GetTransactionsHandler); }); - it("delegates to the selector and returns its batch", async (context) => { + it("delegates to the selector and returns its batch", async ({ handler, selector }) => { const batch = { transactions: [Buffer.from("tx")] }; - context.selector.getBatch = async () => batch; - const getBatch = spy(context.selector, "getBatch"); + selector.getBatch = async () => batch; + const getBatch = spy(selector, "getBatch"); const options = { limit: 5 }; - const result = await context.handler.handle(options as any); + const result = await handler.handle(options as any); getBatch.calledOnce(); getBatch.calledWith(options); diff --git a/packages/transaction-pool-worker/source/handlers/reload-webhooks.test.ts b/packages/transaction-pool-worker/source/handlers/reload-webhooks.test.ts index 80d7546e6..c533f50c3 100644 --- a/packages/transaction-pool-worker/source/handlers/reload-webhooks.test.ts +++ b/packages/transaction-pool-worker/source/handlers/reload-webhooks.test.ts @@ -18,10 +18,10 @@ describe<{ context.handler = context.app.resolve(ReloadWebhooksHandler); }); - it("restores the webhooks database", async (context) => { - const restore = spy(context.database, "restore"); + it("restores the webhooks database", async ({ handler, database }) => { + const restore = spy(database, "restore"); - await context.handler.handle(); + 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 index f66ba9c4d..2e0f71c71 100644 --- a/packages/transaction-pool-worker/source/handlers/remove-transaction.test.ts +++ b/packages/transaction-pool-worker/source/handlers/remove-transaction.test.ts @@ -21,11 +21,11 @@ describe<{ context.handler = context.app.resolve(RemoveTransactionHandler); }); - it("removes the transaction from the mempool and the storage", async (context) => { - const fromMempool = spy(context.mempool, "removeTransaction"); - const fromStorage = spy(context.storage, "removeTransaction"); + 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 context.handler.handle("address-1", "hash-1"); + await handler.handle("address-1", "hash-1"); fromMempool.calledOnce(); fromMempool.calledWith("address-1", "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 index 4a6cfbb7f..97f3cae7b 100644 --- a/packages/transaction-pool-worker/source/handlers/set-peer.test.ts +++ b/packages/transaction-pool-worker/source/handlers/set-peer.test.ts @@ -18,10 +18,10 @@ describe<{ context.handler = context.app.resolve(SetPeerHandler); }); - it("sets the peer by ip", async (context) => { - const setPeer = spy(context.peerRepository, "setPeer"); + it("sets the peer by ip", async ({ handler, peerRepository }) => { + const setPeer = spy(peerRepository, "setPeer"); - await context.handler.handle("127.0.0.1"); + 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 index 14256186a..d3a92af43 100644 --- a/packages/transaction-pool-worker/source/handlers/start.test.ts +++ b/packages/transaction-pool-worker/source/handlers/start.test.ts @@ -40,56 +40,60 @@ describe<{ context.handler = context.app.resolve(StartHandler); }); - it("sets the block number and re-adds the transactions", async (context) => { - const setBlockNumber = spy(context.store, "setBlockNumber"); - const reAdd = spy(context.transactionPoolService, "reAddTransactions"); + 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 context.handler.handle(42); + 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 (context) => { - const http = spy(context.httpServer, "boot"); - const https = spy(context.httpsServer, "boot"); + 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 context.handler.handle(42); + await handler.handle(42); http.neverCalled(); https.neverCalled(); }); - it("boots only the http server when http is enabled", async (context) => { - context.enabled.http = true; - const http = spy(context.httpServer, "boot"); - const https = spy(context.httpsServer, "boot"); + 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 context.handler.handle(42); + await handler.handle(42); http.calledOnce(); https.neverCalled(); }); - it("boots only the https server when https is enabled", async (context) => { - context.enabled.https = true; - const http = spy(context.httpServer, "boot"); - const https = spy(context.httpsServer, "boot"); + 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 context.handler.handle(42); + await handler.handle(42); http.neverCalled(); https.calledOnce(); }); - it("boots both servers when http and https are enabled", async (context) => { - context.enabled.http = true; - context.enabled.https = true; - const http = spy(context.httpServer, "boot"); - const https = spy(context.httpsServer, "boot"); + 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 context.handler.handle(42); + await handler.handle(42); http.calledOnce(); https.calledOnce(); From 845d13fcd326f0b83bcf783d3881d574039742e2 Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 29 May 2026 08:17:36 +0000 Subject: [PATCH 04/13] Claude instructions --- CLAUDE.md | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a4d44abb7..026c9a4bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,22 +214,45 @@ 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`. From cf0d0a155d898572b3aa779e1ea939b11da1cf8e Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 29 May 2026 08:23:41 +0000 Subject: [PATCH 05/13] Remove comment --- packages/transaction-pool-worker/source/handlers/start.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/transaction-pool-worker/source/handlers/start.test.ts b/packages/transaction-pool-worker/source/handlers/start.test.ts index d3a92af43..9ae860a7e 100644 --- a/packages/transaction-pool-worker/source/handlers/start.test.ts +++ b/packages/transaction-pool-worker/source/handlers/start.test.ts @@ -25,8 +25,6 @@ describe<{ key === "server.http.enabled" ? context.enabled.http : context.enabled.https, }; - // Application binds itself as Application.Instance, so the handler resolves the servers - // off the same container the test binds them into. context.app = new Application(); context.app.bind(Identifiers.State.Store).toConstantValue(context.store); context.app.bind(Identifiers.TransactionPool.Service).toConstantValue(context.transactionPoolService); From 74ce25722c1f84117c08cd9bb52f114baac9ed6e Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 29 May 2026 08:28:06 +0000 Subject: [PATCH 06/13] Remove unused injects --- .../transaction-pool-worker/source/handlers/commit.test.ts | 3 --- packages/transaction-pool-worker/source/handlers/commit.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/packages/transaction-pool-worker/source/handlers/commit.test.ts b/packages/transaction-pool-worker/source/handlers/commit.test.ts index 39d9b3161..55fff0c86 100644 --- a/packages/transaction-pool-worker/source/handlers/commit.test.ts +++ b/packages/transaction-pool-worker/source/handlers/commit.test.ts @@ -11,21 +11,18 @@ describe<{ configuration: any; transactionPoolService: any; selector: any; - logger: 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.logger = { error: () => {} }; 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.app.bind(Identifiers.Services.Log.Service).toConstantValue(context.logger); context.handler = context.app.resolve(CommitHandler); }); diff --git a/packages/transaction-pool-worker/source/handlers/commit.ts b/packages/transaction-pool-worker/source/handlers/commit.ts index e7085d9ce..bc58fdfd1 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[], From 86f2f280b44e3f8b8ddee1265b15f29790ce2221 Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 29 May 2026 08:29:27 +0000 Subject: [PATCH 07/13] Remove defaults --- packages/transaction-pool-worker/source/defaults.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 packages/transaction-pool-worker/source/defaults.ts diff --git a/packages/transaction-pool-worker/source/defaults.ts b/packages/transaction-pool-worker/source/defaults.ts deleted file mode 100644 index 0e40a8a2f..000000000 --- a/packages/transaction-pool-worker/source/defaults.ts +++ /dev/null @@ -1 +0,0 @@ -export const defaults = {}; From 2e87c4d46c345edd693d94eb729618a670c5a0bf Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 29 May 2026 08:37:47 +0000 Subject: [PATCH 08/13] Test service provider --- .../source/service-provider.test.ts | 62 +++++++++++++++++++ .../source/service-provider.ts | 5 -- 2 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 packages/transaction-pool-worker/source/service-provider.test.ts 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 000000000..fe2db2d12 --- /dev/null +++ b/packages/transaction-pool-worker/source/service-provider.test.ts @@ -0,0 +1,62 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { ServiceProvider } from "./service-provider"; + +describe<{ + app: Application; + serviceProvider: ServiceProvider; + worker: any; + flags: any; +}>("ServiceProvider", ({ assert, beforeEach, it, spy, stub }) => { + beforeEach((context) => { + context.flags = { network: "testnet" }; + context.worker = { boot: async () => {}, dispose: async () => {} }; + + context.app = new Application(); + context.app.bind(Identifiers.Config.Flags).toConstantValue(context.flags); + + // Resolve the provider before stubbing resolve, so its own injection still works. + context.serviceProvider = context.app.resolve(ServiceProvider); + + // register() resolves the WorkerInstance, whose @postConstruct spawns a real + // worker_threads.Worker. Intercept that resolution so the unit test stays in-process. + 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("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 d56031bd9..8e315bae5 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); - } } From 881f67936da710358f6441c193f2cdd1b86c5d50 Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 29 May 2026 08:38:43 +0000 Subject: [PATCH 09/13] Remove joi --- packages/transaction-pool-worker/package.json | 3 +-- pnpm-lock.yaml | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/transaction-pool-worker/package.json b/packages/transaction-pool-worker/package.json index c8d5ab66d..d64d389f2 100644 --- a/packages/transaction-pool-worker/package.json +++ b/packages/transaction-pool-worker/package.json @@ -25,8 +25,7 @@ "@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:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07fc34c97..1d0e2025c 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:* From f907526556e74f61e1ab361a47ce69446e31bd59 Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 29 May 2026 08:56:37 +0000 Subject: [PATCH 10/13] Test workers --- .../source/worker-handler.test.ts | 121 ++++++++++ .../source/worker-script.test.ts | 10 + .../source/worker.test.ts | 209 ++++++++++++++++++ 3 files changed, 340 insertions(+) create mode 100644 packages/transaction-pool-worker/source/worker-handler.test.ts create mode 100644 packages/transaction-pool-worker/source/worker-script.test.ts create mode 100644 packages/transaction-pool-worker/source/worker.test.ts 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 000000000..9845934bd --- /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 = { transactions: [Buffer.from("tx")] }; + const handle = stub(handler, "handle").resolvedValue(batch); + const options = { limit: 5 } as any; + + 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 000000000..f7cf7777b --- /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 000000000..5df796c98 --- /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 = { transactions: [Buffer.from("tx")] }; + const sendRequest = stub(ipc, "sendRequest").resolvedValue(batch); + const options = { limit: 5 } as any; + + 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); + }); +}); From a437ef4c8a6afa91a5ff08eee56d3c9160f4342f Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 29 May 2026 09:13:57 +0000 Subject: [PATCH 11/13] Use real data --- .../source/handlers/get-transactions.test.ts | 8 ++++---- .../transaction-pool-worker/source/worker-handler.test.ts | 4 ++-- packages/transaction-pool-worker/source/worker.test.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/transaction-pool-worker/source/handlers/get-transactions.test.ts b/packages/transaction-pool-worker/source/handlers/get-transactions.test.ts index 8eb650ef3..afcd744e4 100644 --- a/packages/transaction-pool-worker/source/handlers/get-transactions.test.ts +++ b/packages/transaction-pool-worker/source/handlers/get-transactions.test.ts @@ -10,7 +10,7 @@ describe<{ selector: any; }>("GetTransactionsHandler", ({ assert, beforeEach, it, spy }) => { beforeEach((context) => { - context.selector = { getBatch: async () => ({ transactions: [] }) }; + context.selector = { getBatch: async () => ({ remaining: 0, transactions: [] }) }; context.app = new Application(); context.app.bind(Identifiers.TransactionPool.Selector).toConstantValue(context.selector); @@ -19,12 +19,12 @@ describe<{ }); it("delegates to the selector and returns its batch", async ({ handler, selector }) => { - const batch = { transactions: [Buffer.from("tx")] }; + const batch = { remaining: 2, transactions: [] }; selector.getBatch = async () => batch; const getBatch = spy(selector, "getBatch"); - const options = { limit: 5 }; - const result = await handler.handle(options as any); + const options = { blockRound: "0", maxBytes: 1024, maxSize: 100 }; + const result = await handler.handle(options); getBatch.calledOnce(); getBatch.calledWith(options); diff --git a/packages/transaction-pool-worker/source/worker-handler.test.ts b/packages/transaction-pool-worker/source/worker-handler.test.ts index 9845934bd..b85a08003 100644 --- a/packages/transaction-pool-worker/source/worker-handler.test.ts +++ b/packages/transaction-pool-worker/source/worker-handler.test.ts @@ -68,9 +68,9 @@ describe<{ handler, resolve, }) => { - const batch = { transactions: [Buffer.from("tx")] }; + const batch = { remaining: 0, transactions: [] }; const handle = stub(handler, "handle").resolvedValue(batch); - const options = { limit: 5 } as any; + const options = { blockRound: "0", maxBytes: 1024, maxSize: 100 }; const result = await subject.getTransactions(options); diff --git a/packages/transaction-pool-worker/source/worker.test.ts b/packages/transaction-pool-worker/source/worker.test.ts index 5df796c98..d7f64d44b 100644 --- a/packages/transaction-pool-worker/source/worker.test.ts +++ b/packages/transaction-pool-worker/source/worker.test.ts @@ -115,9 +115,9 @@ describe<{ }); it("getTransactions requests and returns the batch", async ({ worker, ipc }) => { - const batch = { transactions: [Buffer.from("tx")] }; + const batch = { remaining: 0, transactions: [] }; const sendRequest = stub(ipc, "sendRequest").resolvedValue(batch); - const options = { limit: 5 } as any; + const options = { blockRound: "0", maxBytes: 1024, maxSize: 100 }; const result = await worker.getTransactions(options); From 0c36563d93549ad12d4df2591dc28fcacd22e38b Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 29 May 2026 09:34:21 +0000 Subject: [PATCH 12/13] Test script loading --- packages/transaction-pool-worker/package.json | 1 + .../source/service-provider.test.ts | 55 +++++++++++++++++-- pnpm-lock.yaml | 3 + 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/transaction-pool-worker/package.json b/packages/transaction-pool-worker/package.json index d64d389f2..1d42842f4 100644 --- a/packages/transaction-pool-worker/package.json +++ b/packages/transaction-pool-worker/package.json @@ -30,6 +30,7 @@ "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/service-provider.test.ts b/packages/transaction-pool-worker/source/service-provider.test.ts index fe2db2d12..5623dc897 100644 --- a/packages/transaction-pool-worker/source/service-provider.test.ts +++ b/packages/transaction-pool-worker/source/service-provider.test.ts @@ -1,27 +1,59 @@ import { Identifiers } from "@mainsail/constants"; -import { Application } from "@mainsail/kernel"; +import { Application, Ipc } from "@mainsail/kernel"; +import { EventEmitter } from "events"; +import esmock from "esmock"; +import { PassThrough } from "stream"; import { describe } from "@mainsail/test-runner"; -import { ServiceProvider } from "./service-provider"; + +// 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: ServiceProvider; + 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 spawns a real - // worker_threads.Worker. Intercept that resolution so the unit test stays in-process. + // 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); }); @@ -37,6 +69,19 @@ describe<{ 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"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d0e2025c..2181414ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3174,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 From 42e6276935bf6d7942287c791ef4bb2d2c34caf5 Mon Sep 17 00:00:00 2001 From: sebastijankuzner <58827427+sebastijankuzner@users.noreply.github.com> Date: Fri, 29 May 2026 09:38:03 +0000 Subject: [PATCH 13/13] style: resolve style guide violations [ci-lint-fix] --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 026c9a4bb..7cdc8047d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -249,6 +249,7 @@ describe<{ ``` 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.