Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand Down
5 changes: 1 addition & 4 deletions backend/src/__tests__/dispute.cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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);
Expand Down
14 changes: 7 additions & 7 deletions backend/src/__tests__/dispute.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function createMockPrisma() {
disputeCategory: {
findFirst: jest.fn(),
},
} as unknown as PrismaClient;
} as unknown as PrismaClient & { disputeCategory: { findFirst: jest.Mock } };
}

function createMockContractService() {
Expand All @@ -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 = {
Expand All @@ -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({});

Expand Down Expand Up @@ -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");
Expand All @@ -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 },
});
Expand All @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion backend/src/__tests__/dispute.status.transitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});

Expand Down
19 changes: 15 additions & 4 deletions backend/src/__tests__/disputeCategory.routes.test.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down
94 changes: 52 additions & 42 deletions backend/src/__tests__/disputeTransitions.test.ts
Original file line number Diff line number Diff line change
@@ -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<Dispute | null>
>;
create: jest.MockedFunction<
(args: Prisma.DisputeCreateArgs) => Promise<Dispute>
>;
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<Promise<Dispute | null>, [Prisma.DisputeFindUniqueArgs]>(),
create: jest.fn<Promise<Dispute>, [Prisma.DisputeCreateArgs]>(),
updateMany: jest.fn<Promise<{ count: number }>, [Prisma.DisputeUpdateManyArgs]>(),
},
} as any;
};
}

function disputeRow(overrides: Partial<Dispute>): 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", () => {
Expand All @@ -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,
);
Expand All @@ -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,
);
Expand All @@ -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: {
Expand All @@ -74,29 +103,20 @@ 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();
});
});

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: {
Expand All @@ -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",
);
});
Expand Down
10 changes: 9 additions & 1 deletion backend/src/__tests__/stellar.error.handling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -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 ───────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading