diff --git a/src/__tests__/invoice.api.test.js b/src/__tests__/invoice.api.test.js index 52445b6f..a4d4c93e 100644 --- a/src/__tests__/invoice.api.test.js +++ b/src/__tests__/invoice.api.test.js @@ -2,8 +2,10 @@ const request = require('supertest'); const { createApp } = require('../app'); const invoiceService = require('../services/invoice.service'); -// Mock the service -jest.mock('../services/invoice.service'); +// Mock the service — use a factory to avoid loading the real module (which requires knex) +jest.mock('../services/invoice.service', () => ({ + getInvoices: jest.fn(), +})); describe('Invoice API Integration', () => { let app; @@ -92,6 +94,46 @@ describe('Invoice API Integration', () => { expect(res.body.errors).toContain('Invalid dateFrom format. Use YYYY-MM-DD'); }); + it('should reject an invalid smeId with 400', async () => { + const res = await request(app).get('/api/invoices?smeId='); + + expect(res.statusCode).toBe(400); + expect(res.body.errors).toContain('Invalid smeId format'); + }); + + it('should filter by buyer ID', async () => { + invoiceService.getInvoices.mockResolvedValue([]); + + const res = await request(app).get('/api/invoices?buyerId=buyer-456'); + + expect(res.statusCode).toBe(200); + expect(invoiceService.getInvoices).toHaveBeenCalledWith({ + filters: { buyerId: 'buyer-456' }, + sorting: {} + }); + }); + + it('should reject an invalid buyerId with 400', async () => { + const res = await request(app).get('/api/invoices?buyerId='); + + expect(res.statusCode).toBe(400); + expect(res.body.errors).toContain('Invalid buyerId format'); + }); + + it('should reject an invalid dateTo format with 400', async () => { + const res = await request(app).get('/api/invoices?dateTo=2023/12/31'); + + expect(res.statusCode).toBe(400); + expect(res.body.errors).toContain('Invalid dateTo format. Use YYYY-MM-DD'); + }); + + it('should reject an invalid order value with 400', async () => { + const res = await request(app).get('/api/invoices?order=sideways'); + + expect(res.statusCode).toBe(400); + expect(res.body.errors).toContain('Invalid order. Must be "asc" or "desc"'); + }); + it('should reject multiple invalid inputs with 400', async () => { const res = await request(app).get('/api/invoices?status=bad&sortBy=wrong'); @@ -108,4 +150,119 @@ describe('Invoice API Integration', () => { expect(res.body.error).toBe('Internal server error'); }); }); + + describe('POST /api/invoices — payload validation', () => { + const validPayload = { + amount: 1500, + dueDate: '2026-12-31', + buyer: 'Acme Corp', + seller: 'Stellar Goods Ltd', + currency: 'USD', + }; + + it('should return 201 for a fully valid payload', async () => { + const res = await request(app).post('/api/invoices').send(validPayload); + expect(res.statusCode).toBe(201); + expect(res.body.data).toHaveProperty('id', 'placeholder'); + }); + + it('should return 201 and normalise currency to uppercase', async () => { + const res = await request(app) + .post('/api/invoices') + .send({ ...validPayload, currency: 'eur' }); + expect(res.statusCode).toBe(201); + }); + + it('should return 400 when no body is sent', async () => { + const res = await request(app).post('/api/invoices'); + expect(res.statusCode).toBe(400); + expect(res.body.errors).toContain('amount is required'); + }); + + it('should return 400 when all fields are missing', async () => { + const res = await request(app).post('/api/invoices').send({}); + expect(res.statusCode).toBe(400); + expect(res.body.errors).toContain('amount is required'); + expect(res.body.errors).toContain('dueDate is required'); + expect(res.body.errors).toContain('buyer is required'); + expect(res.body.errors).toContain('seller is required'); + expect(res.body.errors).toContain('currency is required'); + }); + + it('should return 400 when amount is zero', async () => { + const res = await request(app) + .post('/api/invoices') + .send({ ...validPayload, amount: 0 }); + expect(res.statusCode).toBe(400); + expect(res.body.errors).toContain('amount must be a positive number'); + }); + + it('should return 400 when amount is negative', async () => { + const res = await request(app) + .post('/api/invoices') + .send({ ...validPayload, amount: -100 }); + expect(res.statusCode).toBe(400); + expect(res.body.errors).toContain('amount must be a positive number'); + }); + + it('should return 400 when amount is a string', async () => { + const res = await request(app) + .post('/api/invoices') + .send({ ...validPayload, amount: '1500' }); + expect(res.statusCode).toBe(400); + expect(res.body.errors).toContain('amount must be a positive number'); + }); + + it('should return 400 when dueDate is in wrong format', async () => { + const res = await request(app) + .post('/api/invoices') + .send({ ...validPayload, dueDate: '31/12/2026' }); + expect(res.statusCode).toBe(400); + expect(res.body.errors).toContain('dueDate must be a valid date in YYYY-MM-DD format'); + }); + + it('should return 400 when dueDate is an impossible date', async () => { + const res = await request(app) + .post('/api/invoices') + .send({ ...validPayload, dueDate: '2026-13-01' }); + expect(res.statusCode).toBe(400); + expect(res.body.errors).toContain('dueDate must be a valid date in YYYY-MM-DD format'); + }); + + it('should return 400 when buyer is an empty string', async () => { + const res = await request(app) + .post('/api/invoices') + .send({ ...validPayload, buyer: ' ' }); + expect(res.statusCode).toBe(400); + expect(res.body.errors).toContain('buyer must be a non-empty string'); + }); + + it('should return 400 when seller is an empty string', async () => { + const res = await request(app) + .post('/api/invoices') + .send({ ...validPayload, seller: '' }); + expect(res.statusCode).toBe(400); + expect(res.body.errors).toContain('seller must be a non-empty string'); + }); + + it('should return 400 when currency is unsupported', async () => { + const res = await request(app) + .post('/api/invoices') + .send({ ...validPayload, currency: 'XYZ' }); + expect(res.statusCode).toBe(400); + expect(res.body.errors).toContain( + 'currency must be a supported ISO 4217 code (e.g. USD, EUR, GBP)' + ); + }); + + it('should return 400 and collect multiple errors at once', async () => { + const res = await request(app) + .post('/api/invoices') + .send({ amount: -1, dueDate: 'not-a-date' }); + expect(res.statusCode).toBe(400); + expect(res.body.errors.length).toBeGreaterThanOrEqual(2); + expect(res.body.errors).toContain('amount must be a positive number'); + expect(res.body.errors).toContain('dueDate must be a valid date in YYYY-MM-DD format'); + }); + }); }); diff --git a/src/app.js b/src/app.js index 8636e9c1..1187a359 100644 --- a/src/app.js +++ b/src/app.js @@ -30,7 +30,7 @@ const { } = require('./middleware/bodySizeLimits'); const invoiceService = require('./services/invoice.service'); -const { validateInvoiceQueryParams } = require('./utils/validators'); +const { validateInvoiceQueryParams, validateInvoicePayload } = require('./utils/validators'); const asyncHandler = require('./utils/asyncHandler'); /** @@ -111,16 +111,31 @@ function createApp() { }); }); - // Invoices — GET (list) - app.get('/api/invoices', (req, res) => { + // Invoices — GET (list) with query-param validation and service call + app.get('/api/invoices', asyncHandler(async (req, res) => { + const { isValid, errors, validatedParams } = validateInvoiceQueryParams(req.query || {}); + + if (!isValid) { + res.status(400).json({ errors }); + return; + } + + const invoices = await invoiceService.getInvoices(validatedParams); res.json({ - data: [], - message: 'Invoice service will list tokenized invoices here.', + data: invoices, + message: 'Invoices retrieved successfully.', }); })); - // Invoices — POST (create) with strict 512 KB body limit + // Invoices — POST (create) with strict payload validation and 512 KB body limit app.post('/api/invoices', ...invoiceBodyLimit(), (req, res) => { + const { isValid, errors } = validateInvoicePayload(req.body); + + if (!isValid) { + res.status(400).json({ errors }); + return; + } + res.status(201).json({ data: { id: 'placeholder', status: 'pending_verification' }, message: 'Invoice upload will be implemented with verification and tokenization.', diff --git a/src/app.test.js b/src/app.test.js index e806b16d..6035ca70 100644 --- a/src/app.test.js +++ b/src/app.test.js @@ -1,11 +1,14 @@ const cors = require('cors'); +const request = require('supertest'); const { createApp, handleCorsError } = require('./app'); const { CORS_REJECTION_MESSAGE } = require('./config/cors'); const { createCorsOptions } = require('./config/cors'); const invoiceService = require('./services/invoice.service'); -jest.mock('./services/invoice.service'); +jest.mock('./services/invoice.service', () => ({ + getInvoices: jest.fn(), +})); function withEnv(env, fn) { const previousValues = new Map(); @@ -286,19 +289,34 @@ describe('LiquiFact app integration', () => { }); }); - it('returns the invoice creation placeholder', async () => { - const response = await invokeApp(createApp(), { - method: 'POST', - path: '/api/invoices', - }); + it('returns the invoice creation placeholder for a valid payload', async () => { + const response = await request(createApp()) + .post('/api/invoices') + .send({ + amount: 1500, + dueDate: '2026-12-31', + buyer: 'Acme Corp', + seller: 'Stellar Goods Ltd', + currency: 'USD', + }); expect(response.statusCode).toBe(201); expect(response.body).toEqual({ - data: { id: 'placeholder', status: 'pending_verification' }, + data: { id: 'placeholder', status: 'pending_verification' }, message: 'Invoice upload will be implemented with verification and tokenization.', }); }); + it('rejects an invoice creation request with missing fields', async () => { + const response = await request(createApp()) + .post('/api/invoices') + .send({ amount: 500 }); + + expect(response.statusCode).toBe(400); + expect(Array.isArray(response.body.errors)).toBe(true); + expect(response.body.errors.length).toBeGreaterThan(0); + }); + it('returns the escrow placeholder through the Soroban wrapper', async () => { const response = await invokeApp(createApp(), { path: '/api/escrow/invoice-123', diff --git a/src/index.js b/src/index.js index a0fbb5ee..9bdb2367 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,6 @@ 'use strict'; /** - * LiquiFact API Gateway - * Express server bootstrap for invoice financing, auth, and Stellar integration. - */ - * Express app configuration for invoice financing, auth, and Stellar integration. * Server startup lives in server.js so this module can be imported cleanly in tests. */ @@ -12,21 +8,15 @@ const express = require('express'); const cors = require('cors'); const { createSecurityMiddleware } = require('./middleware/security'); -require('dotenv').config(); -const express = require('express'); -const cors = require('cors'); const { globalLimiter, sensitiveLimiter } = require('./middleware/rateLimit'); const { authenticateToken } = require('./middleware/auth'); - -const asyncHandler = require('./utils/asyncHandler'); +const AppError = require('./errors/AppError'); const errorHandler = require('./middleware/errorHandler'); const { callSorobanContract } = require('./services/soroban'); const app = express(); const PORT = process.env.PORT || 3001; -const app = express(); - /** * Global Middlewares */ @@ -179,9 +169,14 @@ app.patch('/api/invoices/:id/restore', authenticateToken, (req, res) => { if (!invoices[invoiceIndex].deletedAt) { return res.status(400).json({ error: 'Invoice is not deleted' }); } - res.status(201).json({ - data: { id: 'placeholder', status: 'pending_verification' }, - message: 'Invoice upload will be implemented with verification and tokenization.', + + // eslint-disable-next-line security/detect-object-injection + invoices[invoiceIndex].deletedAt = null; + + return res.json({ + message: 'Invoice restored successfully.', + // eslint-disable-next-line security/detect-object-injection + data: invoices[invoiceIndex], }); }); @@ -219,12 +214,16 @@ app.get('/api/escrow/:invoiceId', authenticateToken, async (req, res) => { /** * Simulated escrow operations (e.g. funding). + * + * @param {import('express').Request} req - The Express request object. + * @param {import('express').Response} res - The Express response object. + * @returns {void} */ app.post('/api/escrow', authenticateToken, sensitiveLimiter, (req, res) => { - res.json({ - data: { status: 'funded' }, - message: 'Escrow operation simulated.' - }); + res.json({ + data: { status: 'funded' }, + message: 'Escrow operation simulated.', + }); }); /** @@ -237,6 +236,9 @@ app.post('/api/escrow', authenticateToken, sensitiveLimiter, (req, res) => { * @returns {void} */ app.use((req, res, next) => { + if (req.path === '/error-test-trigger') { + return next(new Error('Simulated unexpected error')); + } next( new AppError({ type: 'https://liquifact.com/probs/not-found', @@ -248,20 +250,7 @@ app.use((req, res, next) => { ); }); -/** - * Global error handler. - * Logs the error and returns a 500 status. - * - * @param {Error} err - The error object. - * @param {import('express').Request} req - The Express request object. - * @param {import('express').Response} res - The Express response object. - * @param {import('express').NextFunction} _next - The next middleware function. - * @returns {void} - */ -app.use((err, req, res, _next) => { - console.error(err); - return res.status(500).json({ error: 'Internal server error' }); -}); +app.use(errorHandler); /** * Starts the Express server. @@ -289,8 +278,6 @@ if (process.env.NODE_ENV !== 'test') { startServer(); } -// Export app and state for testing -module.exports = { app, startServer, resetStore }; // Export app as default (so `require('./index')` returns the Express app directly), // with startServer and resetStore attached as properties for tests that need them. module.exports = app; diff --git a/src/utils/validators.js b/src/utils/validators.js index 9ef60754..9b7b0976 100644 --- a/src/utils/validators.js +++ b/src/utils/validators.js @@ -100,6 +100,107 @@ function validateInvoiceQueryParams(query) { }; } +/** + * Supported ISO 4217 currency codes accepted by the invoice API. + * + * @constant {Set} + */ +const VALID_CURRENCIES = new Set([ + 'USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'NZD', 'CNY', 'HKD', + 'SGD', 'SEK', 'NOK', 'DKK', 'MXN', 'BRL', 'INR', 'KRW', 'ZAR', 'NGN', + 'GHS', 'KES', 'TZS', 'UGX', 'XOF', 'XAF', 'MAD', 'EGP', 'AED', 'SAR', +]); + +/** + * Validates invoice creation payload fields. + * + * Performs strict type and format checks on all required invoice fields: + * amount, dueDate, buyer, seller, and currency. Collects all validation + * errors in a single pass so the caller can surface them together. + * + * @param {Object} body - The raw request body object. + * @param {number} body.amount - Invoice amount (must be a positive finite number). + * @param {string} body.dueDate - Due date in YYYY-MM-DD format. + * @param {string} body.buyer - Buyer name (non-empty string). + * @param {string} body.seller - Seller name (non-empty string). + * @param {string} body.currency - ISO 4217 currency code (e.g. USD, EUR). + * @returns {{ isValid: boolean, errors: string[], validatedPayload: Object }} + * - `isValid` — true when all fields pass validation. + * - `errors` — list of human-readable error messages. + * - `validatedPayload` — sanitised copy of accepted fields. + */ +function validateInvoicePayload(body) { + const errors = []; + const validatedPayload = {}; + const safeBody = body && typeof body === 'object' ? body : {}; + + // ── amount ─────────────────────────────────────────────────────────────── + const { amount } = safeBody; + if (amount === undefined || amount === null) { + errors.push('amount is required'); + } else if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) { + errors.push('amount must be a positive number'); + } else { + validatedPayload.amount = amount; + } + + // ── dueDate ────────────────────────────────────────────────────────────── + const { dueDate } = safeBody; + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (dueDate === undefined || dueDate === null) { + errors.push('dueDate is required'); + } else if ( + typeof dueDate !== 'string' || + !dateRegex.test(dueDate) || + isNaN(Date.parse(dueDate)) + ) { + errors.push('dueDate must be a valid date in YYYY-MM-DD format'); + } else { + validatedPayload.dueDate = dueDate; + } + + // ── buyer ──────────────────────────────────────────────────────────────── + const { buyer } = safeBody; + if (buyer === undefined || buyer === null) { + errors.push('buyer is required'); + } else if (typeof buyer !== 'string' || buyer.trim().length === 0) { + errors.push('buyer must be a non-empty string'); + } else { + validatedPayload.buyer = buyer.trim(); + } + + // ── seller ─────────────────────────────────────────────────────────────── + const { seller } = safeBody; + if (seller === undefined || seller === null) { + errors.push('seller is required'); + } else if (typeof seller !== 'string' || seller.trim().length === 0) { + errors.push('seller must be a non-empty string'); + } else { + validatedPayload.seller = seller.trim(); + } + + // ── currency ───────────────────────────────────────────────────────────── + const { currency } = safeBody; + if (currency === undefined || currency === null) { + errors.push('currency is required'); + } else if ( + typeof currency !== 'string' || + !VALID_CURRENCIES.has(currency.toUpperCase()) + ) { + errors.push('currency must be a supported ISO 4217 code (e.g. USD, EUR, GBP)'); + } else { + validatedPayload.currency = currency.toUpperCase(); + } + + return { + isValid: errors.length === 0, + errors, + validatedPayload, + }; +} + module.exports = { validateInvoiceQueryParams, + validateInvoicePayload, + VALID_CURRENCIES, };