Skip to content

Commit c06df53

Browse files
authored
Merge pull request #39 from portableDD/feat/email-verification
feat(auth): implement email verification flow
2 parents b0f8f8b + f177a67 commit c06df53

9 files changed

Lines changed: 392 additions & 36 deletions

File tree

jest.config.js

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
module.exports = {
22
testEnvironment: 'node',
3+
setupFiles: ['./jest.setup.js'],
34
coverageDirectory: 'coverage',
4-
collectCoverageFrom: [
5-
'src/**/*.js',
6-
'!src/server.js',
7-
],
5+
collectCoverageFrom: ['src/**/*.js', '!src/server.js'],
86
coverageThreshold: {
97
global: {
108
branches: 0,
@@ -13,8 +11,5 @@ module.exports = {
1311
statements: 0,
1412
},
1513
},
16-
testMatch: [
17-
'**/__tests__/**/*.js',
18-
'**/?(*.)+(spec|test).js',
19-
],
14+
testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'],
2015
};

jest.setup.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
process.env.JWT_SECRET = 'test-secret';
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
const request = require('supertest');
2+
3+
// Mock dependencies before requiring app
4+
jest.mock('../models/User.model', () => {
5+
const mockSave = jest.fn();
6+
const MockUser = jest.fn().mockImplementation(function (data) {
7+
Object.assign(this, data);
8+
this._id = '507f1f77bcf86cd799439011';
9+
this.role = 'user';
10+
this.isVerified = false;
11+
this.createdAt = new Date('2026-01-01T00:00:00.000Z');
12+
this.save = mockSave;
13+
});
14+
MockUser.findOne = jest.fn();
15+
MockUser._mockSave = mockSave;
16+
return MockUser;
17+
});
18+
19+
jest.mock('../services/email.service', () => ({
20+
sendEmail: jest.fn(),
21+
}));
22+
23+
const User = require('../models/User.model');
24+
const { sendEmail } = require('../services/email.service');
25+
const app = require('../app');
26+
27+
describe('POST /api/auth/register', () => {
28+
beforeEach(() => {
29+
jest.clearAllMocks();
30+
User._mockSave.mockResolvedValue(undefined);
31+
});
32+
33+
const validBody = {
34+
fullName: 'Jane Doe',
35+
email: 'jane@example.com',
36+
password: 'securePass1',
37+
};
38+
39+
describe('successful registration', () => {
40+
it('returns 201 with user data and sends a verification email', async () => {
41+
User.findOne.mockResolvedValue(null); // no existing user
42+
sendEmail.mockResolvedValue({ messageId: 'test-id' });
43+
44+
const response = await request(app)
45+
.post('/api/auth/register')
46+
.send(validBody)
47+
.expect(201);
48+
49+
expect(response.body.success).toBe(true);
50+
expect(response.body.message).toBe(
51+
'User registered successfully. Please verify your email.'
52+
);
53+
54+
const { user } = response.body.data;
55+
expect(user.email).toBe('jane@example.com');
56+
expect(user.isVerified).toBe(false);
57+
// Token must NOT be exposed in the response
58+
expect(user.emailVerificationToken).toBeUndefined();
59+
expect(user.emailVerificationExpires).toBeUndefined();
60+
61+
// Verify the email was sent
62+
expect(sendEmail).toHaveBeenCalledTimes(1);
63+
const emailCall = sendEmail.mock.calls[0][0];
64+
expect(emailCall.to).toBe('jane@example.com');
65+
expect(emailCall.subject).toContain('erif'); // "Verify"
66+
expect(emailCall.html).toBeDefined();
67+
});
68+
69+
it('stores emailVerificationToken and emailVerificationExpires on the user', async () => {
70+
User.findOne.mockResolvedValue(null);
71+
sendEmail.mockResolvedValue({});
72+
73+
let capturedInstance;
74+
User.mockImplementationOnce(function (data) {
75+
Object.assign(this, data);
76+
this._id = '507f1f77bcf86cd799439011';
77+
this.role = 'user';
78+
this.isVerified = false;
79+
this.createdAt = new Date();
80+
this.save = User._mockSave;
81+
capturedInstance = this;
82+
});
83+
84+
await request(app).post('/api/auth/register').send(validBody).expect(201);
85+
86+
expect(capturedInstance.emailVerificationToken).toBeDefined();
87+
expect(typeof capturedInstance.emailVerificationToken).toBe('string');
88+
expect(capturedInstance.emailVerificationToken).toHaveLength(64); // 32 bytes hex
89+
expect(capturedInstance.emailVerificationExpires).toBeInstanceOf(Date);
90+
// Expiry should be ~24 hours from now
91+
const msUntilExpiry =
92+
capturedInstance.emailVerificationExpires.getTime() - Date.now();
93+
expect(msUntilExpiry).toBeGreaterThan(23 * 60 * 60 * 1000);
94+
expect(msUntilExpiry).toBeLessThanOrEqual(24 * 60 * 60 * 1000 + 1000);
95+
});
96+
});
97+
98+
describe('duplicate email', () => {
99+
it('returns 409 when the email is already registered', async () => {
100+
User.findOne.mockResolvedValue({ email: 'jane@example.com' });
101+
102+
const response = await request(app)
103+
.post('/api/auth/register')
104+
.send(validBody)
105+
.expect(409);
106+
107+
expect(response.body.success).toBe(false);
108+
expect(response.body.message).toBe('Email already exists');
109+
expect(sendEmail).not.toHaveBeenCalled();
110+
});
111+
});
112+
113+
describe('validation errors', () => {
114+
it('returns 400 when email is missing', async () => {
115+
const response = await request(app)
116+
.post('/api/auth/register')
117+
.send({ fullName: 'Jane', password: 'securePass1' })
118+
.expect(400);
119+
120+
expect(response.body.success).toBe(false);
121+
});
122+
123+
it('returns 400 when password is too short', async () => {
124+
const response = await request(app)
125+
.post('/api/auth/register')
126+
.send({
127+
fullName: 'Jane',
128+
email: 'jane@example.com',
129+
password: 'short',
130+
})
131+
.expect(400);
132+
133+
expect(response.body.success).toBe(false);
134+
});
135+
});
136+
137+
describe('email send failure', () => {
138+
it('still returns 201 even when the email service throws', async () => {
139+
User.findOne.mockResolvedValue(null);
140+
sendEmail.mockRejectedValue(new Error('SMTP unavailable'));
141+
142+
const response = await request(app)
143+
.post('/api/auth/register')
144+
.send(validBody)
145+
.expect(201);
146+
147+
// Registration succeeds — the token is stored in the DB even if email fails
148+
expect(response.body.success).toBe(true);
149+
expect(response.body.data.user.email).toBe('jane@example.com');
150+
});
151+
});
152+
});

src/__tests__/auth.verify.test.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
const request = require('supertest');
2+
3+
// Mock User model before requiring app so the mock is in place at module load time
4+
jest.mock('../models/User.model', () => ({
5+
findOne: jest.fn(),
6+
}));
7+
8+
const User = require('../models/User.model');
9+
const app = require('../app');
10+
11+
describe('GET /api/auth/verify-email/:token', () => {
12+
beforeEach(() => {
13+
jest.clearAllMocks();
14+
});
15+
16+
describe('successful verification', () => {
17+
it('returns 200 and marks the user as verified', async () => {
18+
const token = 'a'.repeat(64); // 64 hex chars
19+
const user = {
20+
isVerified: false,
21+
emailVerificationToken: token,
22+
emailVerificationExpires: new Date(Date.now() + 60_000),
23+
save: jest.fn().mockResolvedValue(undefined),
24+
};
25+
26+
User.findOne.mockResolvedValue(user);
27+
28+
const response = await request(app)
29+
.get(`/api/auth/verify-email/${token}`)
30+
.expect(200);
31+
32+
expect(response.body.success).toBe(true);
33+
expect(response.body.message).toBe(
34+
'Email verified successfully. You can now log in.'
35+
);
36+
37+
// Verify the model was queried with the correct token and expiry check
38+
expect(User.findOne).toHaveBeenCalledWith({
39+
emailVerificationToken: token,
40+
emailVerificationExpires: { $gt: expect.any(Date) },
41+
});
42+
43+
// Verify the user fields were updated before save
44+
expect(user.isVerified).toBe(true);
45+
expect(user.emailVerificationToken).toBeNull();
46+
expect(user.emailVerificationExpires).toBeNull();
47+
expect(user.save).toHaveBeenCalledTimes(1);
48+
});
49+
});
50+
51+
describe('invalid token', () => {
52+
it('returns 400 when no user matches the token', async () => {
53+
User.findOne.mockResolvedValue(null);
54+
55+
const response = await request(app)
56+
.get('/api/auth/verify-email/invalidtoken123')
57+
.expect(400);
58+
59+
expect(response.body.success).toBe(false);
60+
expect(response.body.message).toBe(
61+
'Invalid or expired verification token'
62+
);
63+
});
64+
});
65+
66+
describe('expired token', () => {
67+
it('returns 400 when the token exists but has already expired', async () => {
68+
// When the token is expired, the $gt query returns null — simulate that
69+
User.findOne.mockResolvedValue(null);
70+
71+
const expiredToken = 'b'.repeat(64);
72+
73+
const response = await request(app)
74+
.get(`/api/auth/verify-email/${expiredToken}`)
75+
.expect(400);
76+
77+
expect(response.body.success).toBe(false);
78+
expect(response.body.message).toBe(
79+
'Invalid or expired verification token'
80+
);
81+
82+
// Confirm the expiry filter was passed to the query
83+
expect(User.findOne).toHaveBeenCalledWith({
84+
emailVerificationToken: expiredToken,
85+
emailVerificationExpires: { $gt: expect.any(Date) },
86+
});
87+
});
88+
});
89+
90+
describe('already verified token (token cleared)', () => {
91+
it('returns 400 when token fields have already been nulled out', async () => {
92+
// After a successful verification the token is set to null on the document,
93+
// so a second attempt finds no matching document.
94+
User.findOne.mockResolvedValue(null);
95+
96+
const usedToken = 'c'.repeat(64);
97+
98+
const response = await request(app)
99+
.get(`/api/auth/verify-email/${usedToken}`)
100+
.expect(400);
101+
102+
expect(response.body.success).toBe(false);
103+
expect(response.body.message).toBe(
104+
'Invalid or expired verification token'
105+
);
106+
});
107+
});
108+
});

0 commit comments

Comments
 (0)