diff --git a/src/app/settings/profile/page.tsx b/src/app/settings/profile/page.tsx index 4813872..cccb57f 100644 --- a/src/app/settings/profile/page.tsx +++ b/src/app/settings/profile/page.tsx @@ -1,7 +1,7 @@ import { redirect } from 'next/navigation' import EditProfileForm from '@/components/edit-profile-form' -import { UserDTO } from '@/core/user/application/types' +import UserResponse from '@/core/user/dto/responses/user.response' import { findUser } from '@/core/user/infrastructure/actions/find-user' import { auth } from '@/lib/auth/auth' @@ -12,7 +12,7 @@ export default async function Page() { return redirect('/') } - const user = (await findUser(email)) as UserDTO + const user = (await findUser(email)) as UserResponse return ( <> diff --git a/src/components/book-grid/index.tsx b/src/components/book-grid/index.tsx index be32c48..494c176 100644 --- a/src/components/book-grid/index.tsx +++ b/src/components/book-grid/index.tsx @@ -1,8 +1,8 @@ import Book from '@/components/book' -import { BookDTO } from '@/core/book/application/types' +import BookResponse from '@/core/book/dto/responses/book.response' export interface BookGridProperties { - books: BookDTO[] + books: BookResponse[] } export default function BookGrid(properties: BookGridProperties) { diff --git a/src/components/book/index.tsx b/src/components/book/index.tsx index 2a4d567..84886dc 100644 --- a/src/components/book/index.tsx +++ b/src/components/book/index.tsx @@ -1,9 +1,9 @@ import { Card, CardBody, CardFooter, Image } from '@nextui-org/react' -import { BookDTO } from '@/core/book/application/types' +import BookResponse from '@/core/book/dto/responses/book.response' export interface BookProperties { - book: BookDTO + book: BookResponse } export default async function Book(properties: BookProperties) { diff --git a/src/components/edit-profile-form/index.tsx b/src/components/edit-profile-form/index.tsx index 5f01f89..3dc1330 100644 --- a/src/components/edit-profile-form/index.tsx +++ b/src/components/edit-profile-form/index.tsx @@ -7,12 +7,12 @@ import { useFormState } from 'react-dom' import InputForm from '@/components/input-form' import SubmitButton from '@/components/submit-button' import { showToast } from '@/components/toast' -import { UserDTO } from '@/core/user/application/types' +import UserResponse from '@/core/user/dto/responses/user.response' import { updateUser } from '@/core/user/infrastructure/actions/update-user' import FormResponse from '@/lib/zod/form-response' interface EditProfileFormProperties { - user: UserDTO + user: UserResponse } export default function EditProfileForm(properties: EditProfileFormProperties) { diff --git a/src/components/header/header-authenticated-menu.tsx b/src/components/header/header-authenticated-menu.tsx index d3ce0a4..d3d8745 100644 --- a/src/components/header/header-authenticated-menu.tsx +++ b/src/components/header/header-authenticated-menu.tsx @@ -14,11 +14,11 @@ import { } from '@nextui-org/react' import { useRouter } from 'next/navigation' -import { UserDTO } from '@/core/user/application/types' +import UserResponse from '@/core/user/dto/responses/user.response' import gravatar from '@/lib/utils/gravatar' interface HeaderAuthenticatedMenuProperties { - user: UserDTO + user: UserResponse } export default function HeaderAuthenticatedMenu( diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx index 502367e..459dde5 100644 --- a/src/components/header/index.tsx +++ b/src/components/header/index.tsx @@ -11,10 +11,10 @@ import Link from 'next/link' import HeaderAuthenticatedMenu from '@/components/header/header-authenticated-menu' import HeaderUnauthenticatedMenu from '@/components/header/header-unauthenticated-menu' import ThemeSwitcher from '@/components/theme-switcher' -import { UserDTO } from '@/core/user/application/types' +import UserResponse from '@/core/user/dto/responses/user.response' interface HeaderProperties { - user?: UserDTO + user?: UserResponse } export default function Header(properties: HeaderProperties) { diff --git a/src/core/book/application/create-book.use-case.spec.ts b/src/core/book/application/create-book.use-case.spec.ts index 4ff9878..5e4f5fe 100644 --- a/src/core/book/application/create-book.use-case.spec.ts +++ b/src/core/book/application/create-book.use-case.spec.ts @@ -1,29 +1,20 @@ -import { ulid } from 'ulid' - import CreateBookUseCase from '@/core/book/application/create-book.use-case' -import { CreateBookCommand } from '@/core/book/application/types' import BookIdAlreadyExistsError from '@/core/book/domain/errors/book-id-already-exists.error' -import Book from '@/core/book/domain/model/book.entity' +import CreateBookRequest from '@/core/book/dto/requests/create-book.request' +import BookResponse from '@/core/book/dto/responses/book.response' import BooksInMemory from '@/core/book/infrastructure/services/books-in-memory.repository' import unexpected from '@/lib/utils/unexpected' +import BooksExamples from '@/tests/examples/books.examples' describe('CreateBookUseCase', () => { it('should create a new book', async () => { // Arrange const books = new BooksInMemory() - const book = Book.create({ - authors: ['Jane Doe'], - id: ulid(), - image: 'http://example.com/book.jpeg', - title: 'A book', - })._unsafeUnwrap() + const book = BooksExamples.basic() - const command = CreateBookCommand.with({ - authors: book.authors.map((author) => author.value), - id: book.id.value, - image: book.image.value, - title: book.title.value, - }) + const command = CreateBookRequest.with( + BookResponse.fromModel(book) satisfies CreateBookRequest, + ) const useCase = new CreateBookUseCase(books) // Act @@ -42,20 +33,12 @@ describe('CreateBookUseCase', () => { it('should rejects to create a book with the same id', async () => { // Arrange const books = new BooksInMemory() - const book = Book.create({ - authors: ['Jane Doe'], - id: ulid(), - image: 'http://example.com/book.jpeg', - title: 'A book', - })._unsafeUnwrap() + const book = BooksExamples.basic() books.books.set(book.id.value, book) - const command = CreateBookCommand.with({ - authors: book.authors.map((author) => author.value), - id: book.id.value, - image: book.image.value, - title: book.title.value, - }) + const command = CreateBookRequest.with( + BookResponse.fromModel(book) satisfies CreateBookRequest, + ) const useCase = new CreateBookUseCase(books) // Act diff --git a/src/core/book/application/create-book.use-case.ts b/src/core/book/application/create-book.use-case.ts index a519736..2fff6c5 100644 --- a/src/core/book/application/create-book.use-case.ts +++ b/src/core/book/application/create-book.use-case.ts @@ -1,22 +1,23 @@ import { errAsync } from 'neverthrow' import BookIdAlreadyExistsError from '@/core/book/domain/errors/book-id-already-exists.error' -import Book from '@/core/book/domain/model/book.entity' +import BookFactory from '@/core/book/domain/model/book.factory' import Books from '@/core/book/domain/services/books.repository' +import CreateBookRequest from '@/core/book/dto/requests/create-book.request' import BookId from '@/core/common/domain/value-objects/book-id' -import { CreateBookCommand } from './types' - export default class CreateBookUseCase { constructor(private readonly books: Books) {} - async with(command: CreateBookCommand) { + async with(command: CreateBookRequest) { return await BookId.create(command.id) .asyncAndThen((bookId) => this.books.findById(bookId)) .match( (book) => errAsync(BookIdAlreadyExistsError.withId(book.id)), () => - Book.create(command).asyncAndThen((_book) => this.books.save(_book)), + BookFactory.create(command).asyncAndThen((_book) => + this.books.save(_book), + ), ) } } diff --git a/src/core/book/application/find-books.use-case.spec.ts b/src/core/book/application/find-books.use-case.spec.ts index c70d60f..4903fc3 100644 --- a/src/core/book/application/find-books.use-case.spec.ts +++ b/src/core/book/application/find-books.use-case.spec.ts @@ -1,21 +1,14 @@ -import { ulid } from 'ulid' - import FindBooksUseCase from '@/core/book/application/find-books.use-case' -import { BookDTO } from '@/core/book/application/types' -import Book from '@/core/book/domain/model/book.entity' +import BookResponse from '@/core/book/dto/responses/book.response' import BooksInMemory from '@/core/book/infrastructure/services/books-in-memory.repository' import unexpected from '@/lib/utils/unexpected' +import BooksExamples from '@/tests/examples/books.examples' describe('FindBooksUseCase', () => { it('should get all books', async () => { // Arrange const books = new BooksInMemory() - const book = Book.create({ - authors: ['Jane Doe'], - id: ulid(), - image: 'http://example.com/book.jpeg', - title: 'A book', - })._unsafeUnwrap() + const book = BooksExamples.basic() books.books.set(book.id.value, book) const useCase = new FindBooksUseCase(books) @@ -26,7 +19,7 @@ describe('FindBooksUseCase', () => { // Assert result.match( (_books) => { - expect(_books).toEqual([BookDTO.fromModel(book)]) + expect(_books).toEqual([BookResponse.fromModel(book)]) }, (error) => unexpected.error(error), ) diff --git a/src/core/book/application/find-books.use-case.ts b/src/core/book/application/find-books.use-case.ts index 0b31601..8b91196 100644 --- a/src/core/book/application/find-books.use-case.ts +++ b/src/core/book/application/find-books.use-case.ts @@ -1,15 +1,15 @@ import { okAsync, ResultAsync } from 'neverthrow' -import { BookDTO } from '@/core/book/application/types' import Books from '@/core/book/domain/services/books.repository' +import BookResponse from '@/core/book/dto/responses/book.response' import ApplicationError from '@/core/common/domain/errors/application-error' export default class FindBooksUseCase { constructor(private readonly books: Books) {} - with(): ResultAsync { + with(): ResultAsync { return this.books.findAll().andThen((books) => { - return okAsync(books.map((book) => BookDTO.fromModel(book))) + return okAsync(books.map((book) => BookResponse.fromModel(book))) }) } } diff --git a/src/core/book/application/types.ts b/src/core/book/application/types.ts deleted file mode 100644 index f496dc3..0000000 --- a/src/core/book/application/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { DeepReadonly } from 'ts-essentials' - -import Book from '@/core/book/domain/model/book.entity' - -type CreateBookCommand = DeepReadonly<{ - authors: string[] - id: string - image: string - title: string -}> - -const CreateBookCommand = { - with: (properties: CreateBookCommand) => properties, -} - -type BookDTO = DeepReadonly<{ - authors: string[] - id: string - image: string - title: string -}> - -const BookDTO = { - fromModel: (book: Book): BookDTO => ({ - authors: book.authors.map((author) => author.value), - id: book.id.value, - image: book.image.value, - title: book.title.value, - }), - with: (properties: BookDTO) => properties, -} - -export { BookDTO, CreateBookCommand } diff --git a/src/core/book/domain/errors/book-domain.error.ts b/src/core/book/domain/errors/book-domain.error.ts new file mode 100644 index 0000000..fa40aa5 --- /dev/null +++ b/src/core/book/domain/errors/book-domain.error.ts @@ -0,0 +1,8 @@ +import FullNameError from '@/core/common/domain/value-objects/fullname/fullname.error' +import IdError from '@/core/common/domain/value-objects/id/id.error' +import ImageError from '@/core/common/domain/value-objects/image/image.error' +import TitleError from '@/core/common/domain/value-objects/title/title.error' + +type BookDomainError = IdError | TitleError | FullNameError | ImageError + +export default BookDomainError diff --git a/src/core/book/domain/model/book.entity.ts b/src/core/book/domain/model/book.entity.ts index bd09ea2..32bee74 100644 --- a/src/core/book/domain/model/book.entity.ts +++ b/src/core/book/domain/model/book.entity.ts @@ -1,15 +1,7 @@ -import { ok, Result, safeTry } from 'neverthrow' - -import { BookDTO } from '@/core/book/application/types' -import DomainError from '@/core/common/domain/errors/domain-error' import BookId from '@/core/common/domain/value-objects/book-id' -import FullNameError from '@/core/common/domain/value-objects/fullname/fullname.error' import FullNames from '@/core/common/domain/value-objects/fullnames' -import IdError from '@/core/common/domain/value-objects/id/id.error' import Image from '@/core/common/domain/value-objects/image' -import ImageError from '@/core/common/domain/value-objects/image/image.error' import Title from '@/core/common/domain/value-objects/title' -import TitleError from '@/core/common/domain/value-objects/title/title.error' export default class Book { constructor( @@ -19,27 +11,6 @@ export default class Book { private _image: Image, ) {} - static create( - bookDTO: BookDTO, - ): Result { - return safeTry(function* () { - const bookId = yield* BookId.create(bookDTO.id) - .mapErr((error) => error) - .safeUnwrap() - const title = yield* Title.create(bookDTO.title) - .mapErr((error) => error) - .safeUnwrap() - const authors = yield* FullNames.create(bookDTO.authors) - .mapErr((error) => error) - .safeUnwrap() - const image = yield* Image.create(bookDTO.image) - .mapErr((error) => error) - .safeUnwrap() - - return ok(new Book(bookId, title, authors, image)) - }) - } - get id(): BookId { return this._id } diff --git a/src/core/book/domain/model/book.factory.ts b/src/core/book/domain/model/book.factory.ts new file mode 100644 index 0000000..100e107 --- /dev/null +++ b/src/core/book/domain/model/book.factory.ts @@ -0,0 +1,39 @@ +import { ok, Result, safeTry } from 'neverthrow' + +import BookDomainError from '@/core/book/domain/errors/book-domain.error' +import Book from '@/core/book/domain/model/book.entity' +import BookResponse from '@/core/book/dto/responses/book.response' +import BookId from '@/core/common/domain/value-objects/book-id' +import FullName from '@/core/common/domain/value-objects/fullname' +import FullNames from '@/core/common/domain/value-objects/fullnames' +import Image from '@/core/common/domain/value-objects/image' +import Title from '@/core/common/domain/value-objects/title' + +const BookFactory = { + create: (bookResponse: BookResponse): Result => + safeTry(function* () { + const bookId = yield* BookId.create(bookResponse.id) + .mapErr((error) => error) + .safeUnwrap() + const title = yield* Title.create(bookResponse.title) + .mapErr((error) => error) + .safeUnwrap() + const authors = yield* FullNames.create(bookResponse.authors) + .mapErr((error) => error) + .safeUnwrap() + const image = yield* Image.create(bookResponse.image) + .mapErr((error) => error) + .safeUnwrap() + + return ok(new Book(bookId, title, authors, image)) + }), + with: (bookResponse: BookResponse): Book => + new Book( + new BookId(bookResponse.id), + new Title(bookResponse.title), + new FullNames(bookResponse.authors.map((author) => new FullName(author))), + new Image(bookResponse.image), + ), +} + +export default BookFactory diff --git a/src/core/book/dto/requests/create-book.request.ts b/src/core/book/dto/requests/create-book.request.ts new file mode 100644 index 0000000..2d90424 --- /dev/null +++ b/src/core/book/dto/requests/create-book.request.ts @@ -0,0 +1,14 @@ +import { DeepReadonly } from 'ts-essentials' + +type CreateBookRequest = DeepReadonly<{ + authors: string[] + id: string + image: string + title: string +}> + +const CreateBookRequest = { + with: (properties: CreateBookRequest) => properties, +} + +export default CreateBookRequest diff --git a/src/core/book/dto/responses/book.response.ts b/src/core/book/dto/responses/book.response.ts new file mode 100644 index 0000000..8baa36b --- /dev/null +++ b/src/core/book/dto/responses/book.response.ts @@ -0,0 +1,22 @@ +import { DeepReadonly } from 'ts-essentials' + +import Book from '@/core/book/domain/model/book.entity' + +type BookResponse = DeepReadonly<{ + authors: string[] + id: string + image: string + title: string +}> + +const BookResponse = { + fromModel: (book: Book): BookResponse => ({ + authors: book.authors.map((author) => author.value), + id: book.id.value, + image: book.image.value, + title: book.title.value, + }), + with: (properties: BookResponse) => properties, +} + +export default BookResponse diff --git a/src/core/book/infrastructure/actions/create-book.ts b/src/core/book/infrastructure/actions/create-book.ts index 9288a97..35580c6 100644 --- a/src/core/book/infrastructure/actions/create-book.ts +++ b/src/core/book/infrastructure/actions/create-book.ts @@ -3,7 +3,7 @@ import { ulid } from 'ulid' import { z } from 'zod' -import { CreateBookCommand } from '@/core/book/application/types' +import CreateBookRequest from '@/core/book/dto/requests/create-book.request' import container from '@/lib/container' import FormResponse from '@/lib/zod/form-response' @@ -37,7 +37,7 @@ export async function createBook( const { authors, image, title } = result.data await container.createBook.with( - CreateBookCommand.with({ authors: authors.split(', '), id, image, title }), + CreateBookRequest.with({ authors: authors.split(', '), id, image, title }), ) return FormResponse.success(result.data) diff --git a/src/core/book/infrastructure/actions/find-books.ts b/src/core/book/infrastructure/actions/find-books.ts index 0d1732a..11bf30a 100644 --- a/src/core/book/infrastructure/actions/find-books.ts +++ b/src/core/book/infrastructure/actions/find-books.ts @@ -1,8 +1,8 @@ 'use server' -import { BookDTO } from '@/core/book/application/types' +import BookResponse from '@/core/book/dto/responses/book.response' import container from '@/lib/container' -export async function findBooks(): Promise { +export async function findBooks(): Promise { return await container.findBooks.with().unwrapOr([]) } diff --git a/src/core/book/infrastructure/services/books-prisma.repository.ts b/src/core/book/infrastructure/services/books-prisma.repository.ts index d0df611..a935a63 100644 --- a/src/core/book/infrastructure/services/books-prisma.repository.ts +++ b/src/core/book/infrastructure/services/books-prisma.repository.ts @@ -1,16 +1,13 @@ import { Book as PrismaBook, PrismaClient } from '@prisma/client' import { okAsync, ResultAsync } from 'neverthrow' -import { BookDTO } from '@/core/book/application/types' import BookNotFoundError from '@/core/book/domain/errors/book-not-found.error' import Book from '@/core/book/domain/model/book.entity' +import BookFactory from '@/core/book/domain/model/book.factory' import Books from '@/core/book/domain/services/books.repository' +import BookResponse from '@/core/book/dto/responses/book.response' import ApplicationError from '@/core/common/domain/errors/application-error' import BookId from '@/core/common/domain/value-objects/book-id' -import FullName from '@/core/common/domain/value-objects/fullname' -import FullNames from '@/core/common/domain/value-objects/fullnames' -import Image from '@/core/common/domain/value-objects/image' -import Title from '@/core/common/domain/value-objects/title' export default class BooksPrisma implements Books { constructor(private readonly prisma: PrismaClient) {} @@ -33,7 +30,9 @@ export default class BooksPrisma implements Books { } save(book: Book): ResultAsync { - const { authors, id, image, title } = BookDTO.fromModel(book) as PrismaBook + const { authors, id, image, title } = BookResponse.fromModel( + book, + ) as PrismaBook return ResultAsync.fromPromise( this.prisma.book.upsert({ @@ -57,11 +56,6 @@ export default class BooksPrisma implements Books { } private mapFromPrismaBook(book: PrismaBook): Book { - return new Book( - new BookId(book.id), - new Title(book.title), - new FullNames(book.authors.map((author) => new FullName(author))), - new Image(book.image), - ) + return BookFactory.with(book satisfies BookResponse) } } diff --git a/src/core/common/domain/value-objects/roles/index.ts b/src/core/common/domain/value-objects/roles/index.ts index 753d691..7e0b863 100644 --- a/src/core/common/domain/value-objects/roles/index.ts +++ b/src/core/common/domain/value-objects/roles/index.ts @@ -10,7 +10,7 @@ export default class Roles { this._roles = roles } - static create(roles: string[]): Result { + static create(roles: string[] | readonly string[]): Result { return Result.combine(roles.map((role) => Role.create(role))).match< Result >( diff --git a/src/core/user/application/find-user.use-case.spec.ts b/src/core/user/application/find-user.use-case.spec.ts index 8b11dd2..8bb30e3 100644 --- a/src/core/user/application/find-user.use-case.spec.ts +++ b/src/core/user/application/find-user.use-case.spec.ts @@ -1,46 +1,36 @@ import FindUserUseCase from '@/core/user/application/find-user.use-case' -import { FindUserCommand, UserDTO } from '@/core/user/application/types' import UserNotFoundError from '@/core/user/domain/errors/user-not-found.error' -import User from '@/core/user/domain/model/user.entity' +import FindUserRequest from '@/core/user/dto/requests/find-user.request' import UsersInMemory from '@/core/user/infrastructure/services/users-in-memory.repository' -import gravatar from '@/lib/utils/gravatar' import unexpected from '@/lib/utils/unexpected' +import UsersExamples from '@/tests/examples/users.examples' describe('FindUserUseCase', () => { test('should find a user by email', async () => { // Arrange const userRepository = new UsersInMemory() - const user = User.create( - UserDTO.with({ - email: 'test@example.com', - image: gravatar('test@example.com'), - name: 'Test User', - roles: ['ROLE_USER'], - }), - )._unsafeUnwrap() + const user = UsersExamples.basic() + userRepository.users.set(user.email.value, user) - userRepository.users.set('test@example.com', user) - - const findUserUseCase = new FindUserUseCase(userRepository) - const findUserCommand = FindUserCommand.with({ - email: 'test@example.com', + const useCase = new FindUserUseCase(userRepository) + const request = FindUserRequest.with({ + email: user.email.value, }) + // Act - const result = await findUserUseCase.with(findUserCommand) + const result = await useCase.with(request) // Assert result.match( (_user) => { expect(_user).toEqual({ - email: 'test@example.com', - image: gravatar('test@example.com'), - name: 'Test User', - roles: ['ROLE_USER'], + email: user.email.value, + image: user.image.value, + name: user.name.value, + roles: user.roles.map((role) => role.value), }) }, - (error) => { - unexpected.error(error) - }, + (error) => unexpected.error(error), ) }) @@ -48,19 +38,17 @@ describe('FindUserUseCase', () => { // Arrange const userRepository = new UsersInMemory() - const findUserUseCase = new FindUserUseCase(userRepository) - const findUserCommand = FindUserCommand.with({ + const useCase = new FindUserUseCase(userRepository) + const request = FindUserRequest.with({ email: 'nonexistent@example.com', }) // Act - const result = await findUserUseCase.with(findUserCommand) + const result = await useCase.with(request) // Assert result.match( - (_user) => { - unexpected.success(_user) - }, + (_user) => unexpected.success(_user), (error) => { expect(error).toBeInstanceOf(UserNotFoundError) }, diff --git a/src/core/user/application/find-user.use-case.ts b/src/core/user/application/find-user.use-case.ts index b2560a2..a9557e3 100644 --- a/src/core/user/application/find-user.use-case.ts +++ b/src/core/user/application/find-user.use-case.ts @@ -2,16 +2,17 @@ import { ok, Result } from 'neverthrow' import Email from '@/core/common/domain/value-objects/email' import EmailError from '@/core/common/domain/value-objects/email/email.error' -import { FindUserCommand, UserDTO } from '@/core/user/application/types' import UserNotFoundError from '@/core/user/domain/errors/user-not-found.error' import Users from '@/core/user/domain/services/users.repository' +import FindUserRequest from '@/core/user/dto/requests/find-user.request' +import UserResponse from '@/core/user/dto/responses/user.response' export default class FindUserUseCase { constructor(private readonly users: Users) {} async with( - command: FindUserCommand, - ): Promise> { + command: FindUserRequest, + ): Promise> { return Email.create(command.email) .asyncAndThen((email) => this.users.findByEmail(email)) .andThen((user) => @@ -20,7 +21,7 @@ export default class FindUserUseCase { image: user.image.value, name: user.name.value, roles: user.roles.map((role) => role.value), - } as UserDTO), + } as UserResponse), ) } } diff --git a/src/core/user/application/types.ts b/src/core/user/application/types.ts deleted file mode 100644 index f87c2ce..0000000 --- a/src/core/user/application/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { DeepReadonly } from 'ts-essentials' - -type FindUserCommand = DeepReadonly<{ - email: string -}> - -const FindUserCommand = { - with: (properties: FindUserCommand) => properties, -} - -type UpdateUserCommand = DeepReadonly<{ - email: string - image: string - name: string -}> - -const UpdateUserCommand = { - with: (properties: UpdateUserCommand) => properties, -} - -type UserDTO = DeepReadonly<{ - email: string - image: string - name: string - roles: string[] -}> - -const UserDTO = { - with: (properties: UserDTO) => properties, -} - -export { FindUserCommand, UpdateUserCommand, UserDTO } diff --git a/src/core/user/application/update-user.use-case.spec.ts b/src/core/user/application/update-user.use-case.spec.ts index d17e59d..681b4ed 100644 --- a/src/core/user/application/update-user.use-case.spec.ts +++ b/src/core/user/application/update-user.use-case.spec.ts @@ -1,72 +1,57 @@ -import { UpdateUserCommand, UserDTO } from '@/core/user/application/types' import UpdateUserUseCase from '@/core/user/application/update-user.use-case' import UserNotFoundError from '@/core/user/domain/errors/user-not-found.error' -import User from '@/core/user/domain/model/user.entity' +import UpdateUserRequest from '@/core/user/dto/requests/update-user.request' +import UserResponse from '@/core/user/dto/responses/user.response' import UsersInMemory from '@/core/user/infrastructure/services/users-in-memory.repository' import gravatar from '@/lib/utils/gravatar' import unexpected from '@/lib/utils/unexpected' +import UsersExamples from '@/tests/examples/users.examples' describe('UpdateUserUseCase', () => { test('should update user name by email', async () => { // Arrange const userRepository = new UsersInMemory() - const user = User.create( - UserDTO.with({ - email: 'test@example.com', - image: gravatar('test@example.com'), - name: 'Test User', - roles: ['ROLE_USER'], - }), - )._unsafeUnwrap() - userRepository.users.set('test@example.com', user) + const user = UsersExamples.basic() + userRepository.users.set(user.email.value, user) - const updatedName = 'Updated User' - - const updateUserCommand = UpdateUserCommand.with({ - email: 'test@example.com', - image: gravatar('test@example.com'), - name: updatedName, + const useCase = new UpdateUserUseCase(userRepository) + const request = UpdateUserRequest.with({ + ...UserResponse.fromModel(user), + name: 'Updated User', }) - const updateUserUseCase = new UpdateUserUseCase(userRepository) // Act - const result = await updateUserUseCase.with(updateUserCommand) + const result = await useCase.with(request) // Assert result.match( - (expectedUser) => { - expect(expectedUser.name.value).toEqual(updatedName) - const updatedUser = userRepository.users.get('test@example.com') - expect(updatedUser?.name.value).toEqual(updatedName) - }, - (error) => { - unexpected.error(error) + () => { + const updatedUser = userRepository.users.get(user.email.value) + expect(updatedUser?.name.value).toEqual('Updated User') }, + (error) => unexpected.error(error), ) }) test('should handle updating a non-existent user', async () => { // Arrange const userRepository = new UsersInMemory() - const updateUserUseCase = new UpdateUserUseCase(userRepository) - - const email = 'nonexistent@example.com' - const image = gravatar(email) - const updatedName = 'Updated User' - const updateUserCommand = UpdateUserCommand.with({ - email, - image, - name: updatedName, + const user = UsersExamples.basic() + userRepository.users.set(user.email.value, user) + + const useCase = new UpdateUserUseCase(userRepository) + const request = UpdateUserRequest.with({ + email: 'nonexistent@example.com', + image: gravatar('nonexistent@example.com'), + name: 'Updated User', }) // Act - const result = await updateUserUseCase.with(updateUserCommand) + const result = await useCase.with(request) // Assert result.match( - (_user) => { - unexpected.success(_user) - }, + (_user) => unexpected.success(_user), (error) => { expect(error).toBeInstanceOf(UserNotFoundError) }, diff --git a/src/core/user/application/update-user.use-case.ts b/src/core/user/application/update-user.use-case.ts index ccf2ed9..82a64ca 100644 --- a/src/core/user/application/update-user.use-case.ts +++ b/src/core/user/application/update-user.use-case.ts @@ -4,16 +4,16 @@ import Email from '@/core/common/domain/value-objects/email' import EmailError from '@/core/common/domain/value-objects/email/email.error' import FullName from '@/core/common/domain/value-objects/fullname' import FullNameError from '@/core/common/domain/value-objects/fullname/fullname.error' -import { UpdateUserCommand } from '@/core/user/application/types' import UserNotFoundError from '@/core/user/domain/errors/user-not-found.error' import User from '@/core/user/domain/model/user.entity' import Users from '@/core/user/domain/services/users.repository' +import UpdateUserRequest from '@/core/user/dto/requests/update-user.request' export default class UpdateUserUseCase { constructor(private readonly users: Users) {} async with( - command: UpdateUserCommand, + command: UpdateUserRequest, ): Promise> { return Email.create(command.email) .asyncAndThen((email) => this.users.findByEmail(email)) @@ -21,7 +21,7 @@ export default class UpdateUserUseCase { .andThen((user) => this.users.save(user)) } - private updateUser(user: User, command: UpdateUserCommand) { + private updateUser(user: User, command: UpdateUserRequest) { return FullName.create(command.name).andThen((fullName) => { user.name = fullName diff --git a/src/core/user/domain/errors/user-domain.error.ts b/src/core/user/domain/errors/user-domain.error.ts new file mode 100644 index 0000000..eb93992 --- /dev/null +++ b/src/core/user/domain/errors/user-domain.error.ts @@ -0,0 +1,8 @@ +import EmailError from '@/core/common/domain/value-objects/email/email.error' +import FullNameError from '@/core/common/domain/value-objects/fullname/fullname.error' +import ImageError from '@/core/common/domain/value-objects/image/image.error' +import RoleError from '@/core/common/domain/value-objects/role/role.error' + +type UserDomainError = EmailError | RoleError | FullNameError | ImageError + +export default UserDomainError diff --git a/src/core/user/domain/model/user.entity.ts b/src/core/user/domain/model/user.entity.ts index 385a467..608b04d 100644 --- a/src/core/user/domain/model/user.entity.ts +++ b/src/core/user/domain/model/user.entity.ts @@ -1,15 +1,7 @@ -import { ok, Result, safeTry } from 'neverthrow' - -import DomainError from '@/core/common/domain/errors/domain-error' import Email from '@/core/common/domain/value-objects/email' -import EmailError from '@/core/common/domain/value-objects/email/email.error' import FullName from '@/core/common/domain/value-objects/fullname' -import FullNameError from '@/core/common/domain/value-objects/fullname/fullname.error' import Image from '@/core/common/domain/value-objects/image' -import ImageError from '@/core/common/domain/value-objects/image/image.error' -import RoleError from '@/core/common/domain/value-objects/role/role.error' import Roles from '@/core/common/domain/value-objects/roles' -import { UserDTO } from '@/core/user/application/types' export default class User { constructor( @@ -19,27 +11,6 @@ export default class User { private _image: Image, ) {} - static create( - userDTO: UserDTO, - ): Result { - return safeTry(function* () { - const email = yield* Email.create(userDTO.email) - .mapErr((error) => error) - .safeUnwrap() - const roles = yield* Roles.create(userDTO.roles) - .mapErr((error) => error) - .safeUnwrap() - const name = yield* FullName.create(userDTO.name) - .mapErr((error) => error) - .safeUnwrap() - const image = yield* Image.create(userDTO.image) - .mapErr((error) => error) - .safeUnwrap() - - return ok(new User(email, roles, name, image)) - }) - } - get name(): FullName { return this._name } diff --git a/src/core/user/domain/model/user.factory.ts b/src/core/user/domain/model/user.factory.ts new file mode 100644 index 0000000..14421cf --- /dev/null +++ b/src/core/user/domain/model/user.factory.ts @@ -0,0 +1,39 @@ +import { ok, Result, safeTry } from 'neverthrow' + +import Email from '@/core/common/domain/value-objects/email' +import FullName from '@/core/common/domain/value-objects/fullname' +import Image from '@/core/common/domain/value-objects/image' +import Role from '@/core/common/domain/value-objects/role' +import Roles from '@/core/common/domain/value-objects/roles' +import UserDomainError from '@/core/user/domain/errors/user-domain.error' +import User from '@/core/user/domain/model/user.entity' +import UserResponse from '@/core/user/dto/responses/user.response' + +const UserFactory = { + create: (userResponse: UserResponse): Result => + safeTry(function* () { + const email = yield* Email.create(userResponse.email) + .mapErr((error) => error) + .safeUnwrap() + const roles = yield* Roles.create(userResponse.roles) + .mapErr((error) => error) + .safeUnwrap() + const name = yield* FullName.create(userResponse.name) + .mapErr((error) => error) + .safeUnwrap() + const image = yield* Image.create(userResponse.image) + .mapErr((error) => error) + .safeUnwrap() + + return ok(new User(email, roles, name, image)) + }), + with: (userResponse: UserResponse): User => + new User( + new Email(userResponse.email), + new Roles(userResponse.roles.map((role) => new Role(role))), + new FullName(userResponse.name), + new Image(userResponse.image), + ), +} + +export default UserFactory diff --git a/src/core/user/domain/model/user.spec.ts b/src/core/user/domain/model/user.spec.ts index 89fc254..571bd21 100644 --- a/src/core/user/domain/model/user.spec.ts +++ b/src/core/user/domain/model/user.spec.ts @@ -1,6 +1,6 @@ import Role from '@/core/common/domain/value-objects/role' -import { UserDTO } from '@/core/user/application/types' -import User from '@/core/user/domain/model/user.entity' +import UserFactory from '@/core/user/domain/model/user.factory' +import UserResponse from '@/core/user/dto/responses/user.response' import gravatar from '@/lib/utils/gravatar' import unexpected from '@/lib/utils/unexpected' @@ -9,11 +9,11 @@ describe('User', () => { it('should create a valid user', () => { // Arrange // Act - const user = User.create( - UserDTO.with({ + const user = UserFactory.create( + UserResponse.with({ email: 'admin@example.com', image: gravatar('admin@example.com'), - name: 'Sergio', + name: 'Jane Doe', roles: ['ROLE_USER'], }), ) @@ -21,7 +21,7 @@ describe('User', () => { // Assert user.match( (_user) => { - expect(_user.name.value).toEqual('Sergio') + expect(_user.name.value).toEqual('Jane Doe') expect(_user.roles.has(new Role('ROLE_USER'))).toBeTruthy() expect(_user.email.value).toEqual('admin@example.com') expect(_user.image.value).toEqual(gravatar('admin@example.com')) @@ -36,11 +36,11 @@ describe('User', () => { // Arrange const invalidEmail = 'invalid-email' // Act - const user = User.create( - UserDTO.with({ + const user = UserFactory.create( + UserResponse.with({ email: invalidEmail, image: gravatar(invalidEmail), - name: 'Sergio', + name: 'Jane Doe', roles: ['ROLE_USER'], }), ) @@ -60,8 +60,8 @@ describe('User', () => { // Arrange const emptyName = '' // Act - const user = User.create( - UserDTO.with({ + const user = UserFactory.create( + UserResponse.with({ email: 'admin@example.com', image: gravatar('admin@example.com'), name: emptyName, @@ -83,11 +83,11 @@ describe('User', () => { // Arrange const invalidRole = 'INVALID_ROLE' // Act - const user = User.create( - UserDTO.with({ + const user = UserFactory.create( + UserResponse.with({ email: 'admin@example.com', image: gravatar('admin@example.com'), - name: 'Sergio', + name: 'Jane Doe', roles: [invalidRole], }), ) diff --git a/src/core/user/dto/requests/find-user.request.ts b/src/core/user/dto/requests/find-user.request.ts new file mode 100644 index 0000000..3473357 --- /dev/null +++ b/src/core/user/dto/requests/find-user.request.ts @@ -0,0 +1,11 @@ +import { DeepReadonly } from 'ts-essentials' + +type FindUserRequest = DeepReadonly<{ + email: string +}> + +const FindUserRequest = { + with: (properties: FindUserRequest): FindUserRequest => properties, +} + +export default FindUserRequest diff --git a/src/core/user/dto/requests/update-user.request.ts b/src/core/user/dto/requests/update-user.request.ts new file mode 100644 index 0000000..bb09ff9 --- /dev/null +++ b/src/core/user/dto/requests/update-user.request.ts @@ -0,0 +1,13 @@ +import { DeepReadonly } from 'ts-essentials' + +type UpdateUserRequest = DeepReadonly<{ + email: string + image: string + name: string +}> + +const UpdateUserRequest = { + with: (properties: UpdateUserRequest) => properties, +} + +export default UpdateUserRequest diff --git a/src/core/user/dto/responses/user.response.ts b/src/core/user/dto/responses/user.response.ts new file mode 100644 index 0000000..56ce28a --- /dev/null +++ b/src/core/user/dto/responses/user.response.ts @@ -0,0 +1,22 @@ +import { DeepReadonly } from 'ts-essentials' + +import User from '@/core/user/domain/model/user.entity' + +type UserResponse = DeepReadonly<{ + email: string + image: string + name: string + roles: string[] +}> + +const UserResponse = { + fromModel: (user: User): UserResponse => ({ + email: user.email.value, + image: user.image.value, + name: user.name.value, + roles: user.roles.map((role) => role.value), + }), + with: (properties: UserResponse) => properties, +} + +export default UserResponse diff --git a/src/core/user/infrastructure/actions/find-user.ts b/src/core/user/infrastructure/actions/find-user.ts index 1dcc8c0..bf30c48 100644 --- a/src/core/user/infrastructure/actions/find-user.ts +++ b/src/core/user/infrastructure/actions/find-user.ts @@ -1,10 +1,13 @@ 'use server' -import { FindUserCommand, UserDTO } from '@/core/user/application/types' +import FindUserRequest from '@/core/user/dto/requests/find-user.request' +import UserResponse from '@/core/user/dto/responses/user.response' import container from '@/lib/container' -export async function findUser(email: string): Promise { - const result = await container.findUser.with(FindUserCommand.with({ email })) +export async function findUser( + email: string, +): Promise { + const result = await container.findUser.with(FindUserRequest.with({ email })) return result.match( (user) => user, diff --git a/src/core/user/infrastructure/actions/update-user.ts b/src/core/user/infrastructure/actions/update-user.ts index 383f58e..73cd4fa 100644 --- a/src/core/user/infrastructure/actions/update-user.ts +++ b/src/core/user/infrastructure/actions/update-user.ts @@ -3,7 +3,7 @@ import { revalidateTag } from 'next/cache' import { z } from 'zod' -import { UpdateUserCommand } from '@/core/user/application/types' +import UpdateUserRequest from '@/core/user/dto/requests/update-user.request' import { auth } from '@/lib/auth/auth' import container from '@/lib/container' import gravatar from '@/lib/utils/gravatar' @@ -43,7 +43,7 @@ export async function updateUser( const { name } = result.data await container.updateUser.with( - UpdateUserCommand.with({ email, image: gravatar(email), name }), + UpdateUserRequest.with({ email, image: gravatar(email), name }), ) revalidateTag(`role-for-${email}`) diff --git a/src/core/user/infrastructure/services/users-prisma.repository.ts b/src/core/user/infrastructure/services/users-prisma.repository.ts index 63044d1..d1c7564 100644 --- a/src/core/user/infrastructure/services/users-prisma.repository.ts +++ b/src/core/user/infrastructure/services/users-prisma.repository.ts @@ -3,13 +3,11 @@ import { okAsync, ResultAsync } from 'neverthrow' import ApplicationError from '@/core/common/domain/errors/application-error' import Email from '@/core/common/domain/value-objects/email' -import FullName from '@/core/common/domain/value-objects/fullname' -import Image from '@/core/common/domain/value-objects/image' -import Role from '@/core/common/domain/value-objects/role' -import Roles from '@/core/common/domain/value-objects/roles' import UserNotFoundError from '@/core/user/domain/errors/user-not-found.error' import User from '@/core/user/domain/model/user.entity' +import UserFactory from '@/core/user/domain/model/user.factory' import Users from '@/core/user/domain/services/users.repository' +import UserResponse from '@/core/user/dto/responses/user.response' export default class UsersPrisma implements Users { constructor(private readonly prisma: PrismaClient) {} @@ -61,11 +59,11 @@ export default class UsersPrisma implements Users { name, roles, }: Pick) { - return new User( - new Email(email || ''), - new Roles(roles.map((role) => new Role(role))), - new FullName(name || ''), - new Image(image || ''), - ) + return UserFactory.with({ + email: email || '', + image: image || '', + name: name || '', + roles, + } satisfies UserResponse) } } diff --git a/tests/examples/books.examples.ts b/tests/examples/books.examples.ts new file mode 100644 index 0000000..5883725 --- /dev/null +++ b/tests/examples/books.examples.ts @@ -0,0 +1,15 @@ +import { ulid } from 'ulid' + +import BookFactory from '@/core/book/domain/model/book.factory' + +const BooksExamples = { + basic: () => + BookFactory.create({ + authors: ['Jane Doe'], + id: ulid(), + image: 'http://example.com/book.jpeg', + title: 'A book', + })._unsafeUnwrap(), +} + +export default BooksExamples diff --git a/tests/examples/users.examples.ts b/tests/examples/users.examples.ts new file mode 100644 index 0000000..50d3ccb --- /dev/null +++ b/tests/examples/users.examples.ts @@ -0,0 +1,14 @@ +import UserFactory from '../../src/core/user/domain/model/user.factory' +import gravatar from '../../src/lib/utils/gravatar' + +const UsersExamples = { + basic: () => + UserFactory.create({ + email: 'test@example.com', + image: gravatar('test@example.com'), + name: 'Test User', + roles: ['ROLE_USER'], + })._unsafeUnwrap(), +} + +export default UsersExamples diff --git a/tsconfig.json b/tsconfig.json index 97a8b2c..a2d57f4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,9 +19,10 @@ } ], "paths": { + "@/tests/*": ["./tests/*"], "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "tests"] }