diff --git a/test/env.ts b/test/env.ts index 930b39b..d46233c 100644 --- a/test/env.ts +++ b/test/env.ts @@ -15,11 +15,11 @@ export const WHITELISTED_WALLET_PRIVATE_KEY = getEnvVar
( "WHITELISTED_WALLET_PRIVATE_KEY", ); -// export const BUYER_AGENT_WALLET_ADDRESS = getEnvVar
( -// "BUYER_AGENT_WALLET_ADDRESS", -// ); +export const BUYER_AGENT_WALLET_ADDRESS = getEnvVar
( + "BUYER_AGENT_WALLET_ADDRESS", +); -// export const BUYER_ENTITY_ID = parseInt(getEnvVar("BUYER_ENTITY_ID")); +export const BUYER_ENTITY_ID = parseInt(getEnvVar("BUYER_ENTITY_ID")); export const SELLER_AGENT_WALLET_ADDRESS = getEnvVar
( "SELLER_AGENT_WALLET_ADDRESS", @@ -28,7 +28,7 @@ export const SELLER_AGENT_WALLET_ADDRESS = getEnvVar
( export const SELLER_ENTITY_ID = parseInt(getEnvVar("SELLER_ENTITY_ID")); const entities = { - // BUYER_ENTITY_ID, + BUYER_ENTITY_ID, SELLER_ENTITY_ID, }; diff --git a/test/integration/acpClient.integration.test.ts b/test/integration/acpClient.integration.test.ts new file mode 100644 index 0000000..b616b04 --- /dev/null +++ b/test/integration/acpClient.integration.test.ts @@ -0,0 +1,283 @@ +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"; + +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, + ); + + 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/unit/acpClient.test.ts b/test/unit/acpClient.test.ts new file mode 100644 index 0000000..5ba9ca2 --- /dev/null +++ b/test/unit/acpClient.test.ts @@ -0,0 +1,1165 @@ +import { Address } from "viem"; +import { BaseAcpContractClient } from "../../src"; +import AcpClient, { EvaluateResult } from "../../src/acpClient"; +import AcpError from "../../src/acpError"; +import AcpMemo from "../../src/acpMemo"; +import AcpJob from "../../src/acpJob"; +import { AcpJobPhases } from "../../src"; +import { AcpAccount } from "../../src/acpAccount"; +import { + AcpAgentSort, + AcpGraduationStatus, + AcpOnlineStatus, +} from "../../src/interfaces"; + +jest.mock("socket.io-client", () => ({ + io: jest.fn(() => ({ + on: jest.fn(), + disconnect: jest.fn(), + })), +})); + +describe("AcpClient Unit Testing", () => { + let acpClient: AcpClient; + let mockContractClient: jest.Mocked; + + beforeEach(() => { + mockContractClient = { + contractAddress: "0x1234567890123456789012345678901234567890" as Address, + walletAddress: "0x0987654321098765432109876543210987654321" as Address, + config: { + acpUrl: "https://test-acp-url.com", + contractAddress: + "0x1234567890123456789012345678901234567890" as Address, + chain: { id: 1 }, + }, + handleOperation: jest.fn(), + getJobId: jest.fn(), + } as any; + + acpClient = new AcpClient({ + acpContractClient: mockContractClient, + }); + }); + + it("should able to create EvaluateResult instance", () => { + const result = new EvaluateResult(true, "Approved"); + + expect(result.isApproved).toBe(true); + expect(result.reasoning).toBe("Approved"); + }); + + it("should return first client when address is undefined", () => { + const result = acpClient.contractClientByAddress(undefined); + expect(result).toBe(mockContractClient); + }); + + it("should throw error when contract client not found by address", () => { + expect(() => { + acpClient.contractClientByAddress("0xNonexistent" as Address); + }).toThrow("ACP contract client not found"); + }); + + it("should call defaultOnEvaluate when onEvaluate callback is not provided", async () => { + const mockJob = { + evaluate: jest.fn().mockResolvedValue(undefined), + } as unknown as AcpJob; + + const defaultOnEvaluate = (acpClient as any)["defaultOnEvaluate"]; + + await defaultOnEvaluate.call(acpClient, mockJob); + + expect(mockJob.evaluate).toHaveBeenCalledWith(true, "Evaluated by default"); + }); + + it("should register SIGINT and SIGTERM cleanup handlers", () => { + const processSpy = jest.spyOn(process, "on"); + + const mockClient = new AcpClient({ + acpContractClient: mockContractClient, + }); + + expect(processSpy).toHaveBeenCalledWith("SIGINT", expect.any(Function)); + expect(processSpy).toHaveBeenCalledWith("SIGTERM", expect.any(Function)); + }); + + // describe("Socket Event Handlers", () => { + // it("should handle ON_EVALUATE socket event with memos", (done) => { + // const mockSocketData = { + // id: 123, + // clientAddress: "0xClient" as Address, + // providerAddress: "0xProvider" as Address, + // evaluatorAddress: "0xEvaluator" as Address, + // price: 10, + // priceTokenAddress: "0xUSDCTokenAddress" as Address, + // memos: [ + // { + // id: 1, + // memoType: 0, + // content: "evaluation memo", + // nextPhase: 2, + // status: "PENDING", + // senderAddress: "0xSender" as Address, + // signedReason: undefined, + // expiry: undefined, + // payableDetails: undefined, + // txHash: "0xtxhash123", + // signedTxHash: "0xsignedtxhash123", + // }, + // ], + // phase: 1, + // context: { test: "data" }, + // contractAddress: "0xContract" as Address, + // netPayableAmount: 10, + // }; + // + // const onEvaluateMock = jest.fn((job: AcpJob) => { + // expect(job).toBeInstanceOf(AcpJob); + // expect(job.id).toBe(123); + // expect(job.clientAddress).toBe("0xClient"); + // expect(job.memos.length).toBe(1); + // expect(job.memos[0]).toBeInstanceOf(AcpMemo); + // expect(job.memos[0].content).toBe("evaluation memo"); + // expect(job.memos[0].txHash).toBe("0xtxhash123"); + // done(); + // }); + // + // const mockSocketInstance = { + // on: jest.fn((event: string, handler: any) => { + // if (event === "ON_EVALUATE") { + // setImmediate(() => handler(mockSocketData, jest.fn())); + // } + // }), + // disconnect: jest.fn(), + // }; + // + // const ioMock = require("socket.io-client"); + // ioMock.io.mockReturnValue(mockSocketInstance); + // + // new AcpClient({ + // acpContractClient: mockContractClient, + // onEvaluate: onEvaluateMock, + // }); + // }); + // }); + + describe("Agent Browsing (browseAgents)", () => { + const createMockAgent = (overrides = {}) => ({ + id: 1, + documentId: "doc123", + name: "Test Agent", + description: "A test agent", + walletAddress: "0xAgent" as Address, + isVirtualAgent: false, + profilePic: "pic.jpg", + category: "test", + tokenAddress: null, + ownerAddress: "0xOwner" as Address, + cluster: null, + twitterHandle: "@testagent", + jobs: [ + { + id: 1, + name: "Test Job", + description: "A test job", + priceV2: { + value: 100, + type: "NATIVE", + }, + requirement: "Test requirement", + status: "active", + }, + ], + resources: [], + metrics: {}, + symbol: null, + virtualAgentId: null, + contractAddress: "0x1234567890123456789012345678901234567890" as Address, + ...overrides, + }); + + it("should filter out own wallet address from results", async () => { + const mockAgents = [ + createMockAgent({ + id: 1, + walletAddress: "0xOther" as Address, + }), + createMockAgent({ + id: 2, + walletAddress: + "0x0987654321098765432109876543210987654321" as Address, + }), + ]; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockAgents }), + }); + + const result = await acpClient.browseAgents("keyword", { top_k: 10 }); + + expect(result.length).toBe(1); + expect(result[0].walletAddress).toBe("0xOther"); + }); + + it("should filter agents by matching contract addresses", async () => { + const mockAgents = [ + createMockAgent({ + id: 1, + contractAddress: + "0x1234567890123456789012345678901234567890" as Address, + }), + createMockAgent({ + id: 2, + contractAddress: "0xDifferent" as Address, + }), + ]; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockAgents }), + }); + + const result = await acpClient.browseAgents("keyword", { top_k: 10 }); + + expect(result.length).toBe(1); + expect(result[0].contractAddress).toBe( + "0x1234567890123456789012345678901234567890", + ); + }); + + it("should transform agents to include job offerings", async () => { + const mockAgents = [createMockAgent()]; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockAgents }), + }); + + const result = await acpClient.browseAgents("keyword", { top_k: 10 }); + + expect(result[0]).toHaveProperty("jobOfferings"); + expect(Array.isArray(result[0].jobOfferings)).toBe(true); + expect(result[0].jobOfferings.length).toBe(1); + }); + + it("should return empty array when no agents match filters", async () => { + const mockAgents = [ + createMockAgent({ + contractAddress: "0xDifferent" as Address, + }), + ]; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockAgents }), + }); + + const result = await acpClient.browseAgents("keyword", { top_k: 10 }); + + expect(result).toEqual([]); + }); + + it("should build URL with correct query parameters", async () => { + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: [] }), + }); + + const keyword = "Trading"; + const top_k_value = 5; + + await acpClient.browseAgents(keyword, { + top_k: top_k_value, + sort_by: [AcpAgentSort.SUCCESSFUL_JOB_COUNT], + graduationStatus: AcpGraduationStatus.GRADUATED, + onlineStatus: AcpOnlineStatus.ALL, + }); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0][0]; + expect(fetchCall).toContain( + `https://test-acp-url.com/api/agents/v4/search?search=${keyword}&sortBy=successfulJobCount&top_k=${top_k_value}&walletAddressesToExclude=${acpClient.walletAddress}&graduationStatus=graduated&onlineStatus=all`, + ); + }); + + it("should handle agents with empty jobs array", async () => { + const mockAgents = [ + createMockAgent({ + jobs: [], + }), + ]; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockAgents }), + }); + + const result = await acpClient.browseAgents("keyword", { top_k: 10 }); + + expect(result[0].jobOfferings).toEqual([]); + }); + + it("should include cluster parameter in URL when provided", async () => { + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: [] }), + }); + + await acpClient.browseAgents("keyword", { + top_k: 5, + cluster: "defi", + }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("&cluster=defi"), + ); + }); + }); + + describe("Constructor Validations", () => { + it("should throw error when no contract clients are provided", () => { + expect(() => { + new AcpClient({ acpContractClient: [] as any }); + }).toThrow("ACP contract client is required"); + }); + it("should throw error when contract clients have different addresses", () => { + const mockClient1 = { + contractAddress: + "0x1111111111111111111111111111111111111111" as Address, + walletAddress: "0x0987654321098765432109876543210987654320" as Address, + config: { + acpUrl: "https://test-acp-url.com", + contractAddress: + "0x1111111111111111111111111111111111111111" as Address, + chain: { id: 1 }, + }, + handleOperation: jest.fn(), + getJobId: jest.fn(), + } as any; + + const mockClient2 = { + contractAddress: + "0x2222222222222222222222222222222222222222" as Address, + walletAddress: "0x0987654321098765432109876543210987654321" as Address, + config: { + acpUrl: "https://test-acp-url.com", + contractAddress: + "0x2222222222222222222222222222222222222222" as Address, + chain: { id: 1 }, + }, + handleOperation: jest.fn(), + getJobId: jest.fn(), + } as any; + + expect(() => { + new AcpClient({ + acpContractClient: [mockClient1, mockClient2], + }); + }).toThrow( + "All contract clients must have the same agent wallet address", + ); + }); + }); + + describe("Getting Active Jobs", () => { + it("should get all active jobs successfully", async () => { + const mockIAcpJobResponse = [ + { + id: 1, + phase: AcpJobPhases.REQUEST, + description: "bullish", + clientAddress: "0xClient" as Address, + providerAddress: "0xProvider" as Address, + evaluatorAddress: "0xEvaluator" as Address, + price: 10, + priceTokenAddress: "0xPriceToken" as Address, + deliverable: null, + memos: [], + contractAddress: + "0x1234567890123456789012345678901234567890" as Address, + }, + ]; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockIAcpJobResponse }), + }); + + const result = await acpClient.getActiveJobs(); + + expect(Array.isArray(result)).toBe(true); + expect(result[0]).toBeInstanceOf(AcpJob); + expect(global.fetch).toHaveBeenCalledWith( + "https://test-acp-url.com/api/jobs/active?pagination[page]=1&pagination[pageSize]=10", + { + headers: { + "wallet-address": "0x0987654321098765432109876543210987654321", + }, + }, + ); + }); + + it("should map memos to AcpMemo instances in active jobs", async () => { + const mockIAcpJobResponse = [ + { + id: 1, + phase: AcpJobPhases.REQUEST, + description: "bullish", + clientAddress: "0xClient" as Address, + providerAddress: "0xProvider" as Address, + evaluatorAddress: "0xEvaluator" as Address, + price: 10, + priceTokenAddress: "0xPriceToken" as Address, + deliverable: null, + memos: [ + { + id: 1, + memoType: 0, + content: "test memo", + nextPhase: 1, + status: "PENDING", + senderAddress: "0xSender" as Address, + signedReason: undefined, + expiry: undefined, + payableDetails: undefined, + txHash: "0xtxhash", + signedTxHash: "0xsignedtxhash", + }, + ], + contractAddress: + "0x1234567890123456789012345678901234567890" as Address, + }, + ]; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockIAcpJobResponse }), + }); + + const result = await acpClient.getActiveJobs(); + + expect(result[0].memos.length).toBe(1); + expect(result[0].memos[0]).toBeInstanceOf(AcpMemo); + expect(result[0].memos[0].content).toBe("test memo"); + }); + + it("should throw AcpError when API returns error", async () => { + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ error: { message: "Jobs Not Found" } }), + }); + + await expect(acpClient.getActiveJobs()).resolves.toThrow(AcpError); + await expect(acpClient.getActiveJobs()).resolves.toThrow( + "Jobs Not Found", + ); + }); + + it("should throw AcpError when fetch fails", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("Network Error")); + + await expect(acpClient.getActiveJobs()).rejects.toThrow(AcpError); + await expect(acpClient.getActiveJobs()).rejects.toThrow( + "Failed to get active jobs", + ); + }); + }); + + describe("Getting Pending Memo Jobs", () => { + it("should get all pending memo jobs successfully", async () => { + const mockIAcpJobResponse = [ + { + id: 1, + phase: AcpJobPhases.REQUEST, + description: "bullish", + clientAddress: "0xClient" as Address, + providerAddress: "0xProvider" as Address, + evaluatorAddress: "0xEvaluator" as Address, + price: 10, + priceTokenAddress: "0xPriceToken" as Address, + deliverable: null, + memos: [], + contractAddress: + "0x1234567890123456789012345678901234567890" as Address, + }, + ]; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockIAcpJobResponse }), + }); + + const result = await acpClient.getPendingMemoJobs(); + + expect(Array.isArray(result)).toBe(true); + expect(result[0]).toBeInstanceOf(AcpJob); + expect(global.fetch).toHaveBeenCalledWith( + "https://test-acp-url.com/api/jobs/pending-memos?pagination[page]=1&pagination[pageSize]=10", + { + headers: { + "wallet-address": "0x0987654321098765432109876543210987654321", + }, + }, + ); + }); + + it("should map memos to AcpMemo instances in pending memo jobs", async () => { + const mockIAcpJobResponse = [ + { + id: 1, + phase: AcpJobPhases.REQUEST, + description: "bullish", + clientAddress: "0xClient" as Address, + providerAddress: "0xProvider" as Address, + evaluatorAddress: "0xEvaluator" as Address, + price: 10, + priceTokenAddress: "0xPriceToken" as Address, + deliverable: null, + memos: [ + { + id: 1, + memoType: 0, + content: "pending memo", + nextPhase: 1, + status: "PENDING", + senderAddress: "0xSender" as Address, + signedReason: undefined, + expiry: undefined, + payableDetails: undefined, + txHash: "0xtxhash", + signedTxHash: "0xsignedtxhash", + }, + ], + contractAddress: + "0x1234567890123456789012345678901234567890" as Address, + }, + ]; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockIAcpJobResponse }), + }); + + const result = await acpClient.getPendingMemoJobs(); + + expect(result[0].memos.length).toBe(1); + expect(result[0].memos[0]).toBeInstanceOf(AcpMemo); + expect(result[0].memos[0].content).toBe("pending memo"); + }); + + it("should throw AcpError when API returns error", async () => { + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ error: { message: "Jobs Not Found" } }), + }); + + await expect(acpClient.getPendingMemoJobs()).resolves.toThrow(AcpError); + await expect(acpClient.getPendingMemoJobs()).resolves.toThrow( + "Jobs Not Found", + ); + }); + + it("should throw AcpError when fetch fails", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("Network Error")); + + await expect(acpClient.getPendingMemoJobs()).rejects.toThrow(AcpError); + await expect(acpClient.getPendingMemoJobs()).rejects.toThrow( + "Failed to get pending memo jobs", + ); + }); + }); + + describe("Getting Completed Jobs", () => { + it("should get all completed jobs successfully", async () => { + const mockIAcpJobResponse = [ + { + id: 1, + phase: AcpJobPhases.REQUEST, + description: "bullish", + clientAddress: "0xClient" as Address, + providerAddress: "0xProvider" as Address, + evaluatorAddress: "0xEvaluator" as Address, + price: 10, + priceTokenAddress: "0xPriceToken" as Address, + deliverable: null, + memos: [], + contractAddress: + "0x1234567890123456789012345678901234567890" as Address, + }, + ]; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockIAcpJobResponse }), + }); + + const result = await acpClient.getCompletedJobs(); + + expect(Array.isArray(result)).toBe(true); + expect(result[0]).toBeInstanceOf(AcpJob); + expect(global.fetch).toHaveBeenCalledWith( + "https://test-acp-url.com/api/jobs/completed?pagination[page]=1&pagination[pageSize]=10", + { + headers: { + "wallet-address": "0x0987654321098765432109876543210987654321", + }, + }, + ); + }); + + it("should map memos to AcpMemo instances in completed jobs", async () => { + const mockIAcpJobResponse = [ + { + id: 1, + phase: AcpJobPhases.REQUEST, + description: "bullish", + clientAddress: "0xClient" as Address, + providerAddress: "0xProvider" as Address, + evaluatorAddress: "0xEvaluator" as Address, + price: 10, + priceTokenAddress: "0xPriceToken" as Address, + deliverable: null, + memos: [ + { + id: 1, + memoType: 0, + content: "completed memo", + nextPhase: 1, + status: "COMPLETED", + senderAddress: "0xSender" as Address, + signedReason: undefined, + expiry: undefined, + payableDetails: undefined, + txHash: "0xtxhash", + signedTxHash: "0xsignedtxhash", + }, + ], + contractAddress: + "0x1234567890123456789012345678901234567890" as Address, + }, + ]; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockIAcpJobResponse }), + }); + + const result = await acpClient.getCompletedJobs(); + + expect(result[0].memos.length).toBe(1); + expect(result[0].memos[0]).toBeInstanceOf(AcpMemo); + expect(result[0].memos[0].content).toBe("completed memo"); + }); + + it("should throw AcpError when API returns error", async () => { + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ error: { message: "Jobs Not Found" } }), + }); + + await expect(acpClient.getCompletedJobs()).resolves.toThrow(AcpError); + await expect(acpClient.getCompletedJobs()).resolves.toThrow( + "Jobs Not Found", + ); + }); + + it("should throw AcpError when fetch fails", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("Network Error")); + + await expect(acpClient.getCompletedJobs()).rejects.toThrow(AcpError); + await expect(acpClient.getCompletedJobs()).rejects.toThrow( + "Failed to get completed jobs", + ); + }); + }); + + describe("Getting Cancelled Jobs", () => { + it("should get all cancelled jobs successfully", async () => { + const mockIAcpJobResponse = [ + { + id: 1, + phase: AcpJobPhases.REQUEST, + description: "bullish", + clientAddress: "0xClient" as Address, + providerAddress: "0xProvider" as Address, + evaluatorAddress: "0xEvaluator" as Address, + price: 10, + priceTokenAddress: "0xPriceToken" as Address, + deliverable: null, + memos: [], + contractAddress: + "0x1234567890123456789012345678901234567890" as Address, + }, + ]; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockIAcpJobResponse }), + }); + + const result = await acpClient.getCancelledJobs(); + + expect(Array.isArray(result)).toBe(true); + expect(result[0]).toBeInstanceOf(AcpJob); + expect(global.fetch).toHaveBeenCalledWith( + "https://test-acp-url.com/api/jobs/cancelled?pagination[page]=1&pagination[pageSize]=10", + { + headers: { + "wallet-address": "0x0987654321098765432109876543210987654321", + }, + }, + ); + }); + + it("should map memos to AcpMemo instances in cancelled jobs", async () => { + const mockIAcpJobResponse = [ + { + id: 1, + phase: AcpJobPhases.REQUEST, + description: "bullish", + clientAddress: "0xClient" as Address, + providerAddress: "0xProvider" as Address, + evaluatorAddress: "0xEvaluator" as Address, + price: 10, + priceTokenAddress: "0xPriceToken" as Address, + deliverable: null, + memos: [ + { + id: 1, + memoType: 0, + content: "cancelled memo", + nextPhase: 1, + status: "CANCELLED", + senderAddress: "0xSender" as Address, + signedReason: undefined, + expiry: undefined, + payableDetails: undefined, + txHash: "0xtxhash", + signedTxHash: "0xsignedtxhash", + }, + ], + contractAddress: + "0x1234567890123456789012345678901234567890" as Address, + }, + ]; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockIAcpJobResponse }), + }); + + const result = await acpClient.getCancelledJobs(); + + expect(result[0].memos.length).toBe(1); + expect(result[0].memos[0]).toBeInstanceOf(AcpMemo); + expect(result[0].memos[0].content).toBe("cancelled memo"); + }); + + it("should throw AcpError when API returns error", async () => { + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ error: { message: "Jobs Not Found" } }), + }); + + await expect(acpClient.getCancelledJobs()).resolves.toThrow(AcpError); + await expect(acpClient.getCancelledJobs()).resolves.toThrow( + "Jobs Not Found", + ); + }); + + it("should throw AcpError when fetch fails", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("Network Error")); + + await expect(acpClient.getCancelledJobs()).rejects.toThrow(AcpError); + await expect(acpClient.getCancelledJobs()).rejects.toThrow( + "Failed to get cancelled jobs", + ); + }); + }); + + describe("Getting Job by Id", () => { + it("should get job by job id successfully", async () => { + const mockJobId = 123; + + const mockAcpJobResponse = { + id: 1, + phase: AcpJobPhases.REQUEST, + description: "bullish", + clientAddress: "0xClient" as Address, + providerAddress: "0xProvider" as Address, + evaluatorAddress: "0xEvaluator" as Address, + price: 10, + priceTokenAddress: "0xPriceToken" as Address, + deliverable: null, + memos: [], + contractAddress: + "0x1234567890123456789012345678901234567890" as Address, + }; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockAcpJobResponse }), + }); + + const result = await acpClient.getJobById(mockJobId); + + expect(result).toBeInstanceOf(AcpJob); + expect(result?.id).toBe(1); + expect(global.fetch).toHaveBeenCalledWith( + `https://test-acp-url.com/api/jobs/${mockJobId}`, + { + headers: { + "wallet-address": "0x0987654321098765432109876543210987654321", + }, + }, + ); + }); + + it("should map memos to AcpMemo instances when getting job by id", async () => { + const mockJobId = 123; + + const mockAcpJobResponse = { + id: 1, + phase: AcpJobPhases.REQUEST, + description: "bullish", + clientAddress: "0xClient" as Address, + providerAddress: "0xProvider" as Address, + evaluatorAddress: "0xEvaluator" as Address, + price: 10, + priceTokenAddress: "0xPriceToken" as Address, + deliverable: null, + memos: [ + { + id: 1, + memoType: 0, + content: "job memo", + nextPhase: 1, + status: "PENDING", + senderAddress: "0xSender" as Address, + signedReason: undefined, + expiry: undefined, + payableDetails: undefined, + txHash: "0xtxhash", + signedTxHash: "0xsignedtxhash", + }, + ], + contractAddress: + "0x1234567890123456789012345678901234567890" as Address, + }; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockAcpJobResponse }), + }); + + const result = await acpClient.getJobById(mockJobId); + + expect(result?.memos.length).toBe(1); + expect(result?.memos[0]).toBeInstanceOf(AcpMemo); + expect(result?.memos[0].content).toBe("job memo"); + }); + + it("should return undefined when job doesn't exist", async () => { + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: null }), + }); + + const result = await acpClient.getJobById(123); + + expect(result).toBeUndefined(); + }); + + it("should throw AcpError when API returns error", async () => { + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ error: { message: "Job Not Found" } }), + }); + + await expect(acpClient.getJobById(123)).resolves.toThrow(AcpError); + await expect(acpClient.getJobById(123)).resolves.toThrow("Job Not Found"); + }); + + it("should throw AcpError when fetch fails", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("Network Fail")); + + await expect(acpClient.getJobById(123)).rejects.toThrow(AcpError); + await expect(acpClient.getJobById(123)).rejects.toThrow( + "Failed to get job by id", + ); + }); + }); + + describe("Getting Memo by Id", () => { + it("should get memo by job id successfully", async () => { + const mockJobId = 123; + const mockMemoId = 456; + + const mockMemoData = { + id: mockMemoId, + type: "MESSAGE", + content: "Test memo content", + createdAt: "2024-01-01", + memoType: 0, + nextPhase: 1, + status: "PENDING", + senderAddress: "0xSender" as Address, + signedReason: undefined, + expiry: undefined, + payableDetails: undefined, + contractAddress: + "0x1234567890123456789012345678901234567890" as Address, + }; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: mockMemoData }), + }); + + const result = await acpClient.getMemoById(mockJobId, mockMemoId); + + expect(result).toBeInstanceOf(AcpMemo); + expect(result?.content).toBe("Test memo content"); + expect(global.fetch).toHaveBeenCalledWith( + `https://test-acp-url.com/api/jobs/${mockJobId}/memos/${mockMemoId}`, + { + headers: { + "wallet-address": "0x0987654321098765432109876543210987654321", + }, + }, + ); + }); + + it("should return undefined when memo doesn't exist", async () => { + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: null }), + }); + + const result = await acpClient.getMemoById(123, 456); + + expect(result).toBeUndefined(); + }); + + it("should throw AcpError when API returns error", async () => { + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ error: { message: "Memo Not Found" } }), + }); + + await expect(acpClient.getMemoById(123, 456)).resolves.toThrow(AcpError); + await expect(acpClient.getMemoById(123, 456)).resolves.toThrow( + "Memo Not Found", + ); + }); + + it("should throw AcpError when fetch fails", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("Network Error")); + + await expect(acpClient.getMemoById(123, 456)).rejects.toThrow(AcpError); + await expect(acpClient.getMemoById(123, 456)).rejects.toThrow( + "Failed to get memo by id", + ); + }); + }); + + describe("Getting Agent from Wallet Address", () => { + it("should get first agent from wallet address successfully", async () => { + const mockWalletAddress = "0xClient" as Address; + + const mockAgent1 = { + id: 1, + documentId: "doc123", + name: "Agent One", + description: "Test agent", + walletAddress: mockWalletAddress, + isVirtualAgent: false, + profilePic: "pic.jpg", + category: "test", + tokenAddress: null, + ownerAddress: "0xOwner", + cluster: null, + twitterHandle: "@agent", + jobs: [], + resources: [], + symbol: null, + virtualAgentId: null, + contractAddress: + "0x1234567890123456789012345678901234567890" as Address, + }; + + const mockAgent2 = { + id: 2, + documentId: "doc456", + name: "Agent Two", + description: "Second agent", + walletAddress: mockWalletAddress, + isVirtualAgent: false, + profilePic: "pic2.jpg", + category: "test", + tokenAddress: null, + ownerAddress: "0xOwner", + cluster: null, + twitterHandle: "@agent2", + jobs: [], + resources: [], + symbol: null, + virtualAgentId: null, + contractAddress: + "0x1234567890123456789012345678901234567890" as Address, + }; + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: [mockAgent1, mockAgent2] }), + }); + + const result = await acpClient.getAgent(mockWalletAddress); + + expect(result).toEqual(mockAgent1); + expect(result?.id).toBe(1); + expect(result?.name).toBe("Agent One"); + expect(result?.id).not.toBe(2); + expect(global.fetch).toHaveBeenCalledWith( + `https://test-acp-url.com/api/agents?filters[walletAddress]=${mockWalletAddress}`, + ); + }); + + it("should return undefined when no agents found", async () => { + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: [] }), + }) as jest.Mock; + + const result = await acpClient.getAgent("0xClient" as Address); + + expect(result).toBeUndefined(); + }); + + it("should return undefined when data is null", async () => { + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: null }), + }) as jest.Mock; + + const result = await acpClient.getAgent("0xNonexistent" as Address); + + expect(result).toBeUndefined(); + }); + }); + + describe("Getting Account by Job Id ", () => { + it("should get account by job id", async () => { + const mockJobId = 123; + + const mockResponseData = { + data: { + id: 0, + clientAddress: "0xjohnson" as Address, + providerAddress: "0xjoshua" as Address, + metadata: { status: "Bullish" }, + }, + }; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => mockResponseData, + }); + + const result = await acpClient.getAccountByJobId(mockJobId); + + expect(result).toBeInstanceOf(AcpAccount); + expect(result?.id).toBe(0); + expect(result?.clientAddress).toBe("0xjohnson"); + expect(global.fetch).toHaveBeenCalledWith( + `https://test-acp-url.com/api/accounts/job/${mockJobId}`, + ); + }); + + it("should return null when account doesn't exist", async () => { + const mockJobId = 123; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: null }), + }); + + const result = await acpClient.getAccountByJobId(mockJobId); + + expect(result).toBeNull(); + }); + + it("should throw AcpError when fetch fails", async () => { + global.fetch = jest + .fn() + .mockRejectedValue(new Error("Network Error")) as jest.Mock; + + await expect(acpClient.getAccountByJobId).rejects.toThrow(AcpError); + }); + }); + + describe("Getting Account by Client and Provider", () => { + it("should get account by client and provider successfully", async () => { + const mockClientAddress = "0xClient" as Address; + const mockProviderAddress = "0xProvider" as Address; + + const mockResponseData = { + data: { + id: 0, + clientAddress: "0xjohnson" as Address, + providerAddress: "0xjoshua" as Address, + metadata: { status: "Bullish" }, + }, + }; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => mockResponseData, + }); + + const result = await acpClient.getByClientAndProvider( + mockClientAddress, + mockProviderAddress, + ); + + expect(result).toBeInstanceOf(AcpAccount); + expect(result?.id).toBe(0); + expect(result?.clientAddress).toBe("0xjohnson"); + expect(global.fetch).toHaveBeenCalledWith( + `https://test-acp-url.com/api/accounts/client/${mockClientAddress}/provider/${mockProviderAddress}`, + ); + }); + + it("should return null when account doesn't exist", async () => { + const mockClientAddress = "0xClient"; + const mockProviderAddress = "0xProvider"; + + global.fetch = jest.fn().mockResolvedValue({ + json: async () => ({ data: null }), + }); + + const result = await acpClient.getByClientAndProvider( + mockClientAddress, + mockProviderAddress, + ); + + expect(result).toBeNull(); + }); + + it("should throw AcpError when fetch fails", async () => { + global.fetch = jest + .fn() + .mockRejectedValue(new Error("Network error")) as jest.Mock; + + await expect( + acpClient.getByClientAndProvider( + "0xClient" as Address, + "0xProvider" as Address, + ), + ).rejects.toThrow(AcpError); + + await expect( + acpClient.getByClientAndProvider( + "0xClient" as Address, + "0xProvider" as Address, + ), + ).rejects.toThrow("Failed to get account by client and provider"); + }); + }); + + describe("Getter Methods", () => { + it("should get wallet address correctly", () => { + expect(acpClient.walletAddress).toBe( + "0x0987654321098765432109876543210987654321", + ); + }); + + it("should able to get acpUrl correctly", () => { + expect(acpClient.acpUrl).toBe("https://test-acp-url.com"); + }); + + it("should able to get the first acpContractClient when multi-client exists", () => { + const mockClient1 = { + ...mockContractClient, + walletAddress: "0xfirst" as Address, + } as any; + const mockClient2 = { + ...mockContractClient, + walletAddress: "0xfirst" as Address, + } as any; + + const multiClient = new AcpClient({ + acpContractClient: [mockClient1, mockClient2], + }); + + expect(multiClient.acpContractClient).toBe(mockClient1); + }); + }); +});