From 878a6434c983b3d3e9587bddb80147466aba566c Mon Sep 17 00:00:00 2001 From: Johnson Chin Date: Mon, 15 Dec 2025 17:18:34 +0800 Subject: [PATCH 1/2] test: added unit testing and integration testing for acpX402.ts - modified acpContractClientV2.integration.test.ts to run longer (tests was failing due to short timeframe) - added --runInBand flag for npm test to ensure no parallel execution (to reduce risk of hitting rpc limit) --- package.json | 6 +- .../acpContractClientV2.integration.test.ts | 2 +- test/integration/acpX402.integration.test.ts | 332 ++++++++++++ test/unit/acpX402.test.ts | 491 ++++++++++++++++++ 4 files changed, 827 insertions(+), 4 deletions(-) create mode 100644 test/integration/acpX402.integration.test.ts create mode 100644 test/unit/acpX402.test.ts diff --git a/package.json b/package.json index 81c4e1c..d317b61 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,9 @@ "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "scripts": { - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", + "test": "jest --runInBand", + "test:watch": "jest --watch --runInBand", + "test:coverage": "jest --coverage --runInBand", "tsup": "tsup src/index.ts --dts --format cjs,esm --out-dir dist" }, "author": "", diff --git a/test/integration/acpContractClientV2.integration.test.ts b/test/integration/acpContractClientV2.integration.test.ts index fba965b..ad1c7bc 100644 --- a/test/integration/acpContractClientV2.integration.test.ts +++ b/test/integration/acpContractClientV2.integration.test.ts @@ -7,7 +7,7 @@ import { } from "../env"; describe("AcpContractClientV2 Integration Testing", () => { - jest.setTimeout(10000); + jest.setTimeout(60000); // 60 seconds for network operations let contractClient: AcpContractClientV2; diff --git a/test/integration/acpX402.integration.test.ts b/test/integration/acpX402.integration.test.ts new file mode 100644 index 0000000..730bd09 --- /dev/null +++ b/test/integration/acpX402.integration.test.ts @@ -0,0 +1,332 @@ +import { Address } from "viem"; +import AcpContractClientV2 from "../../src/contractClients/acpContractClientV2"; +import { AcpX402 } from "../../src/acpX402"; +import { + WHITELISTED_WALLET_PRIVATE_KEY, + SELLER_ENTITY_ID, + SELLER_AGENT_WALLET_ADDRESS, +} from "../env"; +import { + X402PayableRequest, + X402PayableRequirements, +} from "../../src/interfaces"; + +describe("AcpX402 Integration Testing", () => { + jest.setTimeout(60000); // 60 seconds for network operations + + let contractClient: AcpContractClientV2; + let acpX402: AcpX402; + + beforeAll(async () => { + // Add delay to avoid rate limiting from previous test suite + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Build and initialize the contract client with real credentials + contractClient = await AcpContractClientV2.build( + WHITELISTED_WALLET_PRIVATE_KEY as Address, + SELLER_ENTITY_ID, + SELLER_AGENT_WALLET_ADDRESS as Address, + ); + + await contractClient.init( + WHITELISTED_WALLET_PRIVATE_KEY as Address, + SELLER_ENTITY_ID, + ); + + acpX402 = contractClient.acpX402; + }); + + afterAll(() => { + contractClient = null as any; + acpX402 = null as any; + }); + + describe("generatePayment", () => { + it("should generate valid payment with real token metadata from blockchain", async () => { + const mockPayableRequest: X402PayableRequest = { + to: "0x9876543210987654321098765432109876543210" as Address, + value: 1000000, // 1 USDC (6 decimals) + maxTimeoutSeconds: 3600, + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as Address, // USDC on Base Sepolia + }; + + const mockRequirements: X402PayableRequirements = { + x402Version: 1, + error: "", + accepts: [ + { + scheme: "eip712", + network: "base-sepolia", + maxAmountRequired: "1000000", + resource: "/api/test", + description: "Test payment", + mimeType: "application/json", + payTo: "0x9876543210987654321098765432109876543210" as Address, + maxTimeoutSeconds: 3600, + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as Address, + extra: { + name: "USD Coin", + version: "2", + }, + outputSchema: {}, + }, + ], + }; + + const result = await acpX402.generatePayment( + mockPayableRequest, + mockRequirements, + ); + + // Verify payment structure + expect(result).toHaveProperty("encodedPayment"); + expect(result).toHaveProperty("signature"); + expect(result).toHaveProperty("message"); + + // Verify signature format + expect(result.signature).toMatch(/^0x[a-fA-F0-9]+$/); + + // Verify message structure + expect(result.message).toHaveProperty("from"); + expect(result.message).toHaveProperty("to"); + expect(result.message).toHaveProperty("value"); + expect(result.message).toHaveProperty("validAfter"); + expect(result.message).toHaveProperty("validBefore"); + expect(result.message).toHaveProperty("nonce"); + + // Verify message content + expect(result.message.to).toBe(mockPayableRequest.to); + expect(result.message.value).toBe(mockPayableRequest.value.toString()); + expect(result.message.nonce).toMatch(/^0x[a-fA-F0-9]{64}$/); + + // Verify encoded payment is valid base64 + expect(result.encodedPayment).toMatch(/^[A-Za-z0-9+/=]+$/); + + // Verify we can decode the payment + const decodedPayment = JSON.parse( + Buffer.from(result.encodedPayment, "base64").toString(), + ); + expect(decodedPayment).toHaveProperty("x402Version"); + expect(decodedPayment).toHaveProperty("scheme"); + expect(decodedPayment).toHaveProperty("network"); + expect(decodedPayment).toHaveProperty("payload"); + expect(decodedPayment.payload).toHaveProperty("signature"); + expect(decodedPayment.payload).toHaveProperty("authorization"); + + // Verify the signature in payload matches + expect(decodedPayment.payload.signature).toBe(result.signature); + }); + + it("should fetch real token name and version from USDC contract", async () => { + const mockPayableRequest: X402PayableRequest = { + to: "0x9876543210987654321098765432109876543210" as Address, + value: 500000, + maxTimeoutSeconds: 3600, + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as Address, + }; + + const mockRequirements: X402PayableRequirements = { + x402Version: 1, + error: "", + accepts: [ + { + scheme: "eip712", + network: "base-sepolia", + maxAmountRequired: "1000000", + resource: "/api/test", + description: "Test payment", + mimeType: "application/json", + payTo: "0x9876543210987654321098765432109876543210" as Address, + maxTimeoutSeconds: 3600, + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as Address, + extra: { + name: "USD Coin", + version: "2", + }, + outputSchema: {}, + }, + ], + }; + + // This will make a real multicall to the blockchain + const result = await acpX402.generatePayment( + mockPayableRequest, + mockRequirements, + ); + + // Verify the payment was generated successfully + expect(result.signature).toBeTruthy(); + expect(result.encodedPayment).toBeTruthy(); + + // The fact that no error was thrown means multicall succeeded + // and we got valid token metadata from the blockchain + }); + + it("should generate unique nonces for each payment", async () => { + const mockPayableRequest: X402PayableRequest = { + to: "0x9876543210987654321098765432109876543210" as Address, + value: 100000, + maxTimeoutSeconds: 3600, + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as Address, + }; + + const mockRequirements: X402PayableRequirements = { + x402Version: 1, + error: "", + accepts: [ + { + scheme: "eip712", + network: "base-sepolia", + maxAmountRequired: "1000000", + resource: "/api/test", + description: "Test payment", + mimeType: "application/json", + payTo: "0x9876543210987654321098765432109876543210" as Address, + maxTimeoutSeconds: 3600, + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as Address, + extra: { + name: "USD Coin", + version: "2", + }, + outputSchema: {}, + }, + ], + }; + + // Generate two payments + const payment1 = await acpX402.generatePayment( + mockPayableRequest, + mockRequirements, + ); + const payment2 = await acpX402.generatePayment( + mockPayableRequest, + mockRequirements, + ); + + // Nonces should be different + expect(payment1.message.nonce).not.toBe(payment2.message.nonce); + + // Signatures should be different (because nonces are different) + expect(payment1.signature).not.toBe(payment2.signature); + + // Encoded payments should be different + expect(payment1.encodedPayment).not.toBe(payment2.encodedPayment); + }); + }); + + describe("signUpdateJobNonceMessage", () => { + it("should sign message with real session key client", async () => { + const jobId = 12345; + const nonce = "test-integration-nonce"; + + const signature = await acpX402.signUpdateJobNonceMessage(jobId, nonce); + + // Verify signature format + expect(signature).toMatch(/^0x[a-fA-F0-9]+$/); + expect(signature.length).toBeGreaterThan(10); + }); + }); + + describe("performRequest", () => { + it("should throw error when x402 url is not configured", async () => { + // This test verifies the config validation works in real environment + const configWithoutX402 = { + ...contractClient["config"], + x402Config: undefined, + }; + + const acpX402WithoutUrl = new AcpX402( + configWithoutX402, + contractClient["sessionKeyClient"], + contractClient["publicClient"], + ); + + await expect( + acpX402WithoutUrl.performRequest("/test", "v1"), + ).rejects.toThrow("X402 URL not configured"); + }); + + // Note: Testing actual X402 requests would require a live X402 server + // and potentially incur real costs. These tests are commented out but + // can be enabled for manual testing against a test server. + + /* + it("should handle 402 payment required response from real server", async () => { + // This would require a real X402 endpoint that returns 402 + const result = await acpX402.performRequest( + "/api/test-endpoint", + "v1" + ); + + if (result.isPaymentRequired) { + expect(result.data).toHaveProperty("x402Version"); + expect(result.data).toHaveProperty("accepts"); + } + }); + + it("should perform successful request with payment", async () => { + // Step 1: Make initial request to get payment requirements + const initialResult = await acpX402.performRequest( + "/api/test-endpoint", + "v1" + ); + + if (initialResult.isPaymentRequired) { + // Step 2: Generate payment + const payableRequest: X402PayableRequest = { + to: initialResult.data.accepts[0].payTo, + value: parseInt(initialResult.data.accepts[0].maxAmountRequired), + maxTimeoutSeconds: initialResult.data.accepts[0].maxTimeoutSeconds, + asset: initialResult.data.accepts[0].asset, + }; + + const payment = await acpX402.generatePayment( + payableRequest, + initialResult.data + ); + + // Step 3: Retry request with payment + const finalResult = await acpX402.performRequest( + "/api/test-endpoint", + "v1", + payableRequest.value.toString(), + payment.encodedPayment + ); + + expect(finalResult.isPaymentRequired).toBe(false); + expect(finalResult.data).toBeDefined(); + } + }); + */ + }); + + describe("updateJobNonce", () => { + // Note: This test requires a valid job ID which would need to be created + // first through the normal job flow. Commenting out but keeping as reference. + + /* + it("should update job nonce via real API", async () => { + // This would require creating a real job first + const jobId = 12345; // Replace with actual job ID + const newNonce = `nonce-${Date.now()}`; + + const result = await acpX402.updateJobNonce(jobId, newNonce); + + expect(result).toHaveProperty("id"); + expect(result).toHaveProperty("x402Nonce"); + expect(result.x402Nonce).toBe(newNonce); + }); + */ + + it("should format and sign nonce update message correctly", async () => { + // Even without updating a real job, we can verify the signing works + const jobId = 99999; + const nonce = `test-nonce-${Date.now()}`; + + const signature = await acpX402.signUpdateJobNonceMessage(jobId, nonce); + + // Verify signature was generated + expect(signature).toMatch(/^0x[a-fA-F0-9]+$/); + }); + }); +}); diff --git a/test/unit/acpX402.test.ts b/test/unit/acpX402.test.ts new file mode 100644 index 0000000..d7da435 --- /dev/null +++ b/test/unit/acpX402.test.ts @@ -0,0 +1,491 @@ +// Mock crypto module before imports +jest.mock("crypto", () => ({ + randomBytes: jest.fn(), +})); + +// Mock fetch globally +global.fetch = jest.fn(); + +import { Address } from "viem"; +import { AcpX402 } from "../../src/acpX402"; +import AcpError from "../../src/acpError"; +import { baseSepoliaAcpX402ConfigV2 } from "../../src/configs/acpConfigs"; +import { + X402PayableRequest, + X402PayableRequirements, +} from "../../src/interfaces"; +import { randomBytes } from "crypto"; + +describe("AcpX402 Unit Testing", () => { + let acpX402: AcpX402; + let mockSessionKeyClient: any; + let mockPublicClient: any; + + beforeEach(() => { + jest.clearAllMocks(); + + mockSessionKeyClient = { + account: { + address: "0x1234567890123456789012345678901234567890" as Address, + getSigner: jest.fn().mockReturnValue({ + signMessage: jest.fn(), + }), + }, + signTypedData: jest.fn(), + }; + + mockPublicClient = { + multicall: jest.fn(), + }; + + acpX402 = new AcpX402( + baseSepoliaAcpX402ConfigV2, + mockSessionKeyClient, + mockPublicClient, + ); + }); + + describe("Constructor", () => { + it("should initialize with valid parameters", () => { + expect(acpX402).toBeInstanceOf(AcpX402); + expect(acpX402["config"]).toBe(baseSepoliaAcpX402ConfigV2); + expect(acpX402["sessionKeyClient"]).toBe(mockSessionKeyClient); + expect(acpX402["publicClient"]).toBe(mockPublicClient); + }); + }); + + describe("signUpdateJobNonceMessage", () => { + it("should format message correctly and return valid signature", async () => { + const jobId = 123; + const nonce = "test-nonce-123"; + const expectedSignature = "0xabcdef" as `0x${string}`; + + mockSessionKeyClient.account + .getSigner() + .signMessage.mockResolvedValue(expectedSignature); + + const signature = await acpX402.signUpdateJobNonceMessage(jobId, nonce); + + expect(mockSessionKeyClient.account.getSigner).toHaveBeenCalled(); + expect( + mockSessionKeyClient.account.getSigner().signMessage, + ).toHaveBeenCalledWith(`${jobId}-${nonce}`); + expect(signature).toBe(expectedSignature); + }); + + it("should throw error when signing fails", async () => { + const jobId = 123; + const nonce = "test-nonce-123"; + const mockError = new Error("Signing failed"); + + mockSessionKeyClient.account + .getSigner() + .signMessage.mockRejectedValue(mockError); + + await expect( + acpX402.signUpdateJobNonceMessage(jobId, nonce), + ).rejects.toThrow(mockError); + }); + }); + + describe("updateJobNonce", () => { + it("should update job nonce successfully with correct headers and body", async () => { + const jobId = 456; + const nonce = "new-nonce-456"; + const signature = "0x123456" as `0x${string}`; + const mockResponse = { + id: jobId, + x402Nonce: nonce, + }; + + mockSessionKeyClient.account + .getSigner() + .signMessage.mockResolvedValue(signature); + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const result = await acpX402.updateJobNonce(jobId, nonce); + + expect(global.fetch).toHaveBeenCalledWith( + `${baseSepoliaAcpX402ConfigV2.acpUrl}/api/jobs/${jobId}/x402-nonce`, + { + method: "POST", + headers: { + "x-signature": signature, + "x-nonce": nonce, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + data: { + nonce, + }, + }), + }, + ); + expect(result).toEqual(mockResponse); + }); + + it("should throw AcpError when response is not ok", async () => { + const jobId = 456; + const nonce = "new-nonce-456"; + const signature = "0x123456" as `0x${string}`; + + mockSessionKeyClient.account + .getSigner() + .signMessage.mockResolvedValue(signature); + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + statusText: "Bad Request", + }); + + await expect(acpX402.updateJobNonce(jobId, nonce)).rejects.toThrow( + AcpError, + ); + await expect(acpX402.updateJobNonce(jobId, nonce)).rejects.toThrow( + "Failed to update job X402 nonce", + ); + }); + + it("should throw AcpError when fetch fails", async () => { + const jobId = 456; + const nonce = "new-nonce-456"; + const mockError = new Error("Network error"); + + mockSessionKeyClient.account + .getSigner() + .signMessage.mockResolvedValue("0x123456"); + + (global.fetch as jest.Mock).mockRejectedValue(mockError); + + await expect(acpX402.updateJobNonce(jobId, nonce)).rejects.toThrow( + AcpError, + ); + await expect(acpX402.updateJobNonce(jobId, nonce)).rejects.toThrow( + "Failed to update job X402 nonce", + ); + }); + }); + + describe("generatePayment", () => { + const mockPayableRequest: X402PayableRequest = { + to: "0x9876543210987654321098765432109876543210" as Address, + value: 1000000, + maxTimeoutSeconds: 3600, + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as Address, + }; + + const mockRequirements: X402PayableRequirements = { + x402Version: 1, + error: "", + accepts: [ + { + scheme: "eip712", + network: "base-sepolia", + maxAmountRequired: "1000000", + resource: "/api/test", + description: "Test payment", + mimeType: "application/json", + payTo: "0x9876543210987654321098765432109876543210" as Address, + maxTimeoutSeconds: 3600, + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as Address, + extra: { + name: "USD Coin", + version: "2", + }, + outputSchema: {}, + }, + ], + }; + + beforeEach(() => { + // Mock randomBytes to return predictable values + (randomBytes as jest.Mock).mockReturnValue( + Buffer.from("a".repeat(64), "hex"), + ); + }); + + it("should generate payment successfully with correct structure", async () => { + const mockSignature = "0xsignature123" as `0x${string}`; + + mockPublicClient.multicall.mockResolvedValue([ + { result: "USD Coin" }, + { result: "2" }, + ]); + + mockSessionKeyClient.signTypedData.mockResolvedValue(mockSignature); + + const result = await acpX402.generatePayment( + mockPayableRequest, + mockRequirements, + ); + + expect(result).toHaveProperty("encodedPayment"); + expect(result).toHaveProperty("signature"); + expect(result).toHaveProperty("message"); + expect(result.signature).toBe(mockSignature); + expect(result.message.from).toBe(mockSessionKeyClient.account.address); + expect(result.message.to).toBe(mockPayableRequest.to); + expect(result.message.value).toBe(mockPayableRequest.value.toString()); + }); + + it("should call multicall to fetch token name and version", async () => { + const mockSignature = "0xsignature123" as `0x${string}`; + + mockPublicClient.multicall.mockResolvedValue([ + { result: "USD Coin" }, + { result: "2" }, + ]); + + mockSessionKeyClient.signTypedData.mockResolvedValue(mockSignature); + + await acpX402.generatePayment(mockPayableRequest, mockRequirements); + + expect(mockPublicClient.multicall).toHaveBeenCalledWith({ + contracts: [ + { + address: baseSepoliaAcpX402ConfigV2.baseFare.contractAddress, + abi: expect.any(Array), + functionName: "name", + }, + { + address: baseSepoliaAcpX402ConfigV2.baseFare.contractAddress, + abi: expect.any(Array), + functionName: "version", + }, + ], + }); + }); + + it("should generate valid EIP-712 signature", async () => { + const mockSignature = "0xsignature123" as `0x${string}`; + + mockPublicClient.multicall.mockResolvedValue([ + { result: "USD Coin" }, + { result: "2" }, + ]); + + mockSessionKeyClient.signTypedData.mockResolvedValue(mockSignature); + + await acpX402.generatePayment(mockPayableRequest, mockRequirements); + + expect(mockSessionKeyClient.signTypedData).toHaveBeenCalledWith({ + typedData: expect.objectContaining({ + types: expect.objectContaining({ + TransferWithAuthorization: expect.any(Array), + }), + domain: expect.objectContaining({ + name: "USD Coin", + version: "2", + chainId: baseSepoliaAcpX402ConfigV2.chain.id, + verifyingContract: + baseSepoliaAcpX402ConfigV2.baseFare.contractAddress, + }), + primaryType: "TransferWithAuthorization", + message: expect.objectContaining({ + from: mockSessionKeyClient.account.address, + to: mockPayableRequest.to, + value: mockPayableRequest.value.toString(), + }), + }), + }); + }); + + it("should encode payment as base64", async () => { + const mockSignature = "0xsignature123" as `0x${string}`; + + mockPublicClient.multicall.mockResolvedValue([ + { result: "USD Coin" }, + { result: "2" }, + ]); + + mockSessionKeyClient.signTypedData.mockResolvedValue(mockSignature); + + const result = await acpX402.generatePayment( + mockPayableRequest, + mockRequirements, + ); + + // Verify it's a valid base64 string + expect(result.encodedPayment).toMatch(/^[A-Za-z0-9+/=]+$/); + + // Verify we can decode it back + const decoded = JSON.parse( + Buffer.from(result.encodedPayment, "base64").toString(), + ); + expect(decoded).toHaveProperty("x402Version"); + expect(decoded).toHaveProperty("scheme"); + expect(decoded).toHaveProperty("network"); + expect(decoded).toHaveProperty("payload"); + }); + + it("should throw AcpError when multicall fails", async () => { + const mockError = new Error("Multicall failed"); + + mockPublicClient.multicall.mockRejectedValue(mockError); + + await expect( + acpX402.generatePayment(mockPayableRequest, mockRequirements), + ).rejects.toThrow(AcpError); + await expect( + acpX402.generatePayment(mockPayableRequest, mockRequirements), + ).rejects.toThrow("Failed to generate X402 payment"); + }); + + it("should throw AcpError when signing fails", async () => { + const mockError = new Error("Signing failed"); + + mockPublicClient.multicall.mockResolvedValue([ + { result: "USD Coin" }, + { result: "2" }, + ]); + + mockSessionKeyClient.signTypedData.mockRejectedValue(mockError); + + await expect( + acpX402.generatePayment(mockPayableRequest, mockRequirements), + ).rejects.toThrow(AcpError); + await expect( + acpX402.generatePayment(mockPayableRequest, mockRequirements), + ).rejects.toThrow("Failed to generate X402 payment"); + }); + }); + + describe("performRequest", () => { + const testUrl = "/api/test"; + const testVersion = "v1"; + const testBudget = "1000000"; + const testSignature = "payment-signature-123"; + + it("should throw AcpError when x402 url is not configured", async () => { + // Create instance without x402Config + const configWithoutX402 = { + ...baseSepoliaAcpX402ConfigV2, + x402Config: undefined, + }; + const acpX402WithoutUrl = new AcpX402( + configWithoutX402, + mockSessionKeyClient, + mockPublicClient, + ); + + await expect( + acpX402WithoutUrl.performRequest(testUrl, testVersion), + ).rejects.toThrow(AcpError); + await expect( + acpX402WithoutUrl.performRequest(testUrl, testVersion), + ).rejects.toThrow("X402 URL not configured"); + }); + + it("should perform request successfully with all headers", async () => { + const mockData = { result: "success" }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(mockData), + }); + + const result = await acpX402.performRequest( + testUrl, + testVersion, + testBudget, + testSignature, + ); + + expect(global.fetch).toHaveBeenCalledWith( + `${baseSepoliaAcpX402ConfigV2.x402Config!.url}${testUrl}`, + { + method: "GET", + headers: { + "x-payment": testSignature, + "x-budget": testBudget, + "x-acp-version": testVersion, + }, + }, + ); + expect(result).toEqual({ + isPaymentRequired: false, + data: mockData, + }); + }); + + it("should handle optional budget and signature parameters", async () => { + const mockData = { result: "success" }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(mockData), + }); + + await acpX402.performRequest(testUrl, testVersion); + + expect(global.fetch).toHaveBeenCalledWith( + `${baseSepoliaAcpX402ConfigV2.x402Config!.url}${testUrl}`, + { + method: "GET", + headers: { + "x-acp-version": testVersion, + }, + }, + ); + }); + + it("should return isPaymentRequired: true when status is 402", async () => { + const mockData = { + x402Version: 1, + error: "Payment required", + accepts: [], + }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 402, + json: jest.fn().mockResolvedValue(mockData), + }); + + const result = await acpX402.performRequest(testUrl, testVersion); + + expect(result).toEqual({ + isPaymentRequired: true, + data: mockData, + }); + }); + + it("should return isPaymentRequired: false when response is ok", async () => { + const mockData = { result: "success" }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(mockData), + }); + + const result = await acpX402.performRequest(testUrl, testVersion); + + expect(result.isPaymentRequired).toBe(false); + expect(result.data).toEqual(mockData); + }); + + it("should throw AcpError when response status is invalid (not ok AND not 402)", async () => { + const mockData = { error: "Server error" }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, + json: jest.fn().mockResolvedValue(mockData), + }); + + await expect( + acpX402.performRequest(testUrl, testVersion), + ).rejects.toThrow(AcpError); + await expect( + acpX402.performRequest(testUrl, testVersion), + ).rejects.toThrow("Invalid response status code for X402 request"); + }); + }); +}); From 29fb278f792f7aa99f5ce5e090c91de362d00c76 Mon Sep 17 00:00:00 2001 From: johnsonchin Date: Mon, 12 Jan 2026 04:47:13 +0800 Subject: [PATCH 2/2] fix: added test configs for test environment - using test configs are able to fix rate limiting issues from integration testing --- .../integration/acpClient.integration.test.ts | 285 ++++++++++++++++++ .../acpContractClientV2.integration.test.ts | 3 + test/testConfigs.ts | 64 ++++ 3 files changed, 352 insertions(+) create mode 100644 test/integration/acpClient.integration.test.ts create mode 100644 test/testConfigs.ts diff --git a/test/integration/acpClient.integration.test.ts b/test/integration/acpClient.integration.test.ts new file mode 100644 index 0000000..4d1a122 --- /dev/null +++ b/test/integration/acpClient.integration.test.ts @@ -0,0 +1,285 @@ +import { Address } from "viem"; +import AcpClient from "../../src/acpClient"; +import AcpContractClientV2 from "../../src/contractClients/acpContractClientV2"; +import { + AcpAgentSort, + AcpGraduationStatus, + AcpOnlineStatus, +} from "../../src/interfaces"; +import AcpJobOffering from "../../src/acpJobOffering"; +import AcpJob from "../../src/acpJob"; +import { + WHITELISTED_WALLET_PRIVATE_KEY, + BUYER_ENTITY_ID, + BUYER_AGENT_WALLET_ADDRESS, +} from "../env"; +import { testBaseAcpConfigV2 } from "../testConfigs"; + +describe("AcpClient Integration Testing", () => { + let acpClient: AcpClient; + let contractClient: AcpContractClientV2; + + beforeAll(async () => { + contractClient = await AcpContractClientV2.build( + WHITELISTED_WALLET_PRIVATE_KEY as Address, + BUYER_ENTITY_ID, + BUYER_AGENT_WALLET_ADDRESS as Address, + testBaseAcpConfigV2, + ); + + acpClient = new AcpClient({ acpContractClient: contractClient }); + }, 45000); + + describe("Initialization (init)", () => { + it("should initialize client successfully", () => { + expect(acpClient).toBeDefined(); + expect(acpClient).toBeInstanceOf(AcpClient); + }); + + it("should have correct wallet address", () => { + expect(acpClient.walletAddress).toBe(BUYER_AGENT_WALLET_ADDRESS); + }); + + it("should have valid acpUrl", () => { + expect(acpClient.acpUrl).toBeDefined(); + expect(acpClient.acpUrl).toBe("https://acpx.virtuals.io"); + }); + + it("should have contract client initialized", () => { + expect(acpClient.acpContractClient).toBeDefined(); + expect(acpClient.acpContractClient).toBe(contractClient); + }); + + it("should establish socket connection on initialization", (done) => { + // The socket connection is established in the constructor via init() + // If we reach this point without errors, the connection was successful + expect(acpClient).toBeDefined(); + + // Give socket time to connect + setTimeout(() => { + // If no connection errors thrown, test passes + done(); + }, 2000); + }, 10000); + + it("should handle onNewTask callback when provided", (done) => { + const onNewTaskMock = jest.fn((job: AcpJob) => { + expect(job).toBeInstanceOf(AcpJob); + done(); + }); + + // Create a new client with callback + const clientWithCallback = new AcpClient({ + acpContractClient: contractClient, + onNewTask: onNewTaskMock, + }); + + expect(clientWithCallback).toBeDefined(); + + // Note: This test will pass even if event doesn't fire + // Real socket event testing would require triggering an actual job + setTimeout(() => { + if (onNewTaskMock.mock.calls.length === 0) { + // No event fired, but that's expected in test environment + done(); + } + }, 5000); + }, 10000); + + it("should handle onEvaluate callback when provided", (done) => { + const onEvaluateMock = jest.fn((job: AcpJob) => { + expect(job).toBeInstanceOf(AcpJob); + done(); + }); + + const clientWithCallback = new AcpClient({ + acpContractClient: contractClient, + onEvaluate: onEvaluateMock, + }); + + expect(clientWithCallback).toBeDefined(); + + setTimeout(() => { + if (onEvaluateMock.mock.calls.length === 0) { + done(); + } + }, 5000); + }, 10000); + }); + + describe("Agent Browsing (browseAgents)", () => { + it("should browse agents with keyword", async () => { + const keyword = "trading"; + const options = { + top_k: 5, + }; + + const result = await acpClient.browseAgents(keyword, options); + + expect(Array.isArray(result)).toBe(true); + + console.log(`Found ${result.length} agents for keyword: ${keyword}`); + }, 30000); + + it("should return agents with correct structure", async () => { + const keyword = "agent"; + const options = { + top_k: 3, + }; + + const result = await acpClient.browseAgents(keyword, options); + + if (result.length > 0) { + const firstAgent = result[0]; + + expect(firstAgent).toHaveProperty("id"); + expect(firstAgent).toHaveProperty("name"); + expect(firstAgent).toHaveProperty("description"); + expect(firstAgent).toHaveProperty("walletAddress"); + expect(firstAgent).toHaveProperty("contractAddress"); + expect(firstAgent).toHaveProperty("jobOfferings"); + expect(firstAgent).toHaveProperty("twitterHandle"); + + expect(typeof firstAgent.id).toBe("number"); + expect(typeof firstAgent.name).toBe("string"); + expect(typeof firstAgent.walletAddress).toBe("string"); + expect(Array.isArray(firstAgent.jobOfferings)).toBe(true); + + console.log("First agent:", { + id: firstAgent.id, + name: firstAgent.name, + jobCount: firstAgent.jobOfferings.length, + }); + } + }, 30000); + + it("should return job offerings as AcpJobOffering instances", async () => { + const keyword = "agent"; + const options = { + top_k: 5, + }; + + const result = await acpClient.browseAgents(keyword, options); + + const agentWithJobs = result.find( + (agent) => agent.jobOfferings.length > 0, + ); + + if (agentWithJobs) { + const jobOffering = agentWithJobs.jobOfferings[0]; + + expect(jobOffering).toBeInstanceOf(AcpJobOffering); + expect(typeof jobOffering.initiateJob).toBe("function"); + + console.log("Job offering:", { + name: jobOffering.name, + price: jobOffering.price, + }); + } else { + console.log("No agents with job offerings found"); + } + }, 30000); + + it("should filter out own wallet address", async () => { + const keyword = "agent"; + const options = { + top_k: 10, + }; + + const result = await acpClient.browseAgents(keyword, options); + + // Verify own wallet is not in results + const ownWalletInResults = result.some( + (agent) => + agent.walletAddress.toLowerCase() === + BUYER_AGENT_WALLET_ADDRESS.toLowerCase(), + ); + + expect(ownWalletInResults).toBe(false); + }, 30000); + + it("should filter by contract address", async () => { + const keyword = "agent"; + const options = { + top_k: 10, + }; + + const result = await acpClient.browseAgents(keyword, options); + + if (result.length > 0) { + // All returned agents should have matching contract address + const allHaveMatchingContract = result.every( + (agent) => + agent.contractAddress.toLowerCase() === + contractClient.contractAddress.toLowerCase(), + ); + + expect(allHaveMatchingContract).toBe(true); + } + }, 30000); + + it("should respect top_k parameter", async () => { + const keyword = "agent"; + const topK = 2; + const options = { + top_k: topK, + }; + + const result = await acpClient.browseAgents(keyword, options); + + expect(result.length).toBeLessThanOrEqual(topK); + }, 30000); + + it("should handle search with sort options", async () => { + const keyword = "trading"; + const options = { + top_k: 5, + sort_by: [AcpAgentSort.SUCCESSFUL_JOB_COUNT], + }; + + const result = await acpClient.browseAgents(keyword, options); + + expect(Array.isArray(result)).toBe(true); + console.log(`Found ${result.length} agents sorted by successfulJobCount`); + }, 30000); + + it("should handle search with graduation status filter", async () => { + const keyword = "agent"; + const options = { + top_k: 5, + graduationStatus: AcpGraduationStatus.GRADUATED, + }; + + const result = await acpClient.browseAgents(keyword, options); + + expect(Array.isArray(result)).toBe(true); + console.log(`Found ${result.length} graduated agents`); + }, 30000); + + it("should handle search with online status filter", async () => { + const keyword = "agent"; + const options = { + top_k: 5, + onlineStatus: AcpOnlineStatus.ONLINE, + }; + + const result = await acpClient.browseAgents(keyword, options); + + expect(Array.isArray(result)).toBe(true); + console.log(`Found ${result.length} online agents`); + }, 30000); + + it("should return empty or minimal results for non-existent keyword", async () => { + const keyword = "thiskeywordisnotakeyworddonotreturnanyagents"; + const options = { + top_k: 5, + }; + + const result = await acpClient.browseAgents(keyword, options); + + expect(Array.isArray(result)).toBe(true); + // May or may not be empty depending on API behavior + console.log(`Found ${result.length} agents for non-existent keyword`); + }, 30000); + }); +}); diff --git a/test/integration/acpContractClientV2.integration.test.ts b/test/integration/acpContractClientV2.integration.test.ts index ad1c7bc..61684df 100644 --- a/test/integration/acpContractClientV2.integration.test.ts +++ b/test/integration/acpContractClientV2.integration.test.ts @@ -5,6 +5,7 @@ import { SELLER_ENTITY_ID, SELLER_AGENT_WALLET_ADDRESS, } from "../env"; +import { testBaseAcpConfigV2 } from "../testConfigs"; describe("AcpContractClientV2 Integration Testing", () => { jest.setTimeout(60000); // 60 seconds for network operations @@ -20,6 +21,7 @@ describe("AcpContractClientV2 Integration Testing", () => { WHITELISTED_WALLET_PRIVATE_KEY as Address, SELLER_ENTITY_ID, SELLER_AGENT_WALLET_ADDRESS as Address, + testBaseAcpConfigV2, ); expect(contractClient).toBeDefined(); @@ -38,6 +40,7 @@ describe("AcpContractClientV2 Integration Testing", () => { WHITELISTED_WALLET_PRIVATE_KEY as Address, SELLER_ENTITY_ID, SELLER_AGENT_WALLET_ADDRESS as Address, + testBaseAcpConfigV2, ); await contractClient.init( diff --git a/test/testConfigs.ts b/test/testConfigs.ts new file mode 100644 index 0000000..dc8fe56 --- /dev/null +++ b/test/testConfigs.ts @@ -0,0 +1,64 @@ +import { AcpContractConfig } from "../src/configs/acpConfigs"; +import { + baseAcpConfig, + baseAcpX402Config, + baseAcpConfigV2, + baseAcpX402ConfigV2, +} from "../src/configs/acpConfigs"; + +/** + * Test-specific configs that use the Alchemy proxy RPC endpoint + * to avoid rate limiting issues when running the full test suite. + * + * IMPORTANT: These should ONLY be used in tests, never in production code. + * The proxy is internal infrastructure and not meant for public SDK users. + */ + +// Create test configs by cloning production configs and overriding rpcEndpoint +export const testBaseAcpConfig = new AcpContractConfig( + baseAcpConfig.chain, + baseAcpConfig.contractAddress, + baseAcpConfig.baseFare, + baseAcpConfig.alchemyRpcUrl, + baseAcpConfig.acpUrl, + baseAcpConfig.abi, + baseAcpConfig.maxRetries, + baseAcpConfig.alchemyRpcUrl, // Use proxy for tests + baseAcpConfig.x402Config, +); + +export const testBaseAcpX402Config = new AcpContractConfig( + baseAcpX402Config.chain, + baseAcpX402Config.contractAddress, + baseAcpX402Config.baseFare, + baseAcpX402Config.alchemyRpcUrl, + baseAcpX402Config.acpUrl, + baseAcpX402Config.abi, + baseAcpX402Config.maxRetries, + baseAcpX402Config.alchemyRpcUrl, // Use proxy for tests + baseAcpX402Config.x402Config, +); + +export const testBaseAcpConfigV2 = new AcpContractConfig( + baseAcpConfigV2.chain, + baseAcpConfigV2.contractAddress, + baseAcpConfigV2.baseFare, + baseAcpConfigV2.alchemyRpcUrl, + baseAcpConfigV2.acpUrl, + baseAcpConfigV2.abi, + baseAcpConfigV2.maxRetries, + baseAcpConfigV2.alchemyRpcUrl, // Use proxy for tests + baseAcpConfigV2.x402Config, +); + +export const testBaseAcpX402ConfigV2 = new AcpContractConfig( + baseAcpX402ConfigV2.chain, + baseAcpX402ConfigV2.contractAddress, + baseAcpX402ConfigV2.baseFare, + baseAcpX402ConfigV2.alchemyRpcUrl, + baseAcpX402ConfigV2.acpUrl, + baseAcpX402ConfigV2.abi, + baseAcpX402ConfigV2.maxRetries, + baseAcpX402ConfigV2.alchemyRpcUrl, // Use proxy for tests + baseAcpX402ConfigV2.x402Config, +);