diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0e701eb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +postgresql +.env diff --git a/.env b/.env new file mode 100644 index 0000000..7632b9a --- /dev/null +++ b/.env @@ -0,0 +1,21 @@ +# SERVICE +SERVICE_CONTAINER=v3_user +SERVICE_PORT=3001 + +# PRISMA +DATABASE_URL="postgresql://user:user@db:5432/user?schema=public" + +# POSTGRES +POSTGRES_CONTAINER=v3_user_db +POSTGRES_IMAGE=postgres:15.0 +POSTGRES_DB=user +POSTGRES_PASSWORD=user +POSTGRES_USER=user +POSTGRES_PORT=5401 + +# PGADMIN +PGADMIN_CONTAINER=v3_user_pgadmin +PGADMIN_IMAGE=dpage/pgadmin4:6.12 +PGADMIN_EMAIL=it@mobicoo.org +PGADMIN_PASSWORD=user +PGADMIN_PORT=8401 diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..27eed38 --- /dev/null +++ b/.env.dist @@ -0,0 +1,21 @@ +# SERVICE +SERVICE_CONTAINER=v3_user +SERVICE_PORT=3001 + +# PRISMA +DATABASE_URL="postgresql://user:user@db:5432/user?schema=public" + +# POSTGRES +POSTGRES_CONTAINER=v3_user_db +POSTGRES_IMAGE=postgres:15.0 +POSTGRES_DB=user +POSTGRES_PASSWORD=user +POSTGRES_USER=user +POSTGRES_PORT=5401 + +# PGADMIN +PGADMIN_CONTAINER=v3_user_pgadmin +PGADMIN_IMAGE=dpage/pgadmin4:6.12 +PGADMIN_EMAIL=it@mobicoop.org +PGADMIN_PASSWORD=user +PGADMIN_PORT=8401 diff --git a/.gitignore b/.gitignore index 22f55ad..21e6321 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# custom +postgresql + # compiled output /dist /node_modules @@ -32,4 +35,4 @@ lerna-debug.log* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json -!.vscode/extensions.json \ No newline at end of file +!.vscode/extensions.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4c7fb74 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,73 @@ +################### +# BUILD FOR LOCAL DEVELOPMENT +################### + +FROM node:18-alpine As development + +# Create app directory +WORKDIR /usr/src/app + +# Copy application dependency manifests to the container image. +# A wildcard is used to ensure copying both package.json AND package-lock.json (when available). +# Copying this first prevents re-running npm install on every code change. +COPY --chown=node:node package*.json ./ + +# Install app dependencies using the `npm ci` command instead of `npm install` +RUN npm ci + +# Bundle app source +COPY --chown=node:node . . + +# Use the node user from the image (instead of the root user) +USER node + +################### +# BUILD FOR PRODUCTION +################### + +FROM node:18-alpine As build + +WORKDIR /usr/src/app + +COPY --chown=node:node package*.json ./ + +# In order to run `npm run build` we need access to the Nest CLI. +# The Nest CLI is a dev dependency, +# In the previous development stage we ran `npm ci` which installed all dependencies. +# So we can copy over the node_modules directory from the development image into this build image. +COPY --chown=node:node --from=development /usr/src/app/node_modules ./node_modules + +COPY --chown=node:node . . + +# Copy prisma (needed for migrations) +COPY --chown=node:node ./prisma prisma + +# Run the build command which creates the production bundle +RUN npm run build + +# Set NODE_ENV environment variable +ENV NODE_ENV production + +# Running `npm ci` removes the existing node_modules directory. +# Passing in --only=production ensures that only the production dependencies are installed. +# This ensures that the node_modules directory is as optimized as possible. +RUN npm ci --only=production && npm cache clean --force + +USER node + +################### +# PRODUCTION +################### + +FROM node:18-alpine As production + +# Copy package.json to be able to execute migration command +COPY --chown=node:node package*.json ./ + +# Copy the bundled code from the build stage to the production image +COPY --chown=node:node --from=build /usr/src/app/node_modules ./node_modules +COPY --chown=node:node --from=build /usr/src/app/prisma ./prisma +COPY --chown=node:node --from=build /usr/src/app/dist ./dist + +# Start the server using the production build +CMD [ "node", "dist/main.js" ] diff --git a/README.md b/README.md index 65f32f9..c7e8beb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Mobicoop V3 - Auth Service +# Mobicoop V3 - User Service -Mobicoop V3 - Authentication (AuthN) & authorization (AuthZ) service. +Mobicoop V3 - User service. ## Installation @@ -36,4 +36,4 @@ $ npm run test:cov ## License -Mobicoop V3 - Auth Service is [AGPL licensed](LICENSE). +Mobicoop V3 - User Service is [AGPL licensed](LICENSE). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7eb5496 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +version: '3.8' + +services: + api: + container_name: ${SERVICE_CONTAINER} + build: + dockerfile: Dockerfile + context: . + target: development + volumes: + - .:/usr/src/app + env_file: + - .env + command: npm run start:dev + ports: + - "${SERVICE_PORT:-3001}:3000" + depends_on: + - db + networks: + - mobicoop-v3 + + db: + container_name: ${POSTGRES_CONTAINER} + image: ${POSTGRES_IMAGE} + restart: always + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - "${POSTGRES_PORT:-5401}:5432" + volumes: + - ./postgresql/.db_data:/var/lib/postgresql/data:rw + networks: + - mobicoop-v3 + + pgadmin: + container_name: ${PGADMIN_CONTAINER} + image: ${PGADMIN_IMAGE} + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD} + PGADMIN_CONFIG_SERVER_MODE: 'False' + ports: + - "${PGADMIN_PORT:-8401}:80" + volumes: + - ./postgresql/.pgadmin_data:/var/lib/pgadmin:rw + restart: unless-stopped + networks: + - mobicoop-v3 + depends_on: + - db + +networks: + mobicoop-v3: + external: + name: mobicoop-v3 diff --git a/package-lock.json b/package-lock.json index 400e323..d2c67cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,12 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@automapper/classes": "^8.7.7", + "@automapper/core": "^8.7.7", "@nestjs/common": "^9.0.0", "@nestjs/core": "^9.0.0", "@nestjs/platform-express": "^9.0.0", + "@prisma/client": "^4.7.1", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0" @@ -31,6 +34,7 @@ "eslint-plugin-prettier": "^4.0.0", "jest": "28.1.3", "prettier": "^2.3.2", + "prisma": "^4.7.1", "source-map-support": "^0.5.20", "supertest": "^6.1.3", "ts-jest": "28.0.8", @@ -197,6 +201,26 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "node_modules/@automapper/classes": { + "version": "8.7.7", + "resolved": "https://registry.npmjs.org/@automapper/classes/-/classes-8.7.7.tgz", + "integrity": "sha512-FSbvt6QE8XnhKKQZA3kpKLuLrr9x1iW+lNYTrawVLjxQ05zsCGccLxe7moMNrg1wFAVAouQKupFgCGQ7XRjmJw==", + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@automapper/core": "8.7.7", + "reflect-metadata": "~0.1.13" + } + }, + "node_modules/@automapper/core": { + "version": "8.7.7", + "resolved": "https://registry.npmjs.org/@automapper/core/-/core-8.7.7.tgz", + "integrity": "sha512-YfpDJ/xqwUuC0S+BLNk81ZJfeL7CmjirUX/Gk9eQyx146DKvneBZgeZ9v5rDB51Ti14jTxVHis+5JuT7W/q0TA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", @@ -1843,6 +1867,38 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/@prisma/client": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.7.1.tgz", + "integrity": "sha512-/GbnOwIPtjiveZNUzGXOdp7RxTEkHL4DZP3vBaFNadfr6Sf0RshU5EULFzVaSi9i9PIK9PYd+1Rn7z2B2npb9w==", + "hasInstallScript": true, + "dependencies": { + "@prisma/engines-version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c" + }, + "engines": { + "node": ">=14.17" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/engines": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.7.1.tgz", + "integrity": "sha512-zWabHosTdLpXXlMefHmnouhXMoTB1+SCbUU3t4FCmdrtIOZcarPKU3Alto7gm/pZ9vHlGOXHCfVZ1G7OIrSbog==", + "devOptional": true, + "hasInstallScript": true + }, + "node_modules/@prisma/engines-version": { + "version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c.tgz", + "integrity": "sha512-Bd4LZ+WAnUHOq31e9X/ihi5zPlr4SzTRwUZZYxvWOxlerIZ7HJlVa9zXpuKTKLpI9O1l8Ec4OYCKsivWCs5a3Q==" + }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -6711,6 +6767,23 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prisma": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.7.1.tgz", + "integrity": "sha512-CCQP+m+1qZOGIZlvnL6T3ZwaU0LAleIHYFPN9tFSzjs/KL6vH9rlYbGOkTuG9Q1s6Ki5D0LJlYlW18Z9EBUpGg==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/engines": "4.7.1" + }, + "bin": { + "prisma": "build/index.js", + "prisma2": "build/index.js" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -8518,6 +8591,17 @@ } } }, + "@automapper/classes": { + "version": "8.7.7", + "resolved": "https://registry.npmjs.org/@automapper/classes/-/classes-8.7.7.tgz", + "integrity": "sha512-FSbvt6QE8XnhKKQZA3kpKLuLrr9x1iW+lNYTrawVLjxQ05zsCGccLxe7moMNrg1wFAVAouQKupFgCGQ7XRjmJw==", + "requires": {} + }, + "@automapper/core": { + "version": "8.7.7", + "resolved": "https://registry.npmjs.org/@automapper/core/-/core-8.7.7.tgz", + "integrity": "sha512-YfpDJ/xqwUuC0S+BLNk81ZJfeL7CmjirUX/Gk9eQyx146DKvneBZgeZ9v5rDB51Ti14jTxVHis+5JuT7W/q0TA==" + }, "@babel/code-frame": { "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", @@ -9740,6 +9824,25 @@ } } }, + "@prisma/client": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.7.1.tgz", + "integrity": "sha512-/GbnOwIPtjiveZNUzGXOdp7RxTEkHL4DZP3vBaFNadfr6Sf0RshU5EULFzVaSi9i9PIK9PYd+1Rn7z2B2npb9w==", + "requires": { + "@prisma/engines-version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c" + } + }, + "@prisma/engines": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.7.1.tgz", + "integrity": "sha512-zWabHosTdLpXXlMefHmnouhXMoTB1+SCbUU3t4FCmdrtIOZcarPKU3Alto7gm/pZ9vHlGOXHCfVZ1G7OIrSbog==", + "devOptional": true + }, + "@prisma/engines-version": { + "version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c.tgz", + "integrity": "sha512-Bd4LZ+WAnUHOq31e9X/ihi5zPlr4SzTRwUZZYxvWOxlerIZ7HJlVa9zXpuKTKLpI9O1l8Ec4OYCKsivWCs5a3Q==" + }, "@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -13425,6 +13528,15 @@ } } }, + "prisma": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.7.1.tgz", + "integrity": "sha512-CCQP+m+1qZOGIZlvnL6T3ZwaU0LAleIHYFPN9tFSzjs/KL6vH9rlYbGOkTuG9Q1s6Ki5D0LJlYlW18Z9EBUpGg==", + "devOptional": true, + "requires": { + "@prisma/engines": "4.7.1" + } + }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", diff --git a/package.json b/package.json index 551f94a..eae4577 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "auth", + "name": "mobicoop-v3-user", "version": "0.0.1", - "description": "", - "author": "", + "description": "Mobicoop V3 User Service", + "author": "Mobicoop", "private": true, - "license": "UNLICENSED", + "license": "AGPL", "scripts": { "prebuild": "rimraf dist", "build": "nest build", @@ -21,9 +21,12 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@automapper/classes": "^8.7.7", + "@automapper/core": "^8.7.7", "@nestjs/common": "^9.0.0", "@nestjs/core": "^9.0.0", "@nestjs/platform-express": "^9.0.0", + "@prisma/client": "^4.7.1", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0" @@ -43,6 +46,7 @@ "eslint-plugin-prettier": "^4.0.0", "jest": "28.1.3", "prettier": "^2.3.2", + "prisma": "^4.7.1", "source-map-support": "^0.5.20", "supertest": "^6.1.3", "ts-jest": "28.0.8", diff --git a/prisma/migrations/20221213134247_init/migration.sql b/prisma/migrations/20221213134247_init/migration.sql new file mode 100644 index 0000000..3af6eeb --- /dev/null +++ b/prisma/migrations/20221213134247_init/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "user" ( + "uuid" UUID NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_uuid_key" ON "user"("uuid"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..17bc3f9 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,21 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + uuid String @unique @default(uuid()) @db.Uuid + firstName String + lastName String + email String + password String + + @@map("user") +} diff --git a/src/modules/database/database.module.ts b/src/modules/database/database.module.ts new file mode 100644 index 0000000..e2b09f4 --- /dev/null +++ b/src/modules/database/database.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from './src/adapters/secondaries/prisma-service'; +import { UserRepository } from './src/domain/user-repository'; + +@Module({ + providers: [PrismaService, UserRepository], + exports: [PrismaService, UserRepository], +}) +export class DatabaseModule {} diff --git a/src/modules/database/src/adapters/secondaries/prisma-repository.abstract.ts b/src/modules/database/src/adapters/secondaries/prisma-repository.abstract.ts new file mode 100644 index 0000000..7e750c4 --- /dev/null +++ b/src/modules/database/src/adapters/secondaries/prisma-repository.abstract.ts @@ -0,0 +1,163 @@ +import { ConsoleLogger, Injectable } from '@nestjs/common'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime'; +import { DatabaseException } from '../../exceptions/DatabaseException'; +import { IRepository } from '../../interfaces/repository.interface'; +import { PrismaService } from './prisma-service'; + +/** + * Child classes MUST redefined _model property with appropriate model name + */ +@Injectable() +export abstract class PrismaRepository implements IRepository { + protected _model: string; + protected _logger: ConsoleLogger = new ConsoleLogger(PrismaRepository.name); + + constructor(protected readonly _prisma: PrismaService) {} + + findAll(where?: any, include?: any): Promise { + return this._prisma[this._model].findMany({ where, include }); + } + + async findOneById(id: number, include?: any): Promise { + try { + const entity = await this._prisma[this._model].findUnique({ + where: { id }, + }); + + return entity; + } catch (e) { + if (e instanceof PrismaClientKnownRequestError) { + throw new DatabaseException(PrismaClientKnownRequestError.name, e.code); + } else { + throw new DatabaseException(); + } + } + } + + async findOneByUuid(uuid: string, include?: any): Promise { + try { + const entity = await this._prisma[this._model].findUnique({ + where: { uuid }, + }); + + return entity; + } catch (e) { + if (e instanceof PrismaClientKnownRequestError) { + throw new DatabaseException( + PrismaClientKnownRequestError.name, + e.code, + e.message, + ); + } else { + throw new DatabaseException(); + } + } + } + + async findOne(where: any, include?: any): Promise { + try { + const entity = await this._prisma[this._model].findFirst({ + where: where, + include: include, + }); + + return entity; + } catch (e) { + if (e instanceof PrismaClientKnownRequestError) { + throw new DatabaseException(PrismaClientKnownRequestError.name, e.code); + } else { + throw new DatabaseException(); + } + } + } + + // TODO : using any is not good, but needed for nested entities + // TODO : Refactor for good clean architecture ? + async create(entity: Partial | any, include?: any): Promise { + try { + const res = await this._prisma[this._model].create({ + data: entity, + include: include, + }); + + return res; + } catch (e) { + if (e instanceof PrismaClientKnownRequestError) { + throw new DatabaseException( + PrismaClientKnownRequestError.name, + e.code, + e.message, + ); + } else { + throw new DatabaseException(); + } + } + } + + async update(uuid: string, entity: Partial, include?: any): Promise { + try { + const updatedEntity = await this._prisma[this._model].update({ + where: { uuid }, + data: entity, + }); + + return updatedEntity; + } catch (e) { + if (e instanceof PrismaClientKnownRequestError) { + throw new DatabaseException( + PrismaClientKnownRequestError.name, + e.code, + e.message, + ); + } else { + throw new DatabaseException(); + } + } + } + + async updateWhere( + where: any, + entity: Partial | any, + include?: any, + ): Promise { + try { + const updatedEntity = await this._prisma[this._model].update({ + where: where, + data: entity, + include: include, + }); + + return updatedEntity; + } catch (e) { + if (e instanceof PrismaClientKnownRequestError) { + throw new DatabaseException( + PrismaClientKnownRequestError.name, + e.code, + e.message, + ); + } else { + throw new DatabaseException(); + } + } + } + + async delete(uuid: string): Promise { + try { + const entity = await this._prisma[this._model].delete({ + where: { uuid }, + }); + + return entity; + } catch (e) { + if (e instanceof PrismaClientKnownRequestError) { + throw new DatabaseException( + PrismaClientKnownRequestError.name, + e.code, + e.message, + ); + } else { + throw new DatabaseException(); + } + } + } +} diff --git a/src/modules/database/src/adapters/secondaries/prisma-service.ts b/src/modules/database/src/adapters/secondaries/prisma-service.ts new file mode 100644 index 0000000..edf6532 --- /dev/null +++ b/src/modules/database/src/adapters/secondaries/prisma-service.ts @@ -0,0 +1,15 @@ +import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit { + async onModuleInit() { + await this.$connect(); + } + + async enableShutdownHooks(app: INestApplication) { + this.$on('beforeExit', async () => { + await app.close(); + }); + } +} diff --git a/src/modules/database/src/domain/user-repository.ts b/src/modules/database/src/domain/user-repository.ts new file mode 100644 index 0000000..3e88dbb --- /dev/null +++ b/src/modules/database/src/domain/user-repository.ts @@ -0,0 +1,3 @@ +import { PrismaRepository } from '../adapters/secondaries/prisma-repository.abstract'; + +export class UserRepository extends PrismaRepository {} diff --git a/src/modules/database/src/exceptions/DatabaseException.ts b/src/modules/database/src/exceptions/DatabaseException.ts new file mode 100644 index 0000000..aa472bd --- /dev/null +++ b/src/modules/database/src/exceptions/DatabaseException.ts @@ -0,0 +1,25 @@ +export class DatabaseException implements Error { + name: string; + message: string; + + constructor( + private _type: string = 'unknown', + private _code: string = '', + message?: string, + ) { + this.name = 'DatabaseException'; + this.message = message ?? 'An error occured with the database.'; + + if (this.message.includes('Unique constraint failed')) { + this.message = 'Already exists.'; + } + } + + get type(): string { + return this._type; + } + + get code(): string { + return this._code; + } +} diff --git a/src/modules/database/src/interfaces/repository.interface.ts b/src/modules/database/src/interfaces/repository.interface.ts new file mode 100644 index 0000000..70fd241 --- /dev/null +++ b/src/modules/database/src/interfaces/repository.interface.ts @@ -0,0 +1,9 @@ +export interface IRepository { + findAll(params?: any, include?: any): Promise; + findOne(where: any, include?: any): Promise; + findOneByUuid(uuid: string, include?: any): Promise; + create(entity: Partial | any, include?: any): Promise; + update(uuid: string, entity: Partial, include?: any): Promise; + updateWhere(where: any, entity: Partial | any, include?: any): Promise; + delete(uuid: string): Promise; +} diff --git a/src/modules/database/tests/unit/prisma-repository.spec.ts b/src/modules/database/tests/unit/prisma-repository.spec.ts new file mode 100644 index 0000000..ae4e97c --- /dev/null +++ b/src/modules/database/tests/unit/prisma-repository.spec.ts @@ -0,0 +1,234 @@ +import { Injectable } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaService } from '../../src/adapters/secondaries/prisma-service'; +import { PrismaRepository } from '../../src/adapters/secondaries/prisma-repository.abstract'; +import { DatabaseException } from '../../src/exceptions/DatabaseException'; + +class FakeEntity { + id?: number; + uuid?: string; + name: string; +} + +let entityId = 2; +const entityUuid = 'uuid-'; +const entityName = 'name-'; + +const createRandomEntity = (): FakeEntity => { + const entity: FakeEntity = { + id: entityId, + uuid: `${entityUuid}${entityId}`, + name: `${entityName}${entityId}`, + }; + + entityId++; + + return entity; +}; + +const fakeEntityToCreate: FakeEntity = { + name: 'test', +}; + +const fakeEntityCreated: FakeEntity = { + ...fakeEntityToCreate, + id: 1, + uuid: 'some-uuid', +}; + +const fakeEntities: FakeEntity[] = []; +Array.from({ length: 10 }).forEach(() => { + fakeEntities.push(createRandomEntity()); +}); + +@Injectable() +class FakePrismaRepository extends PrismaRepository { + protected _model = 'fake'; +} + +class FakePrismaService extends PrismaService { + fake: any; +} + +const mockPrismaService = { + fake: { + findMany: jest.fn().mockImplementation((params?: any) => { + if (params?.where?.limit == 1) { + return Promise.resolve([fakeEntityCreated]); + } + + return Promise.resolve(fakeEntities); + }), + + create: jest.fn().mockResolvedValue(fakeEntityCreated), + + findUnique: jest.fn().mockImplementation(async (params?: any) => { + let entity; + + if (params?.where?.id) { + entity = fakeEntities.find((entity) => entity.id === params?.where?.id); + } + + if (params?.where?.uuid) { + entity = fakeEntities.find( + (entity) => entity.uuid === params?.where?.uuid, + ); + } + + if (!entity) { + throw new Error('no entity'); + } + + return entity; + }), + + findFirst: jest.fn().mockImplementation((params?: any) => { + if (params?.where?.name) { + return Promise.resolve( + fakeEntities.find((entity) => entity.name === params?.where?.name), + ); + } + }), + + update: jest.fn().mockImplementation((params: any) => { + const entity = fakeEntities.find( + (entity) => entity.uuid === params.where.uuid, + ); + Object.entries(params.data).map(([key, value]) => { + entity[key] = value; + }); + + return Promise.resolve(entity); + }), + + delete: jest.fn().mockImplementation((params: any) => { + let found = false; + + fakeEntities.forEach((entity, index) => { + if (entity.uuid === params?.where?.uuid) { + found = true; + fakeEntities.splice(index, 1); + } + }); + + if (!found) { + throw new Error(); + } + }), + }, +}; + +describe('PrismaRepository', () => { + let fakeRepository: FakePrismaRepository; + let prisma: FakePrismaService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FakePrismaRepository, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + fakeRepository = module.get(FakePrismaRepository); + prisma = module.get(PrismaService) as FakePrismaService; + }); + + it('should be defined', () => { + expect(fakeRepository).toBeDefined(); + expect(prisma).toBeDefined(); + }); + + describe('findAll', () => { + it('should return an array of entities', async () => { + jest.spyOn(prisma.fake, 'findMany'); + + const entities = await fakeRepository.findAll(); + expect(entities).toBe(fakeEntities); + }); + + it('should return an array containing only one entity', async () => { + jest.spyOn(prisma.fake, 'findMany'); + + const entities = await fakeRepository.findAll({ limit: 1 }); + + expect(prisma.fake.findMany).toHaveBeenCalledWith({ + where: { limit: 1 }, + }); + expect(entities).toEqual([fakeEntityCreated]); + }); + }); + + describe('create', () => { + it('should create an entity', async () => { + jest.spyOn(prisma.fake, 'create'); + + const newEntity = await fakeRepository.create(fakeEntityToCreate); + expect(newEntity).toBe(fakeEntityCreated); + expect(prisma.fake.create).toHaveBeenCalledTimes(1); + }); + }); + + describe('findOne', () => { + it('should find an entity by uuid', async () => { + const entity = await fakeRepository.findOneByUuid(fakeEntities[0].uuid); + expect(entity).toBe(fakeEntities[0]); + }); + + it('should throw a DatabaseException if uuid is not found', async () => { + await expect( + fakeRepository.findOneByUuid('wrong-uuid'), + ).rejects.toBeInstanceOf(DatabaseException); + }); + }); + + describe('update', () => { + it('should update an entity', async () => { + const newName = 'random-name'; + + await fakeRepository.update(fakeEntities[0].uuid, { + name: newName, + }); + expect(fakeEntities[0].name).toBe(newName); + }); + + it("should throw an exception if an entity doesn't exist", async () => { + await expect( + fakeRepository.update('fake-uuid', { name: 'error' }), + ).rejects.toBeInstanceOf(DatabaseException); + }); + }); + + describe('delete', () => { + it('should delete an entity', async () => { + const savedUuid = fakeEntities[0].uuid; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const res = await fakeRepository.delete(savedUuid); + + const deletedEntity = fakeEntities.find( + (entity) => entity.uuid === savedUuid, + ); + expect(deletedEntity).toBeUndefined(); + }); + + it("should throw an exception if an entity doesn't exist", async () => { + await expect(fakeRepository.delete('fake-uuid')).rejects.toBeInstanceOf( + DatabaseException, + ); + }); + }); + + describe('findOne', () => { + it('should find one entity', async () => { + const entity = await fakeRepository.findOne({ + name: fakeEntities[0].name, + }); + + expect(entity.name).toBe(fakeEntities[0].name); + }); + }); +}); diff --git a/src/modules/users/adapters/primaries/user.presenter.ts b/src/modules/users/adapters/primaries/user.presenter.ts new file mode 100644 index 0000000..25f63a8 --- /dev/null +++ b/src/modules/users/adapters/primaries/user.presenter.ts @@ -0,0 +1,15 @@ +import { AutoMap } from '@automapper/classes'; + +export class UserPresenter { + @AutoMap() + uuid: string; + + @AutoMap() + firstName: string; + + @AutoMap() + lastName: string; + + @AutoMap() + email: string; +} diff --git a/src/modules/users/adapters/secondaries/users.repository.ts b/src/modules/users/adapters/secondaries/users.repository.ts new file mode 100644 index 0000000..62178b7 --- /dev/null +++ b/src/modules/users/adapters/secondaries/users.repository.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; +import { UserRepository } from 'src/modules/database/src/domain/user-repository'; +import { User } from '../../domain/entities/user'; + +@Injectable() +export class UsersRepository extends UserRepository { + protected _model = 'user'; +} diff --git a/src/modules/users/domain/entities/user.ts b/src/modules/users/domain/entities/user.ts new file mode 100644 index 0000000..d74d2d4 --- /dev/null +++ b/src/modules/users/domain/entities/user.ts @@ -0,0 +1,18 @@ +import { AutoMap } from '@automapper/classes'; + +export class User { + @AutoMap() + uuid: string; + + @AutoMap() + firstName: string; + + @AutoMap() + lastName: string; + + @AutoMap() + email: string; + + @AutoMap() + password: string; +}