diff --git a/backend/middleware/validation.js b/backend/middleware/validation.js index 0ea98e1..6540b85 100644 --- a/backend/middleware/validation.js +++ b/backend/middleware/validation.js @@ -21,18 +21,28 @@ const sanitizeValue = (value) => { }; /** - * Validate request body against Joi schema + * Format Joi validation errors into a consistent array of { field, message } objects. + */ +const formatErrors = (joiError) => + joiError.details.map((d) => ({ + field: d.context?.key ?? d.path.join("."), + message: d.message.replace(/['"]/g, ""), + })); + +/** + * Validate request body against a Joi schema. + * Returns all validation errors at once (abortEarly: false). */ export const validate = (schema) => { return (req, res, next) => { const { error, value } = schema.validate(req.body, { + abortEarly: false, stripUnknown: true, }); - + if (error) { return res.status(400).json({ - error: error.details[0].message, - field: error.details[0].context.key, + errors: formatErrors(error), }); } @@ -43,18 +53,19 @@ export const validate = (schema) => { }; /** - * Validate query parameters against Joi schema + * Validate query parameters against a Joi schema. + * Returns all validation errors at once (abortEarly: false). */ export const validateQuery = (schema) => { return (req, res, next) => { const { error, value } = schema.validate(req.query, { + abortEarly: false, stripUnknown: true, }); - + if (error) { return res.status(400).json({ - error: error.details[0].message, - field: error.details[0].context.key, + errors: formatErrors(error), }); } @@ -65,18 +76,19 @@ export const validateQuery = (schema) => { }; /** - * Validate URL parameters + * Validate URL parameters against a Joi schema. + * Returns all validation errors at once (abortEarly: false). */ export const validateParams = (schema) => { return (req, res, next) => { const { error, value } = schema.validate(req.params, { + abortEarly: false, stripUnknown: true, }); - + if (error) { return res.status(400).json({ - error: error.details[0].message, - field: error.details[0].context.key, + errors: formatErrors(error), }); } diff --git a/backend/package.json b/backend/package.json index 0dda834..73a793e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,7 +15,12 @@ "seed": "knex seed:run", "seed:make": "knex seed:make", "db:reset": "knex migrate:rollback --all && knex migrate:latest && knex seed:run", - "test:db": "node scripts/test-db-setup.js" + "test:db": "node scripts/test-db-setup.js", + "test": "node --experimental-vm-modules $(pnpm bin)/jest --testPathPattern=tests/ --forceExit" + }, + "jest": { + "testEnvironment": "node", + "transform": {} }, "dependencies": { "@bull-board/api": "^6.13.0", @@ -51,6 +56,9 @@ "starknet": "^7.6.4" }, "devDependencies": { - "nodemon": "^3.0.1" + "@jest/globals": "^29.0.0", + "jest": "^29.0.0", + "nodemon": "^3.0.1", + "supertest": "^7.0.0" } -} +} \ No newline at end of file diff --git a/backend/routes/balances.js b/backend/routes/balances.js index b4daa55..c9d95fa 100644 --- a/backend/routes/balances.js +++ b/backend/routes/balances.js @@ -10,12 +10,14 @@ import { getBalanceByTag, } from "../controllers/balanceController.js"; import { authenticate } from "../middleware/auth.js"; +import { validate } from "../middleware/validation.js"; +import { balanceCreateSchema } from "../schemas/balance.js"; import { balanceQueryLimiter } from "../config/rateLimiting.js"; const router = express.Router(); // Apply balance query rate limiter: 1000 per hour per API key/user -router.post("/", authenticate, createBalance); +router.post("/", authenticate, validate(balanceCreateSchema), createBalance); router.get("/all", authenticate, balanceQueryLimiter, getBalances); router.get("/", authenticate, balanceQueryLimiter, getBalanceByUser); router.get("/sync", authenticate, updateUserBalance); diff --git a/backend/routes/kycs.js b/backend/routes/kycs.js index 5d967c7..6c618cd 100644 --- a/backend/routes/kycs.js +++ b/backend/routes/kycs.js @@ -7,13 +7,15 @@ import { getKycByUser, } from "../controllers/kycController.js"; import { authenticate } from "../middleware/auth.js"; +import { validate } from "../middleware/validation.js"; +import { kycCreateSchema, kycUpdateSchema } from "../schemas/kyc.js"; const router = express.Router(); -router.post("/", authenticate, createKyc); +router.post("/", authenticate, validate(kycCreateSchema), createKyc); router.get("/", authenticate, getKycByUser); router.get("/:id", authenticate, getKycById); -router.put("/:id", authenticate, updateKyc); +router.put("/:id", authenticate, validate(kycUpdateSchema), updateKyc); router.delete("/:id", authenticate, deleteKyc); export default router; diff --git a/backend/routes/transactions.js b/backend/routes/transactions.js index fd9b82b..c50aef3 100644 --- a/backend/routes/transactions.js +++ b/backend/routes/transactions.js @@ -14,6 +14,7 @@ import { import { authenticate } from "../middleware/auth.js"; import { validate, validateQuery } from "../middleware/validation.js"; import { transactionSchema, transactionQuerySchema } from "../schemas/transaction.js"; +import { processPaymentSchema } from "../schemas/payment.js"; import { paymentLimiter } from "../config/rateLimiting.js"; const router = express.Router(); @@ -25,7 +26,7 @@ router.put("/:id", authenticate, paymentLimiter, validate(transactionSchema), up router.delete("/:id", authenticate, paymentLimiter, deleteTransaction); // Payment operations -router.post("/payment", authenticate, processPayment); +router.post("/payment", authenticate, paymentLimiter, validate(processPaymentSchema), processPayment); router.get("/payment/limits", getPaymentLimits); router.get("/tag/:tag/history", getPaymentHistory); diff --git a/backend/routes/users.js b/backend/routes/users.js index 643a052..3e85113 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -1,23 +1,17 @@ import express from "express"; import { - // getUsers, - // getUserById, - // updateUser, - // deleteUser, profile, edit_profile, dashboard_summary, } from "../controllers/userController.js"; import { authenticate } from "../middleware/auth.js"; +import { validate } from "../middleware/validation.js"; +import { editProfileSchema } from "../schemas/user.js"; const router = express.Router(); router.get("/profile", authenticate, profile); router.get("/dashboard-summary", authenticate, dashboard_summary); -router.post("/profile", authenticate, edit_profile); -// router.get("/", authenticate, getUsers); -// router.get("/:id", authenticate, getUserById); -// router.put("/:id", authenticate, updateUser); -// router.delete("/:id", authenticate, deleteUser); +router.post("/profile", authenticate, validate(editProfileSchema), edit_profile); export default router; diff --git a/backend/routes/wallets.js b/backend/routes/wallets.js index b6af0ca..490c1a9 100644 --- a/backend/routes/wallets.js +++ b/backend/routes/wallets.js @@ -8,12 +8,14 @@ import { send_to_wallet, } from "../controllers/walletController.js"; import { authenticate } from "../middleware/auth.js"; +import { validate } from "../middleware/validation.js"; +import { sendToTagSchema, sendToWalletSchema } from "../schemas/wallet.js"; const router = express.Router(); router.get("/", authenticate, getWalletByUserId); -router.post("/send-to-tag", authenticate, send_to_tag); -router.post("/send-to-wallet", authenticate, send_to_wallet); +router.post("/send-to-tag", authenticate, validate(sendToTagSchema), send_to_tag); +router.post("/send-to-wallet", authenticate, validate(sendToWalletSchema), send_to_wallet); router.get("/:id", authenticate, getWalletById); router.put("/:id", authenticate, updateWallet); router.delete("/:id", authenticate, deleteWallet); diff --git a/backend/schemas/auth.js b/backend/schemas/auth.js index 48ddf0b..a51ea5c 100644 --- a/backend/schemas/auth.js +++ b/backend/schemas/auth.js @@ -1,16 +1,63 @@ import Joi from "joi"; +const passwordRule = Joi.string() + .min(8) + .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/) + .required() + .messages({ + "string.min": "Password must be at least 8 characters long", + "string.pattern.base": + "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)", + "any.required": "Password is required", + "string.empty": "Password cannot be empty", + }); + export const authSchemas = { register: Joi.object({ - tag: Joi.string().min(3).max(50).required(), - email: Joi.string().email().required(), - address: Joi.any().allow("", null), - password: Joi.string().min(6).required(), - role: Joi.any().allow("", null), + tag: Joi.string() + .min(3) + .max(50) + .pattern(/^[a-zA-Z0-9_]+$/) + .required() + .messages({ + "string.min": "Tag must be at least 3 characters long", + "string.max": "Tag must be at most 50 characters long", + "string.pattern.base": "Tag may only contain letters, numbers, and underscores", + "any.required": "Tag is required", + "string.empty": "Tag cannot be empty", + }), + + email: Joi.string() + .email() + .lowercase() + .required() + .messages({ + "string.email": "Please provide a valid email address", + "any.required": "Email is required", + "string.empty": "Email cannot be empty", + }), + + password: passwordRule, + + address: Joi.string().allow("", null).optional(), + role: Joi.string().valid("user", "admin").allow("", null).optional(), }), login: Joi.object({ - email: Joi.string().required(), - password: Joi.string().required(), + email: Joi.string() + .email() + .required() + .messages({ + "string.email": "Please provide a valid email address", + "any.required": "Email is required", + "string.empty": "Email cannot be empty", + }), + + password: Joi.string() + .required() + .messages({ + "any.required": "Password is required", + "string.empty": "Password cannot be empty", + }), }), }; diff --git a/backend/schemas/balance.js b/backend/schemas/balance.js index 86c7b0e..c3c92cc 100644 --- a/backend/schemas/balance.js +++ b/backend/schemas/balance.js @@ -1,13 +1,61 @@ import Joi from "joi"; -export const balanceSchema = Joi.object({ - user_id: Joi.any().allow("", null), - token: Joi.any().allow("", null), - symbol: Joi.any().allow("", null), - chain: Joi.any().allow("", null), - amount: Joi.any().allow("", null), - usd_value: Joi.any().allow("", null), - tag: Joi.any().allow("", null), - address: Joi.any().allow("", null), - auto_convert_threshold: Joi.any().allow("", null), +/** + * Schema for creating a new balance entry. + */ +export const balanceCreateSchema = Joi.object({ + token: Joi.string() + .required() + .messages({ + "any.required": "Token is required", + "string.empty": "Token cannot be empty", + }), + + symbol: Joi.string() + .uppercase() + .max(12) + .required() + .messages({ + "string.max": "Symbol must be at most 12 characters", + "any.required": "Symbol is required", + "string.empty": "Symbol cannot be empty", + }), + + chain: Joi.string() + .required() + .messages({ + "any.required": "Chain is required", + "string.empty": "Chain cannot be empty", + }), + + amount: Joi.number() + .min(0) + .optional() + .messages({ + "number.min": "Amount cannot be negative", + "number.base": "Amount must be a number", + }), + + usd_value: Joi.number().min(0).optional().messages({ + "number.min": "USD value cannot be negative", + "number.base": "USD value must be a number", + }), + + tag: Joi.string().allow("", null).optional(), + + address: Joi.string().allow("", null).optional(), + + auto_convert_threshold: Joi.number() + .min(0) + .allow(null) + .optional() + .messages({ + "number.min": "Auto-convert threshold cannot be negative", + "number.base": "Auto-convert threshold must be a number", + }), }); + +/** + * Alias used in existing route (kept for backwards compatibility). + */ +export const balanceSchema = balanceCreateSchema; diff --git a/backend/schemas/kyc.js b/backend/schemas/kyc.js index e69de29..a4fcf80 100644 --- a/backend/schemas/kyc.js +++ b/backend/schemas/kyc.js @@ -0,0 +1,119 @@ +import Joi from "joi"; + +/** + * Schema for creating a new KYC submission. + * All identity fields are required on initial submission. + */ +export const kycCreateSchema = Joi.object({ + first_name: Joi.string() + .min(2) + .max(100) + .required() + .messages({ + "string.min": "First name must be at least 2 characters", + "string.max": "First name must be at most 100 characters", + "any.required": "First name is required", + "string.empty": "First name cannot be empty", + }), + + last_name: Joi.string() + .min(2) + .max(100) + .required() + .messages({ + "string.min": "Last name must be at least 2 characters", + "string.max": "Last name must be at most 100 characters", + "any.required": "Last name is required", + "string.empty": "Last name cannot be empty", + }), + + dob: Joi.string() + .isoDate() + .required() + .messages({ + "string.isoDate": "Date of birth must be a valid ISO 8601 date (YYYY-MM-DD)", + "any.required": "Date of birth is required", + "string.empty": "Date of birth cannot be empty", + }), + + country: Joi.string() + .min(2) + .max(100) + .required() + .messages({ + "string.min": "Country must be at least 2 characters", + "any.required": "Country is required", + "string.empty": "Country cannot be empty", + }), + + id_type: Joi.string() + .valid("passport", "national_id", "drivers_license", "residence_permit") + .required() + .messages({ + "any.only": "ID type must be one of: passport, national_id, drivers_license, residence_permit", + "any.required": "ID type is required", + "string.empty": "ID type cannot be empty", + }), + + id_number: Joi.string() + .min(3) + .max(50) + .required() + .messages({ + "string.min": "ID number must be at least 3 characters", + "string.max": "ID number must be at most 50 characters", + "any.required": "ID number is required", + "string.empty": "ID number cannot be empty", + }), + + id_image_url: Joi.string().uri().allow("", null).optional().messages({ + "string.uri": "ID image URL must be a valid URL", + }), + + selfie_url: Joi.string().uri().allow("", null).optional().messages({ + "string.uri": "Selfie URL must be a valid URL", + }), +}); + +/** + * Schema for updating an existing KYC record. + * All fields are optional (partial update); at least one must be provided. + */ +export const kycUpdateSchema = Joi.object({ + first_name: Joi.string().min(2).max(100).optional().messages({ + "string.min": "First name must be at least 2 characters", + "string.max": "First name must be at most 100 characters", + }), + + last_name: Joi.string().min(2).max(100).optional().messages({ + "string.min": "Last name must be at least 2 characters", + "string.max": "Last name must be at most 100 characters", + }), + + dob: Joi.string().isoDate().optional().messages({ + "string.isoDate": "Date of birth must be a valid ISO 8601 date (YYYY-MM-DD)", + }), + + country: Joi.string().min(2).max(100).optional(), + + id_type: Joi.string() + .valid("passport", "national_id", "drivers_license", "residence_permit") + .optional() + .messages({ + "any.only": "ID type must be one of: passport, national_id, drivers_license, residence_permit", + }), + + id_number: Joi.string().min(3).max(50).optional(), + + id_image_url: Joi.string().uri().allow("", null).optional().messages({ + "string.uri": "ID image URL must be a valid URL", + }), + + selfie_url: Joi.string().uri().allow("", null).optional().messages({ + "string.uri": "Selfie URL must be a valid URL", + }), +}) + .min(1) + .messages({ + "object.min": "At least one field must be provided for update", + }); diff --git a/backend/schemas/transaction.js b/backend/schemas/transaction.js index 9ecf1c6..34a4c5f 100644 --- a/backend/schemas/transaction.js +++ b/backend/schemas/transaction.js @@ -1,34 +1,79 @@ import Joi from "joi"; +/** + * Query parameter schema for listing transactions. + */ export const transactionQuerySchema = Joi.object({ - limit: Joi.number().integer().min(1).max(100).default(20), - offset: Joi.number().integer().min(0).default(0), - from: Joi.string().isoDate().allow(null, ""), - to: Joi.string().isoDate().allow(null, ""), + limit: Joi.number().integer().min(1).max(100).default(20).messages({ + "number.min": "Limit must be at least 1", + "number.max": "Limit cannot exceed 100", + "number.integer": "Limit must be a whole number", + }), + offset: Joi.number().integer().min(0).default(0).messages({ + "number.min": "Offset cannot be negative", + "number.integer": "Offset must be a whole number", + }), + from: Joi.string().isoDate().allow(null, "").optional().messages({ + "string.isoDate": "From date must be a valid ISO 8601 date", + }), + to: Joi.string().isoDate().allow(null, "").optional().messages({ + "string.isoDate": "To date must be a valid ISO 8601 date", + }), type: Joi.string() .valid("payment", "account_merge", "credit", "debit", "transfer", "deposit", "withdrawal") - .allow(null, ""), + .allow(null, "") + .optional() + .messages({ + "any.only": "Type must be one of: payment, account_merge, credit, debit, transfer, deposit, withdrawal", + }), sortBy: Joi.string() .valid("created_at", "amount", "usd_value", "type", "status") - .default("created_at"), - sortOrder: Joi.string().valid("asc", "desc").default("desc"), + .default("created_at") + .messages({ + "any.only": "sortBy must be one of: created_at, amount, usd_value, type, status", + }), + sortOrder: Joi.string().valid("asc", "desc").default("desc").messages({ + "any.only": "sortOrder must be asc or desc", + }), }); +/** + * Body schema for updating a transaction (all fields optional — partial update). + */ export const transactionSchema = Joi.object({ - user_id: Joi.any().allow("", null), - wallet_id: Joi.any().allow("", null), - reference: Joi.any().allow("", null), - type: Joi.any().allow("", null), - action: Joi.any().allow("", null), - amount: Joi.any().allow("", null), - balance_before: Joi.any().allow("", null), - balance_after: Joi.any().allow("", null), - status: Joi.any().allow("", null), - hash: Joi.any().allow("", null), - token: Joi.any().allow("", null), - rate: Joi.any().allow("", null), - description: Joi.any().allow("", null), - extra: Joi.any().allow("", null), - created_at: Joi.any().allow("", null), - updated_at: Joi.any().allow("", null), -}); + reference: Joi.string().max(100).optional(), + + type: Joi.string() + .valid("payment", "credit", "debit", "transfer", "deposit", "withdrawal") + .optional() + .messages({ + "any.only": "Type must be one of: payment, credit, debit, transfer, deposit, withdrawal", + }), + + action: Joi.string().max(50).allow(null, "").optional(), + + amount: Joi.number().positive().optional().messages({ + "number.positive": "Amount must be greater than 0", + "number.base": "Amount must be a number", + }), + + status: Joi.string() + .valid("pending", "completed", "failed", "cancelled") + .optional() + .messages({ + "any.only": "Status must be one of: pending, completed, failed, cancelled", + }), + + hash: Joi.string().allow(null, "").optional(), + token: Joi.string().allow(null, "").optional(), + + rate: Joi.number().min(0).allow(null).optional(), + + description: Joi.string().max(500).allow(null, "").optional(), + + extra: Joi.object().allow(null).optional(), +}) + .min(1) + .messages({ + "object.min": "At least one field must be provided for update", + }); diff --git a/backend/schemas/user.js b/backend/schemas/user.js new file mode 100644 index 0000000..fec7924 --- /dev/null +++ b/backend/schemas/user.js @@ -0,0 +1,37 @@ +import Joi from "joi"; + +/** + * Schema for editing the authenticated user's profile. + * Email and password changes are handled via dedicated endpoints, + * so they are intentionally excluded here. + */ +export const editProfileSchema = Joi.object({ + tag: Joi.string() + .min(3) + .max(50) + .pattern(/^[a-zA-Z0-9_]+$/) + .optional() + .messages({ + "string.min": "Tag must be at least 3 characters long", + "string.max": "Tag must be at most 50 characters long", + "string.pattern.base": "Tag may only contain letters, numbers, and underscores", + }), + + phone: Joi.string() + .pattern(/^[+]?[(]?[0-9]{3}[)]?[-\s.]?[0-9]{3}[-\s.]?[0-9]{4,6}$/) + .allow("", null) + .optional() + .messages({ + "string.pattern.base": "Please provide a valid phone number", + }), + + avatar_url: Joi.string().uri().allow("", null).optional().messages({ + "string.uri": "Avatar URL must be a valid URL", + }), + + full_name: Joi.string().min(2).max(200).allow("", null).optional(), +}) + .min(1) + .messages({ + "object.min": "At least one field must be provided to update your profile", + }); diff --git a/backend/schemas/wallet.js b/backend/schemas/wallet.js new file mode 100644 index 0000000..f50dcb0 --- /dev/null +++ b/backend/schemas/wallet.js @@ -0,0 +1,69 @@ +import Joi from "joi"; + +/** + * Schema for sending funds to another user via their @tag. + */ +export const sendToTagSchema = Joi.object({ + receiver_tag: Joi.string() + .pattern(/^[a-zA-Z0-9_]{3,20}$/) + .required() + .messages({ + "string.pattern.base": "Receiver tag must be 3-20 alphanumeric characters (underscores allowed)", + "any.required": "Receiver tag is required", + "string.empty": "Receiver tag cannot be empty", + }), + + amount: Joi.number() + .positive() + .required() + .messages({ + "number.positive": "Amount must be greater than 0", + "number.base": "Amount must be a valid number", + "any.required": "Amount is required", + }), + + balance_id: Joi.number() + .integer() + .positive() + .required() + .messages({ + "number.integer": "Balance ID must be a whole number", + "number.positive": "Balance ID must be a positive number", + "number.base": "Balance ID must be a valid number", + "any.required": "Balance ID is required", + }), +}); + +/** + * Schema for sending funds to an external blockchain wallet address. + */ +export const sendToWalletSchema = Joi.object({ + receiver_address: Joi.string() + .min(10) + .required() + .messages({ + "string.min": "Receiver address appears to be too short", + "any.required": "Receiver address is required", + "string.empty": "Receiver address cannot be empty", + }), + + amount: Joi.number() + .positive() + .required() + .messages({ + "number.positive": "Amount must be greater than 0", + "number.base": "Amount must be a valid number", + "any.required": "Amount is required", + }), + + balance_id: Joi.number() + .integer() + .positive() + .required() + .messages({ + "number.integer": "Balance ID must be a whole number", + "number.positive": "Balance ID must be a positive number", + "number.base": "Balance ID must be a valid number", + "any.required": "Balance ID is required", + }), +}); diff --git a/backend/tests/validation.test.js b/backend/tests/validation.test.js new file mode 100644 index 0000000..f3ff384 --- /dev/null +++ b/backend/tests/validation.test.js @@ -0,0 +1,390 @@ +/** + * Validation Middleware Tests + * + * These tests spin up a minimal Express app — no database, no real controllers — + * and verify that the validation middleware correctly: + * - Rejects invalid request bodies with HTTP 400 and a structured errors array + * - Allows valid request bodies to pass through to the (mocked) controller + * + * Run with: pnpm test + */ + +import express from "express"; +import request from "supertest"; +import { describe, it, expect } from "@jest/globals"; + +import { validate, validateQuery } from "../middleware/validation.js"; +import { authSchemas } from "../schemas/auth.js"; +import { processPaymentSchema } from "../schemas/payment.js"; +import { kycCreateSchema } from "../schemas/kyc.js"; +import { sendToTagSchema, sendToWalletSchema } from "../schemas/wallet.js"; +import { transactionQuerySchema } from "../schemas/transaction.js"; +import { balanceCreateSchema } from "../schemas/balance.js"; +import { editProfileSchema } from "../schemas/user.js"; + +// Helper: build a tiny Express app with a single route using the given middleware +function buildApp(method, path, ...middlewares) { + const app = express(); + app.use(express.json()); + app[method](path, ...middlewares, (req, res) => { + res.status(200).json({ ok: true }); + }); + return app; +} + +// ───────────────────────────────────────────── +// AUTH – Register +// ───────────────────────────────────────────── +describe("POST /auth/register validation", () => { + const app = buildApp("post", "/register", validate(authSchemas.register)); + + it("returns 400 with errors array when body is empty", async () => { + const res = await request(app).post("/register").send({}); + expect(res.status).toBe(400); + expect(Array.isArray(res.body.errors)).toBe(true); + expect(res.body.errors.length).toBeGreaterThan(0); + }); + + it("returns 400 when email is invalid", async () => { + const res = await request(app).post("/register").send({ + tag: "validtag", + email: "not-an-email", + password: "StrongP@ss1", + }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("email"); + }); + + it("returns 400 when password is too weak (no uppercase/special char)", async () => { + const res = await request(app).post("/register").send({ + tag: "validtag", + email: "user@test.com", + password: "weakpassword", + }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("password"); + }); + + it("returns 400 when tag contains invalid characters", async () => { + const res = await request(app).post("/register").send({ + tag: "bad tag!", + email: "user@test.com", + password: "StrongP@ss1", + }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("tag"); + }); + + it("returns 200 when all required fields are valid", async () => { + const res = await request(app).post("/register").send({ + tag: "valid_user", + email: "user@test.com", + password: "StrongP@ss1", + }); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + }); +}); + +// ───────────────────────────────────────────── +// AUTH – Login +// ───────────────────────────────────────────── +describe("POST /auth/login validation", () => { + const app = buildApp("post", "/login", validate(authSchemas.login)); + + it("returns 400 when email is missing", async () => { + const res = await request(app).post("/login").send({ password: "test1234" }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("email"); + }); + + it("returns 400 when password is missing", async () => { + const res = await request(app).post("/login").send({ email: "user@test.com" }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("password"); + }); + + it("returns 200 when both email and password are provided", async () => { + const res = await request(app).post("/login").send({ + email: "user@test.com", + password: "anypassword", + }); + expect(res.status).toBe(200); + }); +}); + +// ───────────────────────────────────────────── +// PAYMENT – processPayment body +// ───────────────────────────────────────────── +describe("POST /transactions/payment validation", () => { + const app = buildApp("post", "/payment", validate(processPaymentSchema)); + + it("returns 400 when body is empty", async () => { + const res = await request(app).post("/payment").send({}); + expect(res.status).toBe(400); + expect(Array.isArray(res.body.errors)).toBe(true); + }); + + it("returns 400 when senderTag is missing", async () => { + const res = await request(app).post("/payment").send({ + recipientTag: "bob", + amount: 10, + senderSecret: "SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("senderTag"); + }); + + it("returns 400 when amount is negative", async () => { + const res = await request(app).post("/payment").send({ + senderTag: "alice", + recipientTag: "bob", + amount: -5, + senderSecret: "SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("amount"); + }); + + it("returns 400 when senderSecret format is invalid", async () => { + const res = await request(app).post("/payment").send({ + senderTag: "alice", + recipientTag: "bob", + amount: 10, + senderSecret: "INVALID", + }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("senderSecret"); + }); + + it("returns 200 with a valid payment payload", async () => { + const res = await request(app).post("/payment").send({ + senderTag: "alice", + recipientTag: "bob", + amount: 10, + senderSecret: "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }); + expect(res.status).toBe(200); + }); +}); + +// ───────────────────────────────────────────── +// KYC – Create +// ───────────────────────────────────────────── +describe("POST /kycs validation", () => { + const app = buildApp("post", "/kyc", validate(kycCreateSchema)); + + it("returns 400 when body is empty", async () => { + const res = await request(app).post("/kyc").send({}); + expect(res.status).toBe(400); + expect(Array.isArray(res.body.errors)).toBe(true); + }); + + it("returns 400 when required fields are missing", async () => { + const res = await request(app).post("/kyc").send({ first_name: "John" }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("last_name"); + expect(fields).toContain("dob"); + expect(fields).toContain("country"); + expect(fields).toContain("id_type"); + expect(fields).toContain("id_number"); + }); + + it("returns 400 when id_type is invalid", async () => { + const res = await request(app).post("/kyc").send({ + first_name: "John", + last_name: "Doe", + dob: "1990-01-01", + country: "Nigeria", + id_type: "credit_card", + id_number: "AB12345", + }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("id_type"); + }); + + it("returns 200 with a valid KYC payload", async () => { + const res = await request(app).post("/kyc").send({ + first_name: "John", + last_name: "Doe", + dob: "1990-01-01", + country: "Nigeria", + id_type: "national_id", + id_number: "AB12345", + }); + expect(res.status).toBe(200); + }); +}); + +// ───────────────────────────────────────────── +// WALLETS – send-to-tag +// ───────────────────────────────────────────── +describe("POST /wallets/send-to-tag validation", () => { + const app = buildApp("post", "/send-to-tag", validate(sendToTagSchema)); + + it("returns 400 when receiver_tag is missing", async () => { + const res = await request(app).post("/send-to-tag").send({ amount: 10, balance_id: 1 }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("receiver_tag"); + }); + + it("returns 400 when amount is zero", async () => { + const res = await request(app) + .post("/send-to-tag") + .send({ receiver_tag: "bob", amount: 0, balance_id: 1 }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("amount"); + }); + + it("returns 400 when balance_id is missing", async () => { + const res = await request(app) + .post("/send-to-tag") + .send({ receiver_tag: "bob", amount: 10 }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("balance_id"); + }); + + it("returns 200 with a valid payload", async () => { + const res = await request(app) + .post("/send-to-tag") + .send({ receiver_tag: "bob_123", amount: 10, balance_id: 1 }); + expect(res.status).toBe(200); + }); +}); + +// ───────────────────────────────────────────── +// WALLETS – send-to-wallet +// ───────────────────────────────────────────── +describe("POST /wallets/send-to-wallet validation", () => { + const app = buildApp("post", "/send-to-wallet", validate(sendToWalletSchema)); + + it("returns 400 when receiver_address is missing", async () => { + const res = await request(app).post("/send-to-wallet").send({ amount: 5, balance_id: 2 }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("receiver_address"); + }); + + it("returns 400 when amount is missing", async () => { + const res = await request(app) + .post("/send-to-wallet") + .send({ receiver_address: "0xABC1234567890DEF", balance_id: 2 }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("amount"); + }); + + it("returns 200 with a valid payload", async () => { + const res = await request(app).post("/send-to-wallet").send({ + receiver_address: "0xABC1234567890DEFABC", + amount: 5, + balance_id: 2, + }); + expect(res.status).toBe(200); + }); +}); + +// ───────────────────────────────────────────── +// TRANSACTIONS – query params +// ───────────────────────────────────────────── +describe("GET /transactions query validation", () => { + const app = buildApp("get", "/txns", validateQuery(transactionQuerySchema)); + + it("returns 400 when sortOrder is invalid", async () => { + const res = await request(app).get("/txns?sortOrder=random"); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("sortOrder"); + }); + + it("returns 400 when limit exceeds 100", async () => { + const res = await request(app).get("/txns?limit=200"); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("limit"); + }); + + it("returns 200 with valid query params", async () => { + const res = await request(app).get("/txns?limit=10&sortOrder=asc&sortBy=amount"); + expect(res.status).toBe(200); + }); + + it("returns 200 with default (empty) query params", async () => { + const res = await request(app).get("/txns"); + expect(res.status).toBe(200); + }); +}); + +// ───────────────────────────────────────────── +// BALANCE – create +// ───────────────────────────────────────────── +describe("POST /balances validation", () => { + const app = buildApp("post", "/balances", validate(balanceCreateSchema)); + + it("returns 400 when required token field is missing", async () => { + const res = await request(app).post("/balances").send({ symbol: "ETH", chain: "evm" }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("token"); + }); + + it("returns 400 when amount is negative", async () => { + const res = await request(app) + .post("/balances") + .send({ token: "ethereum", symbol: "ETH", chain: "evm", amount: -1 }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("amount"); + }); + + it("returns 200 with valid balance payload", async () => { + const res = await request(app) + .post("/balances") + .send({ token: "ethereum", symbol: "ETH", chain: "evm" }); + expect(res.status).toBe(200); + }); +}); + +// ───────────────────────────────────────────── +// USER – edit profile +// ───────────────────────────────────────────── +describe("POST /users/profile validation", () => { + const app = buildApp("post", "/profile", validate(editProfileSchema)); + + it("returns 400 when body is empty (nothing to update)", async () => { + const res = await request(app).post("/profile").send({}); + expect(res.status).toBe(400); + }); + + it("returns 400 when tag has invalid characters", async () => { + const res = await request(app).post("/profile").send({ tag: "bad tag!" }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("tag"); + }); + + it("returns 400 when avatar_url is not a valid URL", async () => { + const res = await request(app).post("/profile").send({ avatar_url: "not-a-url" }); + expect(res.status).toBe(400); + const fields = res.body.errors.map((e) => e.field); + expect(fields).toContain("avatar_url"); + }); + + it("returns 200 with a valid profile update", async () => { + const res = await request(app).post("/profile").send({ tag: "new_tag_99" }); + expect(res.status).toBe(200); + }); +});