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
1 change: 1 addition & 0 deletions src/modules/payments/payments.schema.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { StrKey } from "@stellar/stellar-sdk";

Check failure on line 1 in src/modules/payments/payments.schema.ts

View workflow job for this annotation

GitHub Actions / test

'StrKey' is defined but never used
import { z } from "zod";

/**
Expand All @@ -16,7 +17,7 @@
return intPart + decPart;
};

const decimalAmountRegex = /^\d+(\.\d{1,7})?$/;

Check failure on line 20 in src/modules/payments/payments.schema.ts

View workflow job for this annotation

GitHub Actions / test

'decimalAmountRegex' is assigned a value but never used

const amountString = z
.string()
Expand Down
330 changes: 330 additions & 0 deletions tests/payments.schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,36 @@ describe("stellarAssetCodeSchema", () => {
});
});

describe("stellarAddressSchema and isValidStellarAddress", () => {
const validAddress =
"GAJ5AO4AC3FPUCOR7VNSCH4AFW3VUDDAELHBBVY3TQ7GI3GZS2S3WIHA";

it("accepts a valid 56-character Stellar public key", () => {
expect(stellarAddressSchema.safeParse(validAddress).success).toBe(true);
expect(isValidStellarAddress(validAddress)).toBe(true);
});

it("rejects address with invalid length", () => {
const shortAddress = "GAJ5AO4AC3FPUCOR7VNSCH4";
expect(stellarAddressSchema.safeParse(shortAddress).success).toBe(false);
expect(isValidStellarAddress(shortAddress)).toBe(false);
});

it("rejects address with wrong prefix", () => {
const invalidPrefix =
"AAJ5AO4AC3FPUCOR7VNSCH4AFW3VUDDAELHBBVY3TQ7GI3GZS2S3WIHA";
expect(stellarAddressSchema.safeParse(invalidPrefix).success).toBe(false);
expect(isValidStellarAddress(invalidPrefix)).toBe(false);
});

it("rejects address with invalid checksum", () => {
const invalidChecksum =
"GAJ5AO4AC3FPUCOR7VNSCH4AFW3VUDDAELHBBVY3TQ7GI3GZS2S3WIHB";
expect(stellarAddressSchema.safeParse(invalidChecksum).success).toBe(false);
expect(isValidStellarAddress(invalidChecksum)).toBe(false);
});
});

describe("quoteSchema", () => {
it("accepts a valid quote request with XLM", () => {
const result = quoteSchema.safeParse({
Expand Down Expand Up @@ -338,3 +368,303 @@ describe("amountString (via quoteSchema.amount)", () => {
}
});
});

describe("createQuoteSchema", () => {
const validAddress =
"GAJ5AO4AC3FPUCOR7VNSCH4AFW3VUDDAELHBBVY3TQ7GI3GZS2S3WIHA";

it("accepts valid quote creation request with asset codes", () => {
const result = createQuoteSchema.safeParse({
sourceAsset: "XLM",
destinationAsset: "USDC",
sourceAmount: "100.50",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sourceAmount).toBe("100.50");
}
});

it("accepts valid quote creation request with Stellar account address as asset", () => {
const result = createQuoteSchema.safeParse({
sourceAsset: "XLM",
destinationAsset: validAddress,
sourceAmount: "50.00",
});
expect(result.success).toBe(true);
});

it("normalizes leading zeros in sourceAmount", () => {
const result = createQuoteSchema.safeParse({
sourceAsset: "XLM",
destinationAsset: "USDC",
sourceAmount: "007.00",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sourceAmount).toBe("7.00");
}
});

it("rejects malformed sourceAsset", () => {
const result = createQuoteSchema.safeParse({
sourceAsset: "US DC",
destinationAsset: "USDC",
sourceAmount: "100",
});
expect(result.success).toBe(false);
});

it("rejects malformed destinationAsset", () => {
const result = createQuoteSchema.safeParse({
sourceAsset: "XLM",
destinationAsset: "USD-CDC",
sourceAmount: "100",
});
expect(result.success).toBe(false);
});

it("rejects negative sourceAmount", () => {
const result = createQuoteSchema.safeParse({
sourceAsset: "XLM",
destinationAsset: "USDC",
sourceAmount: "-10.00",
});
expect(result.success).toBe(false);
});

it("rejects zero sourceAmount", () => {
const result = createQuoteSchema.safeParse({
sourceAsset: "XLM",
destinationAsset: "USDC",
sourceAmount: "0.00",
});
expect(result.success).toBe(false);
});

it("rejects non-numeric sourceAmount", () => {
const result = createQuoteSchema.safeParse({
sourceAsset: "XLM",
destinationAsset: "USDC",
sourceAmount: "not-a-number",
});
expect(result.success).toBe(false);
});

it("rejects empty sourceAmount", () => {
const result = createQuoteSchema.safeParse({
sourceAsset: "XLM",
destinationAsset: "USDC",
sourceAmount: "",
});
expect(result.success).toBe(false);
});

it("rejects missing required fields", () => {
expect(
createQuoteSchema.safeParse({
destinationAsset: "USDC",
sourceAmount: "100",
}).success,
).toBe(false);

expect(
createQuoteSchema.safeParse({
sourceAsset: "XLM",
sourceAmount: "100",
}).success,
).toBe(false);

expect(
createQuoteSchema.safeParse({
sourceAsset: "XLM",
destinationAsset: "USDC",
}).success,
).toBe(false);
});
});

describe("executePaymentSchema", () => {
it("accepts valid execute payment request", () => {
const result = executePaymentSchema.safeParse({
quoteId: "quote_1700000000_abc123",
confirmed: true,
});
expect(result.success).toBe(true);
});

it("accepts execute payment request with confirmed false", () => {
const result = executePaymentSchema.safeParse({
quoteId: "quote_1700000000_abc123",
confirmed: false,
});
expect(result.success).toBe(true);
});

it("rejects missing confirmed flag", () => {
const result = executePaymentSchema.safeParse({
quoteId: "quote_1700000000_abc123",
});
expect(result.success).toBe(false);
});

it("rejects non-boolean confirmed flag", () => {
const result = executePaymentSchema.safeParse({
quoteId: "quote_1700000000_abc123",
confirmed: "true",
});
expect(result.success).toBe(false);
});

it("rejects non-string quoteId", () => {
const result = executePaymentSchema.safeParse({
quoteId: 123456,
confirmed: true,
});
expect(result.success).toBe(false);
});

it("rejects empty quoteId", () => {
const result = executePaymentSchema.safeParse({
quoteId: "",
confirmed: true,
});
expect(result.success).toBe(false);
});

it("rejects missing quoteId", () => {
const result = executePaymentSchema.safeParse({
confirmed: true,
});
expect(result.success).toBe(false);
});
});

describe("HTTP payments route validation via validateBody", () => {
const app = express();
app.use(express.json());
app.use("/api/v1/payments", paymentsRouter);
app.use(
(
err: Error & {
statusCode?: number;
code?: string;
details?: unknown;
},
_req: express.Request,
res: express.Response,
_next: express.NextFunction,
) => {
void _next;
res.status(err.statusCode || 500).json({
success: false,
code: err.code || "VALIDATION_ERROR",
message: err.message,
details: err.details,
});
},
);

describe("POST /api/v1/payments", () => {
it("returns 400 with VALIDATION_ERROR envelope for malformed Stellar asset code", async () => {
const response = await request(app).post("/api/v1/payments").send({
sourceAsset: "US DC",
destinationAsset: "USDC",
sourceAmount: "100.00",
});

expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.code).toBe("VALIDATION_ERROR");
expect(response.body.details.fieldErrors.sourceAsset).toBeDefined();
});

it("returns 400 with VALIDATION_ERROR envelope for negative sourceAmount", async () => {
const response = await request(app).post("/api/v1/payments").send({
sourceAsset: "XLM",
destinationAsset: "USDC",
sourceAmount: "-50.00",
});

expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.code).toBe("VALIDATION_ERROR");
expect(response.body.details.fieldErrors.sourceAmount).toBeDefined();
});

it("returns 400 with VALIDATION_ERROR envelope for non-numeric sourceAmount", async () => {
const response = await request(app).post("/api/v1/payments").send({
sourceAsset: "XLM",
destinationAsset: "USDC",
sourceAmount: "abc",
});

expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.code).toBe("VALIDATION_ERROR");
expect(response.body.details.fieldErrors.sourceAmount).toBeDefined();
});

it("creates a quote and returns 201 for valid input", async () => {
const response = await request(app).post("/api/v1/payments").send({
sourceAsset: "XLM",
destinationAsset: "USDC",
sourceAmount: "007.00",
});

expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(response.body.data.quote.sourceAmount).toBe("7.00");
});
});

describe("POST /api/v1/payments/execute", () => {
it("returns 400 when confirmed flag is missing", async () => {
const response = await request(app)
.post("/api/v1/payments/execute")
.send({
quoteId: "quote_12345_test",
});

expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.code).toBe("VALIDATION_ERROR");
expect(response.body.details.fieldErrors.confirmed).toBeDefined();
});

it("returns 400 when quoteId is not a string", async () => {
const response = await request(app)
.post("/api/v1/payments/execute")
.send({
quoteId: 12345,
confirmed: true,
});

expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.code).toBe("VALIDATION_ERROR");
expect(response.body.details.fieldErrors.quoteId).toBeDefined();
});

it("returns 200 for valid execute payment payload", async () => {
// First create quote so paymentsService has it
const quoteRes = await request(app).post("/api/v1/payments").send({
sourceAsset: "XLM",
destinationAsset: "USDC",
sourceAmount: "10.00",
});
const quoteId = quoteRes.body.data.quote.id;

const response = await request(app)
.post("/api/v1/payments/execute")
.send({
quoteId,
confirmed: true,
});

expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.payment.status).toBe("settled");
});
});
});
Loading