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
11 changes: 3 additions & 8 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
module.exports = {
testEnvironment: 'node',
setupFiles: ['./jest.setup.js'],
coverageDirectory: 'coverage',
collectCoverageFrom: [
'src/**/*.js',
'!src/server.js',
],
collectCoverageFrom: ['src/**/*.js', '!src/server.js'],
coverageThreshold: {
global: {
branches: 0,
Expand All @@ -13,8 +11,5 @@ module.exports = {
statements: 0,
},
},
testMatch: [
'**/__tests__/**/*.js',
'**/?(*.)+(spec|test).js',
],
testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'],
};
1 change: 1 addition & 0 deletions jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
process.env.JWT_SECRET = 'test-secret';
152 changes: 152 additions & 0 deletions src/__tests__/auth.register.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
const request = require('supertest');

// Mock dependencies before requiring app
jest.mock('../models/User.model', () => {
const mockSave = jest.fn();
const MockUser = jest.fn().mockImplementation(function (data) {
Object.assign(this, data);
this._id = '507f1f77bcf86cd799439011';
this.role = 'user';
this.isVerified = false;
this.createdAt = new Date('2026-01-01T00:00:00.000Z');
this.save = mockSave;
});
MockUser.findOne = jest.fn();
MockUser._mockSave = mockSave;
return MockUser;
});

jest.mock('../services/email.service', () => ({
sendEmail: jest.fn(),
}));

const User = require('../models/User.model');
const { sendEmail } = require('../services/email.service');
const app = require('../app');

describe('POST /api/auth/register', () => {
beforeEach(() => {
jest.clearAllMocks();
User._mockSave.mockResolvedValue(undefined);
});

const validBody = {
fullName: 'Jane Doe',
email: 'jane@example.com',
password: 'securePass1',
};

describe('successful registration', () => {
it('returns 201 with user data and sends a verification email', async () => {
User.findOne.mockResolvedValue(null); // no existing user
sendEmail.mockResolvedValue({ messageId: 'test-id' });

const response = await request(app)
.post('/api/auth/register')
.send(validBody)
.expect(201);

expect(response.body.success).toBe(true);
expect(response.body.message).toBe(
'User registered successfully. Please verify your email.'
);

const { user } = response.body.data;
expect(user.email).toBe('jane@example.com');
expect(user.isVerified).toBe(false);
// Token must NOT be exposed in the response
expect(user.emailVerificationToken).toBeUndefined();
expect(user.emailVerificationExpires).toBeUndefined();

// Verify the email was sent
expect(sendEmail).toHaveBeenCalledTimes(1);
const emailCall = sendEmail.mock.calls[0][0];
expect(emailCall.to).toBe('jane@example.com');
expect(emailCall.subject).toContain('erif'); // "Verify"
expect(emailCall.html).toBeDefined();
});

it('stores emailVerificationToken and emailVerificationExpires on the user', async () => {
User.findOne.mockResolvedValue(null);
sendEmail.mockResolvedValue({});

let capturedInstance;
User.mockImplementationOnce(function (data) {
Object.assign(this, data);
this._id = '507f1f77bcf86cd799439011';
this.role = 'user';
this.isVerified = false;
this.createdAt = new Date();
this.save = User._mockSave;
capturedInstance = this;
});

await request(app).post('/api/auth/register').send(validBody).expect(201);

expect(capturedInstance.emailVerificationToken).toBeDefined();
expect(typeof capturedInstance.emailVerificationToken).toBe('string');
expect(capturedInstance.emailVerificationToken).toHaveLength(64); // 32 bytes hex
expect(capturedInstance.emailVerificationExpires).toBeInstanceOf(Date);
// Expiry should be ~24 hours from now
const msUntilExpiry =
capturedInstance.emailVerificationExpires.getTime() - Date.now();
expect(msUntilExpiry).toBeGreaterThan(23 * 60 * 60 * 1000);
expect(msUntilExpiry).toBeLessThanOrEqual(24 * 60 * 60 * 1000 + 1000);
});
});

describe('duplicate email', () => {
it('returns 409 when the email is already registered', async () => {
User.findOne.mockResolvedValue({ email: 'jane@example.com' });

const response = await request(app)
.post('/api/auth/register')
.send(validBody)
.expect(409);

expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Email already exists');
expect(sendEmail).not.toHaveBeenCalled();
});
});

describe('validation errors', () => {
it('returns 400 when email is missing', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({ fullName: 'Jane', password: 'securePass1' })
.expect(400);

expect(response.body.success).toBe(false);
});

it('returns 400 when password is too short', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({
fullName: 'Jane',
email: 'jane@example.com',
password: 'short',
})
.expect(400);

expect(response.body.success).toBe(false);
});
});

describe('email send failure', () => {
it('still returns 201 even when the email service throws', async () => {
User.findOne.mockResolvedValue(null);
sendEmail.mockRejectedValue(new Error('SMTP unavailable'));

const response = await request(app)
.post('/api/auth/register')
.send(validBody)
.expect(201);

// Registration succeeds — the token is stored in the DB even if email fails
expect(response.body.success).toBe(true);
expect(response.body.data.user.email).toBe('jane@example.com');
});
});
});
108 changes: 108 additions & 0 deletions src/__tests__/auth.verify.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
const request = require('supertest');

// Mock User model before requiring app so the mock is in place at module load time
jest.mock('../models/User.model', () => ({
findOne: jest.fn(),
}));

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

describe('GET /api/auth/verify-email/:token', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('successful verification', () => {
it('returns 200 and marks the user as verified', async () => {
const token = 'a'.repeat(64); // 64 hex chars
const user = {
isVerified: false,
emailVerificationToken: token,
emailVerificationExpires: new Date(Date.now() + 60_000),
save: jest.fn().mockResolvedValue(undefined),
};

User.findOne.mockResolvedValue(user);

const response = await request(app)
.get(`/api/auth/verify-email/${token}`)
.expect(200);

expect(response.body.success).toBe(true);
expect(response.body.message).toBe(
'Email verified successfully. You can now log in.'
);

// Verify the model was queried with the correct token and expiry check
expect(User.findOne).toHaveBeenCalledWith({
emailVerificationToken: token,
emailVerificationExpires: { $gt: expect.any(Date) },
});

// Verify the user fields were updated before save
expect(user.isVerified).toBe(true);
expect(user.emailVerificationToken).toBeNull();
expect(user.emailVerificationExpires).toBeNull();
expect(user.save).toHaveBeenCalledTimes(1);
});
});

describe('invalid token', () => {
it('returns 400 when no user matches the token', async () => {
User.findOne.mockResolvedValue(null);

const response = await request(app)
.get('/api/auth/verify-email/invalidtoken123')
.expect(400);

expect(response.body.success).toBe(false);
expect(response.body.message).toBe(
'Invalid or expired verification token'
);
});
});

describe('expired token', () => {
it('returns 400 when the token exists but has already expired', async () => {
// When the token is expired, the $gt query returns null — simulate that
User.findOne.mockResolvedValue(null);

const expiredToken = 'b'.repeat(64);

const response = await request(app)
.get(`/api/auth/verify-email/${expiredToken}`)
.expect(400);

expect(response.body.success).toBe(false);
expect(response.body.message).toBe(
'Invalid or expired verification token'
);

// Confirm the expiry filter was passed to the query
expect(User.findOne).toHaveBeenCalledWith({
emailVerificationToken: expiredToken,
emailVerificationExpires: { $gt: expect.any(Date) },
});
});
});

describe('already verified token (token cleared)', () => {
it('returns 400 when token fields have already been nulled out', async () => {
// After a successful verification the token is set to null on the document,
// so a second attempt finds no matching document.
User.findOne.mockResolvedValue(null);

const usedToken = 'c'.repeat(64);

const response = await request(app)
.get(`/api/auth/verify-email/${usedToken}`)
.expect(400);

expect(response.body.success).toBe(false);
expect(response.body.message).toBe(
'Invalid or expired verification token'
);
});
});
});
Loading