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
2 changes: 2 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,7 @@ jobs:
run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
- name: Lint
run: ${{ steps.detect-package-manager.outputs.runner }} lint
- name: Test E2E
run: ${{ steps.detect-package-manager.outputs.runner }} test:e2e
- name: Build
run: ${{ steps.detect-package-manager.outputs.runner }} build
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,11 @@
},
"devDependencies": {
"@compodoc/compodoc": "^1.1.23",
"@faker-js/faker": "^9.9.0",
"@nestjs/cli": "^11.0.2",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.9",
"@testcontainers/postgresql": "^11.5.1",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
Expand All @@ -89,6 +91,7 @@
"kysely-ctl": "^0.11.1",
"prettier": "^3.2.5",
"supertest": "^6.3.4",
"testcontainers": "^11.5.1",
"ts-jest": "^29.1.2",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
Expand Down
607 changes: 604 additions & 3 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions test/@types/globals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { StartedPostgreSqlContainer } from '@testcontainers/postgresql';

declare global {
// eslint-disable-next-line no-var
var __TEST__: boolean;
// eslint-disable-next-line no-var
var __Container__: {
postgres: StartedPostgreSqlContainer | null;
};
}

export {};
36 changes: 36 additions & 0 deletions test/factory/test-module.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { createTestConfig } from '../helpers/test-configuration';
import { TestAppModule } from '../test-app.module';
export class TestModuleFactory {
static async createTestModule(dbUrl: string): Promise<TestingModule> {
const databaseUrl = dbUrl + '?sslmode=disable';
// this is to ignore warning from env not found error. does not matter what we put
process.env.DATABASE_URL = databaseUrl;
process.env.SECRET = 'fqipdmzavlbb9cvbzpab2fw5h5j4tu';
const testConfig = createTestConfig(databaseUrl);
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
load: [() => testConfig],
isGlobal: true,
}),
TestAppModule,
],
})
.overrideProvider(ConfigService)
.useValue({
get: (key: string) => {
const keys = key.split('.');
let value = testConfig;
for (const k of keys) {
value = value[k];
}
return value;
},
})
.compile();

return moduleRef;
}
}
17 changes: 17 additions & 0 deletions test/helpers/test-configuration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// test/config/test-configuration.ts
import { AppConfig, LoggerFormat } from '../../src/config/configuration';

export const createTestConfig = (databaseUrl: string): AppConfig => ({
corsMaxAge: 86400,
database: {
poolSize: 5,
url: databaseUrl,
},
port: 3000,
secret: 'kugk2iz30q5mlc6056der8sdnadibb',
logger: {
format: LoggerFormat.Json,
level: 'error',
},
isDevEnv: false,
});
12 changes: 10 additions & 2 deletions test/jest-e2e.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"rootDir": "./",
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/src/$1"
},
"modulePaths": ["<rootDir>"],
"moduleDirectories": ["<rootDir>/", "node_modules", "src", "<rootDir>/../"],
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
},
"globalSetup": "./setup/global-setup.ts",
"globalTeardown": "./setup/global-teardown.ts",
"setupFilesAfterEnv": ["<rootDir>/@types/globals.d.ts"]
}
11 changes: 11 additions & 0 deletions test/mocks/mock-jwt.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { CanActivate, ExecutionContext } from '@nestjs/common';
import MOCK_USERS from './user-mocks';

export class MockJwtGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
// Attach a mock user to the request for testing
req.user = { ...MOCK_USERS.user1 };
return true;
}
}
12 changes: 12 additions & 0 deletions test/mocks/user-mocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const MOCK_USERS = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test/users/mock/users.ts

user1: {
email: 'user1@example.com',
password: 'password1',
},
user2: {
email: 'user2@example.com',
password: 'password2',
},
};

export default MOCK_USERS;
161 changes: 161 additions & 0 deletions test/modules/auth.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { TestingModule } from '@nestjs/testing';
import { TestModuleFactory } from '../factory/test-module.factory';
import { ValidationException } from 'common/exceptions/validation.exception';
import { BaseExceptionsFilter } from 'common/filters/base-exception.filter';
import { AllExceptionsFilter } from 'common/filters/all-exception.filter';
import MOCK_USERS from '../mocks/user-mocks';
import request from 'supertest';
import { PostgresContainer } from '../utils/postgres-container';

describe('AuthModule', () => {
let testingModule: TestingModule;
let app: INestApplication;
let registeredUser: { email: string; password: string };
let authToken = '';
beforeAll(async () => {
const postgresContainer = await PostgresContainer.getInstance();

testingModule = await TestModuleFactory.createTestModule(
postgresContainer.getConnectionUri(),
);
app = testingModule.createNestApplication();

app.useGlobalFilters(new AllExceptionsFilter(), new BaseExceptionsFilter());
app.useGlobalPipes(
new ValidationPipe({
exceptionFactory: (errors) => {
return new ValidationException(errors);
},
transform: true,
whitelist: true,
validationError: { target: false },
}),
);

await app.init();
}, 60000);

afterAll(async () => {
await app.close();
}, 60000);
const generateValidUserDto = () => ({
email: MOCK_USERS.user2.email,
password: MOCK_USERS.user2.password,
});
describe('Authentication Flow', () => {
beforeAll(async () => {
// Ensure we have a registered user for login tests
if (!registeredUser) {
registeredUser = generateValidUserDto();
await request(app.getHttpServer())
.post('/users/register')
.send(registeredUser)
.expect(201);
}
});

describe('POST /auth/login', () => {
it('should login with valid credentials', async () => {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({
login: registeredUser.email,
password: registeredUser.password,
})
.expect(200);

expect(response.body).toHaveProperty('accessToken');
expect(response.body).toHaveProperty('user');
expect(response.body.user).toHaveProperty(
'email',
registeredUser.email.toLowerCase(),
);

authToken = response.body.accessToken;
});

it('should return 401 with invalid email', async () => {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({
login: 'nonexistent@example.com',
password: registeredUser.password,
})
.expect(401);

expect(response.body.message).toContain('Invalid login or password');
});

it('should return 401 with invalid password', async () => {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({
login: registeredUser.email,
password: 'wrongpassword',
})
.expect(401);

expect(response.body.message).toContain('Invalid login or password');
});

it('should return 400 when login is missing', async () => {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({
password: registeredUser.password,
})
.expect(400);

expect(response.body.message).toBe('Validation Failed');
expect(response.body.errors).toBeDefined();
});

it('should return 400 when password is missing', async () => {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({
login: registeredUser.email,
})
.expect(400);

expect(response.body.message).toBe('Validation Failed');
expect(response.body.errors).toBeDefined();
});
});

describe('GET /auth/me', () => {
it('should return current user when authenticated', async () => {
const response = await request(app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);

expect(response.body).toHaveProperty('user');
expect(response.body.user).toHaveProperty(
'email',
registeredUser.email.toLowerCase(),
);
expect(response.body.user).toHaveProperty('id');
expect(response.body.user).not.toHaveProperty('password');
});

it('should return 401 when not authenticated', async () => {
const response = await request(app.getHttpServer())
.get('/auth/me')
.expect(401);

expect(response.body.message).toContain('Unauthorized');
});

it('should return 401 with malformed authorization header', async () => {
const response = await request(app.getHttpServer())
.get('/auth/me')
.set('Authorization', 'InvalidFormat')
.expect(401);

expect(response.body.message).toContain('Unauthorized');
});
});
});
});
Loading