Compare commits

...

5 Commits

Author SHA1 Message Date
Roland Schneider
c169faf288 add e2e 2025-10-30 21:02:05 +01:00
Schneider Roland
b599e79273 add e2e with add groups 2025-10-30 03:51:50 +01:00
Schneider Roland
45a69eea8a add e2e with add groups 2025-10-29 22:56:11 +01:00
Roland Schneider
2f54770720 add e2e 2025-10-29 16:30:17 +01:00
Schneider Roland
bdf16a3189 add e2e with docker-compose support 2025-10-29 08:12:23 +01:00
21 changed files with 1845 additions and 13 deletions

6
.env.e2e Normal file
View File

@ -0,0 +1,6 @@
DATABASE_USER=test
DATABASE_PASS=test
DATABASE_HOST=localhost
DATABASE_NAME=test
DATABASE_PORT=4401
JWT_SECRET="secret"

View File

@ -13,3 +13,22 @@ Content-Type: application/json
GET http://localhost:3000/users GET http://localhost:3000/users
Accept: application/json Accept: application/json
Authorization: Bearer {{auth_token}} Authorization: Bearer {{auth_token}}
### POST create user
POST http://localhost:3000/users
Content-Type: application/json
Accept: application/json
Authorization: Bearer {{auth_token}}
{
"username": "test1",
"password": "123456",
"email": "test1@gmail.com",
"groups": [
{
"id": 1
}
]
}

View File

@ -0,0 +1,17 @@
version: '3.8'
services:
postgres:
image: postgres:18
restart: always
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports:
- '4401:5432'
volumes:
- e2epgdata:/var/lib/postgresql
volumes:
e2epgdata: {}

1360
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -52,6 +52,7 @@
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.0", "@types/passport-jwt": "^4.0.0",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"dotenv": "^16.4.5",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
@ -60,6 +61,7 @@
"prettier": "^3.4.2", "prettier": "^3.4.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"testcontainers": "^10.9.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",

View File

@ -13,6 +13,7 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: (configService: ConfigService) => { useFactory: (configService: ConfigService) => {
// console.log("config Service", configService)
return { return {
type: 'postgres', type: 'postgres',
host: configService.get<string>('DATABASE_HOST'), host: configService.get<string>('DATABASE_HOST'),
@ -27,8 +28,16 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({
}, },
}); });
const envFilePath =
process.env.NODE_ENV === 'test' ? '.env.e2e' : '.env';
// throw new Error("envFilePath:"+envFilePath);
const moduleConfig = ConfigModule.forRoot({
envFilePath,
});
@Module({ @Module({
imports: [ConfigModule.forRoot(), moduleTypeOrm, UserModule, AuthModule], imports: [moduleConfig, moduleTypeOrm, UserModule, AuthModule],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
}) })

View File

@ -5,8 +5,14 @@ import { User } from './entity/user';
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import { UserGroup } from './entity/user-group'; import { UserGroup } from './entity/user-group';
import { UserRole } from './entity/user-role'; import { UserRole } from './entity/user-role';
import path from 'path';
dotenv.config(); let envFilePath = path.resolve(process.cwd(), '.env');
if (process.env.DATA_SOURCE_ENV) {
envFilePath = path.resolve(process.cwd(), process.env.DATA_SOURCE_ENV);
}
dotenv.config({ path: envFilePath });
export const AppDataSource = new DataSource({ export const AppDataSource = new DataSource({
type: 'postgres', type: 'postgres',
@ -18,8 +24,6 @@ export const AppDataSource = new DataSource({
synchronize: false, synchronize: false,
logging: false, logging: false,
entities: [User, UserGroup, UserRole], entities: [User, UserGroup, UserRole],
migrations: [ migrations: ['src/migration/**/*.ts'],
'src/migration/**/*.ts'
],
subscribers: [], subscribers: [],
}); });

View File

@ -1,4 +1,4 @@
import { IsString, IsEmail, MinLength } from 'class-validator'; import { IsString, IsEmail, MinLength, IsArray } from 'class-validator';
export class CreateUserDto { export class CreateUserDto {
@IsString() @IsString()
@ -11,4 +11,7 @@ export class CreateUserDto {
@IsString() @IsString()
@MinLength(6) @MinLength(6)
password: string; password: string;
@IsArray()
groups: [{ id: number }];
} }

View File

@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserGroup } from '../entity/user-group';
import { FindOptionsRelations } from 'typeorm/find-options/FindOptionsRelations';
@Injectable()
export class UserGroupService {
constructor(
@InjectRepository(UserGroup)
private userGroupRepository: Repository<UserGroup>,
) {}
findByName(
name: string,
relations?: FindOptionsRelations<UserGroup>,
): Promise<UserGroup | null> {
return this.userGroupRepository.findOne({ where: { name }, relations });
}
findAll(): Promise<UserGroup[]> {
return this.userGroupRepository.find();
}
findOne(id: number): Promise<UserGroup | null> {
return this.userGroupRepository.findOneBy({ id });
}
async create(userGroup: Partial<UserGroup>): Promise<UserGroup> {
const newUserGroup = this.userGroupRepository.create(userGroup);
return this.userGroupRepository.save(newUserGroup);
}
async update(
id: number,
userGroup: Partial<UserGroup>,
): Promise<UserGroup | null> {
await this.userGroupRepository.update(id, userGroup);
return this.userGroupRepository.findOneBy({ id });
}
async remove(id: number): Promise<void> {
await this.userGroupRepository.delete(id);
}
}

View File

@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserRole } from '../entity/user-role';
@Injectable()
export class UserRoleService {
constructor(
@InjectRepository(UserRole)
private userGroupRepository: Repository<UserRole>,
) {}
findAll(): Promise<UserRole[]> {
return this.userGroupRepository.find();
}
findOne(id: number): Promise<UserRole | null> {
return this.userGroupRepository.findOneBy({ id });
}
async create(userGroup: Partial<UserRole>): Promise<UserRole> {
const newUserRole = this.userGroupRepository.create(userGroup);
return this.userGroupRepository.save(newUserRole);
}
async update(
id: number,
userRole: Partial<UserRole>,
): Promise<UserRole | null> {
await this.userGroupRepository.update(id, userRole);
return this.userGroupRepository.findOneBy({ id });
}
async remove(id: number): Promise<void> {
await this.userGroupRepository.delete(id);
}
}

View File

@ -33,6 +33,7 @@ export class UserController {
@Get() @Get()
findAll(): Promise<User[]> { findAll(): Promise<User[]> {
console.log("findall", process.env);
return this.userService.findAll(); return this.userService.findAll();
} }

View File

@ -3,11 +3,15 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { UserService } from './user.service'; import { UserService } from './user.service';
import { UserController } from './user.controller'; import { UserController } from './user.controller';
import { User } from '../entity/user'; import { User } from '../entity/user';
import { UserGroup } from '../entity/user-group';
import { UserRole } from '../entity/user-role';
import { UserGroupService } from './user-group.service';
import { UserRoleService } from './user-role.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([User])], imports: [TypeOrmModule.forFeature([User, UserGroup, UserRole])],
providers: [UserService], providers: [UserService, UserGroupService, UserRoleService],
controllers: [UserController], controllers: [UserController],
exports: [UserService], exports: [UserService, UserGroupService, UserRoleService],
}) })
export class UserModule {} export class UserModule {}

View File

@ -1,15 +1,19 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { User } from '../entity/user'; import { User } from '../entity/user';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { FindOptionsRelations } from 'typeorm/find-options/FindOptionsRelations'; import { FindOptionsRelations } from 'typeorm/find-options/FindOptionsRelations';
import { UserGroupService } from './user-group.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UserGroup } from '../entity/user-group';
@Injectable() @Injectable()
export class UserService { export class UserService {
constructor( constructor(
@InjectRepository(User) @InjectRepository(User)
private usersRepository: Repository<User>, private usersRepository: Repository<User>,
private userGroupService: UserGroupService,
) {} ) {}
findAll(): Promise<User[]> { findAll(): Promise<User[]> {
@ -22,16 +26,30 @@ export class UserService {
findByUsername( findByUsername(
username: string, username: string,
relations: FindOptionsRelations<User>, relations?: FindOptionsRelations<User>,
): Promise<User | null> { ): Promise<User | null> {
return this.usersRepository.findOne({ where: { username }, relations }); return this.usersRepository.findOne({ where: { username }, relations });
} }
async create(user: Partial<User>): Promise<User> { async create(userDto: Partial<CreateUserDto>): Promise<User> {
const { groups, ...user } = userDto;
if (user.password) { if (user.password) {
user.password = await bcrypt.hash(user.password, 12); user.password = await bcrypt.hash(user.password, 12);
} }
if (groups) {
const userGroups: UserGroup[] = [];
for (const group of groups) {
const userGroup = await this.userGroupService.findOne(group.id);
if (userGroup) {
userGroups.push(userGroup);
} else {
throw new NotFoundException('User group does not exist:' + group.id);
}
}
(user as Partial<User>).groups = userGroups;
}
const newUser = this.usersRepository.create(user); const newUser = this.usersRepository.create(user);
return this.usersRepository.save(newUser); return this.usersRepository.save(newUser);
} }

View File

@ -0,0 +1,8 @@
import { User } from '../../src/entity/user';
import { INestApplication } from '@nestjs/common';
export interface DvbookingApiContext {
token: string;
user: User;
app: INestApplication<any>;
}

View File

@ -0,0 +1,78 @@
import { DvbookingApiContext } from './dvbooking.api-context';
import { CreateUserDto } from '../../src/user/dto/create-user.dto';
import { TestingModule } from '@nestjs/testing';
import { UserService } from '../../src/user/user.service';
import { UserGroupService } from '../../src/user/user-group.service';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
export class DvbookingApi {
public context: DvbookingApiContext;
systemUser: { username: string; password: string; token?: string } = {
username: 'admin',
password: '123456',
token: undefined,
};
private userService: UserService;
private userGroupService: UserGroupService;
constructor(
private app: INestApplication<any>,
private moduleFixture: TestingModule,
) {
this.userService = moduleFixture.get<UserService>(UserService);
this.userGroupService =
moduleFixture.get<UserGroupService>(UserGroupService);
}
public async init() {
const response = await request(this.app.getHttpServer())
.post('/auth/login')
.send({
username: this.systemUser.username,
password: this.systemUser.password,
});
this.systemUser.token = (
response.body as { access_token: string }
).access_token;
}
public async destroy() {
// do cleanup
}
public async loginWithGroup(
username?: string,
groupName?: string,
): Promise<void> {
const group = await this.userGroupService.findByName(groupName);
if (!username) {
username = 'e2e-user-' + Math.floor(100 * Math.random());
}
const password = 'password';
const user = await this.createUser({
username: username,
email: 'user@dvbooking.hu',
password: 'password',
groups: [{ id: group!.id }],
});
const response = await request(this.app.getHttpServer())
.post('/auth/login')
.send({ username, password });
const token = (response.body as { access_token: string }).access_token;
this.context = {
user,
token,
app: this.app,
};
}
public async createUser(user: CreateUserDto) {
return await this.userService.create(user);
}
}

View File

@ -0,0 +1,17 @@
import request from 'supertest';
import { DvbookingApiContext } from './dvbooking.api-context';
export class DvBookingHttpClient {
constructor(private context: DvbookingApiContext) {}
private createRequest() {
return request(this.context.app.getHttpServer());
}
public async httpPost(path: string, data?: string | object) {
return await this.createRequest().post(path).send(data);
}
public async httpGet(path: string) {
return await this.createRequest().get(path).send();
}
}

View File

@ -0,0 +1,21 @@
import { DvbookingApiContext } from './dvbooking.api-context';
import { DvBookingHttpClient } from './dvbooking.http-client';
import { User } from '../../src/entity/user';
export class UserApiClient {
private http: DvBookingHttpClient;
constructor(context: DvbookingApiContext) {
this.http = new DvBookingHttpClient(context);
}
public async find() {
const response = await this.http.httpGet('/users');
return response.body as User[];
}
public async findById(id: number) {
const response = await this.http.httpGet('/users/' + id);
return response.body as User[];
}
}

43
test/global-setup.ts Normal file
View File

@ -0,0 +1,43 @@
import { exec } from 'child_process';
import * as path from 'path';
import { DockerComposeEnvironment, Wait } from 'testcontainers';
export default async () => {
const composeFilePath = path.resolve(__dirname, '../environment/e2e');
const environment = await new DockerComposeEnvironment(
composeFilePath,
'docker-compose.yaml',
)
.withWaitStrategy('postgres_1', Wait.forHealthCheck())
.up();
// Store the environment details for teardown
(global as any).__TESTCONTAINERS_ENVIRONMENT__ = environment;
// Run migrations
await new Promise<void>((resolve, reject) => {
console.info('running migration');
exec(
'env && npm run migration:run',
{ env: { ...process.env, DATA_SOURCE_ENV: '.env.e2e' } },
(err, stdout, stderr) => {
if (err) {
console.error(stderr);
return reject(err);
}
console.log(stdout);
resolve();
},
);
});
// await new Promise(resolve => {setTimeout(resolve, 60000)})
};
// function readEnvFile() {
// const fs = require('fs');
// const dotenv = require('dotenv');
// const envConfig = dotenv.parse(fs.readFileSync('.env.e2e'));
// return envConfig;
// }

11
test/global-teardown.ts Normal file
View File

@ -0,0 +1,11 @@
import { StartedDockerComposeEnvironment } from 'testcontainers';
export default async () => {
const environment: StartedDockerComposeEnvironment = (
global as any as { __TESTCONTAINERS_ENVIRONMENT__: any }
).__TESTCONTAINERS_ENVIRONMENT__ as StartedDockerComposeEnvironment;
if (environment) {
await environment.down();
}
};

View File

@ -5,5 +5,7 @@
"testRegex": ".e2e-spec.ts$", "testRegex": ".e2e-spec.ts$",
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
} },
"globalSetup": "<rootDir>/global-setup.ts",
"globalTeardown": "<rootDir>/global-teardown.ts"
} }

128
test/user.e2e-spec.ts Normal file
View File

@ -0,0 +1,128 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { CreateUserDto } from '../src/user/dto/create-user.dto';
import { UserService } from '../src/user/user.service';
import { User } from '../src/entity/user';
import dotenv from 'dotenv';
import path from 'path';
import { DvbookingApi } from './client/dvbooking.api';
dotenv.config({ path: path.resolve(process.cwd(), '.env.e2e') });
describe('UserController (e2e)', () => {
process.env.DATA_SOURCE_ENV = '.env.e2e';
process.env.DATABASE_HOST = 'localhost';
process.env.DATABASE_PORT = '4401';
process.env.DATABASE_USER = 'test';
process.env.DATABASE_PASS = 'test';
let app: INestApplication;
let jwtToken: string;
let adminUserId: number;
let adminGroupId: number;
let api: DvbookingApi;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
// process.env.DATA_SOURCE_ENV=".env.e2e";
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe());
await app.init();
api = new DvbookingApi(app, moduleFixture);
await api.init();
});
afterAll(async () => {
await api.destroy();
await app.close();
});
describe('/users', () => {
it('(GET) should get all users', async () => {
const response = await request(app.getHttpServer())
.get('/users')
.set('Authorization', `Bearer ${jwtToken}`);
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
});
it('(POST) should create a user', async () => {
const createUserDto: CreateUserDto = {
username: 'e2e_user',
email: 'user@dvbooking.hu',
password: 'password',
groups: [{ id: adminGroupId }],
};
const response = await request(app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${jwtToken}`)
.send(createUserDto);
expect(response.status).toBe(201);
expect(response.body.username).toEqual(createUserDto.username);
const userService = app.get<UserService>(UserService);
await userService.remove(response.body.id);
});
});
describe('/users/:id', () => {
let user: User;
let userService: UserService;
beforeEach(async () => {
userService = app.get<UserService>(UserService);
await api.loginWithGroup(undefined, 'admin');
user = api.context.user!;
});
afterEach(async () => {
const userExists = await userService.findOne(user.id);
if (userExists) {
await userService.remove(user.id);
}
});
it('(GET) should get a user by id', async () => {
const response = await request(app.getHttpServer())
.get(`/users/${user.id}`)
.set('Authorization', `Bearer ${jwtToken}`);
expect(response.status).toBe(200);
expect(response.body.id).toEqual(user.id);
});
// it('(PATCH) should update a user', async () => {
// const updateUserDto: UpdateUserDto = {
// username: 'e2e_updated_user',
// };
//
// const response = await request(app.getHttpServer())
// .patch(`/users/${user.id}`)
// .set('Authorization', `Bearer ${jwtToken}`)
// .send(updateUserDto);
//
// expect(response.status).toBe(200);
// expect(response.body.username).toEqual(updateUserDto.username);
// });
//
// it('(DELETE) should delete a user', async () => {
// const response = await request(app.getHttpServer())
// .delete(`/users/${user.id}`)
// .set('Authorization', `Bearer ${jwtToken}`);
//
// expect(response.status).toBe(200);
//
// const deletedUser = await userService.findOne(user.id);
// expect(deletedUser).toBeNull();
// });
});
});