Skip to content

Commit c327def

Browse files
committed
feat: initial test coverage pattern with super test
1 parent 1df311b commit c327def

12 files changed

Lines changed: 298 additions & 28 deletions

File tree

jest.config.ts

Lines changed: 0 additions & 19 deletions
This file was deleted.

src/controllers/admin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Op, WhereOptions } from 'sequelize';
44

55
import { AuthEvent, AuthEventAttributes } from '../models/authEvents.js';
66
import { Credential } from '../models/credentials.js';
7-
import { sequelize } from '../models/index.js';
7+
import { getSequelize } from '../models/index.js';
88
import { Session } from '../models/sessions.js';
99
import { User } from '../models/users.js';
1010
import { AuthEventQuerySchema } from '../schemas/internal.query.js';
@@ -334,7 +334,7 @@ export const listAllSessions = async (req: Request, res: Response) => {
334334
};
335335

336336
export const getDatabaseSize = async () => {
337-
const [result] = await sequelize.query(`
337+
const [result] = await getSequelize().query(`
338338
SELECT pg_database_size(current_database()) as size
339339
`);
340340

src/controllers/registration.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const register = async (req: Request, res: Response) => {
2424
logger.info(`Registering phone and email account`);
2525

2626
try {
27+
// TODO: These checks can go away thanks to the zod refactor
2728
if (!email) {
2829
logger.error(`Missing email`);
2930
AuthEventService.log({
@@ -35,6 +36,7 @@ export const register = async (req: Request, res: Response) => {
3536
return res.status(400).json({ message: 'Invalid data.' });
3637
}
3738

39+
// TODO: These checks can go away thanks to the zod refactor
3840
if (!phone) {
3941
logger.error(`Missing phone`);
4042
AuthEventService.log({
@@ -136,6 +138,6 @@ export const register = async (req: Request, res: Response) => {
136138
user_agent: req.headers['user-agent'],
137139
metadata: { reason: 'Catch all error' },
138140
});
139-
return res.status(500).json({ message: 'Internal server error' });
141+
return res.status(500).json({ error: 'Internal server error' });
140142
}
141143
};

src/models/index.ts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Copyright © 2026 Fells Code, LLC
33
* Licensed under the GNU Affero General Public License v3.0
44
*/
5+
56
import { readdirSync } from 'fs';
67
import path from 'path';
78
import { Sequelize } from 'sequelize';
@@ -10,22 +11,71 @@ import { fileURLToPath } from 'url';
1011
import getLogger from '../utils/logger.js';
1112

1213
const logger = getLogger('sequelize');
14+
1315
const __filename = fileURLToPath(import.meta.url);
1416
const __dirname = path.dirname(__filename);
17+
1518
const isProduction = process.env.NODE_ENV === 'production';
1619
const enableDbLogging = !isProduction && process.env.DB_LOGGING === 'true';
17-
const { DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD } = process.env;
1820

19-
const DATABASE_URL = `postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}`;
21+
let sequelizeInstance: Sequelize | null = null;
22+
23+
function buildDatabaseUrl(): string {
24+
if (process.env.DATABASE_URL) {
25+
return process.env.DATABASE_URL;
26+
}
27+
28+
const { DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD } = process.env;
29+
30+
if (!DB_HOST || !DB_PORT || !DB_NAME || !DB_USER) {
31+
throw new Error('Missing required DB environment variables.');
32+
}
2033

21-
export const sequelize = new Sequelize(DATABASE_URL, {
22-
logging: enableDbLogging ? (msg) => logger.debug(msg) : false,
23-
});
34+
return `postgres://${DB_USER}:${DB_PASSWORD ?? ''}@${DB_HOST}:${DB_PORT}/${DB_NAME}`;
35+
}
36+
37+
export function getSequelize(): Sequelize {
38+
if (sequelizeInstance) return sequelizeInstance;
39+
40+
const testDbMode = process.env.TEST_DB;
41+
42+
if (process.env.NODE_ENV === 'test' && testDbMode === 'sqlite') {
43+
logger.info('Using SQLite in-memory database for tests');
44+
45+
sequelizeInstance = new Sequelize('sqlite::memory:', {
46+
logging: false,
47+
});
48+
49+
return sequelizeInstance;
50+
}
51+
52+
if (process.env.NODE_ENV === 'test' && testDbMode === 'mock') {
53+
logger.warn('TEST_DB=mock → Sequelize initialized but should not be used');
54+
55+
sequelizeInstance = new Sequelize('sqlite::memory:', {
56+
logging: false,
57+
});
58+
59+
return sequelizeInstance;
60+
}
61+
62+
const DATABASE_URL = buildDatabaseUrl();
63+
64+
logger.info('Using Postgres database');
65+
66+
sequelizeInstance = new Sequelize(DATABASE_URL, {
67+
logging: enableDbLogging ? (msg) => logger.debug(msg) : false,
68+
});
69+
70+
return sequelizeInstance;
71+
}
2472

2573
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2674
const models: { [key: string]: any } = {};
2775

2876
export async function initializeModels() {
77+
const sequelize = getSequelize();
78+
2979
const files = readdirSync(__dirname).filter((file) => {
3080
const ext = path.extname(file);
3181
return file.endsWith(ext) && file !== `index${ext}`;
@@ -34,6 +84,11 @@ export async function initializeModels() {
3484
const modelDefs = await Promise.all(
3585
files.map(async (file) => {
3686
const modelModule = await import(path.join(__dirname, file));
87+
88+
if (!modelModule.default) {
89+
throw new Error(`Model file ${file} does not export default`);
90+
}
91+
3792
return modelModule.default(sequelize);
3893
}),
3994
);
@@ -48,8 +103,10 @@ export async function initializeModels() {
48103
}
49104
}
50105

51-
models.sequelize = sequelize;
106+
models.sequelize = getSequelize();
52107
models.Sequelize = Sequelize;
53108

54109
return models;
55110
}
111+
112+
export { models };

tests/factories/requestFactory.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function buildRegistrationRequest(overrides = {}) {
2+
return {
3+
email: 'test@example.com',
4+
phone: '+14155552671', // ✅ VALID
5+
...overrides,
6+
};
7+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { vi } from 'vitest';
2+
3+
let idCounter = 1;
4+
5+
export function buildUser(overrides: Partial<any> = {}) {
6+
return {
7+
id: `user-${Date.now()}`,
8+
email: 'test@example.com',
9+
phone: '+14155552671', // ✅ VALID US number
10+
roles: ['user'],
11+
...overrides,
12+
};
13+
}
14+
15+
export function mockUserModel() {
16+
return {
17+
findOne: vi.fn(),
18+
create: vi.fn(),
19+
};
20+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import request from 'supertest';
2+
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
3+
import { createApp } from '../../../src/app';
4+
import { Application } from 'express';
5+
6+
import { buildUser } from '../../factories/users/userFactory.js';
7+
8+
// 🔥 mocks
9+
vi.mock('../../../src/models/users.js', () => ({
10+
User: {
11+
findOne: vi.fn(),
12+
create: vi.fn(),
13+
},
14+
}));
15+
16+
vi.mock('../../../src/models/authEvents.js', () => ({
17+
AuthEvent: {
18+
create: vi.fn(),
19+
},
20+
}));
21+
22+
vi.mock('../../../src/lib/token.js', () => ({
23+
signEphemeralToken: vi.fn(),
24+
}));
25+
26+
vi.mock('../../../src/utils/otp.js', () => ({
27+
generatePhoneOTP: vi.fn(),
28+
}));
29+
30+
vi.mock('../../../src/config/getSystemConfig.js', () => ({
31+
getSystemConfig: vi.fn(),
32+
}));
33+
34+
vi.mock('../../../src/services/authEventService.js', () => ({
35+
AuthEventService: {
36+
log: vi.fn(),
37+
notificationSent: vi.fn(),
38+
},
39+
}));
40+
41+
vi.mock('../../../src/lib/cookie.js', () => ({
42+
setAuthCookies: vi.fn(),
43+
}));
44+
45+
// imports after mocks
46+
import { User } from '../../../src/models/users.js';
47+
import { signEphemeralToken } from '../../../src/lib/token.js';
48+
import { getSystemConfig } from '../../../src/config/getSystemConfig.js';
49+
import { buildRegistrationRequest } from '../../factories/requestFactory.js';
50+
51+
let app: Application;
52+
53+
beforeAll(async () => {
54+
app = await createApp();
55+
});
56+
57+
beforeEach(() => {
58+
vi.clearAllMocks();
59+
60+
(getSystemConfig as any).mockResolvedValue({
61+
default_roles: ['user'],
62+
});
63+
64+
(signEphemeralToken as any).mockResolvedValue('mock-token');
65+
});
66+
67+
describe('POST /registration/register', () => {
68+
// ✅ Happy path - new user
69+
it('creates a new user', async () => {
70+
(User.findOne as any).mockResolvedValue(null);
71+
72+
const user = buildUser();
73+
74+
(User.create as any).mockResolvedValue(user);
75+
76+
const res = await request(app).post('/registration/register').send(buildRegistrationRequest());
77+
78+
expect(res.status).toBe(200);
79+
expect(res.body.message).toBe('Success');
80+
81+
expect(User.create).toHaveBeenCalled();
82+
expect(signEphemeralToken).toHaveBeenCalledWith(user.id);
83+
});
84+
85+
// ✅ Existing user
86+
it('handles existing user', async () => {
87+
const user = buildUser();
88+
89+
(User.findOne as any).mockResolvedValue(user);
90+
91+
const res = await request(app).post('/registration/register').send(buildRegistrationRequest());
92+
93+
expect(res.status).toBe(200);
94+
95+
expect(User.create).not.toHaveBeenCalled();
96+
expect(signEphemeralToken).toHaveBeenCalledWith(user.id);
97+
});
98+
99+
// ❌ Missing email
100+
it('fails without email', async () => {
101+
const res = await request(app).post('/registration/register').send({ phone: '+15555555555' });
102+
103+
expect(res.status).toBe(400);
104+
});
105+
106+
// ❌ Missing phone
107+
it('fails without phone', async () => {
108+
const res = await request(app)
109+
.post('/registration/register')
110+
.send({ email: 'test@example.com' });
111+
112+
expect(res.status).toBe(400);
113+
});
114+
115+
// ❌ Invalid email
116+
it('fails invalid email', async () => {
117+
const res = await request(app)
118+
.post('/registration/register')
119+
.send(buildRegistrationRequest({ email: 'bad' }));
120+
121+
expect(res.status).toBe(400);
122+
});
123+
124+
// 💥 Error case
125+
it('handles unexpected errors', async () => {
126+
(User.findOne as any).mockRejectedValue(new Error('boom'));
127+
128+
const res = await request(app).post('/registration/register').send(buildRegistrationRequest());
129+
130+
expect(res.status).toBe(500);
131+
});
132+
});

tests/setup/db.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { getSequelize } from '../../src/models/index.js';
2+
3+
export async function setupTestDb() {
4+
if (process.env.TEST_DB === 'postgres') {
5+
const sequelize = getSequelize();
6+
await sequelize.sync({ force: true });
7+
}
8+
}
9+
10+
export async function teardownTestDb() {
11+
if (process.env.TEST_DB === 'postgres') {
12+
const sequelize = getSequelize();
13+
await sequelize.close();
14+
}
15+
}

tests/setup/env.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
process.env.NODE_ENV = 'test';
2+
process.env.AUTH_MODE = 'api';
3+
process.env.APP_ORIGIN = 'http://localhost:5174';
4+
5+
// Default: use mock DB mode
6+
process.env.TEST_DB = process.env.TEST_DB || 'mock';
7+
8+
// Only needed if postgres mode used
9+
process.env.DB_USER ||= 'test';
10+
process.env.DB_PASSWORD ||= 'test';
11+
process.env.DB_HOST ||= 'localhost';
12+
process.env.DB_PORT ||= '5432';
13+
process.env.DB_NAME ||= 'seamless_test';

tests/setup/globalSetup.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { setupTestDb, teardownTestDb } from './db';
2+
3+
export default async () => {
4+
await setupTestDb();
5+
6+
return async () => {
7+
await teardownTestDb();
8+
};
9+
};

0 commit comments

Comments
 (0)