diff --git a/.env.example b/.env.example index 42e6494..0083c79 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ # Database DATABASE_HOST=localhost -DATABASE_PORT=5432 -DATABASE_USER=docker -DATABASE_PASS=docker +DATABASE_PORT=3306 +DATABASE_USER=root +DATABASE_PASS=toor DATABASE_NAME=app -DATABASE_URL="postgresql://${DATABASE_USER}:${DATABASE_PASS}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}" +DATABASE_URL="mysql://${DATABASE_USER}:${DATABASE_PASS}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}" -JWT_SECRET_KEY= -JWT_PUBLIC_KEY= +JWT_SECRET_KEY='JWT_SECRET_KEY' +JWT_PUBLIC_KEY='JWT_PUBLIC_KEY' diff --git a/.env.testing.example b/.env.testing.example index 6bd0c5d..d45bddc 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -1,6 +1,6 @@ # Database DATABASE_HOST=localhost -DATABASE_PORT=5432 -DATABASE_USER=docker -DATABASE_PASS=docker +DATABASE_PORT=3306 +DATABASE_USER=root +DATABASE_PASS=toor DATABASE_NAME=app diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml index 29256e4..efb177c 100644 --- a/.github/workflows/run-e2e-tests.yml +++ b/.github/workflows/run-e2e-tests.yml @@ -8,19 +8,23 @@ jobs: runs-on: ubuntu-latest services: - postgres: - image: postgres:13 - ports: - - 5432:5432 + mysql: + image: bitnami/mysql env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: docker - POSTGRES_DB: app + MYSQL_DATABASE: app + MYSQL_USER: docker + MYSQL_PASSWORD: docker + MYSQL_ROOT_PASSWORD: toor + ports: + - 3306:3306 + volumes: + - mysql:/var/lib/mysql options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + zookeeper: image: bitnami/zookeeper:3 ports: @@ -57,7 +61,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 - name: Install dependencies (with cache) uses: bahmutov/npm-install@v1 @@ -66,9 +70,8 @@ jobs: run: yarn test:e2e env: DATABASE_HOST: localhost - DATABASE_PORT: 5432 - DATABASE_USER: postgres - DATABASE_PASS: docker - DATABASE_NAME: app + DATABASE_PORT: ${{ job.services.mysql.ports[3306] }} + DATABASE_USER: root + DATABASE_PASS: toor JWT_PUBLIC_KEY: ${{secrets.JWT_DUMMY_PUBLIC}} JWT_SECRET_KEY: ${{secrets.JWT_DUMMY_PRIVATE}} diff --git a/docker-compose.yml b/docker-compose.yml index d3edd90..3661f77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,20 +4,23 @@ services: # Application database: - image: 'bitnami/postgresql' + image: 'bitnami/mysql' + container_name: 'mysql' ports: - - '5432:5432' + - '3306:3306' environment: - - POSTGRESQL_USERNAME=docker - - POSTGRESQL_PASSWORD=docker - - POSTGRESQL_DATABASE=app + - MYSQL_DATABASE=app + - MYSQL_USER=docker + - MYSQL_PASSWORD=docker + - MYSQL_ROOT_PASSWORD=toor volumes: - - 'postgresql_data:/bitnami/postgresql' + - 'mysql_data:/bitnami/mysql' networks: - app-net redis: image: 'bitnami/redis' + container_name: 'redis' ports: - '6379:6379' environment: @@ -29,6 +32,7 @@ services: zookeeper: image: 'bitnami/zookeeper:3' + container_name: 'zookeeper' ports: - '2181:2181' volumes: @@ -40,6 +44,7 @@ services: kafka: image: 'bitnami/kafka:3' + container_name: 'kafka' ports: - '9092:9092' volumes: @@ -63,7 +68,7 @@ volumes: driver: local kafka_data: driver: local - postgresql_data: + mysql_data: driver: local redis_data: - driver: local \ No newline at end of file + driver: local diff --git a/package.json b/package.json index 16b96ee..6650059 100644 --- a/package.json +++ b/package.json @@ -51,15 +51,14 @@ "dotenv-expand": "8.0.3", "graphql": "16.3.0", "graphql-query-complexity": "0.11.0", + "mysql2": "3.2.0", "nestjs-pino": "2.5.2", "passport": "0.6.0", "passport-jwt": "4.0.1", - "pg": "8.7.3", "pino-http": "6.6.0", "reflect-metadata": "0.1.13", "rimraf": "3.0.2", - "rxjs": "7.5.5", - "uuid": "8.3.2" + "rxjs": "7.5.5" }, "devDependencies": { "@commitlint/cli": "16.2.3", diff --git a/prisma/migrations/20230411210350_users_table/migration.sql b/prisma/migrations/20230411210350_users_table/migration.sql new file mode 100644 index 0000000..330d88b --- /dev/null +++ b/prisma/migrations/20230411210350_users_table/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE `users` ( + `id` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `users_email_key`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..e5a788a --- /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 = "mysql" \ No newline at end of file diff --git a/prisma/prisma-test-environment.ts b/prisma/prisma-test-environment.ts index 24f8af6..08cb84b 100644 --- a/prisma/prisma-test-environment.ts +++ b/prisma/prisma-test-environment.ts @@ -1,12 +1,12 @@ import type { Config } from '@jest/types'; -import { exec } from 'child_process'; import dotenv from 'dotenv'; import NodeEnvironment from 'jest-environment-node'; -import { Client } from 'pg'; -import util from 'util'; -import { v4 as uuid } from 'uuid'; +import mysql from 'mysql2/promise'; +import { exec } from 'node:child_process'; +import crypto from 'node:crypto'; +import util from 'node:util'; -import { HOST, NAME, PASSWORD, PORT, USER } from '../src/config/database'; +import { HOST, PASSWORD, PORT, USER } from '../src/config/database'; dotenv.config({ path: '.env.testing' }); @@ -21,8 +21,8 @@ export default class PrismaTestEnvironment extends NodeEnvironment { constructor(config: Config.ProjectConfig) { super(config); - this.schema = `test_${uuid()}`; - this.connectionString = `postgresql://${USER}:${PASSWORD}@${HOST}:${PORT}/${NAME}?schema=${this.schema}`; + this.schema = `test_${crypto.randomUUID()}`; + this.connectionString = `mysql://${USER}:${PASSWORD}@${HOST}:${PORT}/${this.schema}`; } async setup() { @@ -35,12 +35,12 @@ export default class PrismaTestEnvironment extends NodeEnvironment { } async teardown() { - const client = new Client({ - connectionString: this.connectionString, - }); + const client = await mysql.createConnection(this.connectionString); await client.connect(); - await client.query(`DROP SCHEMA IF EXISTS "${this.schema}" CASCADE`); + await client.query( + `DROP DATABASE IF EXISTS \`${client.config.database}\`;`, + ); await client.end(); } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5c8fab7..0a58c96 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,5 +1,5 @@ datasource db { - provider = "postgresql" + provider = "mysql" url = env("DATABASE_URL") } @@ -8,9 +8,12 @@ generator client { binaryTargets = ["native", "linux-musl", "rhel-openssl-1.0.x"] } -model Example { - id String @id +model User { + id String @id + email String @unique created_at DateTime @default(now()) updated_at DateTime @default(now()) @updatedAt + + @@map("users") } diff --git a/src/application/use-cases/example-use-case.spec.ts b/src/application/use-cases/example-use-case.spec.ts deleted file mode 100644 index d26914b..0000000 --- a/src/application/use-cases/example-use-case.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ExampleUseCase } from '@application/use-cases/example-use-case'; - -describe('Example UseCase', () => { - let exampleUseCase: ExampleUseCase; - - beforeEach(() => { - exampleUseCase = new ExampleUseCase(); - }); - - it('should be able to run example use case', async () => { - const response = await exampleUseCase.handle({ - number: 5, - }); - - expect(response).not.toBeNaN(); - expect(response).toBe(10); - }); -}); diff --git a/src/application/use-cases/example-use-case.ts b/src/application/use-cases/example-use-case.ts deleted file mode 100644 index 22c6337..0000000 --- a/src/application/use-cases/example-use-case.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -type ExampleRequest = { - number: number; -}; - -type ExampleResponse = number; - -@Injectable() -export class ExampleUseCase { - // constructor() {} - - async handle({ number }: ExampleRequest): Promise { - return number * 2; - } -} diff --git a/src/application/use-cases/user-case.module.ts b/src/application/use-cases/user-case.module.ts new file mode 100644 index 0000000..07e2ce0 --- /dev/null +++ b/src/application/use-cases/user-case.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { DatabaseModule } from '@infra/database/database.module'; + +import { GetUserByEmailUseCase } from './user/get-user-by-email.use-case'; + +@Module({ + imports: [DatabaseModule], + providers: [GetUserByEmailUseCase], + exports: [GetUserByEmailUseCase], +}) +export class UseCasesModule {} diff --git a/src/application/use-cases/user/errors/user-by-email-not-found.error.ts b/src/application/use-cases/user/errors/user-by-email-not-found.error.ts new file mode 100644 index 0000000..4c0b241 --- /dev/null +++ b/src/application/use-cases/user/errors/user-by-email-not-found.error.ts @@ -0,0 +1,8 @@ +import { DomainError } from '@core/domain/errors/DomainError'; + +export class UserByEmailNotFoundError extends Error implements DomainError { + constructor(email: string) { + super(`User with email '${email}' was not found.`); + this.name = 'UserNotFound'; + } +} diff --git a/src/application/use-cases/user/get-user-by-email.use-case.spec.ts b/src/application/use-cases/user/get-user-by-email.use-case.spec.ts new file mode 100644 index 0000000..5fae8a5 --- /dev/null +++ b/src/application/use-cases/user/get-user-by-email.use-case.spec.ts @@ -0,0 +1,50 @@ +import { EmailBadFormattedError } from '@domain/value-objects/errors/email-bad-formatted-error'; + +import { UsersRepository } from '@infra/database/repositories/users.repository'; + +import { makeFakeUser } from '@test/factories/users.factory'; +import { InMemoryUsersRepository } from '@test/repositories/in-memory-users.repository'; + +import { UserByEmailNotFoundError } from './errors/user-by-email-not-found.error'; +import { GetUserByEmailUseCase } from './get-user-by-email.use-case'; + +describe('GetUserByEmailUseCase', () => { + let usersRepository: UsersRepository; + + let getUserByEmailUseCase: GetUserByEmailUseCase; + + beforeEach(() => { + usersRepository = new InMemoryUsersRepository(); + + getUserByEmailUseCase = new GetUserByEmailUseCase(usersRepository); + }); + + it('should be able to get user by email', async () => { + const user = makeFakeUser(); + + await usersRepository.create(user); + + const output = await getUserByEmailUseCase.handle(user.email); + + expect(output.isRight()).toBeTruthy(); + expect(output.value).toEqual(user); + }); + + it('should be able an error is returned in case an invalid email address is provided', async () => { + const invalidEmail = 'invalid_email'; + + const output = await getUserByEmailUseCase.handle(invalidEmail); + + expect(output.isLeft()).toBeTruthy(); + expect(output.value).toBeInstanceOf(EmailBadFormattedError); + }); + + it('should be able to return user not found error', async () => { + const email = 'oi@rocketseat.com.br'; + + const output = await getUserByEmailUseCase.handle(email); + + expect(output.isLeft()).toBeTruthy(); + expect(output.value).toBeInstanceOf(UserByEmailNotFoundError); + }); +}); diff --git a/src/application/use-cases/user/get-user-by-email.use-case.ts b/src/application/use-cases/user/get-user-by-email.use-case.ts new file mode 100644 index 0000000..6ab0c4a --- /dev/null +++ b/src/application/use-cases/user/get-user-by-email.use-case.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; + +import { Either, left, right } from '@core/logic/Either'; + +import { User } from '@domain/entities/user.entity'; +import { Email } from '@domain/value-objects/email'; +import { EmailBadFormattedError } from '@domain/value-objects/errors/email-bad-formatted-error'; + +import { UsersRepository } from '@infra/database/repositories/users.repository'; + +import { UserByEmailNotFoundError } from './errors/user-by-email-not-found.error'; + +type GetUserByEmailResponse = Either< + EmailBadFormattedError | UserByEmailNotFoundError, + User +>; + +@Injectable() +export class GetUserByEmailUseCase { + constructor(private readonly usersRepository: UsersRepository) {} + + async handle(email: string): Promise { + const isInvalidEmail = !Email.validate(email); + + if (isInvalidEmail) { + return left(new EmailBadFormattedError(email)); + } + + const user = await this.usersRepository.findByEmail(email); + + if (!user) { + return left(new UserByEmailNotFoundError(email)); + } + + return right(user); + } +} diff --git a/src/config/database.ts b/src/config/database.ts index 74d2cd0..003f7cb 100644 --- a/src/config/database.ts +++ b/src/config/database.ts @@ -1,7 +1,7 @@ import 'dotenv/config'; -export const USER = process.env.DATABASE_USER ?? 'docker'; -export const PASSWORD = process.env.DATABASE_PASS ?? 'docker'; +export const USER = process.env.DATABASE_USER ?? 'root'; +export const PASSWORD = process.env.DATABASE_PASS ?? 'toor'; export const HOST = process.env.DATABASE_HOST ?? 'localhost'; -export const PORT = process.env.DATABASE_PORT ?? '5432'; +export const PORT = process.env.DATABASE_PORT ?? '3306'; export const NAME = process.env.DATABASE_NAME ?? 'app'; diff --git a/src/core/logic/Replace.ts b/src/core/logic/Replace.ts new file mode 100644 index 0000000..5a48cf3 --- /dev/null +++ b/src/core/logic/Replace.ts @@ -0,0 +1,14 @@ +/** + * Replaces the property definitions from a given type + * @example + * ```typescript + * type Post { + * id: string; + * name: string; + * } + * + * Replace + * ``` + **/ + +export type Replace = Omit & R; diff --git a/src/domain/entities/.gitkeep b/src/domain/entities/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/domain/entities/user.entity.ts b/src/domain/entities/user.entity.ts new file mode 100644 index 0000000..3e707eb --- /dev/null +++ b/src/domain/entities/user.entity.ts @@ -0,0 +1,37 @@ +import { Entity } from '@core/domain/Entity'; +import { Replace } from '@core/logic/Replace'; + +export type UserProps = { + email: string; + createdAt: Date; +}; + +export class User extends Entity { + get email() { + return this.props.email; + } + + get createdAt() { + return this.props.createdAt; + } + + static create( + props: Replace< + UserProps, + { + createdAt?: Date; + } + >, + id?: string, + ) { + const user = new User( + { + ...props, + createdAt: props.createdAt ?? new Date(), + }, + id, + ); + + return user; + } +} diff --git a/src/domain/value-objects/email.ts b/src/domain/value-objects/email.ts new file mode 100644 index 0000000..6d7d358 --- /dev/null +++ b/src/domain/value-objects/email.ts @@ -0,0 +1,27 @@ +import { Either, left, right } from '@core/logic/Either'; + +import { EmailBadFormattedError } from './errors/email-bad-formatted-error'; + +export class Email { + protected constructor(private readonly email: string) {} + + get value(): string { + return this.email; + } + + static validate(email: string): boolean { + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + + return emailRegex.test(email); + } + + static create(email: string): Either { + const isValidEmail = this.validate(email); + + if (!isValidEmail) { + return left(new EmailBadFormattedError(email)); + } + + return right(new Email(email)); + } +} diff --git a/src/domain/value-objects/errors/email-bad-formatted-error.ts b/src/domain/value-objects/errors/email-bad-formatted-error.ts new file mode 100644 index 0000000..da99df0 --- /dev/null +++ b/src/domain/value-objects/errors/email-bad-formatted-error.ts @@ -0,0 +1,8 @@ +import { DomainError } from '@core/domain/errors/DomainError'; + +export class EmailBadFormattedError extends Error implements DomainError { + constructor(email: string) { + super(`The email '${email}' is bad formatted.`); + this.name = 'EmailBadFormatted'; + } +} diff --git a/src/infra/database/database.module.ts b/src/infra/database/database.module.ts index d754df5..86b7c02 100644 --- a/src/infra/database/database.module.ts +++ b/src/infra/database/database.module.ts @@ -1,9 +1,17 @@ import { Module } from '@nestjs/common'; import { PrismaService } from './prisma/prisma.service'; +import { PrismaUsersRepository } from './prisma/repositories/prisma-users-repository'; +import { UsersRepository } from './repositories/users.repository'; @Module({ - providers: [PrismaService], - exports: [PrismaService], + providers: [ + PrismaService, + { + provide: UsersRepository, + useClass: PrismaUsersRepository, + }, + ], + exports: [PrismaService, UsersRepository], }) export class DatabaseModule {} diff --git a/src/infra/database/prisma/mappers/.gitkeep b/src/infra/database/prisma/mappers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/infra/database/prisma/mappers/user.mapper.ts b/src/infra/database/prisma/mappers/user.mapper.ts new file mode 100644 index 0000000..57775d1 --- /dev/null +++ b/src/infra/database/prisma/mappers/user.mapper.ts @@ -0,0 +1,22 @@ +import { Prisma, User as RawUser } from '@prisma/client'; + +import { User } from '@domain/entities/user.entity'; + +export class UserMapper { + static toDomain(raw: RawUser): User { + const user = User.create({ + email: raw.email, + createdAt: raw.created_at, + }); + + return user; + } + + static toPersistence(user: User): Prisma.UserCreateInput { + return { + id: user.id, + email: user.email, + created_at: user.createdAt, + }; + } +} diff --git a/src/infra/database/prisma/repositories/.gitkeep b/src/infra/database/prisma/repositories/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/infra/database/prisma/repositories/prisma-users-repository.ts b/src/infra/database/prisma/repositories/prisma-users-repository.ts new file mode 100644 index 0000000..fa37be0 --- /dev/null +++ b/src/infra/database/prisma/repositories/prisma-users-repository.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; + +import { AsyncMaybe } from '@core/logic/Maybe'; + +import { User } from '@domain/entities/user.entity'; + +import { UsersRepository } from '@infra/database/repositories/users.repository'; + +import { UserMapper } from '../mappers/user.mapper'; +import { PrismaService } from '../prisma.service'; + +@Injectable() +export class PrismaUsersRepository implements UsersRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(user: User): Promise { + await this.prisma.user.create({ + data: UserMapper.toPersistence(user), + }); + + return user; + } + + async findByEmail(email: string): AsyncMaybe { + const user = await this.prisma.user.findUnique({ + where: { + email, + }, + }); + + if (!user) { + return null; + } + + return UserMapper.toDomain(user); + } +} diff --git a/src/infra/database/repositories/.gitkeep b/src/infra/database/repositories/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/infra/database/repositories/users.repository.ts b/src/infra/database/repositories/users.repository.ts new file mode 100644 index 0000000..6a39019 --- /dev/null +++ b/src/infra/database/repositories/users.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; + +import { AsyncMaybe } from '@core/logic/Maybe'; + +import { User } from '@domain/entities/user.entity'; + +@Injectable() +export abstract class UsersRepository { + abstract create(user: User): Promise; + abstract findByEmail(email: string): AsyncMaybe; +} diff --git a/src/infra/http/graphql/dto/models/.gitkeep b/src/infra/http/graphql/dto/models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/infra/http/graphql/dto/models/user.model.ts b/src/infra/http/graphql/dto/models/user.model.ts new file mode 100644 index 0000000..2153466 --- /dev/null +++ b/src/infra/http/graphql/dto/models/user.model.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { Field, ID, ObjectType } from '@nestjs/graphql'; + +import { Paginated } from '../../common/dto/models/paginated'; + +@ObjectType() +export class User { + @Field(() => ID) + id: string; + + @Field(() => String) + email: string; + + @Field(() => Date) + createdAt: Date; +} + +@Injectable() +export class PaginatedUsers extends Paginated(User) {} diff --git a/src/infra/http/graphql/resolvers/example.resolver.e2e-spec.ts b/src/infra/http/graphql/resolvers/example.resolver.e2e-spec.ts deleted file mode 100644 index 600b6db..0000000 --- a/src/infra/http/graphql/resolvers/example.resolver.e2e-spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { INestApplication } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; -import request from 'supertest'; - -import { DatabaseModule } from '@infra/database/database.module'; -import { HttpModule } from '@infra/http/http.module'; - -describe('Example Resolver (e2e)', () => { - let app: INestApplication; - - beforeAll(async () => { - const moduleRef = await Test.createTestingModule({ - imports: [DatabaseModule, HttpModule], - providers: [], - }).compile(); - - app = moduleRef.createNestApplication(); - - await app.init(); - }); - - it('(Query) helloWorld', async () => { - const response = await request(app.getHttpServer()) - .post('/graphql') - .send({ - query: ` - query { - helloWorld - } - `, - }) - .expect(200); - - expect(response.body.data.helloWorld).toBe('Hello World!'); - }); -}); diff --git a/src/infra/http/graphql/resolvers/example.resolver.ts b/src/infra/http/graphql/resolvers/example.resolver.ts deleted file mode 100644 index 2b41870..0000000 --- a/src/infra/http/graphql/resolvers/example.resolver.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Query, Resolver } from '@nestjs/graphql'; - -@Resolver(() => String) -export class ExampleResolver { - @Query((_returns) => String) - async helloWorld() { - return 'Hello World!'; - } -} diff --git a/src/infra/http/graphql/resolvers/user.resolver.e2e-spec.ts b/src/infra/http/graphql/resolvers/user.resolver.e2e-spec.ts new file mode 100644 index 0000000..14ea22c --- /dev/null +++ b/src/infra/http/graphql/resolvers/user.resolver.e2e-spec.ts @@ -0,0 +1,57 @@ +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; + +import { DatabaseModule } from '@infra/database/database.module'; +import { HttpModule } from '@infra/http/http.module'; + +import { UserFactory } from '@test/factories/users.factory'; + +describe('UserResolver (e2e)', () => { + let app: INestApplication; + let userFactory: UserFactory; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [DatabaseModule, HttpModule], + providers: [UserFactory], + }).compile(); + + app = moduleRef.createNestApplication(); + + userFactory = moduleRef.get(UserFactory); + + await app.init(); + }); + + it('(Query) userByEmail', async () => { + const user = await userFactory.makeUser(); + + const response = await request(app.getHttpServer()) + .post('/graphql') + .send({ + query: ` + query UserByEmail ($email: String!){ + userByEmail(email: $email) { + id + email + createdAt + } + } + `, + variables: { + email: user.email, + }, + }) + .expect(200); + + const { + data: { userByEmail: output }, + } = response.body; + + expect(output).toMatchObject({ + id: expect.any(String), + email: user.email, + }); + }); +}); diff --git a/src/infra/http/graphql/resolvers/user.resolver.ts b/src/infra/http/graphql/resolvers/user.resolver.ts new file mode 100644 index 0000000..5298a53 --- /dev/null +++ b/src/infra/http/graphql/resolvers/user.resolver.ts @@ -0,0 +1,23 @@ +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { GetUserByEmailUseCase } from '@application/use-cases/user/get-user-by-email.use-case'; + +import { User } from '../dto/models/user.model'; +import { UseCaseErrorViewModel } from '../view-models/use-case-error.view-model'; +import { UserViewModel } from '../view-models/user.view-model'; + +@Resolver(() => User) +export class UserResolver { + constructor(private readonly getUserByEmailUseCase: GetUserByEmailUseCase) {} + + @Query((_returns) => User) + async userByEmail(@Args('email') email: string) { + const output = await this.getUserByEmailUseCase.handle(email); + + if (output.isLeft()) { + return UseCaseErrorViewModel.toGraphQL(output.value); + } + + return UserViewModel.toGraphQL(output.value); + } +} diff --git a/src/infra/http/graphql/view-models/user.view-model.ts b/src/infra/http/graphql/view-models/user.view-model.ts new file mode 100644 index 0000000..75ad514 --- /dev/null +++ b/src/infra/http/graphql/view-models/user.view-model.ts @@ -0,0 +1,13 @@ +import { User as UserEntity } from '@domain/entities/user.entity'; + +import { User } from '../dto/models/user.model'; + +export class UserViewModel { + static toGraphQL(user: UserEntity): User { + return { + id: user.id, + email: user.email, + createdAt: user.createdAt, + }; + } +} diff --git a/src/infra/http/http.module.ts b/src/infra/http/http.module.ts index aed77c3..edd7434 100644 --- a/src/infra/http/http.module.ts +++ b/src/infra/http/http.module.ts @@ -3,17 +3,19 @@ import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { GraphQLModule } from '@nestjs/graphql'; -// import { DatabaseModule } from '@infra/database/database.module'; +import { UseCasesModule } from '@application/use-cases/user-case.module'; + +import { DatabaseModule } from '@infra/database/database.module'; import { ComplexityPlugin } from '@infra/http/graphql/complexity-plugin'; -import { ExampleResolver } from '@infra/http/graphql/resolvers/example.resolver'; import { JWTAuthGuard } from './auth/jwt-auth-guard'; import { JwtStrategy } from './auth/jwt.strategy'; +import { UserResolver } from './graphql/resolvers/user.resolver'; @Module({ imports: [ - // DatabaseModule, - + DatabaseModule, + UseCasesModule, GraphQLModule.forRoot({ driver: ApolloDriver, autoSchemaFile: true, @@ -28,7 +30,7 @@ import { JwtStrategy } from './auth/jwt.strategy'; }), ], providers: [ - ExampleResolver, + UserResolver, JwtStrategy, { provide: APP_GUARD, diff --git a/test/factories/.gitkeep b/test/factories/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/test/factories/users.factory.ts b/test/factories/users.factory.ts new file mode 100644 index 0000000..5057b57 --- /dev/null +++ b/test/factories/users.factory.ts @@ -0,0 +1,48 @@ +import faker from '@faker-js/faker'; +import { Injectable } from '@nestjs/common'; + +import { Replace } from '@core/logic/Replace'; + +import { User, UserProps } from '@domain/entities/user.entity'; + +import { UserMapper } from '@infra/database/prisma/mappers/user.mapper'; +import { PrismaService } from '@infra/database/prisma/prisma.service'; + +type Overrides = Partial< + Replace< + UserProps, + { + email?: string; + createdAt?: Date; + } + > +>; + +export function makeFakeUser(data = {} as Overrides) { + const email = faker.internet.email(); + const createdAt = faker.date.past(); + + const props: UserProps = { + email: data.email || email, + createdAt: data.createdAt || createdAt, + }; + + const user = User.create(props); + + return user; +} + +@Injectable() +export class UserFactory { + constructor(private prisma: PrismaService) {} + + async makeUser(data = {} as Overrides): Promise { + const user = makeFakeUser(data); + + await this.prisma.user.create({ + data: UserMapper.toPersistence(user), + }); + + return user; + } +} diff --git a/test/repositories/.gitkeep b/test/repositories/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/test/repositories/in-memory-users.repository.ts b/test/repositories/in-memory-users.repository.ts new file mode 100644 index 0000000..5a792f1 --- /dev/null +++ b/test/repositories/in-memory-users.repository.ts @@ -0,0 +1,25 @@ +import { AsyncMaybe } from '@core/logic/Maybe'; + +import { User } from '@domain/entities/user.entity'; + +import { UsersRepository } from '@infra/database/repositories/users.repository'; + +export class InMemoryUsersRepository implements UsersRepository { + public items: Array = []; + + async create(user: User): Promise { + this.items.push(user); + + return user; + } + + async findByEmail(email: string): AsyncMaybe { + const user = this.items.find((item) => item.email === email); + + if (!user) { + return null; + } + + return user; + } +} diff --git a/yarn.lock b/yarn.lock index 5e6af7a..6264092 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2599,11 +2599,6 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -buffer-writer@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04" - integrity sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw== - buffer@^5.5.0: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" @@ -3160,6 +3155,11 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= +denque@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" + integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== + depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" @@ -3879,6 +3879,13 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= +generate-function@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.3.1.tgz#f069617690c10c868e73b8465746764f97c3479f" + integrity sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ== + dependencies: + is-property "^1.0.2" + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -4139,6 +4146,13 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +iconv-lite@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -4339,6 +4353,11 @@ is-potential-custom-element-name@^1.0.1: resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== +is-property@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" + integrity sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g== + is-stream@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" @@ -5148,6 +5167,11 @@ long@^4.0.0: resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== +long@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/long/-/long-5.2.1.tgz#e27595d0083d103d2fa2c20c7699f8e0c92b897f" + integrity sha512-GKSNGeNAtw8IryjjkhZxuKB3JzlcLTwjtiQCHKvqQet81I93kXslhDQruGI/QsddO83mcDToBVy7GqGS/zYf/A== + lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" @@ -5155,6 +5179,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lru-cache@^7.14.1: + version "7.18.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" + integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== + macos-release@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.5.0.tgz#067c2c88b5f3fb3c56a375b2ec93826220fa1ff2" @@ -5399,6 +5428,27 @@ mylas@^2.1.9: resolved "https://registry.yarnpkg.com/mylas/-/mylas-2.1.13.tgz#1e23b37d58fdcc76e15d8a5ed23f9ae9fc0cbdf4" integrity sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg== +mysql2@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.2.0.tgz#3613a8903bcb7ade0ae35b29945a0378eb67da89" + integrity sha512-0Vn6a9WSrq6fWwvPgrvIwnOCldiEcgbzapVRDAtDZ4cMTxN7pnGqCTx8EG32S/NYXl6AXkdO+9hV1tSIi/LigA== + dependencies: + denque "^2.1.0" + generate-function "^2.3.1" + iconv-lite "^0.6.3" + long "^5.2.1" + lru-cache "^7.14.1" + named-placeholders "^1.1.3" + seq-queue "^0.0.5" + sqlstring "^2.3.2" + +named-placeholders@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/named-placeholders/-/named-placeholders-1.1.3.tgz#df595799a36654da55dda6152ba7a137ad1d9351" + integrity sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w== + dependencies: + lru-cache "^7.14.1" + nan@^2.14.0, nan@^2.14.2: version "2.15.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" @@ -5641,11 +5691,6 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== -packet-reader@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/packet-reader/-/packet-reader-1.0.0.tgz#9238e5480dedabacfe1fe3f2771063f164157d74" - integrity sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ== - parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -5740,27 +5785,17 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= -pg-connection-string@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34" - integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ== - pg-int8@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== -pg-pool@^3.5.1: - version "3.5.1" - resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.5.1.tgz#f499ce76f9bf5097488b3b83b19861f28e4ed905" - integrity sha512-6iCR0wVrro6OOHFsyavV+i6KYL4lVNyYAB9RD18w66xSzN+d8b66HiwuP30Gp1SH5O9T82fckkzsRjlrhD0ioQ== - -pg-protocol@*, pg-protocol@^1.5.0: +pg-protocol@*: version "1.5.0" resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.5.0.tgz#b5dd452257314565e2d54ab3c132adc46565a6a0" integrity sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ== -pg-types@^2.1.0, pg-types@^2.2.0: +pg-types@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== @@ -5771,26 +5806,6 @@ pg-types@^2.1.0, pg-types@^2.2.0: postgres-date "~1.0.4" postgres-interval "^1.1.0" -pg@8.7.3: - version "8.7.3" - resolved "https://registry.yarnpkg.com/pg/-/pg-8.7.3.tgz#8a5bdd664ca4fda4db7997ec634c6e5455b27c44" - integrity sha512-HPmH4GH4H3AOprDJOazoIcpI49XFsHCe8xlrjHkWiapdbHK+HLtbm/GQzXYAZwmPju/kzKhjaSfMACG+8cgJcw== - dependencies: - buffer-writer "2.0.0" - packet-reader "1.0.0" - pg-connection-string "^2.5.0" - pg-pool "^3.5.1" - pg-protocol "^1.5.0" - pg-types "^2.1.0" - pgpass "1.x" - -pgpass@1.x: - version "1.0.4" - resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.4.tgz#85eb93a83800b20f8057a2b029bf05abaf94ea9c" - integrity sha512-YmuA56alyBq7M59vxVBfPJrGSozru8QAdoNlWuW3cz8l+UX3cWge0vTvjKhsSHSJpo3Bom8/Mm6hf0TR5GY0+w== - dependencies: - split2 "^3.1.1" - picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -6331,7 +6346,7 @@ safe-stable-stringify@^2.1.0: resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.3.0.tgz#d09eb692386d7faa846d78922605c67cc0ab9c1c" integrity sha512-VFlmNrvZ44a0QnRY2yfEIUhbMh8BjTFWf2mRG/8mCEAKTfQYV8xxfn6P+00OLej0gznC5C+hfgcEF7AGrN5Stw== -"safer-buffer@>= 2.1.2 < 3": +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -6410,6 +6425,11 @@ send@0.17.2: range-parser "~1.2.1" statuses "~1.5.0" +seq-queue@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/seq-queue/-/seq-queue-0.0.5.tgz#d56812e1c017a6e4e7c3e3a37a1da6d78dd3c93e" + integrity sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q== + serialize-javascript@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" @@ -6568,7 +6588,7 @@ spdx-license-ids@^3.0.0: resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz#0d9becccde7003d6c658d487dd48a32f0bf3014b" integrity sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA== -split2@^3.0.0, split2@^3.1.1: +split2@^3.0.0: version "3.2.2" resolved "https://registry.yarnpkg.com/split2/-/split2-3.2.2.tgz#bf2cf2a37d838312c249c89206fd7a17dd12365f" integrity sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg== @@ -6592,6 +6612,11 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= +sqlstring@^2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.3.tgz#2ddc21f03bce2c387ed60680e739922c65751d0c" + integrity sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg== + stack-utils@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.3.tgz#cd5f030126ff116b78ccb3c027fe302713b61277"