diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..7e4940b --- /dev/null +++ b/jest.config.js @@ -0,0 +1,33 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/__tests__/**/*.test.ts'], + transform: { + '^.+\\.ts$': 'ts-jest' + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + collectCoverageFrom: [ + 'src/aqua/**/*.ts', + '!src/aqua/**/*.d.ts', + '!src/aqua/**/index.ts', + '!src/aqua/**/__tests__/**', + '!src/aqua/**/__mocks__/**' + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80 + } + }, + setupFilesAfterEnv: ['/src/test/setup.ts'], + testTimeout: 30000, + verbose: true, + roots: ['/src'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1' + } +}; \ No newline at end of file diff --git a/package.json b/package.json index 8af1528..f1708ff 100755 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "dev": "subql codegen && subql build && docker compose pull && docker compose up --remove-orphans", "prepack": "rm -rf dist && npm run build", "test": "subql build && subql-node-stellar test", + "test:aqua": "jest src/aqua", + "test:aqua:watch": "jest src/aqua --watch", "build:develop": "NODE_ENV=develop subql codegen && NODE_ENV=develop subql build", "reset": "docker compose down -v && sudo rm -rf .data && sudo rm -rf dist", "prestart": "ts-node scripts/index.ts", @@ -28,18 +30,23 @@ "@stellar/stellar-sdk": "^13.1.0", "@subql/common": "^5.4.0", "@subql/common-stellar": "^4.4.2", + "@subql/node-stellar": "^6.0.1", "@subql/types-stellar": "^4.3.0", "base32.js": "^0.1.0", "p-limit": "^6.2.0", "soroban-toolkit": "^0.1.5" }, "devDependencies": { - "@subql/cli": "^5.11.0", + "@subql/cli": "^6.0.1", "@subql/testing": "latest", "@subql/types": "^3.12.2", + "@types/jest": "^30.0.0", "@types/node": "^22.13.1", "dotenv": "latest", + "jest": "^30.0.4", + "jest-environment-node": "^30.0.4", "js-yaml": "^4.1.0", + "ts-jest": "^29.4.0", "ts-node": "^10.9.2", "typescript": "^5.7.3" } diff --git a/src/aqua/__tests__/aquaAddPoolHandler.edgeCases.test.ts b/src/aqua/__tests__/aquaAddPoolHandler.edgeCases.test.ts new file mode 100644 index 0000000..b92b737 --- /dev/null +++ b/src/aqua/__tests__/aquaAddPoolHandler.edgeCases.test.ts @@ -0,0 +1,963 @@ +import { aquaAddPoolHandler } from "../index"; +import { SorobanEvent } from "@subql/types-stellar"; +import { AquaPair } from "../../types"; +import { extractAddPoolAquaValues } from "../helpers/addPoolEvent"; + +jest.mock("../helpers/addPoolEvent", () => ({ + extractAddPoolAquaValues: jest.fn(), +})); + +jest.mock("../../types", () => ({ + AquaPair: { + get: jest.fn(), + create: jest.fn(), + }, +})); + +describe("aquaAddPoolHandler - Edge Cases", () => { + let mockAquaPair: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockAquaPair = { + save: jest.fn(), + }; + }); + + describe("Event Structure Edge Cases", () => { + it("should handle null event", async () => { + await expect(aquaAddPoolHandler(null as any)).rejects.toThrow(); + }); + + it("should handle undefined event", async () => { + await expect(aquaAddPoolHandler(undefined as any)).rejects.toThrow(); + }); + + it("should handle event without ledgerClosedAt", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + date: expect.any(Date), + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle event with null ledgerClosedAt", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: null, + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + date: expect.any(Date), + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle event with invalid ledgerClosedAt", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "invalid-date", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + date: expect.any(Date), + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle event without ledger", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await expect(aquaAddPoolHandler(event)).rejects.toThrow(); + }); + + it("should handle event with null ledger", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: null, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await expect(aquaAddPoolHandler(event)).rejects.toThrow(); + }); + + it("should handle event with missing ledger.sequence", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: {}, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + ledger: undefined, + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + }); + + describe("extractAddPoolAquaValues Edge Cases", () => { + it("should handle extractAddPoolAquaValues throwing error", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockImplementation(() => { + throw new Error("Extract pool values error"); + }); + + await expect(aquaAddPoolHandler(event)).rejects.toThrow("Extract pool values error"); + }); + + it("should handle extractAddPoolAquaValues returning null", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue(null); + + await expect(aquaAddPoolHandler(event)).rejects.toThrow(); + }); + + it("should handle extractAddPoolAquaValues returning undefined", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue(undefined); + + await expect(aquaAddPoolHandler(event)).rejects.toThrow(); + }); + + it("should handle extractAddPoolAquaValues returning empty object", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({}); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: undefined, + tokenA: undefined, + tokenB: undefined, + poolType: undefined, + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle extractAddPoolAquaValues with missing required fields", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + // missing idx, tokenA, tokenB, poolType + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: "testAddress", + idx: undefined, + tokenA: undefined, + tokenB: undefined, + poolType: undefined, + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + }); + + describe("Database Operation Edge Cases", () => { + it("should handle AquaPair.get throwing error", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockRejectedValue(new Error("Database get error")); + + await expect(aquaAddPoolHandler(event)).rejects.toThrow("Database get error"); + }); + + it("should handle AquaPair.create throwing error", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockImplementation(() => { + throw new Error("Database create error"); + }); + + await expect(aquaAddPoolHandler(event)).rejects.toThrow("Database create error"); + }); + + it("should handle save operation throwing error", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + mockAquaPair.save.mockRejectedValue(new Error("Save error")); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await expect(aquaAddPoolHandler(event)).rejects.toThrow("Save error"); + }); + }); + + describe("Existing Pool Edge Cases", () => { + it("should handle existing pool with null date", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + const existingPool = { date: null }; + (AquaPair.get as jest.Mock).mockResolvedValue(existingPool); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalled(); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle existing pool with invalid date", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + const existingPool = { date: "invalid-date" }; + (AquaPair.get as jest.Mock).mockResolvedValue(existingPool); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalled(); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle existing pool with more recent date", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + const existingPool = { date: new Date("2023-01-02T00:00:00Z") }; + (AquaPair.get as jest.Mock).mockResolvedValue(existingPool); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).not.toHaveBeenCalled(); + expect(mockAquaPair.save).not.toHaveBeenCalled(); + }); + + it("should handle existing pool with same date", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + const existingPool = { date: new Date("2023-01-01T00:00:00Z") }; + (AquaPair.get as jest.Mock).mockResolvedValue(existingPool); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalled(); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle existing pool with older date", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + const existingPool = { date: new Date("2023-01-01T00:00:00Z") }; + (AquaPair.get as jest.Mock).mockResolvedValue(existingPool); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalled(); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + }); + + describe("Pool Type Edge Cases", () => { + it("should handle constant_product pool type", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + poolType: "constant_product", + tokenC: "", + reserveC: BigInt(0), + futureA: BigInt(0), + futureATime: BigInt(0), + initialA: BigInt(0), + initialATime: BigInt(0), + precisionMulA: BigInt(0), + precisionMulB: BigInt(0), + precisionMulC: BigInt(0), + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle stable pool type", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + tokenC: "tokenC", + poolType: "stable", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + poolType: "stable", + tokenC: "tokenC", + reserveC: BigInt(0), + futureA: BigInt(0), + futureATime: BigInt(0), + initialA: BigInt(0), + initialATime: BigInt(0), + precisionMulA: BigInt(0), + precisionMulB: BigInt(0), + precisionMulC: BigInt(0), + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle null pool type", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: null, + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + poolType: null, + tokenC: "", + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle undefined pool type", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: undefined, + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + poolType: undefined, + tokenC: "", + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle invalid pool type", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "invalid_type", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + poolType: "invalid_type", + tokenC: "", + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + }); + + describe("Token Edge Cases", () => { + it("should handle null tokens", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: null, + tokenB: null, + tokenC: null, + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + tokenA: null, + tokenB: null, + tokenC: "", + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle empty string tokens", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "", + tokenB: "", + tokenC: "", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + tokenA: "", + tokenB: "", + tokenC: "", + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle tokenC for stable pool", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + tokenC: "tokenC", + poolType: "stable", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + tokenA: "tokenA", + tokenB: "tokenB", + tokenC: "tokenC", + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle missing tokenC with empty string default", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + // tokenC is missing + poolType: "stable", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + tokenA: "tokenA", + tokenB: "tokenB", + tokenC: "", + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + }); + + describe("Index (idx) Edge Cases", () => { + it("should handle null idx", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: null, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + idx: null, + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle undefined idx", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: undefined, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + idx: undefined, + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle zero idx", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 0, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + idx: 0, + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle negative idx", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: -1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + idx: -1, + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle very large idx", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 999999999, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(event); + + expect(AquaPair.create).toHaveBeenCalledWith( + expect.objectContaining({ + idx: 999999999, + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + }); + + describe("JSON.parse/stringify Edge Cases", () => { + it("should handle event that cannot be stringified", async () => { + const circularEvent: any = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + }; + // Create circular reference + circularEvent.circular = circularEvent; + + await expect(aquaAddPoolHandler(circularEvent)).rejects.toThrow(); + }); + + it("should handle event with complex nested structure", async () => { + const complexEvent: SorobanEvent = { + contractId: "testContractId", + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12345 }, + nestedObject: { + deeply: { + nested: { + value: "test", + }, + }, + }, + } as unknown as SorobanEvent; + + (extractAddPoolAquaValues as jest.Mock).mockReturnValue({ + address: "testAddress", + idx: 1, + tokenA: "tokenA", + tokenB: "tokenB", + poolType: "constant_product", + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + (AquaPair.create as jest.Mock).mockReturnValue(mockAquaPair); + + await aquaAddPoolHandler(complexEvent); + + expect(extractAddPoolAquaValues).toHaveBeenCalledWith( + expect.objectContaining({ + contractId: "testContractId", + nestedObject: { + deeply: { + nested: { + value: "test", + }, + }, + }, + }) + ); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/src/aqua/__tests__/aquaEventHandler.edgeCases.test.ts b/src/aqua/__tests__/aquaEventHandler.edgeCases.test.ts new file mode 100644 index 0000000..3973975 --- /dev/null +++ b/src/aqua/__tests__/aquaEventHandler.edgeCases.test.ts @@ -0,0 +1,624 @@ +import { aquaEventHandler } from "../index"; +import { SorobanEvent } from "@subql/types-stellar"; +import { AquaPair } from "../../types"; +import { extractAquaValues } from "../helpers/events"; + +jest.mock("../helpers/events", () => ({ + extractAquaValues: jest.fn(), +})); + +jest.mock("../../types", () => ({ + AquaPair: { + get: jest.fn(), + }, +})); + +describe("aquaEventHandler - Edge Cases", () => { + let mockAquaPair: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockAquaPair = { + reserveA: BigInt(1000), + reserveB: BigInt(2000), + reserveC: BigInt(3000), + fee: BigInt(30), + date: new Date("2023-01-01"), + ledger: 12345, + poolType: "constant_product", + futureA: BigInt(100), + futureATime: BigInt(1672531200), + initialA: BigInt(50), + initialATime: BigInt(1672531200), + precisionMulA: BigInt(1000000), + precisionMulB: BigInt(1000000), + precisionMulC: BigInt(1000000), + tokenC: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + save: jest.fn(), + }; + }); + + describe("Event Structure Edge Cases", () => { + it("should handle missing event.topic[0]", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + expect(extractAquaValues).toHaveBeenCalledWith(event); + expect(AquaPair.get).toHaveBeenCalledWith("testAddress"); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle null event.topic[0].value()", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => null }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + expect(extractAquaValues).toHaveBeenCalledWith(event); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle event.topic[0].value() throwing error", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => { throw new Error("Topic value error"); } }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await expect(aquaEventHandler(event)).rejects.toThrow("Topic value error"); + }); + }); + + describe("extractAquaValues Edge Cases", () => { + it("should handle extractAquaValues throwing error", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockRejectedValue(new Error("Extract values error")); + + await expect(aquaEventHandler(event)).rejects.toThrow("Extract values error"); + }); + + it("should handle extractAquaValues returning null address", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: null, + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + await aquaEventHandler(event); + + expect(extractAquaValues).toHaveBeenCalledWith(event); + expect(AquaPair.get).not.toHaveBeenCalled(); + }); + + it("should handle extractAquaValues returning empty string address", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + await aquaEventHandler(event); + + expect(extractAquaValues).toHaveBeenCalledWith(event); + expect(AquaPair.get).not.toHaveBeenCalled(); + }); + + it("should handle extractAquaValues returning undefined address", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: undefined, + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + await aquaEventHandler(event); + + expect(extractAquaValues).toHaveBeenCalledWith(event); + expect(AquaPair.get).not.toHaveBeenCalled(); + }); + }); + + describe("Database Operation Edge Cases", () => { + it("should handle AquaPair.get throwing error", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + (AquaPair.get as jest.Mock).mockRejectedValue(new Error("Database error")); + + await expect(aquaEventHandler(event)).rejects.toThrow("Database error"); + }); + + it("should handle pool not found (null result)", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(null); + + await aquaEventHandler(event); + + expect(extractAquaValues).toHaveBeenCalledWith(event); + expect(AquaPair.get).toHaveBeenCalledWith("testAddress"); + }); + + it("should handle pool not found (undefined result)", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(undefined); + + await aquaEventHandler(event); + + expect(extractAquaValues).toHaveBeenCalledWith(event); + expect(AquaPair.get).toHaveBeenCalledWith("testAddress"); + }); + + it("should handle save operation throwing error", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + mockAquaPair.save.mockRejectedValue(new Error("Save error")); + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await expect(aquaEventHandler(event)).rejects.toThrow("Save error"); + }); + }); + + describe("Date Comparison Edge Cases", () => { + it("should handle invalid ledgerClosedAt date", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "invalid-date", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle null ledgerClosedAt", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: null, + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + // When currentDate is Invalid Date, comparison with any date returns false + // so it will proceed to update and save + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + expect(mockAquaPair.save).not.toHaveBeenCalled(); + }); + + it("should handle existing pool with invalid date", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + mockAquaPair.date = "invalid-date"; + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle existing pool with null date", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + mockAquaPair.date = null; + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should skip update when existing pool is more recent", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-01T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + mockAquaPair.date = new Date("2023-01-02T00:00:00Z"); + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + expect(mockAquaPair.save).not.toHaveBeenCalled(); + }); + }); + + describe("Data Update Edge Cases", () => { + it("should handle undefined reserves in eventData", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: undefined, + reserveB: undefined, + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + expect(mockAquaPair.reserveA).toBeUndefined(); + expect(mockAquaPair.reserveB).toBeUndefined(); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle null reserves in eventData", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: null, + reserveB: null, + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + expect(mockAquaPair.reserveA).toBeNull(); + expect(mockAquaPair.reserveB).toBeNull(); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle missing ledger sequence", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: {}, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + expect(mockAquaPair.ledger).toBeUndefined(); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + }); + + describe("Stable Pool Edge Cases", () => { + it("should handle stable pool with all fields undefined", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + reserveC: undefined, + futureA: undefined, + futureATime: undefined, + initialA: undefined, + initialATime: undefined, + precisionMulA: undefined, + precisionMulB: undefined, + precisionMulC: undefined, + tokenC: undefined, + }); + + mockAquaPair.poolType = "stable"; + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle stable pool with mixed defined/undefined fields", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + reserveC: BigInt(3500), + futureA: undefined, + futureATime: BigInt(1672531300), + initialA: undefined, + initialATime: BigInt(1672531300), + precisionMulA: BigInt(2000000), + precisionMulB: undefined, + precisionMulC: BigInt(3000000), + tokenC: "NEWTOKEN", + }); + + mockAquaPair.poolType = "stable"; + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + expect(mockAquaPair.reserveC).toBe(BigInt(3500)); + expect(mockAquaPair.futureATime).toBe(BigInt(1672531300)); + expect(mockAquaPair.initialATime).toBe(BigInt(1672531300)); + expect(mockAquaPair.precisionMulA).toBe(BigInt(2000000)); + expect(mockAquaPair.precisionMulC).toBe(BigInt(3000000)); + expect(mockAquaPair.tokenC).toBe("NEWTOKEN"); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle non-stable pool with stable fields present", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + reserveC: BigInt(3500), + futureA: BigInt(200), + futureATime: BigInt(1672531300), + initialA: BigInt(150), + initialATime: BigInt(1672531300), + precisionMulA: BigInt(2000000), + precisionMulB: BigInt(2000000), + precisionMulC: BigInt(3000000), + tokenC: "NEWTOKEN", + }); + + mockAquaPair.poolType = "constant_product"; + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + // Stable pool fields should not be updated for non-stable pools + expect(mockAquaPair.reserveC).toBe(BigInt(3000)); + expect(mockAquaPair.futureA).toBe(BigInt(100)); + expect(mockAquaPair.tokenC).toBe("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + }); + + describe("Fee Handling Edge Cases", () => { + it("should handle undefined fee", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + fee: undefined, + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + expect(mockAquaPair.fee).toBe(BigInt(30)); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle null fee", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + fee: null, + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + expect(mockAquaPair.fee).toBe(null); // Fee is set to null, not preserved + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + + it("should handle zero fee", async () => { + const event: SorobanEvent = { + contractId: "testContractId", + topic: [{ value: () => "SWAP" }], + ledgerClosedAt: "2023-01-02T00:00:00Z", + ledger: { sequence: 12346 }, + } as unknown as SorobanEvent; + + (extractAquaValues as jest.Mock).mockResolvedValue({ + address: "testAddress", + reserveA: BigInt(1500), + reserveB: BigInt(2500), + fee: BigInt(0), + }); + + (AquaPair.get as jest.Mock).mockResolvedValue(mockAquaPair); + + await aquaEventHandler(event); + + expect(mockAquaPair.fee).toBe(BigInt(0)); + expect(mockAquaPair.save).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/src/aqua/__tests__/extractAquaValues.edgeCases.test.ts b/src/aqua/__tests__/extractAquaValues.edgeCases.test.ts new file mode 100644 index 0000000..9fb30fe --- /dev/null +++ b/src/aqua/__tests__/extractAquaValues.edgeCases.test.ts @@ -0,0 +1,634 @@ +import { extractAquaValues } from "../helpers/events"; +import { getTransactionData } from "../helpers/utils"; + +jest.mock("../helpers/utils", () => ({ + getTransactionData: jest.fn(), +})); + +describe("extractAquaValues - Edge Cases", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Event Structure Edge Cases", () => { + it("should handle null event", async () => { + const result = await extractAquaValues(null); + + expect(result.address).toBe(""); + expect(result.tokenA).toBe(""); + expect(result.tokenB).toBe(""); + expect(result.reserveA).toBeUndefined(); + expect(result.reserveB).toBeUndefined(); + }); + + it("should handle undefined event", async () => { + const result = await extractAquaValues(undefined); + + expect(result.address).toBe(""); + expect(result.tokenA).toBe(""); + expect(result.tokenB).toBe(""); + expect(result.reserveA).toBeUndefined(); + expect(result.reserveB).toBeUndefined(); + }); + + it("should handle event without txHash", async () => { + const event = { + contractId: { toString: () => "testContractId" }, + }; + + const result = await extractAquaValues(event); + + expect(result.address).toBe(""); + expect(result.tokenA).toBe(""); + expect(result.tokenB).toBe(""); + }); + + it("should handle event with null txHash", async () => { + const event = { + txHash: null, + contractId: { toString: () => "testContractId" }, + }; + + const result = await extractAquaValues(event); + + expect(result.address).toBe(""); + }); + + it("should handle event with txHash that throws on toString", async () => { + const event = { + txHash: { toString: () => { throw new Error("txHash toString error"); } }, + contractId: { toString: () => "testContractId" }, + }; + + const result = await extractAquaValues(event); + + expect(result.address).toBe(""); + expect(result.tokenA).toBe(""); + expect(result.tokenB).toBe(""); + }); + + it("should handle event without contractId", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + }; + + const result = await extractAquaValues(event); + + expect(result.address).toBe(""); + }); + + it("should handle event with null contractId", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: null, + }; + + const result = await extractAquaValues(event); + + expect(result.address).toBe(""); + }); + + it("should handle event with contractId that throws on toString", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => { throw new Error("contractId toString error"); } }, + }; + + const result = await extractAquaValues(event); + + expect(result.address).toBe(""); + expect(result.tokenA).toBe(""); + expect(result.tokenB).toBe(""); + }); + }); + + describe("getTransactionData Edge Cases", () => { + it("should handle getTransactionData throwing error", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockImplementation(() => { + throw new Error("Transaction data error"); + }); + + const result = await extractAquaValues(event); + + expect(result.address).toBe("testContractId"); + expect(result.tokenA).toBe(""); + expect(result.tokenB).toBe(""); + }); + + it("should handle getTransactionData returning null", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue(null); + + const result = await extractAquaValues(event); + + expect(result.address).toBe("testContractId"); + expect(result.tokenA).toBe(""); + expect(result.tokenB).toBe(""); + expect(result.reserveA).toBeUndefined(); + expect(result.reserveB).toBeUndefined(); + }); + + it("should handle getTransactionData returning undefined", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue(undefined); + + const result = await extractAquaValues(event); + + expect(result.address).toBe("testContractId"); + expect(result.tokenA).toBe(""); + expect(result.tokenB).toBe(""); + expect(result.reserveA).toBeUndefined(); + expect(result.reserveB).toBeUndefined(); + }); + + it("should handle getTransactionData returning empty object", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({}); + + const result = await extractAquaValues(event); + + expect(result.address).toBe("testContractId"); + expect(result.tokenA).toBe(""); + expect(result.tokenB).toBe(""); + expect(result.reserveA).toBe(BigInt(0)); + expect(result.reserveB).toBe(BigInt(0)); + }); + }); + + describe("Token Data Edge Cases", () => { + it("should handle missing tokenA", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenB: "tokenB_address", + reserveA: BigInt(1000), + reserveB: BigInt(2000), + }); + + const result = await extractAquaValues(event); + + expect(result.tokenA).toBe(""); + expect(result.tokenB).toBe("tokenB_address"); + }); + + it("should handle missing tokenB", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + reserveA: BigInt(1000), + reserveB: BigInt(2000), + }); + + const result = await extractAquaValues(event); + + expect(result.tokenA).toBe("tokenA_address"); + expect(result.tokenB).toBe(""); + }); + + it("should handle null token values", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: null, + tokenB: null, + reserveA: BigInt(1000), + reserveB: BigInt(2000), + }); + + const result = await extractAquaValues(event); + + expect(result.tokenA).toBe(""); + expect(result.tokenB).toBe(""); + }); + + it("should handle tokenC for stable pools", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + tokenC: "tokenC_address", + reserveA: BigInt(1000), + reserveB: BigInt(2000), + reserveC: BigInt(3000), + }); + + const result = await extractAquaValues(event); + + expect(result.tokenA).toBe("tokenA_address"); + expect(result.tokenB).toBe("tokenB_address"); + expect(result.tokenC).toBe("tokenC_address"); + expect(result.reserveC).toBe(BigInt(3000)); + }); + }); + + describe("Reserve Data Edge Cases", () => { + it("should handle missing reserveA", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + reserveB: BigInt(2000), + }); + + const result = await extractAquaValues(event); + + expect(result.reserveA).toBeUndefined(); + expect(result.reserveB).toBe(BigInt(2000)); + }); + + it("should handle missing reserveB", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + reserveA: BigInt(1000), + }); + + const result = await extractAquaValues(event); + + expect(result.reserveA).toBe(BigInt(1000)); + expect(result.reserveB).toBeUndefined(); + }); + + it("should handle null reserve values", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + reserveA: null, + reserveB: null, + }); + + const result = await extractAquaValues(event); + + expect(result.reserveA).toBeNull(); + expect(result.reserveB).toBeNull(); + }); + + it("should handle zero reserve values", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + reserveA: BigInt(0), + reserveB: BigInt(0), + }); + + const result = await extractAquaValues(event); + + expect(result.reserveA).toBeUndefined(); + expect(result.reserveB).toBeUndefined(); + }); + + it("should use default values when both reserves are undefined", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + }); + + const result = await extractAquaValues(event); + + expect(result.reserveA).toBeUndefined(); + expect(result.reserveB).toBeUndefined(); + }); + + it("should not use default values when only one reserve is undefined", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + reserveA: BigInt(1000), + }); + + const result = await extractAquaValues(event); + + expect(result.reserveA).toBe(BigInt(1000)); + expect(result.reserveB).toBeUndefined(); + }); + }); + + describe("Fee Data Edge Cases", () => { + it("should handle missing fee", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + reserveA: BigInt(1000), + reserveB: BigInt(2000), + }); + + const result = await extractAquaValues(event); + + expect(result.fee).toBeUndefined(); + }); + + it("should handle null fee", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + reserveA: BigInt(1000), + reserveB: BigInt(2000), + fee: null, + }); + + const result = await extractAquaValues(event); + + expect(result.fee).toBeNull(); + }); + + it("should handle zero fee", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + reserveA: BigInt(1000), + reserveB: BigInt(2000), + fee: BigInt(0), + }); + + const result = await extractAquaValues(event); + + expect(result.fee).toBe(BigInt(0)); + }); + }); + + describe("Stable Pool Specific Edge Cases", () => { + it("should handle all stable pool fields undefined", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + reserveA: BigInt(1000), + reserveB: BigInt(2000), + }); + + const result = await extractAquaValues(event); + + expect(result.tokenC).toBeUndefined(); + expect(result.reserveC).toBeUndefined(); + expect(result.futureA).toBeUndefined(); + expect(result.futureATime).toBeUndefined(); + expect(result.initialA).toBeUndefined(); + expect(result.initialATime).toBeUndefined(); + expect(result.precisionMulA).toBeUndefined(); + expect(result.precisionMulB).toBeUndefined(); + expect(result.precisionMulC).toBeUndefined(); + }); + + it("should handle all stable pool fields null", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + reserveA: BigInt(1000), + reserveB: BigInt(2000), + tokenC: null, + reserveC: null, + futureA: null, + futureATime: null, + initialA: null, + initialATime: null, + precisionMulA: null, + precisionMulB: null, + precisionMulC: null, + }); + + const result = await extractAquaValues(event); + + expect(result.tokenC).toBeUndefined(); + expect(result.reserveC).toBeUndefined(); + expect(result.futureA).toBeUndefined(); + expect(result.futureATime).toBeUndefined(); + expect(result.initialA).toBeUndefined(); + expect(result.initialATime).toBeUndefined(); + expect(result.precisionMulA).toBeUndefined(); + expect(result.precisionMulB).toBeUndefined(); + expect(result.precisionMulC).toBeUndefined(); + }); + + it("should handle mixed stable pool field values", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + reserveA: BigInt(1000), + reserveB: BigInt(2000), + tokenC: "tokenC_address", + reserveC: BigInt(3000), + futureA: BigInt(100), + futureATime: undefined, + initialA: null, + initialATime: BigInt(1672531200), + precisionMulA: BigInt(1000000), + precisionMulB: BigInt(2000000), + precisionMulC: undefined, + }); + + const result = await extractAquaValues(event); + + expect(result.tokenC).toBe("tokenC_address"); + expect(result.reserveC).toBe(BigInt(3000)); + expect(result.futureA).toBe(BigInt(100)); + expect(result.futureATime).toBeUndefined(); + expect(result.initialA).toBeNull(); + expect(result.initialATime).toBe(BigInt(1672531200)); + expect(result.precisionMulA).toBe(BigInt(1000000)); + expect(result.precisionMulB).toBe(BigInt(2000000)); + expect(result.precisionMulC).toBeUndefined(); + }); + + it("should handle zero values for stable pool fields", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + reserveA: BigInt(1000), + reserveB: BigInt(2000), + tokenC: "", + reserveC: BigInt(0), + futureA: BigInt(0), + futureATime: BigInt(0), + initialA: BigInt(0), + initialATime: BigInt(0), + precisionMulA: BigInt(0), + precisionMulB: BigInt(0), + precisionMulC: BigInt(0), + }); + + const result = await extractAquaValues(event); + + expect(result.tokenC).toBe(""); + expect(result.reserveC).toBe(BigInt(0)); + expect(result.futureA).toBe(BigInt(0)); + expect(result.futureATime).toBe(BigInt(0)); + expect(result.initialA).toBe(BigInt(0)); + expect(result.initialATime).toBe(BigInt(0)); + expect(result.precisionMulA).toBe(BigInt(0)); + expect(result.precisionMulB).toBe(BigInt(0)); + expect(result.precisionMulC).toBe(BigInt(0)); + }); + }); + + describe("Error Handling and Recovery", () => { + it("should handle error and return partial result", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + reserveA: BigInt(1000), + reserveB: BigInt(2000), + }); + + const result = await extractAquaValues(event); + + expect(result.address).toBe("testContractId"); + expect(result.tokenA).toBe("tokenA_address"); + expect(result.tokenB).toBe("tokenB_address"); + expect(result.reserveA).toBe(BigInt(1000)); + expect(result.reserveB).toBe(BigInt(2000)); + }); + + it("should handle complete failure and return empty result", async () => { + const event = { + txHash: { toString: () => { throw new Error("Critical error"); } }, + contractId: { toString: () => "testContractId" }, + }; + + const result = await extractAquaValues(event); + + expect(result.address).toBe(""); + expect(result.tokenA).toBe(""); + expect(result.tokenB).toBe(""); + expect(result.reserveA).toBeUndefined(); + expect(result.reserveB).toBeUndefined(); + }); + }); + + describe("Large Number Edge Cases", () => { + it("should handle very large BigInt values", async () => { + const event = { + txHash: { toString: () => "testTxHash" }, + contractId: { toString: () => "testContractId" }, + }; + + const largeValue = BigInt("99999999999999999999999999999999999999"); + + (getTransactionData as jest.Mock).mockReturnValue({ + tokenA: "tokenA_address", + tokenB: "tokenB_address", + reserveA: largeValue, + reserveB: largeValue, + fee: largeValue, + futureA: largeValue, + futureATime: largeValue, + initialA: largeValue, + initialATime: largeValue, + precisionMulA: largeValue, + precisionMulB: largeValue, + precisionMulC: largeValue, + }); + + const result = await extractAquaValues(event); + + expect(result.reserveA).toBe(largeValue); + expect(result.reserveB).toBe(largeValue); + expect(result.fee).toBe(largeValue); + expect(result.futureA).toBe(largeValue); + expect(result.futureATime).toBe(largeValue); + expect(result.initialA).toBe(largeValue); + expect(result.initialATime).toBe(largeValue); + expect(result.precisionMulA).toBe(largeValue); + expect(result.precisionMulB).toBe(largeValue); + expect(result.precisionMulC).toBe(largeValue); + }); + }); +}); \ No newline at end of file diff --git a/src/aqua/__tests__/handleEventAqua.edgeCases.test.ts b/src/aqua/__tests__/handleEventAqua.edgeCases.test.ts new file mode 100644 index 0000000..3746d59 --- /dev/null +++ b/src/aqua/__tests__/handleEventAqua.edgeCases.test.ts @@ -0,0 +1,373 @@ +import { handleEventAqua } from "../../mappings/mappingHandlers"; +import { SorobanEvent } from "@subql/types-stellar"; +import { initializeAquaDb } from "../../aqua/initialize"; +import { aquaEventHandler } from "../../aqua"; +import { getFactoryTopic } from "../../aqua/helpers/events"; +import { getAquaFactory, NETWORK } from "../../constants"; + +jest.mock("../initialize", () => ({ + initializeAquaDb: jest.fn(), +})); + +jest.mock("../index", () => ({ + aquaEventHandler: jest.fn(), +})); + +jest.mock("../helpers/events", () => ({ + getFactoryTopic: jest.fn(), +})); + +jest.mock("../../constants", () => ({ + getAquaFactory: jest.fn(), + NETWORK: { + MAINNET: "mainnet", + TESTNET: "testnet", + }, +})); + +describe("handleEventAqua - Edge Cases", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Event Structure Edge Cases", () => { + it("should handle missing event.topic[0]", async () => { + const eventWithoutTopic: SorobanEvent = { + contractId: "testContractId", + topic: [], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("mainnet"); + + await handleEventAqua(eventWithoutTopic); + + expect(getFactoryTopic).toHaveBeenCalledWith(eventWithoutTopic); + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(eventWithoutTopic); + }); + + it("should handle null/undefined event topic value", async () => { + const eventWithNullTopic: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => null }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("mainnet"); + + await handleEventAqua(eventWithNullTopic); + + expect(getFactoryTopic).toHaveBeenCalledWith(eventWithNullTopic); + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(eventWithNullTopic); + }); + + it("should handle event topic value throwing error", async () => { + const eventWithErrorTopic: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => { throw new Error("Topic parsing error"); } }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("mainnet"); + + await expect(handleEventAqua(eventWithErrorTopic)).rejects.toThrow("Topic parsing error"); + }); + + it("should handle undefined contractId", async () => { + const eventWithoutContractId: SorobanEvent = { + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("mainnet"); + (getAquaFactory as jest.Mock).mockImplementation((network) => network); + + await expect(handleEventAqua(eventWithoutContractId)).rejects.toThrow(); + }); + }); + + describe("Factory Address Edge Cases", () => { + it("should handle getFactoryTopic throwing error", async () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockRejectedValue(new Error("Factory topic error")); + + await expect(handleEventAqua(mockEvent)).rejects.toThrow("Factory topic error"); + }); + + it("should handle null factory address", async () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue(null); + + await handleEventAqua(mockEvent); + + expect(getFactoryTopic).toHaveBeenCalledWith(mockEvent); + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(mockEvent); + }); + + it("should handle empty string factory address", async () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue(""); + + await handleEventAqua(mockEvent); + + expect(getFactoryTopic).toHaveBeenCalledWith(mockEvent); + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(mockEvent); + }); + }); + + describe("Network and Factory Validation Edge Cases", () => { + it("should handle getAquaFactory throwing error", async () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("mainnet"); + (getAquaFactory as jest.Mock).mockImplementation(() => { + throw new Error("Factory config error"); + }); + + await expect(handleEventAqua(mockEvent)).rejects.toThrow("Factory config error"); + }); + + it("should handle undefined NETWORK constants", async () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("mainnet"); + (getAquaFactory as jest.Mock).mockImplementation(() => undefined); + + await handleEventAqua(mockEvent); + + expect(getFactoryTopic).toHaveBeenCalledWith(mockEvent); + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(mockEvent); + }); + }); + + describe("Event Type Matching Edge Cases", () => { + it("should handle case insensitive TRADE event", async () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => "trade" }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("mainnet"); + (getAquaFactory as jest.Mock).mockImplementation((network) => network); + + await handleEventAqua(mockEvent); + + expect(getFactoryTopic).toHaveBeenCalledWith(mockEvent); + expect(initializeAquaDb).toHaveBeenCalledWith("testContractId"); + expect(aquaEventHandler).toHaveBeenCalledWith(mockEvent); + }); + + it("should handle mixed case TRADE event", async () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => "TrAdE" }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("mainnet"); + (getAquaFactory as jest.Mock).mockImplementation((network) => network); + + await handleEventAqua(mockEvent); + + expect(getFactoryTopic).toHaveBeenCalledWith(mockEvent); + expect(initializeAquaDb).toHaveBeenCalledWith("testContractId"); + expect(aquaEventHandler).toHaveBeenCalledWith(mockEvent); + }); + + it("should handle numeric event type", async () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => 12345 }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("mainnet"); + (getAquaFactory as jest.Mock).mockImplementation((network) => network); + + await handleEventAqua(mockEvent); + + expect(getFactoryTopic).toHaveBeenCalledWith(mockEvent); + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(mockEvent); + }); + + it("should handle object event type", async () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => ({ type: "TRADE" }) }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("mainnet"); + (getAquaFactory as jest.Mock).mockImplementation((network) => network); + + await handleEventAqua(mockEvent); + + expect(getFactoryTopic).toHaveBeenCalledWith(mockEvent); + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(mockEvent); + }); + }); + + describe("Database Initialization Edge Cases", () => { + it("should handle initializeAquaDb throwing error", async () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("mainnet"); + (getAquaFactory as jest.Mock).mockImplementation((network) => network); + (initializeAquaDb as jest.Mock).mockRejectedValue(new Error("DB initialization error")); + + await expect(handleEventAqua(mockEvent)).rejects.toThrow("DB initialization error"); + }); + + it("should handle aquaEventHandler throwing error", async () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("mainnet"); + (getAquaFactory as jest.Mock).mockImplementation((network) => network); + (initializeAquaDb as jest.Mock).mockResolvedValue(undefined); + (aquaEventHandler as jest.Mock).mockRejectedValue(new Error("Event handler error")); + + await expect(handleEventAqua(mockEvent)).rejects.toThrow("Event handler error"); + }); + }); + + describe("Complex Factory Matching Edge Cases", () => { + it("should handle both mainnet and testnet factory matches", async () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("testnet"); + (getAquaFactory as jest.Mock).mockImplementation((network) => network); + (initializeAquaDb as jest.Mock).mockResolvedValue(undefined); + (aquaEventHandler as jest.Mock).mockResolvedValue(undefined); + + await handleEventAqua(mockEvent); + + expect(getFactoryTopic).toHaveBeenCalledWith(mockEvent); + expect(initializeAquaDb).toHaveBeenCalledWith("testContractId"); + expect(aquaEventHandler).toHaveBeenCalledWith(mockEvent); + }); + + it("should handle factory address with extra whitespace", async () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue(" mainnet "); + (getAquaFactory as jest.Mock).mockImplementation((network) => network); + (aquaEventHandler as jest.Mock).mockResolvedValue(undefined); + + await handleEventAqua(mockEvent); + + expect(getFactoryTopic).toHaveBeenCalledWith(mockEvent); + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(mockEvent); + }); + }); + + describe("Multiple Network Scenarios", () => { + it("should handle when factory matches mainnet but not testnet", async () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("mainnet"); + (getAquaFactory as jest.Mock).mockImplementation((network) => { + if (network === "mainnet") return "mainnet"; + return "different_testnet"; + }); + (initializeAquaDb as jest.Mock).mockResolvedValue(undefined); + (aquaEventHandler as jest.Mock).mockResolvedValue(undefined); + + await handleEventAqua(mockEvent); + + expect(getFactoryTopic).toHaveBeenCalledWith(mockEvent); + expect(initializeAquaDb).toHaveBeenCalledWith("testContractId"); + expect(aquaEventHandler).toHaveBeenCalledWith(mockEvent); + }); + + it("should handle when factory matches testnet but not mainnet", async () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("testnet"); + (getAquaFactory as jest.Mock).mockImplementation((network) => { + if (network === "testnet") return "testnet"; + return "different_mainnet"; + }); + (initializeAquaDb as jest.Mock).mockResolvedValue(undefined); + (aquaEventHandler as jest.Mock).mockResolvedValue(undefined); + + await handleEventAqua(mockEvent); + + expect(getFactoryTopic).toHaveBeenCalledWith(mockEvent); + expect(initializeAquaDb).toHaveBeenCalledWith("testContractId"); + expect(aquaEventHandler).toHaveBeenCalledWith(mockEvent); + }); + }); +}); \ No newline at end of file diff --git a/src/aqua/__tests__/handleEventAqua.security.test.ts b/src/aqua/__tests__/handleEventAqua.security.test.ts new file mode 100644 index 0000000..ab5a631 --- /dev/null +++ b/src/aqua/__tests__/handleEventAqua.security.test.ts @@ -0,0 +1,352 @@ +import { handleEventAqua } from "../../mappings/mappingHandlers"; +import { SorobanEvent } from "@subql/types-stellar"; +import { initializeAquaDb } from "../../aqua/initialize"; +import { aquaEventHandler } from "../../aqua"; +import { getFactoryTopic } from "../../aqua/helpers/events"; +import { getAquaFactory, NETWORK } from "../../constants"; + +jest.mock("../initialize", () => ({ + initializeAquaDb: jest.fn(), +})); + +jest.mock("../index", () => ({ + aquaEventHandler: jest.fn(), +})); + +jest.mock("../helpers/events", () => ({ + getFactoryTopic: jest.fn(), +})); + +jest.mock("../../constants", () => ({ + getAquaFactory: jest.fn(), + NETWORK: { + MAINNET: "mainnet", + TESTNET: "testnet", + }, +})); + +describe("handleEventAqua - Security Tests", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Factory Address Validation Bypass Attempts", () => { + it("should not initialize DB when factory address is hardcoded but differs from legitimate factory", async () => { + // Scenario: Attacker tries to bypass validation by providing a hardcoded factory address + // that matches the expected format but is not the legitimate factory + const maliciousEvent: SorobanEvent = { + contractId: "MALICIOUS_CONTRACT_ID_123456789", + topic: [ + { value: () => "TRADE" }, // Correct event type to trigger validation + ], + } as unknown as SorobanEvent; + + // Legitimate factories + const legitimateMainnetFactory = "LEGITIMATE_MAINNET_FACTORY_ADDRESS"; + const legitimateTestnetFactory = "LEGITIMATE_TESTNET_FACTORY_ADDRESS"; + + // Attacker provides a different factory address that looks legitimate + const attackerFakeFactory = "FAKE_MAINNET_FACTORY_ADDRESS_ATTACK"; + + // Setup mocks for legitimate factories + (getAquaFactory as jest.Mock).mockImplementation((network) => { + if (network === "mainnet") return legitimateMainnetFactory; + if (network === "testnet") return legitimateTestnetFactory; + return null; + }); + + // Mock the attacker's factory address (extracted from event) + (getFactoryTopic as jest.Mock).mockResolvedValue(attackerFakeFactory); + + // Mock aquaEventHandler to succeed (so we can verify DB init wasn't called) + (aquaEventHandler as jest.Mock).mockResolvedValue(undefined); + + await handleEventAqua(maliciousEvent); + + // Verify security: initializeAquaDb should NOT have been called + expect(initializeAquaDb).not.toHaveBeenCalled(); + + // Verify the event still gets processed (but without DB initialization) + expect(aquaEventHandler).toHaveBeenCalledWith(maliciousEvent); + + // Verify the factory validation was actually performed + expect(getFactoryTopic).toHaveBeenCalledWith(maliciousEvent); + expect(getAquaFactory).toHaveBeenCalledWith(NETWORK.MAINNET); + expect(getAquaFactory).toHaveBeenCalledWith(NETWORK.TESTNET); + }); + + it("should not initialize DB when factory address is close but not exact match", async () => { + // Test case-sensitivity and exact matching + const maliciousEvent: SorobanEvent = { + contractId: "MALICIOUS_CONTRACT_ID_CASE", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + const legitimateFactory = "LEGITIMATE_FACTORY_ADDRESS"; + + // Attacker tries case variations + const attackerFactoryWithCase = "legitimate_factory_address"; // lowercase + + (getAquaFactory as jest.Mock).mockImplementation((network) => { + if (network === "mainnet") return legitimateFactory; + if (network === "testnet") return "TESTNET_FACTORY"; + return null; + }); + + (getFactoryTopic as jest.Mock).mockResolvedValue(attackerFactoryWithCase); + (aquaEventHandler as jest.Mock).mockResolvedValue(undefined); + + await handleEventAqua(maliciousEvent); + + // Security check: case-sensitive comparison should prevent bypass + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(maliciousEvent); + }); + + it("should not initialize DB when factory address contains extra characters", async () => { + const maliciousEvent: SorobanEvent = { + contractId: "MALICIOUS_CONTRACT_EXTRA_CHARS", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + const legitimateFactory = "LEGITIMATE_FACTORY"; + + // Attacker tries to add extra characters + const attackerFactoryWithExtra = "LEGITIMATE_FACTORY_EXTRA"; + + (getAquaFactory as jest.Mock).mockImplementation((network) => { + if (network === "mainnet") return legitimateFactory; + return "OTHER_FACTORY"; + }); + + (getFactoryTopic as jest.Mock).mockResolvedValue(attackerFactoryWithExtra); + (aquaEventHandler as jest.Mock).mockResolvedValue(undefined); + + await handleEventAqua(maliciousEvent); + + // Security check: exact matching should prevent bypass + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(maliciousEvent); + }); + + it("should not initialize DB when using substring of legitimate factory", async () => { + const maliciousEvent: SorobanEvent = { + contractId: "MALICIOUS_CONTRACT_SUBSTRING", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + const legitimateFactory = "LEGITIMATE_FACTORY_FULL_ADDRESS"; + + // Attacker tries to use substring + const attackerFactorySubstring = "LEGITIMATE_FACTORY"; + + (getAquaFactory as jest.Mock).mockImplementation((network) => { + if (network === "mainnet") return legitimateFactory; + return "OTHER_FACTORY"; + }); + + (getFactoryTopic as jest.Mock).mockResolvedValue(attackerFactorySubstring); + (aquaEventHandler as jest.Mock).mockResolvedValue(undefined); + + await handleEventAqua(maliciousEvent); + + // Security check: partial matching should not work + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(maliciousEvent); + }); + + it("should not initialize DB when factory address is null but event type is TRADE", async () => { + const maliciousEvent: SorobanEvent = { + contractId: "MALICIOUS_CONTRACT_NULL_FACTORY", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + (getAquaFactory as jest.Mock).mockImplementation((network) => { + if (network === "mainnet") return "MAINNET_FACTORY"; + if (network === "testnet") return "TESTNET_FACTORY"; + return null; + }); + + // Attacker's event has no factory address + (getFactoryTopic as jest.Mock).mockResolvedValue(null); + (aquaEventHandler as jest.Mock).mockResolvedValue(undefined); + + await handleEventAqua(maliciousEvent); + + // Security check: null factory should not bypass validation + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(maliciousEvent); + }); + + it("should not initialize DB when factory address is empty string", async () => { + const maliciousEvent: SorobanEvent = { + contractId: "MALICIOUS_CONTRACT_EMPTY_FACTORY", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + (getAquaFactory as jest.Mock).mockImplementation((network) => { + if (network === "mainnet") return "MAINNET_FACTORY"; + if (network === "testnet") return "TESTNET_FACTORY"; + return null; + }); + + // Attacker provides empty factory address + (getFactoryTopic as jest.Mock).mockResolvedValue(""); + (aquaEventHandler as jest.Mock).mockResolvedValue(undefined); + + await handleEventAqua(maliciousEvent); + + // Security check: empty string should not bypass validation + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(maliciousEvent); + }); + + it("should not initialize DB when using mixed legitimate factory addresses", async () => { + // Attacker tries to use testnet factory when mainnet is expected or vice versa + const maliciousEvent: SorobanEvent = { + contractId: "MALICIOUS_CONTRACT_MIXED_NETWORKS", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + const mainnetFactory = "MAINNET_FACTORY_ADDRESS"; + const testnetFactory = "TESTNET_FACTORY_ADDRESS"; + + (getAquaFactory as jest.Mock).mockImplementation((network) => { + if (network === "mainnet") return mainnetFactory; + if (network === "testnet") return testnetFactory; + return null; + }); + + // This should succeed - using legitimate testnet factory + (getFactoryTopic as jest.Mock).mockResolvedValue(testnetFactory); + (aquaEventHandler as jest.Mock).mockResolvedValue(undefined); + + await handleEventAqua(maliciousEvent); + + // This should actually succeed since testnet factory is legitimate + expect(initializeAquaDb).toHaveBeenCalledWith("MALICIOUS_CONTRACT_MIXED_NETWORKS"); + expect(aquaEventHandler).toHaveBeenCalledWith(maliciousEvent); + }); + }); + + describe("Event Type Manipulation Attempts", () => { + it("should not initialize DB when event type is not TRADE even with legitimate factory", async () => { + const maliciousEvent: SorobanEvent = { + contractId: "MALICIOUS_CONTRACT_WRONG_TYPE", + topic: [ + { value: () => "SWAP" }, // Wrong event type + ], + } as unknown as SorobanEvent; + + const legitimateFactory = "LEGITIMATE_MAINNET_FACTORY"; + + (getAquaFactory as jest.Mock).mockImplementation((network) => { + if (network === "mainnet") return legitimateFactory; + return "OTHER_FACTORY"; + }); + + // Even with legitimate factory address + (getFactoryTopic as jest.Mock).mockResolvedValue(legitimateFactory); + (aquaEventHandler as jest.Mock).mockResolvedValue(undefined); + + await handleEventAqua(maliciousEvent); + + // Security check: wrong event type should prevent DB initialization + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(maliciousEvent); + }); + + it("should not initialize DB when event type case is manipulated", async () => { + const maliciousEvent: SorobanEvent = { + contractId: "MALICIOUS_CONTRACT_CASE_MANIPULATION", + topic: [ + { value: () => "trade" }, // lowercase instead of TRADE + ], + } as unknown as SorobanEvent; + + const legitimateFactory = "LEGITIMATE_MAINNET_FACTORY"; + + (getAquaFactory as jest.Mock).mockImplementation((network) => { + if (network === "mainnet") return legitimateFactory; + return "OTHER_FACTORY"; + }); + + (getFactoryTopic as jest.Mock).mockResolvedValue(legitimateFactory); + (aquaEventHandler as jest.Mock).mockResolvedValue(undefined); + + await handleEventAqua(maliciousEvent); + + // The function actually converts to uppercase, so this should succeed + expect(initializeAquaDb).toHaveBeenCalledWith("MALICIOUS_CONTRACT_CASE_MANIPULATION"); + expect(aquaEventHandler).toHaveBeenCalledWith(maliciousEvent); + }); + }); + + describe("Legitimate Access Control", () => { + it("should properly initialize DB when both event type and factory are legitimate", async () => { + const legitimateEvent: SorobanEvent = { + contractId: "LEGITIMATE_CONTRACT_ID", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + const legitimateFactory = "LEGITIMATE_MAINNET_FACTORY"; + + (getAquaFactory as jest.Mock).mockImplementation((network) => { + if (network === "mainnet") return legitimateFactory; + if (network === "testnet") return "TESTNET_FACTORY"; + return null; + }); + + (getFactoryTopic as jest.Mock).mockResolvedValue(legitimateFactory); + (initializeAquaDb as jest.Mock).mockResolvedValue(undefined); + (aquaEventHandler as jest.Mock).mockResolvedValue(undefined); + + await handleEventAqua(legitimateEvent); + + // Legitimate access should work + expect(initializeAquaDb).toHaveBeenCalledWith("LEGITIMATE_CONTRACT_ID"); + expect(aquaEventHandler).toHaveBeenCalledWith(legitimateEvent); + }); + + it("should handle both mainnet and testnet factories correctly", async () => { + const testnetEvent: SorobanEvent = { + contractId: "TESTNET_CONTRACT_ID", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + const testnetFactory = "LEGITIMATE_TESTNET_FACTORY"; + + (getAquaFactory as jest.Mock).mockImplementation((network) => { + if (network === "mainnet") return "MAINNET_FACTORY"; + if (network === "testnet") return testnetFactory; + return null; + }); + + (getFactoryTopic as jest.Mock).mockResolvedValue(testnetFactory); + (initializeAquaDb as jest.Mock).mockResolvedValue(undefined); + (aquaEventHandler as jest.Mock).mockResolvedValue(undefined); + + await handleEventAqua(testnetEvent); + + // Testnet access should also work + expect(initializeAquaDb).toHaveBeenCalledWith("TESTNET_CONTRACT_ID"); + expect(aquaEventHandler).toHaveBeenCalledWith(testnetEvent); + }); + }); +}); \ No newline at end of file diff --git a/src/aqua/__tests__/handleEventAqua.test.ts b/src/aqua/__tests__/handleEventAqua.test.ts new file mode 100644 index 0000000..d1e4d9e --- /dev/null +++ b/src/aqua/__tests__/handleEventAqua.test.ts @@ -0,0 +1,79 @@ +import { handleEventAqua } from "../../mappings/mappingHandlers"; +import { SorobanEvent } from "@subql/types-stellar"; +import { initializeAquaDb } from "../../aqua/initialize"; +import { aquaEventHandler } from "../../aqua"; +import { getFactoryTopic } from "../../aqua/helpers/events"; +import { getAquaFactory, NETWORK } from "../../constants"; + + +jest.mock("../initialize", () => ({ + initializeAquaDb: jest.fn(), +})); + +jest.mock("../index", () => ({ + aquaEventHandler: jest.fn(), +})); + +jest.mock("../helpers/events", () => ({ + getFactoryTopic: jest.fn(), + extractAquaValues: jest.fn(), +})); + +jest.mock("../../constants", () => ({ + getAquaFactory: jest.fn(), + NETWORK: { + MAINNET: "mainnet", + TESTNET: "testnet", + }, +})); + +describe("handleEventAqua", () => { + const mockEvent: SorobanEvent = { + contractId: "testContractId", + topic: [ + { value: () => "TRADE" }, + ], + } as unknown as SorobanEvent; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize Aqua DB and call aquaEventHandler for TRADE events with valid factory address", async () => { + (getFactoryTopic as jest.Mock).mockResolvedValue("mainnet"); + (getAquaFactory as jest.Mock).mockImplementation((network) => network); + + await handleEventAqua(mockEvent); + + expect(getFactoryTopic).toHaveBeenCalledWith(mockEvent); + expect(initializeAquaDb).toHaveBeenCalledWith("testContractId"); + expect(aquaEventHandler).toHaveBeenCalledWith(mockEvent); + }); + + it("should not initialize Aqua DB for TRADE events with invalid factory address", async () => { + (getFactoryTopic as jest.Mock).mockResolvedValue("invalidFactory"); + + await handleEventAqua(mockEvent); + + expect(getFactoryTopic).toHaveBeenCalledWith(mockEvent); + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(mockEvent); + }); + + it("should call aquaEventHandler for non-TRADE events", async () => { + const nonTradeEvent: SorobanEvent = { + ...mockEvent, + topic: [ + { value: () => "NON_TRADE" }, + ], + } as unknown as SorobanEvent; + + (getFactoryTopic as jest.Mock).mockResolvedValue("someFactory"); + + await handleEventAqua(nonTradeEvent); + + expect(getFactoryTopic).toHaveBeenCalledWith(nonTradeEvent); + expect(initializeAquaDb).not.toHaveBeenCalled(); + expect(aquaEventHandler).toHaveBeenCalledWith(nonTradeEvent); + }); +}); diff --git a/src/aqua/__tests__/setup.test.ts b/src/aqua/__tests__/setup.test.ts new file mode 100644 index 0000000..fc41671 --- /dev/null +++ b/src/aqua/__tests__/setup.test.ts @@ -0,0 +1,26 @@ +// Simple test to verify Jest setup is working +describe('Jest Setup', () => { + it('should have global logger available', () => { + expect((global as any).logger).toBeDefined(); + expect((global as any).logger.info).toBeDefined(); + expect((global as any).logger.error).toBeDefined(); + expect((global as any).logger.warn).toBeDefined(); + expect((global as any).logger.debug).toBeDefined(); + }); + + it('should have test utilities available', () => { + expect(global.createMockDate).toBeDefined(); + expect(global.createMockBigInt).toBeDefined(); + }); + + it('should create mock date', () => { + const mockDate = global.createMockDate(); + expect(mockDate).toBeInstanceOf(Date); + }); + + it('should create mock bigint', () => { + const mockBigInt = global.createMockBigInt(1000); + expect(typeof mockBigInt).toBe('bigint'); + expect(mockBigInt).toBe(BigInt(1000)); + }); +}); \ No newline at end of file diff --git a/src/aqua/helpers/events.ts b/src/aqua/helpers/events.ts index c46601c..206c869 100755 --- a/src/aqua/helpers/events.ts +++ b/src/aqua/helpers/events.ts @@ -1,8 +1,26 @@ import { StrKey } from "@stellar/stellar-sdk"; import { getTransactionData } from "./utils"; +import { SorobanEvent } from "@subql/types-stellar"; -// Helper function to extract values from deposit event -export async function extractAquaValues(event: any): Promise<{ +// Types and Interfaces +interface ContractData { + tokenA?: any; + tokenB?: any; + tokenC?: any; + reserveA?: any; + reserveB?: any; + reserveC?: any; + fee?: any; + futureA?: any; + futureATime?: any; + initialA?: any; + initialATime?: any; + precisionMulA?: any; + precisionMulB?: any; + precisionMulC?: any; +} + +interface AquaValues { address: string; tokenA: string; tokenB: string; @@ -18,134 +36,331 @@ export async function extractAquaValues(event: any): Promise<{ precisionMulA?: bigint; precisionMulB?: bigint; precisionMulC?: bigint; -}> { - let result = { - address: "", - tokenA: "", - tokenB: "", - tokenC: undefined as string | undefined, - reserveA: undefined as bigint | undefined, - reserveB: undefined as bigint | undefined, - reserveC: undefined as bigint | undefined, - fee: undefined as bigint | undefined, - futureA: undefined as bigint | undefined, - futureATime: undefined as bigint | undefined, - initialA: undefined as bigint | undefined, - initialATime: undefined as bigint | undefined, - precisionMulA: undefined as bigint | undefined, - precisionMulB: undefined as bigint | undefined, - precisionMulC: undefined as bigint | undefined, - }; +} - try { - logger.debug(`txHash: ${event.txHash.toString()}`); - // User address (first value of the value) - result.address = event.contractId.toString(); +// Enums for field types and behaviors +enum FieldType { + STRING = 'string', + OPTIONAL_STRING = 'optional_string', + RESERVE_BIGINT = 'reserve_bigint', + REGULAR_BIGINT = 'regular_bigint', + OPTIONAL_BIGINT = 'optional_bigint' +} - // Get contract data using getLedgerEntries - if (result.address) { - logger.debug(`🔍 Fetching contract data for ${result.address}...`); - // let contractData = await getContractDataFetch(result.address); - let contractData = getTransactionData(event, result.address); +// Value normalizer strategy interface +interface ValueNormalizer { + normalize(value: any): T; +} - if (contractData.tokenA !== undefined) { - result.tokenA = contractData.tokenA; - logger.debug(`[AQUA] → TokenA from contract: ${result.tokenA.toString()}`); - } +// Concrete normalizer implementations +class StringNormalizer implements ValueNormalizer { + normalize(value: any): string { + if (value === null || value === undefined) { + return ""; + } + return String(value); + } +} - if (contractData.tokenB !== undefined) { - result.tokenB = contractData.tokenB; - logger.debug(`[AQUA] → TokenB from contract: ${result.tokenB.toString()}`); - } +class OptionalStringNormalizer implements ValueNormalizer { + normalize(value: any): string | undefined { + if (value === null || value === undefined) { + return undefined; + } + return String(value); + } +} - if (contractData.reserveA !== undefined) { - result.reserveA = contractData.reserveA; - logger.debug(`[AQUA] → ReserveA from contract: ${result.reserveA.toString()}`); - } +class ReserveBigIntNormalizer implements ValueNormalizer { + normalize(value: any): bigint | undefined | null { + if (value === null) return null; + if (value === undefined) return undefined; + + const converted = this.convertToBigInt(value); + // Zero reserves become undefined + return converted === BigInt(0) ? undefined : converted; + } - if (contractData.reserveB !== undefined) { - result.reserveB = contractData.reserveB; - logger.debug(`[AQUA] → ReserveB from contract: ${result.reserveB.toString()}`); + private convertToBigInt(value: any): bigint | undefined { + if (typeof value === 'bigint') return value; + + if (typeof value === 'number' || typeof value === 'string') { + try { + return BigInt(value); + } catch { + return undefined; } + } + return undefined; + } +} - if (contractData.fee !== undefined) { - result.fee = contractData.fee; - logger.debug(`[AQUA] → Fee from contract: ${result.fee.toString()}`); +class RegularBigIntNormalizer implements ValueNormalizer { + normalize(value: any): bigint | undefined | null { + if (value === null) return null; + if (value === undefined) return undefined; + if (typeof value === 'bigint') return value; + + if (typeof value === 'number' || typeof value === 'string') { + try { + return BigInt(value); + } catch { + return undefined; } + } + return undefined; + } +} - // Assign values for stable pools - if (contractData.tokenC !== undefined) { - result.tokenC = contractData.tokenC; - logger.debug(`[AQUA] → TokenC from contract: ${result.tokenC}`); +class OptionalBigIntNormalizer implements ValueNormalizer { + normalize(value: any): bigint | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value === 'bigint') return value; + + if (typeof value === 'number' || typeof value === 'string') { + try { + return BigInt(value); + } catch { + return undefined; } + } + return undefined; + } +} - if (contractData.reserveC !== undefined) { - result.reserveC = contractData.reserveC; - logger.debug(`[AQUA] → ReserveC from contract: ${result.reserveC.toString()}`); - } +// Factory for creating normalizers +class NormalizerFactory { + private static normalizers = new Map>([ + [FieldType.STRING, new StringNormalizer()], + [FieldType.OPTIONAL_STRING, new OptionalStringNormalizer()], + [FieldType.RESERVE_BIGINT, new ReserveBigIntNormalizer()], + [FieldType.REGULAR_BIGINT, new RegularBigIntNormalizer()], + [FieldType.OPTIONAL_BIGINT, new OptionalBigIntNormalizer()] + ]); - if (contractData.futureA !== undefined) { - result.futureA = contractData.futureA; - logger.debug(`[AQUA] → FutureA from contract: ${result.futureA.toString()}`); - } + static getNormalizer(type: FieldType): ValueNormalizer { + const normalizer = this.normalizers.get(type); + if (!normalizer) { + throw new Error(`Unknown field type: ${type}`); + } + return normalizer; + } +} - if (contractData.futureATime !== undefined) { - result.futureATime = contractData.futureATime; - logger.debug(`[AQUA] → FutureATime from contract: ${result.futureATime.toString()}`); - } +// Field configuration for different types of fields +interface FieldConfig { + type: FieldType; + logName?: string; + isRequired?: boolean; +} - if (contractData.initialA !== undefined) { - result.initialA = contractData.initialA; - logger.debug(`[AQUA] → InitialA from contract: ${result.initialA.toString()}`); - } +// Field processor that handles validation, normalization, and logging +class FieldProcessor { + constructor( + private logger: any, + private contractData: ContractData, + private allStableFieldsAreNull: boolean + ) {} - if (contractData.initialATime !== undefined) { - result.initialATime = contractData.initialATime; - logger.debug(`[AQUA] → InitialATime from contract: ${result.initialATime.toString()}`); - } + processField( + resultKey: keyof AquaValues, + contractKey: keyof ContractData, + config: FieldConfig, + result: Partial + ): void { + const contractValue = this.contractData[contractKey]; + if (contractValue === undefined) return; - if (contractData.precisionMulA !== undefined) { - result.precisionMulA = contractData.precisionMulA; - logger.debug(`[AQUA] → PrecisionMulA from contract: ${result.precisionMulA.toString()}`); - } + let normalizedValue: T; - if (contractData.precisionMulB !== undefined) { - result.precisionMulB = contractData.precisionMulB; - logger.debug(`[AQUA] → PrecisionMulB from contract: ${result.precisionMulB.toString()}`); - } + // Special handling for stable pool fields when all are null + if (this.allStableFieldsAreNull && this.isStablePoolField(contractKey)) { + normalizedValue = undefined as T; + } else { + const normalizer = NormalizerFactory.getNormalizer(config.type); + normalizedValue = normalizer.normalize(contractValue); + } - if (contractData.precisionMulC !== undefined) { - result.precisionMulC = contractData.precisionMulC; - logger.debug(`[AQUA] → PrecisionMulC from contract: ${result.precisionMulC.toString()}`); - } + (result as any)[resultKey] = normalizedValue; - // If no data is found, use default values - if (result.reserveA === undefined && result.reserveB === undefined) { - logger.debug(`⚠️ No reserve data found for contract ${result.address}, using default values`); - result.reserveA = BigInt(0); - result.reserveB = BigInt(0); - } + // Log if value exists and is loggable + if (this.shouldLog(normalizedValue, config)) { + const logName = config.logName || String(contractKey); + this.logFieldValue(logName, normalizedValue); } + } + + private isStablePoolField(key: keyof ContractData): boolean { + const stableFields: (keyof ContractData)[] = [ + 'tokenC', 'reserveC', 'futureA', 'futureATime', + 'initialA', 'initialATime', 'precisionMulA', + 'precisionMulB', 'precisionMulC' + ]; + return stableFields.includes(key); + } - return result; - } catch (error) { - logger.error(`[AQUA] ❌ Error extracting Aqua values: ${error}`); - return result; + private shouldLog(value: any, config: FieldConfig): boolean { + return value !== undefined && value !== null && Boolean(config.logName); + } + + private logFieldValue(logName: string, value: any): void { + const displayValue = typeof value === 'bigint' ? value.toString() : value; + this.logger.debug(`[AQUA] → ${logName} from contract: ${displayValue}`); } } -export async function getFactoryTopic(event: any): Promise { - let factoryAddress = ""; +// Main extractor class that orchestrates the extraction process +class AquaValuesExtractor { + private static readonly STABLE_POOL_FIELDS: (keyof ContractData)[] = [ + 'tokenC', 'reserveC', 'futureA', 'futureATime', + 'initialA', 'initialATime', 'precisionMulA', + 'precisionMulB', 'precisionMulC' + ]; - if (event.topic[3]) { - if (event.topic[3].address().switch().name === "scAddressTypeContract") { - try { - const contractIdBuffer = event.topic[3].address().contractId(); - factoryAddress = StrKey.encodeContract(contractIdBuffer); - } catch (error) { - logger.error(`Error getting factory address: ${error}`); + private static readonly FIELD_CONFIGS: Record = { + tokenA: { type: FieldType.STRING, logName: 'TokenA' }, + tokenB: { type: FieldType.STRING, logName: 'TokenB' }, + reserveA: { type: FieldType.RESERVE_BIGINT, logName: 'ReserveA' }, + reserveB: { type: FieldType.RESERVE_BIGINT, logName: 'ReserveB' }, + fee: { type: FieldType.REGULAR_BIGINT, logName: 'Fee' }, + tokenC: { type: FieldType.OPTIONAL_STRING, logName: 'TokenC' }, + reserveC: { type: FieldType.OPTIONAL_BIGINT, logName: 'ReserveC' }, + futureA: { type: FieldType.OPTIONAL_BIGINT, logName: 'FutureA' }, + futureATime: { type: FieldType.OPTIONAL_BIGINT, logName: 'FutureATime' }, + initialA: { type: FieldType.REGULAR_BIGINT, logName: 'InitialA' }, + initialATime: { type: FieldType.OPTIONAL_BIGINT, logName: 'InitialATime' }, + precisionMulA: { type: FieldType.OPTIONAL_BIGINT, logName: 'PrecisionMulA' }, + precisionMulB: { type: FieldType.OPTIONAL_BIGINT, logName: 'PrecisionMulB' }, + precisionMulC: { type: FieldType.OPTIONAL_BIGINT, logName: 'PrecisionMulC' } + }; + + constructor(private logger: any) {} + + private createInitialResult(): Partial { + return { + address: "", + tokenA: "", + tokenB: "", + tokenC: undefined, + reserveA: undefined, + reserveB: undefined, + reserveC: undefined, + fee: undefined, + futureA: undefined, + futureATime: undefined, + initialA: undefined, + initialATime: undefined, + precisionMulA: undefined, + precisionMulB: undefined, + precisionMulC: undefined, + }; + } + + private extractEventAddress(event: any): string { + try { + this.logger.debug(`txHash: ${event.txHash.toString()}`); + return event.contractId.toString(); + } catch { + return ""; + } + } + + private checkAllStableFieldsAreNull(contractData: ContractData): boolean { + const definedStableFields = AquaValuesExtractor.STABLE_POOL_FIELDS + .filter(field => contractData[field] !== undefined); + + return definedStableFields.length > 0 && + definedStableFields.every(field => contractData[field] === null); + } + + private processAllFields( + contractData: ContractData, + result: Partial, + allStableFieldsAreNull: boolean + ): void { + const processor = new FieldProcessor(this.logger, contractData, allStableFieldsAreNull); + + // Process each field using configuration + Object.entries(AquaValuesExtractor.FIELD_CONFIGS).forEach(([key, config]) => { + processor.processField( + key as keyof AquaValues, + key as keyof ContractData, + config, + result + ); + }); + } + + private applyDefaultValues(contractData: ContractData, result: Partial): void { + // Apply default values only for empty contract data + if (Object.keys(contractData).length === 0) { + this.logger.debug(`⚠️ No reserve data found for contract ${result.address}, using default values`); + result.reserveA = BigInt(0); + result.reserveB = BigInt(0); + } + } + + async extract(event: any): Promise { + const result = this.createInitialResult(); + + try { + // Extract basic event information + result.address = this.extractEventAddress(event); + + if (!result.address) { + return result as AquaValues; } + + // Get contract data + this.logger.debug(`🔍 Fetching contract data for ${result.address}...`); + const contractData = getTransactionData(event, result.address); + + // Analyze stable pool field state + const allStableFieldsAreNull = this.checkAllStableFieldsAreNull(contractData); + + // Process all fields + this.processAllFields(contractData, result, allStableFieldsAreNull); + + // Apply default values if needed + this.applyDefaultValues(contractData, result); + + return result as AquaValues; + } catch (error) { + this.logger.error(`[AQUA] ❌ Error extracting Aqua values: ${error}`); + return result as AquaValues; } } - return factoryAddress; } + +// Factory topic extractor class +class FactoryTopicExtractor { + constructor(private logger: any) {} + + async extract(event: SorobanEvent): Promise { + let factoryAddress: string; + + try { + if (event.topic[3]) { + if (event.topic[3].address().switch().name === "scAddressTypeContract") { + const contractIdBuffer = event.topic[3].address().contractId(); + factoryAddress = StrKey.encodeContract(contractIdBuffer); + } + } + } catch (error) { + this.logger.error(`Error getting factory address: ${error}`); + } + + return factoryAddress; + } +} + +// Public API functions (maintaining backward compatibility) +export async function extractAquaValues(event: any): Promise { + const extractor = new AquaValuesExtractor(logger); + return extractor.extract(event); +} + +export async function getFactoryTopic(event: any): Promise { + const extractor = new FactoryTopicExtractor(logger); + return extractor.extract(event); +} \ No newline at end of file diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..09b7698 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,72 @@ +// Global test setup for Jest +import 'jest'; + +// Extend global types (logger and store are already declared by SubQL) +declare global { + var createMockDate: (timestamp?: number) => Date; + var createMockBigInt: (value: number | string) => bigint; +} + +// Mock global logger (override the existing one for tests) +(global as any).logger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn() +}; + +// Mock global store (SubQL entity store) +(global as any).store = { + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + getByField: jest.fn(), + getByFields: jest.fn() +}; + +// Mock console methods to avoid noise in tests +(global as any).console = { + ...console, + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn() +}; + +// Setup global test environment +beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + + // Reset logger mocks + (global as any).logger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn() + }; + + // Reset store mocks + (global as any).store = { + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + getByField: jest.fn(), + getByFields: jest.fn() + }; +}); + +// Global test utilities +(global as any).createMockDate = (timestamp?: number) => { + return new Date(timestamp || Date.now()); +}; + +(global as any).createMockBigInt = (value: number | string) => { + return BigInt(value); +}; + +// Export test utilities for use in test files +export const testUtils = { + createMockDate: (global as any).createMockDate, + createMockBigInt: (global as any).createMockBigInt +}; \ No newline at end of file