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
197 changes: 197 additions & 0 deletions src/__tests__/users.kyc.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
const jwt = require('jsonwebtoken');
const request = require('supertest');
const fs = require('fs');
const path = require('path');

jest.mock('../models/User.model', () => ({
findById: jest.fn(),
}));

jest.mock('../models/KYC.model', () => ({
create: jest.fn(),
}));

const User = require('../models/User.model');
const KYC = require('../models/KYC.model');
const app = require('../app');

describe('GET /api/users/me/kyc', () => {
Expand Down Expand Up @@ -71,3 +78,193 @@ describe('GET /api/users/me/kyc', () => {
});
});
});

describe('POST /api/users/me/kyc', () => {
const userId = '507f1f77bcf86cd799439011';
let token;
const dummyFilePath = path.join(__dirname, 'dummy.png');

beforeAll(() => {
token = jwt.sign(
{ sub: userId, type: 'access' },
process.env.JWT_SECRET || 'test-secret',
{ expiresIn: '1h' }
);
// Create dummy file for attachment
fs.writeFileSync(dummyFilePath, 'dummy content');
});

afterAll(() => {
if (fs.existsSync(dummyFilePath)) {
fs.unlinkSync(dummyFilePath);
}
// Clean up uploaded files in uploads/kyc
const kycUploadDir = path.join(process.cwd(), 'uploads', 'kyc');
if (fs.existsSync(kycUploadDir)) {
const files = fs.readdirSync(kycUploadDir);
for (const file of files) {
if (file.startsWith(`kyc-${userId}-`)) {
fs.unlinkSync(path.join(kycUploadDir, file));
}
}
}
});

beforeEach(() => {
jest.clearAllMocks();
});

it('requires authentication', async () => {
const response = await request(app)
.post('/api/users/me/kyc')
.field('documentType', 'passport')
.expect(401);

expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Authentication required');
});

it('rejects duplicate submission if user has a pending KYC status', async () => {
const mockUser = {
_id: userId,
kycStatus: 'pending',
kycSubmissionDate: null,
kycReviewNotes: null,
save: jest.fn().mockResolvedValue(true),
};
User.findById.mockResolvedValue(mockUser);

const response = await request(app)
.post('/api/users/me/kyc')
.set('Authorization', `Bearer ${token}`)
.field('documentType', 'passport')
.attach('document', dummyFilePath)
.expect(400);

expect(response.body.success).toBe(false);
expect(response.body.message).toBe('KYC submission already exists or is already approved');
});

it('rejects duplicate submission if user has an approved KYC status', async () => {
const mockUser = {
_id: userId,
kycStatus: 'approved',
kycSubmissionDate: new Date(),
kycReviewNotes: null,
save: jest.fn().mockResolvedValue(true),
};
User.findById.mockResolvedValue(mockUser);

const response = await request(app)
.post('/api/users/me/kyc')
.set('Authorization', `Bearer ${token}`)
.field('documentType', 'passport')
.attach('document', dummyFilePath)
.expect(400);

expect(response.body.success).toBe(false);
expect(response.body.message).toBe('KYC submission already exists or is already approved');
});

it('rejects request if documentType is missing', async () => {
const mockUser = {
_id: userId,
kycStatus: 'rejected', // User has a rejected KYC, so they should be allowed to submit again
kycSubmissionDate: new Date(),
kycReviewNotes: 'invalid document',
save: jest.fn().mockResolvedValue(true),
};
User.findById.mockResolvedValue(mockUser);

const response = await request(app)
.post('/api/users/me/kyc')
.set('Authorization', `Bearer ${token}`)
.attach('document', dummyFilePath)
.expect(400);

expect(response.body.success).toBe(false);
expect(response.body.message).toContain('Document type is required');
});

it('rejects request if documentType is invalid', async () => {
const mockUser = {
_id: userId,
kycStatus: 'rejected',
kycSubmissionDate: new Date(),
kycReviewNotes: 'invalid document',
save: jest.fn().mockResolvedValue(true),
};
User.findById.mockResolvedValue(mockUser);

const response = await request(app)
.post('/api/users/me/kyc')
.set('Authorization', `Bearer ${token}`)
.field('documentType', 'invalid_type')
.attach('document', dummyFilePath)
.expect(400);

expect(response.body.success).toBe(false);
expect(response.body.message).toContain('Document type must be one of: passport, national_id, driving_license');
});

it('rejects request if no file is uploaded', async () => {
const mockUser = {
_id: userId,
kycStatus: 'rejected',
kycSubmissionDate: new Date(),
kycReviewNotes: 'invalid document',
save: jest.fn().mockResolvedValue(true),
};
User.findById.mockResolvedValue(mockUser);

const response = await request(app)
.post('/api/users/me/kyc')
.set('Authorization', `Bearer ${token}`)
.field('documentType', 'passport')
.expect(400);

expect(response.body.success).toBe(false);
expect(response.body.message).toBe('KYC document file is required');
});

it('submits KYC successfully and updates user status to pending', async () => {
const mockUser = {
_id: userId,
kycStatus: 'rejected',
kycSubmissionDate: null,
kycReviewNotes: 'rejected before',
save: jest.fn().mockImplementation(function() {
return Promise.resolve(this);
}),
};
User.findById.mockResolvedValue(mockUser);

const mockKyc = {
_id: '607f1f77bcf86cd799439012',
userId,
documentType: 'passport',
documentUrl: 'uploads/kyc/kyc-507f1f77bcf86cd799439011-mocked.png',
status: 'pending',
submittedAt: new Date('2026-06-26T01:00:00.000Z'),
};
KYC.create.mockResolvedValue(mockKyc);

const response = await request(app)
.post('/api/users/me/kyc')
.set('Authorization', `Bearer ${token}`)
.field('documentType', 'passport')
.attach('document', dummyFilePath)
.expect(201);

expect(response.body.success).toBe(true);
expect(response.body.message).toBe('KYC document submitted successfully');
expect(response.body.data.documentType).toBe('passport');
expect(response.body.data.status).toBe('pending');

expect(User.findById).toHaveBeenCalledWith(userId);
expect(KYC.create).toHaveBeenCalled();
expect(mockUser.kycStatus).toBe('pending');
expect(mockUser.kycReviewNotes).toBeNull();
expect(mockUser.save).toHaveBeenCalledTimes(1);
});
});
52 changes: 52 additions & 0 deletions src/controllers/users.controller.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const User = require('../models/User.model');
const KYC = require('../models/KYC.model');
const { sendSuccess } = require('../utils/response');
const multer = require('multer');
const path = require('path');
Expand Down Expand Up @@ -159,10 +160,61 @@ const uploadAvatar = async (req, res, next) => {
}
};

/**
* Submit KYC documents
* @route POST /api/users/me/kyc
* @access Private (requires authentication)
*/
const submitKyc = async (req, res, next) => {
try {
// 1. Check if user already has a pending or approved KYC submission
if (req.user.kycStatus === 'pending' || req.user.kycStatus === 'approved') {
const error = new Error('KYC submission already exists or is already approved');
error.statusCode = 400;
error.isOperational = true;
return next(error);
}

// 2. Ensure a file was uploaded
if (!req.file) {
const error = new Error('KYC document file is required');
error.statusCode = 400;
error.isOperational = true;
return next(error);
}

const { documentType } = req.body;

// 3. Create document URL/relative path
const documentUrl = `uploads/kyc/${req.file.filename}`;

// 4. Create the KYC record
const kyc = await KYC.create({
userId: req.userId,
documentType,
documentUrl,
status: 'pending',
submittedAt: new Date(),
});

// 5. Update the User's KYC status fields
req.user.kycStatus = 'pending';
req.user.kycSubmissionDate = kyc.submittedAt;
req.user.kycReviewNotes = null;
await req.user.save();

// 6. Return response
return sendSuccess(res, kyc, 201, 'KYC document submitted successfully');
} catch (error) {
next(error);
}
};

module.exports = {
getCurrentUser,
getCurrentUserKyc,
updateCurrentUser,
uploadAvatar,
submitKyc,
upload // Export multer upload middleware
};
72 changes: 72 additions & 0 deletions src/middlewares/kycUpload.middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs');

const ALLOWED_MIME_TYPES = [
'application/pdf',
'image/jpeg',
'image/png',
];

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB

// Ensure upload directory exists
const uploadDir = path.join(process.cwd(), 'uploads', 'kyc');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}

const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir),
filename: (req, file, cb) => {
const uniqueSuffix = crypto.randomBytes(16).toString('hex');
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `kyc-${req.userId || 'anonymous'}-${uniqueSuffix}${ext}`);
},
});

const fileFilter = (_req, file, cb) => {
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
cb(null, true);
} else {
const error = new Error(
'Invalid file type. Only PDF, JPG, and PNG are allowed.'
);
error.statusCode = 400;
error.isOperational = true;
cb(error, false);
}
};

const upload = multer({
storage,
fileFilter,
limits: {
fileSize: MAX_FILE_SIZE,
},
});

// Wrap multer to convert its errors into operational errors your errorHandler understands
const handleKycUpload = (req, res, next) => {
upload.single('document')(req, res, (err) => {
if (!err) return next();

if (err.code === 'LIMIT_FILE_SIZE') {
err.message = 'The file must be 5MB or smaller.';
err.statusCode = 400;
err.isOperational = true;
} else if (err.code === 'LIMIT_UNEXPECTED_FILE') {
err.message = 'Unexpected field name. Use "document" as the field name.';
err.statusCode = 400;
err.isOperational = true;
} else if (!err.statusCode) {
err.statusCode = 400;
err.isOperational = true;
}

return next(err);
});
};

module.exports = { handleKycUpload };
Loading
Loading