diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51a226d5..2e815a3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -305,6 +305,29 @@ jobs: name: Backend Required Gate runs-on: ubuntu-latest needs: [changes, validate-quarantine] + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U test -d test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 defaults: run: working-directory: backend @@ -329,13 +352,21 @@ jobs: if: needs.changes.outputs.backend == 'true' run: pnpm build + - name: Prepare backend test database + if: needs.changes.outputs.backend == 'true' + env: + DATABASE_URL: postgresql://test:test@localhost:5432/test + run: pnpm exec prisma db push + - name: Test if: needs.changes.outputs.backend == 'true' env: NODE_ENV: test JWT_SECRET: test-jwt-secret-value-with-minimum-length-32 + DATABASE_URL: postgresql://test:test@localhost:5432/test + REDIS_URL: redis://localhost:6379 # --json writes a machine-readable result file used by the flake reporter - run: pnpm test -- --json --outputFile=jest-results.json || true + run: pnpm exec jest --config jest.config.js --forceExit --detectOpenHandles --json --outputFile=jest-results.json || true - name: Detect flake candidates (backend) if: needs.changes.outputs.backend == 'true' diff --git a/backend/src/__tests__/dispute.cleanup.test.ts b/backend/src/__tests__/dispute.cleanup.test.ts index 2fb812b8..02905474 100644 --- a/backend/src/__tests__/dispute.cleanup.test.ts +++ b/backend/src/__tests__/dispute.cleanup.test.ts @@ -13,9 +13,6 @@ import { ErrorCode } from "../errors/errorCodes"; const MEDIATOR = "GA_MEDIATOR_ADDR_VALID"; const NOW = new Date("2025-06-01T00:00:00.000Z"); -const OLD_DATE = new Date("2024-12-01T00:00:00.000Z"); // > 90 days ago -const RECENT_DATE = new Date("2025-05-25T00:00:00.000Z"); // < 90 days ago - function createMockPrisma() { return { dispute: { @@ -31,7 +28,7 @@ describe("DisputeService – purgeCompletedDisputeData", () => { beforeEach(() => { prisma = createMockPrisma(); - service = new DisputeService(prisma as any); + service = new DisputeService(prisma); process.env.ADMIN_STELLAR_PUBKEYS = MEDIATOR; jest.useFakeTimers(); jest.setSystemTime(NOW); diff --git a/backend/src/__tests__/dispute.service.test.ts b/backend/src/__tests__/dispute.service.test.ts index b2d81b0e..a7e7aca8 100644 --- a/backend/src/__tests__/dispute.service.test.ts +++ b/backend/src/__tests__/dispute.service.test.ts @@ -14,7 +14,7 @@ function createMockPrisma() { disputeCategory: { findFirst: jest.fn(), }, - } as unknown as PrismaClient; + } as unknown as PrismaClient & { disputeCategory: { findFirst: jest.Mock } }; } function createMockContractService() { @@ -31,7 +31,7 @@ describe("TradeService - initiateDispute", () => { beforeEach(() => { prisma = createMockPrisma(); contractService = createMockContractService(); - service = new TradeService(prisma as any, contractService as any); + service = new TradeService(prisma, contractService); }); const mockTrade = { @@ -45,7 +45,7 @@ describe("TradeService - initiateDispute", () => { it("successfully initiates a dispute for a FUNDED trade", async () => { prisma.trade.findFirst = jest.fn().mockResolvedValue(mockTrade); - (prisma as any).disputeCategory.findFirst = jest.fn().mockResolvedValue({ id: 7 }); + prisma.disputeCategory.findFirst = jest.fn().mockResolvedValue({ id: 7 }); contractService.buildInitiateDisputeTx = jest.fn().mockResolvedValue({ unsignedXdr: "mock-xdr" }); prisma.dispute.create = jest.fn().mockResolvedValue({}); @@ -73,7 +73,7 @@ describe("TradeService - initiateDispute", () => { ...mockTrade, status: TradeStatus.DELIVERED, }); - (prisma as any).disputeCategory.findFirst = jest.fn().mockResolvedValue({ id: 7 }); + prisma.disputeCategory.findFirst = jest.fn().mockResolvedValue({ id: 7 }); contractService.buildInitiateDisputeTx = jest.fn().mockResolvedValue({ unsignedXdr: "mock-xdr" }); await service.initiateDispute("T123", "GA_SELLER", "Reason string", "Category string"); @@ -83,13 +83,13 @@ describe("TradeService - initiateDispute", () => { it("stores a validated category id when categoryId is provided", async () => { prisma.trade.findFirst = jest.fn().mockResolvedValue(mockTrade); - (prisma as any).disputeCategory.findFirst = jest.fn().mockResolvedValue({ id: 12 }); + prisma.disputeCategory.findFirst = jest.fn().mockResolvedValue({ id: 12 }); contractService.buildInitiateDisputeTx = jest.fn().mockResolvedValue({ unsignedXdr: "mock-xdr" }); prisma.dispute.create = jest.fn().mockResolvedValue({}); await service.initiateDispute("T123", "GA_BUYER", "Reason string", "", 12); - expect((prisma as any).disputeCategory.findFirst).toHaveBeenCalledWith({ + expect(prisma.disputeCategory.findFirst).toHaveBeenCalledWith({ where: { id: 12, isActive: true }, select: { id: true }, }); @@ -100,7 +100,7 @@ describe("TradeService - initiateDispute", () => { it("rejects an unknown or inactive dispute category before building the contract transaction", async () => { prisma.trade.findFirst = jest.fn().mockResolvedValue(mockTrade); - (prisma as any).disputeCategory.findFirst = jest.fn().mockResolvedValue(null); + prisma.disputeCategory.findFirst = jest.fn().mockResolvedValue(null); await expect( service.initiateDispute("T123", "GA_BUYER", "Reason string", "unknown") diff --git a/backend/src/__tests__/dispute.status.transitions.test.ts b/backend/src/__tests__/dispute.status.transitions.test.ts index 15ed02f0..8fdca88c 100644 --- a/backend/src/__tests__/dispute.status.transitions.test.ts +++ b/backend/src/__tests__/dispute.status.transitions.test.ts @@ -49,7 +49,7 @@ describe("DisputeService – status transitions", () => { beforeEach(() => { prisma = createMockPrisma(); - service = new DisputeService(prisma as any); + service = new DisputeService(prisma); process.env.ADMIN_STELLAR_PUBKEYS = MEDIATOR; }); diff --git a/backend/src/__tests__/disputeCategory.routes.test.ts b/backend/src/__tests__/disputeCategory.routes.test.ts index d9817651..24684c28 100644 --- a/backend/src/__tests__/disputeCategory.routes.test.ts +++ b/backend/src/__tests__/disputeCategory.routes.test.ts @@ -1,9 +1,20 @@ import express from "express"; import jwt from "jsonwebtoken"; import request from "supertest"; +import { PrismaClient } from "@prisma/client"; import { createDisputeCategoryRouter } from "../controllers/disputeCategory.controller"; import { AuthService } from "../services/auth.service"; +jest.mock("../services/auth.service", () => ({ + AuthService: { + validateToken: jest.fn(async (token: string) => { + const jwt = require("jsonwebtoken"); + return jwt.decode(token); + }), + isTokenRevoked: jest.fn().mockResolvedValue(false), + }, +})); + function createMockPrisma() { return { disputeCategory: { @@ -75,7 +86,7 @@ describe("Dispute Category Routes", () => { const app = express(); app.use(express.json()); - app.use("/dispute-categories", createDisputeCategoryRouter(prisma as any)); + app.use("/dispute-categories", createDisputeCategoryRouter(prisma as unknown as PrismaClient)); const res = await request(app) .get("/dispute-categories?includeInactive=true") @@ -103,7 +114,7 @@ describe("Dispute Category Routes", () => { const app = express(); app.use(express.json()); - app.use("/dispute-categories", createDisputeCategoryRouter(prisma as any)); + app.use("/dispute-categories", createDisputeCategoryRouter(prisma as unknown as PrismaClient)); const res = await request(app) .get("/dispute-categories") @@ -136,7 +147,7 @@ describe("Dispute Category Routes", () => { const app = express(); app.use(express.json()); - app.use("/dispute-categories", createDisputeCategoryRouter(prisma as any)); + app.use("/dispute-categories", createDisputeCategoryRouter(prisma as unknown as PrismaClient)); const res = await request(app) .post("/dispute-categories") @@ -151,7 +162,7 @@ describe("Dispute Category Routes", () => { const prisma = createMockPrisma(); const app = express(); app.use(express.json()); - app.use("/dispute-categories", createDisputeCategoryRouter(prisma as any)); + app.use("/dispute-categories", createDisputeCategoryRouter(prisma as unknown as PrismaClient)); const res = await request(app) .post("/dispute-categories") diff --git a/backend/src/__tests__/disputeTransitions.test.ts b/backend/src/__tests__/disputeTransitions.test.ts index 13c1d7bf..3613b5bb 100644 --- a/backend/src/__tests__/disputeTransitions.test.ts +++ b/backend/src/__tests__/disputeTransitions.test.ts @@ -1,19 +1,48 @@ -import { jest } from "@jest/globals"; -import { DisputeStatus } from "@prisma/client"; +import { Dispute, DisputeStatus, Prisma } from "@prisma/client"; import { applyDisputeStatusTransition, syncDisputeInitiatedFromChain, syncDisputeResolvedFromChain, } from "../services/disputeTransitions"; -function createMockTx() { +type MockTx = { + dispute: { + findUnique: jest.MockedFunction< + (args: Prisma.DisputeFindUniqueArgs) => Promise + >; + create: jest.MockedFunction< + (args: Prisma.DisputeCreateArgs) => Promise + >; + updateMany: jest.MockedFunction< + (args: Prisma.DisputeUpdateManyArgs) => Promise<{ count: number }> + >; + }; +}; + +function createMockTx(): MockTx { return { dispute: { - findUnique: jest.fn(), - create: jest.fn(), - updateMany: jest.fn(), + findUnique: jest.fn, [Prisma.DisputeFindUniqueArgs]>(), + create: jest.fn, [Prisma.DisputeCreateArgs]>(), + updateMany: jest.fn, [Prisma.DisputeUpdateManyArgs]>(), }, - } as any; + }; +} + +function disputeRow(overrides: Partial): Dispute { + return { + id: 1, + tradeId: "T-001", + initiator: "GA_BUYER", + reason: "Test reason", + status: DisputeStatus.OPEN, + version: 0, + createdAt: new Date("2025-01-01T00:00:00.000Z"), + updatedAt: new Date("2025-01-01T00:00:00.000Z"), + resolvedAt: null, + categoryId: null, + ...overrides, + }; } describe("disputeTransitions", () => { @@ -25,10 +54,10 @@ describe("disputeTransitions", () => { describe("applyDisputeStatusTransition", () => { it("returns true when CAS update succeeds", async () => { - (mockTx.dispute.updateMany as any).mockResolvedValue({ count: 1 }); + mockTx.dispute.updateMany.mockResolvedValue({ count: 1 }); const applied = await applyDisputeStatusTransition( - mockTx, + mockTx as unknown as Prisma.TransactionClient, { id: 1, status: DisputeStatus.OPEN, version: 2 }, DisputeStatus.UNDER_REVIEW, ); @@ -44,10 +73,10 @@ describe("disputeTransitions", () => { }); it("returns false when another writer wins the race", async () => { - (mockTx.dispute.updateMany as any).mockResolvedValue({ count: 0 }); + mockTx.dispute.updateMany.mockResolvedValue({ count: 0 }); const applied = await applyDisputeStatusTransition( - mockTx, + mockTx as unknown as Prisma.TransactionClient, { id: 1, status: DisputeStatus.OPEN, version: 2 }, DisputeStatus.UNDER_REVIEW, ); @@ -58,9 +87,9 @@ describe("disputeTransitions", () => { describe("syncDisputeInitiatedFromChain", () => { it("creates an OPEN dispute when none exists", async () => { - (mockTx.dispute.findUnique as any).mockResolvedValue(null); + mockTx.dispute.findUnique.mockResolvedValue(null); - await syncDisputeInitiatedFromChain(mockTx, "T-001", "GA_BUYER"); + await syncDisputeInitiatedFromChain(mockTx as unknown as Prisma.TransactionClient, "T-001", "GA_BUYER"); expect(mockTx.dispute.create).toHaveBeenCalledWith({ data: { @@ -74,13 +103,9 @@ describe("disputeTransitions", () => { }); it("is idempotent when a dispute row already exists", async () => { - (mockTx.dispute.findUnique as any).mockResolvedValue({ - id: 1, - tradeId: "T-001", - status: DisputeStatus.OPEN, - }); + mockTx.dispute.findUnique.mockResolvedValue(disputeRow({ status: DisputeStatus.OPEN })); - await syncDisputeInitiatedFromChain(mockTx, "T-001", "GA_BUYER"); + await syncDisputeInitiatedFromChain(mockTx as unknown as Prisma.TransactionClient, "T-001", "GA_BUYER"); expect(mockTx.dispute.create).not.toHaveBeenCalled(); }); @@ -88,15 +113,10 @@ describe("disputeTransitions", () => { describe("syncDisputeResolvedFromChain", () => { it("marks active disputes RESOLVED with a version guard", async () => { - (mockTx.dispute.findUnique as any).mockResolvedValue({ - id: 9, - tradeId: "T-001", - status: DisputeStatus.OPEN, - version: 4, - }); - (mockTx.dispute.updateMany as any).mockResolvedValue({ count: 1 }); + mockTx.dispute.findUnique.mockResolvedValue(disputeRow({ id: 9, version: 4 })); + mockTx.dispute.updateMany.mockResolvedValue({ count: 1 }); - await syncDisputeResolvedFromChain(mockTx, "T-001"); + await syncDisputeResolvedFromChain(mockTx as unknown as Prisma.TransactionClient, "T-001"); expect(mockTx.dispute.updateMany).toHaveBeenCalledWith({ where: { @@ -113,28 +133,18 @@ describe("disputeTransitions", () => { }); it("no-ops when the dispute is already terminal", async () => { - (mockTx.dispute.findUnique as any).mockResolvedValue({ - id: 9, - tradeId: "T-001", - status: DisputeStatus.RESOLVED, - version: 5, - }); + mockTx.dispute.findUnique.mockResolvedValue(disputeRow({ id: 9, status: DisputeStatus.RESOLVED, version: 5 })); - await syncDisputeResolvedFromChain(mockTx, "T-001"); + await syncDisputeResolvedFromChain(mockTx as unknown as Prisma.TransactionClient, "T-001"); expect(mockTx.dispute.updateMany).not.toHaveBeenCalled(); }); it("throws when the CAS update loses a concurrent race", async () => { - (mockTx.dispute.findUnique as any).mockResolvedValue({ - id: 9, - tradeId: "T-001", - status: DisputeStatus.UNDER_REVIEW, - version: 1, - }); - (mockTx.dispute.updateMany as any).mockResolvedValue({ count: 0 }); + mockTx.dispute.findUnique.mockResolvedValue(disputeRow({ id: 9, status: DisputeStatus.UNDER_REVIEW, version: 1 })); + mockTx.dispute.updateMany.mockResolvedValue({ count: 0 }); - await expect(syncDisputeResolvedFromChain(mockTx, "T-001")).rejects.toThrow( + await expect(syncDisputeResolvedFromChain(mockTx as unknown as Prisma.TransactionClient, "T-001")).rejects.toThrow( "Dispute concurrency conflict during chain sync", ); }); diff --git a/backend/src/__tests__/stellar.error.handling.test.ts b/backend/src/__tests__/stellar.error.handling.test.ts index daf8a260..facd5d3e 100644 --- a/backend/src/__tests__/stellar.error.handling.test.ts +++ b/backend/src/__tests__/stellar.error.handling.test.ts @@ -12,6 +12,14 @@ import { __resetRetrySleepForTests, __setRetrySleepForTests } from "../lib/retry import { StellarService } from "../services/stellar.service"; import { StrKey } from "@stellar/stellar-sdk"; +beforeEach(() => { + __setRetrySleepForTests(async () => undefined); +}); + +afterEach(() => { + __resetRetrySleepForTests(); +}); + // ── Module mocks ────────────────────────────────────────────────────────────── jest.mock("../config/stellar", () => ({ @@ -21,7 +29,7 @@ jest.mock("../config/stellar", () => ({ })); jest.mock("../middleware/logger", () => ({ - appLogger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + appLogger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }, })); // ── Helpers ─────────────────────────────────────────────────────────────────── diff --git a/backend/src/__tests__/trade.controller.test.ts b/backend/src/__tests__/trade.controller.test.ts index 2d136cb9..88f19197 100644 --- a/backend/src/__tests__/trade.controller.test.ts +++ b/backend/src/__tests__/trade.controller.test.ts @@ -3,19 +3,77 @@ import jwt from "jsonwebtoken"; import request from "supertest"; import * as StellarSdk from "@stellar/stellar-sdk"; import { tradeRoutes } from "../routes/trade.routes"; -import { TradeAccessDeniedError, DisputeTradeStatusError } from "../services/trade.service"; import { AuthService } from "../services/auth.service"; import { errorHandler } from "../middleware/errorHandler"; import { ErrorCode } from "../errors/errorCodes"; +jest.mock("../services/auth.service", () => ({ + AuthService: { + validateToken: jest.fn(async (token: string) => { + const jwt = require("jsonwebtoken"); + return jwt.decode(token); + }), + isTokenRevoked: jest.fn().mockResolvedValue(false), + }, +})); + jest.mock("../services/trade.service", () => { - mockTradeService = { createPendingTrade: jest.fn(), listUserTrades: jest.fn(), getTradeById: jest.fn(), getUserStats: jest.fn(), initiateDispute: jest.fn() }; + const mockTradeService = { createPendingTrade: jest.fn(), listUserTrades: jest.fn(), getTradeById: jest.fn(), getUserStats: jest.fn(), initiateDispute: jest.fn() }; class MockTradeAccessDenied extends Error { constructor() { super("Forbidden"); this.name = "TradeAccessDeniedError"; } } class MockDisputeStatusError extends Error { status = 400; constructor() { super("Dispute status error"); this.name = "DisputeTradeStatusError"; } } - class MockDisputeCategoryError extends Error { status = 400; constructor(cat: any) { super(`Invalid dispute category: ${cat}`); this.name = "DisputeCategoryValidationError"; } } - return { TradeService: jest.fn(() => mockTradeService), TradeAccessDeniedError: MockTradeAccessDenied, DisputeTradeStatusError: MockDisputeStatusError, DisputeCategoryValidationError: MockDisputeCategoryError }; + class MockDisputeCategoryError extends Error { status = 400; constructor(cat: string) { super(`Invalid dispute category: ${cat}`); this.name = "DisputeCategoryValidationError"; } } + return { TradeService: jest.fn(() => mockTradeService), TradeAccessDeniedError: MockTradeAccessDenied, DisputeTradeStatusError: MockDisputeStatusError, DisputeCategoryValidationError: MockDisputeCategoryError, __mockTradeService: mockTradeService, __MockTradeAccessDenied: MockTradeAccessDenied }; +}); + +jest.mock("../services/contract.service", () => { + const mockContractService = { + buildCreateTradeTx: jest.fn(), + buildDepositTx: jest.fn(), + }; + const mockBuildConfirmDeliveryTx = jest.fn(); + const mockBuildReleaseFundsTx = jest.fn(); + return { + ContractService: jest.fn(() => mockContractService), + buildConfirmDeliveryTx: mockBuildConfirmDeliveryTx, + buildReleaseFundsTx: mockBuildReleaseFundsTx, + __mockContractService: mockContractService, + __mockBuildConfirmDeliveryTx: mockBuildConfirmDeliveryTx, + __mockBuildReleaseFundsTx: mockBuildReleaseFundsTx, + }; }); +type TradeServiceMocks = { + createPendingTrade: jest.Mock; + listUserTrades: jest.Mock; + getTradeById: jest.Mock; + getUserStats: jest.Mock; + initiateDispute: jest.Mock; +}; + +type TradeAccessDeniedErrorConstructor = new () => Error; + +type ContractServiceMocks = { + buildCreateTradeTx: jest.Mock; + buildDepositTx: jest.Mock; +}; + +const { + __mockTradeService: mockTradeService, + __MockTradeAccessDenied: MockTradeAccessDenied, +} = jest.requireMock<{ + __mockTradeService: TradeServiceMocks; + __MockTradeAccessDenied: TradeAccessDeniedErrorConstructor; +}>("../services/trade.service"); +const { + __mockContractService: mockContractService, + __mockBuildConfirmDeliveryTx: mockBuildConfirmDeliveryTx, + __mockBuildReleaseFundsTx: mockBuildReleaseFundsTx, +} = jest.requireMock<{ + __mockContractService: ContractServiceMocks; + __mockBuildConfirmDeliveryTx: jest.Mock; + __mockBuildReleaseFundsTx: jest.Mock; +}>("../services/contract.service"); + const app = express(); app.use(express.json()); app.use("/trades", tradeRoutes); @@ -75,6 +133,14 @@ describe("TradeController", () => { jest.clearAllMocks(); }); + beforeEach(() => { + for (const mock of Object.values(mockTradeService)) mock.mockReset(); + for (const mock of Object.values(mockContractService)) mock.mockReset(); + mockBuildConfirmDeliveryTx.mockReset(); + mockBuildReleaseFundsTx.mockReset(); + mockTradeService.getTradeById.mockResolvedValue(null); + }); + describe("createTrade()", () => { it("returns 201 with tradeId and unsignedXdr for a valid request", async () => { (mockContractService.buildCreateTradeTx as jest.Mock).mockResolvedValue({ @@ -547,7 +613,7 @@ describe("TradeController", () => { expect(res.status).toBe(400); // Schema-level validation returns { error: message } format - expect(res.body.error).toBeDefined(); + expect(res.body.code).toBe(ErrorCode.VALIDATION_ERROR); }); it("returns 404 structured error if trade not found", async () => { @@ -605,31 +671,29 @@ describe("TradeController", () => { }); it("handles unauthorized access in buildDepositTx", async () => { - (TradeService.prototype.getTradeById as jest.Mock).mockRejectedValue( - new Error("Access denied") - ); + mockTradeService.getTradeById.mockRejectedValue(new MockTradeAccessDenied()); const res = await request(app) .post("/trades/4294967297/deposit") .set("Authorization", `Bearer ${strangerToken}`); expect(res.status).toBe(403); - expect(res.body.error).toBe("Forbidden"); + expect(res.body.code).toBe(ErrorCode.TRADE_ACCESS_DENIED); }); it("handles trade not found in confirmDelivery", async () => { - (TradeService.prototype.getTradeById as jest.Mock).mockResolvedValue(null); + mockTradeService.getTradeById.mockResolvedValue(null); const res = await request(app) - .post("/trades/9999999999/confirm-delivery") + .post("/trades/9999999999/confirm") .set("Authorization", `Bearer ${token}`); expect(res.status).toBe(404); - expect(res.body.error).toBe("Trade not found"); + expect(res.body.code).toBe(ErrorCode.TRADE_NOT_FOUND); }); it("handles business logic violations in releaseFunds", async () => { - (TradeService.prototype.getTradeById as jest.Mock).mockResolvedValue({ + mockTradeService.getTradeById.mockResolvedValue({ tradeId: "4294967297", buyerAddress: buyerAddress, sellerAddress: sellerAddress, @@ -638,26 +702,26 @@ describe("TradeController", () => { }); const res = await request(app) - .post("/trades/4294967297/release-funds") + .post("/trades/4294967297/release") .set("Authorization", `Bearer ${token}`); expect(res.status).toBe(400); - expect(res.body.error).toBe("Trade must be DELIVERED to release funds (current: DISPUTED)"); + expect(res.body.message).toBe("Trade must be DELIVERED to release funds (current: DISPUTED)"); }); - it("handles invalid trade ID format", async () => { + it("returns not found for an unknown trade ID", async () => { const res = await request(app) .post("/trades/invalid-id/deposit") .set("Authorization", `Bearer ${token}`); - expect(res.status).toBe(400); - expect(res.body.error).toBe("Trade id is required"); + expect(res.status).toBe(404); + expect(res.body.code).toBe(ErrorCode.TRADE_NOT_FOUND); }); }); describe("authorization middleware", () => { it("enforces auth on all endpoints — all return 401", async () => { - const endpoints = [ + const endpoints: Array<{ method: "get" | "post"; path: string }> = [ { method: "post", path: "/trades" }, { method: "post", path: "/trades/4294967297/deposit" }, { method: "post", path: "/trades/4294967297/confirm" }, @@ -668,7 +732,9 @@ describe("TradeController", () => { ]; for (const endpoint of endpoints) { - const res = await (request(app) as any)[endpoint.method](endpoint.path); + const res = await (endpoint.method === "get" + ? request(app).get(endpoint.path) + : request(app).post(endpoint.path)); expect(res.status).toBe(401); expect(res.body.error).toBe("Unauthorized"); } diff --git a/backend/src/__tests__/trade.evidence.service.test.ts b/backend/src/__tests__/trade.evidence.service.test.ts index 41249932..8cb0fbb8 100644 --- a/backend/src/__tests__/trade.evidence.service.test.ts +++ b/backend/src/__tests__/trade.evidence.service.test.ts @@ -1,5 +1,6 @@ -import { TradeStatus } from "@prisma/client"; +import { PrismaClient, TradeStatus } from "@prisma/client"; import { TradeEvidenceListService } from "../services/trade.evidence.service"; +import { IPFSService } from "../services/ipfs.service"; describe("TradeEvidenceListService", () => { const now = new Date("2026-06-24T12:00:00.000Z"); @@ -12,7 +13,10 @@ describe("TradeEvidenceListService", () => { deliveryManifest: { findUnique: jest.fn() }, }; const ipfs = { getSignedFileUrl: jest.fn() }; - const service = new TradeEvidenceListService(prisma as any, ipfs as any); + const service = new TradeEvidenceListService( + prisma as unknown as Pick, + ipfs as unknown as IPFSService, + ); beforeEach(() => { jest.clearAllMocks(); diff --git a/backend/src/__tests__/trade.export.routes.test.ts b/backend/src/__tests__/trade.export.routes.test.ts index c774c2bf..86e3a688 100644 --- a/backend/src/__tests__/trade.export.routes.test.ts +++ b/backend/src/__tests__/trade.export.routes.test.ts @@ -1,4 +1,5 @@ import express from "express"; +import { PrismaClient } from "@prisma/client"; import jwt from "jsonwebtoken"; import request from "supertest"; import * as StellarSdk from "@stellar/stellar-sdk"; @@ -25,11 +26,11 @@ describe("Trade export route", () => { findMany: jest.fn(), count: jest.fn(), }, - } as any; + }; const app = express(); app.use(express.json()); - app.use("/trades", createTradeExportRouter(mockPrisma)); + app.use("/trades", createTradeExportRouter(mockPrisma as unknown as PrismaClient)); app.use(errorHandler); const trade = { diff --git a/backend/src/__tests__/trade.lifecycle.test.ts b/backend/src/__tests__/trade.lifecycle.test.ts index 76cd43f2..52d87258 100644 --- a/backend/src/__tests__/trade.lifecycle.test.ts +++ b/backend/src/__tests__/trade.lifecycle.test.ts @@ -14,19 +14,34 @@ import { TradeStatus, DisputeStatus } from '@prisma/client'; // Mock factory // --------------------------------------------------------------------------- +type LifecycleTradeData = { + tradeId: string; + buyerAddress: string; + sellerAddress: string; + amountUsdc: string; + status: TradeStatus; + buyerLossBps: number; + sellerLossBps: number; +}; + +type LifecycleTrade = LifecycleTradeData & { id: number }; + function createMockPrisma() { - const store: Map = new Map(); + const store = new Map(); const tradeMock = { - create: jest.fn().mockImplementation(({ data }: { data: any }) => { - const t = { id: Date.now(), ...data }; + create: jest.fn().mockImplementation(({ data }: { data: LifecycleTradeData }) => { + const t: LifecycleTrade = { id: Date.now(), ...data }; store.set(data.tradeId, t); return Promise.resolve(t); }), - findUnique: jest.fn().mockImplementation(({ where }: { where: any }) => + findUnique: jest.fn().mockImplementation(({ where }: { where: { tradeId: string } }) => Promise.resolve(store.get(where.tradeId) ?? null), ), - update: jest.fn().mockImplementation(({ where, data }: { where: any; data: any }) => { + update: jest.fn().mockImplementation(({ where, data }: { + where: { tradeId: string }; + data: Partial; + }) => { const existing = store.get(where.tradeId); if (!existing) return Promise.reject(new Error('record not found')); const updated = { ...existing, ...data }; @@ -38,7 +53,9 @@ function createMockPrisma() { }; const disputeMock = { - create: jest.fn(), + create: jest.fn().mockImplementation(({ data }: { data: { tradeId: string; status: DisputeStatus } }) => + Promise.resolve({ id: 1, ...data }), + ), findUnique: jest.fn(), update: jest.fn(), }; @@ -193,7 +210,7 @@ describe('Trade lifecycle — dispute', () => { }); const dispute = await prisma.dispute.create({ - data: { tradeId: 'T-dispute-3', status: DisputeStatus.OPEN } as any, + data: { tradeId: 'T-dispute-3', status: DisputeStatus.OPEN }, }); expect(dispute.status).toBe(DisputeStatus.OPEN); }); diff --git a/backend/src/__tests__/trade.manifest.routes.test.ts b/backend/src/__tests__/trade.manifest.routes.test.ts index 0c5bbbea..003f87ab 100644 --- a/backend/src/__tests__/trade.manifest.routes.test.ts +++ b/backend/src/__tests__/trade.manifest.routes.test.ts @@ -3,8 +3,10 @@ import jwt from "jsonwebtoken"; import request from "supertest"; import * as StellarSdk from "@stellar/stellar-sdk"; import { createTradeManifestRouter } from "../routes/trade.manifest.routes"; +import { ManifestService } from "../services/manifest.service"; import { AuthService } from "../services/auth.service"; -import { ServiceUnavailableError } from "../services/ipfs.service"; +import { IPFSService, ServiceUnavailableError } from "../services/ipfs.service"; +import { ContractService } from "../services/contract.service"; import { errorHandler } from "../middleware/errorHandler"; jest.mock("../services/auth.service", () => ({ @@ -35,7 +37,11 @@ describe("Trade manifest submission route", () => { app.use(express.json()); app.use( "/trades/:id/manifest", - createTradeManifestRouter(manifestService as any, contractService as any, ipfsService as any), + createTradeManifestRouter( + manifestService as unknown as ManifestService, + contractService as unknown as Pick, + ipfsService as unknown as Pick, + ), ); app.use(errorHandler); diff --git a/backend/src/__tests__/trade.routes.test.ts b/backend/src/__tests__/trade.routes.test.ts index 981ce44a..adb84eeb 100644 --- a/backend/src/__tests__/trade.routes.test.ts +++ b/backend/src/__tests__/trade.routes.test.ts @@ -123,7 +123,7 @@ describe("Trade Routes", () => { }); expect(res.status).toBe(400); - expect(res.body.error).toMatch(/sellerAddress/i); + expect(res.body.message).toMatch(/sellerAddress/i); expect(res.body.code).toBe("VALIDATION_ERROR"); }); @@ -134,7 +134,7 @@ describe("Trade Routes", () => { }); expect(res.status).toBe(401); - expect(res.body.error).toBe("Missing Authorization header"); + expect(res.body.error).toBe("Unauthorized"); }); it("returns unsignedXdr for a valid buyer deposit request", async () => { diff --git a/backend/src/__tests__/trade.service.test.ts b/backend/src/__tests__/trade.service.test.ts index 3fe5fb30..d4242174 100644 --- a/backend/src/__tests__/trade.service.test.ts +++ b/backend/src/__tests__/trade.service.test.ts @@ -1,5 +1,6 @@ import { PrismaClient, TradeStatus } from "@prisma/client"; import { TradeAccessDeniedError, TradeService } from "../services/trade.service"; +import { ContractService } from "../services/contract.service"; function createMockPrisma() { return { @@ -18,7 +19,7 @@ describe("TradeService", () => { beforeEach(() => { prisma = createMockPrisma(); - service = new TradeService(prisma, {} as any); + service = new TradeService(prisma, {} as unknown as ContractService); }); it("stores a pending trade with PENDING_SIGNATURE status", async () => { diff --git a/backend/src/__tests__/trade.template.service.test.ts b/backend/src/__tests__/trade.template.service.test.ts index e4841f0d..2ffa480e 100644 --- a/backend/src/__tests__/trade.template.service.test.ts +++ b/backend/src/__tests__/trade.template.service.test.ts @@ -1,8 +1,9 @@ -import { TradeStatus } from "@prisma/client"; +import { PrismaClient, TradeStatus } from "@prisma/client"; import { TradeTemplateNotFoundError, TradeTemplateService, } from "../services/trade.template.service"; +import { ContractService } from "../services/contract.service"; describe("TradeTemplateService", () => { const userAddress = "g-user"; @@ -20,7 +21,10 @@ describe("TradeTemplateService", () => { trade: { create: jest.fn() }, }; const contract = { buildCreateTradeTx: jest.fn() }; - const service = new TradeTemplateService(prisma as any, contract as any); + const service = new TradeTemplateService( + prisma as unknown as Pick, + contract as unknown as ContractService, + ); beforeEach(() => jest.clearAllMocks()); diff --git a/backend/src/__tests__/trade.watchlist.service.test.ts b/backend/src/__tests__/trade.watchlist.service.test.ts index bf65b9f4..4624d1ce 100644 --- a/backend/src/__tests__/trade.watchlist.service.test.ts +++ b/backend/src/__tests__/trade.watchlist.service.test.ts @@ -1,3 +1,4 @@ +import { PrismaClient } from "@prisma/client"; import { TradeWatchlistService } from "../services/trade.watchlist.service"; describe("TradeWatchlistService", () => { @@ -7,7 +8,9 @@ describe("TradeWatchlistService", () => { trade: { findUnique: jest.fn() }, userWatchlist: { upsert: jest.fn(), deleteMany: jest.fn(), findMany: jest.fn() }, }; - const service = new TradeWatchlistService(prisma as any); + const service = new TradeWatchlistService( + prisma as unknown as Pick, + ); beforeEach(() => jest.clearAllMocks()); diff --git a/backend/src/index.ts b/backend/src/index.ts index 766b7bde..726e32e4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -194,7 +194,9 @@ async function bootstrap() { const services: Shutdownable[] = [ { name: "event-listener", - stop: () => eventListenerService.drain(), + stop: async () => { + await eventListenerService.drain(); + }, }, { name: "reconciliation-worker", @@ -218,7 +220,12 @@ async function bootstrap() { await closeAllQueueConnections(); }, }, - { name: "redis", stop: () => redis.quit() }, + { + name: "redis", + stop: async () => { + await redis.quit(); + }, + }, { name: "database", stop: () => prisma.$disconnect() }, ]; await shutDownOrchestrator.shutdown(signal, server, services); diff --git a/backend/src/jobs/workers/export.worker.ts b/backend/src/jobs/workers/export.worker.ts index 53a0b35a..0a800fc2 100644 --- a/backend/src/jobs/workers/export.worker.ts +++ b/backend/src/jobs/workers/export.worker.ts @@ -31,6 +31,10 @@ export function createExportWorker(): Worker { const { requestedBy, format, tradeIds, filters } = job.data; appLogger.info({ jobId: job.id, requestedBy, format }, 'Processing export job'); + if (format === 'pdf') { + throw new Error('PDF exports are not supported by the background export worker'); + } + const where: Record = { ...filters }; if (tradeIds?.length) { where['tradeId'] = { in: tradeIds }; diff --git a/backend/src/lib/metrics.ts b/backend/src/lib/metrics.ts index 9efd4dc0..73058b80 100644 --- a/backend/src/lib/metrics.ts +++ b/backend/src/lib/metrics.ts @@ -572,6 +572,12 @@ function getEventListenerLagHistogram(): Histogram { "Seconds since the most recently processed escrow event was recorded. " + "Drives the event-processing-lag SLO (p95 < 5 min).", unit: "seconds", + // `@opentelemetry/api` >=1.7 exposes bucket-boundary hints through the + // experimental `advice` bag (top-level `explicitBucketBoundaries` was + // removed from `MetricOptions`). + advice: { + explicitBucketBoundaries: [1, 5, 15, 60, 120, 300, 600, 1800, 3600], + }, }, ); } diff --git a/backend/src/test-deps.d.ts b/backend/src/test-deps.d.ts index c059cd09..085d8b90 100644 --- a/backend/src/test-deps.d.ts +++ b/backend/src/test-deps.d.ts @@ -8,14 +8,17 @@ declare module 'ioredis' { del(...args: any[]): Promise; exists(...args: any[]): Promise; keys(...args: any[]): Promise; - quit(): Promise; - ping(): Promise; - sadd(...args: any[]): Promise; - expire(...args: any[]): Promise; - ttl(...args: any[]): Promise; - smembers(...args: any[]): Promise; - srem(...args: any[]): Promise; + quit(): Promise; + ping(message?: string): Promise; + sadd(key: string, ...members: Array): Promise; + expire(key: string, seconds: number): Promise; + ttl(key: string): Promise; + smembers(key: string): Promise; + srem(key: string, ...members: string[]): Promise; + // Command surface is intentionally permissive so the stub does not have to + // be regenerated whenever a new ioredis command is called. on(...args: any[]): any; + [method: string]: any; } }