Skip to content
Open
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
161 changes: 159 additions & 2 deletions src/__tests__/invoice.api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');

Expand All @@ -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');
});
});
});
27 changes: 21 additions & 6 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

/**
Expand Down Expand Up @@ -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.',
Expand Down
32 changes: 25 additions & 7 deletions src/app.test.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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',
Expand Down
55 changes: 21 additions & 34 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,22 @@
'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.
*/

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
*/
Expand Down Expand Up @@ -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],
});
});

Expand Down Expand Up @@ -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.',
});
});

/**
Expand All @@ -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',
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading