feat(users): users module

users module with tests
main
flavien 2023-04-26 19:48:29 +00:00
parent 687e54a3e4
commit 9252605b6d
11 changed files with 222 additions and 2 deletions

View File

@ -1,6 +1,6 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run format
npm run format:check
npm run lint
npm run test

View File

@ -9,12 +9,13 @@
"install:husky": "./node_modules/.bin/husky install",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest --passWithNoTests",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",

View File

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './database/database.module';
import * as Joi from 'joi';
import { UsersModule } from './users/users.module';
@Module({
imports: [
@ -15,6 +16,7 @@ import * as Joi from 'joi';
}),
}),
DatabaseModule,
UsersModule,
],
})
export class AppModule {}

17
src/users/users.entity.ts Normal file
View File

@ -0,0 +1,17 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity()
class User {
@PrimaryColumn({
unique: true,
type: 'bigint',
})
public discordId: string;
@Column({
unique: true,
})
public username: string;
}
export default User;

11
src/users/users.module.ts Normal file
View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import User from './users.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@ -0,0 +1,81 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { mockedUsersRepository, userModel } from '../utils/mocks/users.service';
import User from './users.entity';
import { UsersService } from './users.service';
describe('UsersService', () => {
let service: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: getRepositoryToken(User),
useValue: mockedUsersRepository,
},
],
}).compile();
service = module.get<UsersService>(UsersService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('when getting a user by discordId', () => {
beforeEach(() => {
mockedUsersRepository.findOneBy.mockImplementationOnce(
({ discordId }) => {
if (discordId === userModel.discordId) {
return userModel;
}
return undefined;
},
);
});
describe('and the user is matched', () => {
it('should return the user', async () => {
const fetchedUser = await service.getByDiscordId(userModel.discordId);
expect(fetchedUser).toEqual(userModel);
});
});
describe('and the user is not matched', () => {
it('should throw an error', async () => {
await expect(service.getByDiscordId('unknown')).rejects.toThrow();
});
});
});
describe('when getting a user by username', () => {
beforeEach(() => {
mockedUsersRepository.findOneBy.mockImplementationOnce(({ username }) => {
if (username === userModel.username) {
return userModel;
}
return undefined;
});
});
describe('and the user is matched', () => {
it('should return the user', async () => {
const fetchedUser = await service.getByUserName(userModel.username);
expect(fetchedUser).toEqual(userModel);
});
});
describe('and the user is not matched', () => {
it('should throw an error', async () => {
await expect(service.getByUserName('unknown')).rejects.toThrow();
});
});
});
describe('when creating a user', () => {
it('should return the user', async () => {
mockedUsersRepository.create.mockResolvedValueOnce(userModel);
const fetchedUser = await service.create(userModel);
expect(fetchedUser).toEqual(userModel);
});
});
});

View File

@ -0,0 +1,50 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import User from './users.entity';
import { NotFoundError } from '../utils/errors/notFound.error';
import {
AlreadyExistsError,
extractDetailsQueryError,
} from '../utils/errors/alreadyExists.error';
import PostgresErrorCode from '../utils/errors/postgresErrorCode.enum';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
private static readonly logger: Logger = new Logger('UsersService');
async getByDiscordId(discordId: string) {
const user = await this.usersRepository.findOneBy({ discordId });
if (user) {
return user;
}
throw new NotFoundError('User', 'discordId', discordId);
}
async getByUserName(username: string) {
const user = await this.usersRepository.findOneBy({ username });
if (user) {
return user;
}
throw new NotFoundError('User', 'username', username);
}
async create(user: User) {
try {
const newUser = this.usersRepository.create(user);
await this.usersRepository.save(newUser);
UsersService.logger.log(`New user ${user.username} created`);
return newUser;
} catch (error) {
if (error.code === PostgresErrorCode.UniqueViolation) {
const details = extractDetailsQueryError(error.detail);
throw new AlreadyExistsError('User', details.property, details.value);
}
throw error;
}
}
}

View File

@ -0,0 +1,29 @@
interface AlreadyExistsErrorDetails {
property: string;
value: string;
}
export function extractDetailsQueryError(
detail: string,
): AlreadyExistsErrorDetails {
const matches: string[] = detail
.match(/\(.*?\)/g)
.map((x) => x.replace(/[()]/g, ''));
return {
property: matches[0],
value: matches[1],
};
}
export class AlreadyExistsError extends Error {
constructor(entity: string, property: string, value: string) {
super(`${entity} with ${property} ${value} already exists`);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, AlreadyExistsError);
}
}
get name() {
return this.constructor.name;
}
}

View File

@ -0,0 +1,12 @@
export class NotFoundError extends Error {
constructor(entity: string, property: string, value: string) {
super(`${entity} with ${property} ${value} not found`);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, NotFoundError);
}
}
get name() {
return this.constructor.name;
}
}

View File

@ -0,0 +1,5 @@
enum PostgresErrorCode {
UniqueViolation = '23505',
}
export default PostgresErrorCode;

View File

@ -0,0 +1,12 @@
import User from '../../users/users.entity';
export const userModel: User = {
discordId: 'test',
username: 'test',
};
export const mockedUsersRepository = {
findOneBy: jest.fn(),
create: jest.fn(),
save: () => undefined,
};