diff --git a/package-lock.json b/package-lock.json index fb2b04f..f17fc19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2831,6 +2831,12 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true }, + "regenerator-runtime": { + "version": "0.13.9", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", + "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==", + "dev": true + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index 7b1cc3f..7e439aa 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/node": "^17.0.18", "@types/uuid": "^8.3.4", "jest": "^27.5.1", + "regenerator-runtime": "^0.13.9", "sucrase": "^3.20.3", "ts-node": "^10.5.0", "typescript": "^4.5.5" diff --git a/src/@core/@seedwork/application/dto/pagination-output.dto.spec.ts b/src/@core/@seedwork/application/dto/pagination-output.dto.spec.ts new file mode 100644 index 0000000..872fab3 --- /dev/null +++ b/src/@core/@seedwork/application/dto/pagination-output.dto.spec.ts @@ -0,0 +1,21 @@ +import { SearchResult } from "../../domain/repository/repository-contracts"; +import { PaginationOutputDto } from "./pagination-output.dto"; + +describe("PaginationOuputDto Unit Tests", () => { + it("should convert SearchResult to PaginationOutputDto", () => { + const searchResult = new SearchResult({ + items: [], + total: 10, + current_page: 2, + per_page: 2, + }); + + const dto = PaginationOutputDto.fromRepoSearchResult(searchResult); + expect(dto).toStrictEqual({ + total: 10, + current_page: 2, + per_page: 2, + last_page: 5, + }); + }); +}); diff --git a/src/@core/@seedwork/application/dto/pagination-output.dto.ts b/src/@core/@seedwork/application/dto/pagination-output.dto.ts new file mode 100644 index 0000000..862e702 --- /dev/null +++ b/src/@core/@seedwork/application/dto/pagination-output.dto.ts @@ -0,0 +1,19 @@ +import { SearchResult } from "../../domain/repository/repository-contracts"; + +export class PaginationOutputDto { + total: number; + current_page: number; + last_page: number; + per_page: number; + + static fromRepoSearchResult( + result: SearchResult + ): PaginationOutputDto { + return { + total: result.total, + current_page: result.current_page, + per_page: result.per_page, + last_page: result.last_page, + }; + } +} diff --git a/src/@core/@seedwork/application/dto/pagination.output.ts b/src/@core/@seedwork/application/dto/pagination.output.ts deleted file mode 100644 index e56ec0f..0000000 --- a/src/@core/@seedwork/application/dto/pagination.output.ts +++ /dev/null @@ -1,7 +0,0 @@ -export default interface PaginationOutput { - items?: T[]; - current_page: number; - last_page: number; - per_page: number; - total?: number; -} diff --git a/src/@core/@seedwork/domain/entity/__tests__/entity.spec.ts b/src/@core/@seedwork/domain/entity/__tests__/entity.spec.ts index aac75b5..e6f2e4e 100644 --- a/src/@core/@seedwork/domain/entity/__tests__/entity.spec.ts +++ b/src/@core/@seedwork/domain/entity/__tests__/entity.spec.ts @@ -1,6 +1,6 @@ import Entity from "../entity"; import { validate as uuidValidate } from "uuid"; -import UniqueEntityId from "../unique-entity-id"; +import UniqueEntityId from "../../value-objects/unique-entity-id"; class StubEntity extends Entity<{ prop1: string; prop2: number }> {} diff --git a/src/@core/@seedwork/domain/entity/entity.ts b/src/@core/@seedwork/domain/entity/entity.ts index 804f1ce..4b0364e 100644 --- a/src/@core/@seedwork/domain/entity/entity.ts +++ b/src/@core/@seedwork/domain/entity/entity.ts @@ -1,4 +1,4 @@ -import UniqueEntityId from "./unique-entity-id"; +import UniqueEntityId from "../value-objects/unique-entity-id"; export default abstract class Entity { protected readonly _id: UniqueEntityId; @@ -11,10 +11,10 @@ export default abstract class Entity { return this._id.value; } - toJSON() { + toJSON(): Required<{ id: string } & Props> { return { id: this._id.value, ...this.props, - }; + } as Required<{id: string} & Props>; } -} \ No newline at end of file +} diff --git a/src/@core/@seedwork/domain/errors/invalid-uuid.error.ts b/src/@core/@seedwork/domain/errors/invalid-uuid.error.ts new file mode 100644 index 0000000..4d17e11 --- /dev/null +++ b/src/@core/@seedwork/domain/errors/invalid-uuid.error.ts @@ -0,0 +1,7 @@ +export default class InvalidUuidError extends Error { + constructor() { + super('ID must be a valid UUID'); + this.name = 'InvalidUuidError'; + } + } + \ No newline at end of file diff --git a/src/@core/@seedwork/domain/mapper-interface.ts b/src/@core/@seedwork/domain/mapper-interface.ts new file mode 100644 index 0000000..1605092 --- /dev/null +++ b/src/@core/@seedwork/domain/mapper-interface.ts @@ -0,0 +1,6 @@ +import Entity from "./entity/entity"; + +export default interface Mapper { + toEntity(persistence: P): E; + toPersistence(t: E): P; +} diff --git a/src/@core/@seedwork/domain/repository-interface.ts b/src/@core/@seedwork/domain/repository-interface.ts deleted file mode 100644 index df134f7..0000000 --- a/src/@core/@seedwork/domain/repository-interface.ts +++ /dev/null @@ -1,13 +0,0 @@ -import Entity from "./entity/entity"; -import UniqueEntityId from "./entity/unique-entity-id"; -//import { PaginatorInputParams, PaginatorResult } from './paginator'; - -export default interface RepositoryInterface { - insert(entity: E): Promise; - findById(id: string | UniqueEntityId): Promise; - //paginate(params: PaginatorInputParams): Promise>; - findAll(): Promise; - update(entity: E): Promise; - delete(id: string | UniqueEntityId): Promise; - toEntity(model: any): E; -} diff --git a/src/@core/@seedwork/domain/repository/__tests__/in-memory-searchable.repository.spec.ts b/src/@core/@seedwork/domain/repository/__tests__/in-memory-searchable.repository.spec.ts new file mode 100644 index 0000000..2ede7cc --- /dev/null +++ b/src/@core/@seedwork/domain/repository/__tests__/in-memory-searchable.repository.spec.ts @@ -0,0 +1,317 @@ +import Entity from "../../entity/entity"; +import { InMemorySearchableRepository } from "../in-memory.repository"; +import { + SearchParams, + SearchResult, + SearchableRepositoryInterface, +} from "../repository-contracts"; + +type StubEntityProps = { + name: string; + price: number; +}; + +class StubEntity extends Entity {} + +class StubInMemorySearchableRepository extends InMemorySearchableRepository { + sortableFields: string[] = ["name"]; + + protected async applyFilter( + items: StubEntity[], + filter?: string + ): Promise { + if (filter) { + return items.filter((i) => { + return ( + i.props.name.toLowerCase().includes(filter.toLowerCase()) || + i.props.price.toString() === filter + ); + }); + } + return items; + } +} + +describe("InMemorySearchableRepository Unit tests", () => { + let repository: StubInMemorySearchableRepository; + + beforeEach(() => (repository = new StubInMemorySearchableRepository())); + + describe("applyFilter method tests", () => { + it("should no filter items when filter object is null", async () => { + const items = [new StubEntity({ name: "test", price: 5 })]; + const filterSpy = jest.spyOn(items, "filter" as any); + + let itemsFiltered = await repository["applyFilter"](items); + expect(filterSpy).not.toHaveBeenCalled(); + expect(itemsFiltered).toStrictEqual(itemsFiltered); + }); + + it("should filter items using filter parameter", async () => { + const items = [ + new StubEntity({ name: "test", price: 5 }), + new StubEntity({ name: "TEST", price: 5 }), + new StubEntity({ name: "fake", price: 0 }), + ]; + const filterSpy = jest.spyOn(items, "filter" as any); + + let itemsFiltered = await repository["applyFilter"](items, "TEST"); + expect(filterSpy).toHaveBeenCalledTimes(1); + expect(itemsFiltered).toStrictEqual([items[0], items[1]]); + + itemsFiltered = await repository["applyFilter"](items, "5"); + expect(filterSpy).toHaveBeenCalledTimes(2); + expect(itemsFiltered).toStrictEqual([items[0], items[1]]); + }); + }); + + describe("applyOrder method tests", () => { + it("should no sort items", async () => { + const items = [ + new StubEntity({ name: "b", price: 1 }), + new StubEntity({ name: "a", price: 1 }), + ]; + const sortSpy = jest.spyOn(items, "sort" as any); + + let itemsSorted = await repository["applySort"](items); + expect(sortSpy).not.toHaveBeenCalled(); + expect(itemsSorted).toStrictEqual(items); + + itemsSorted = await repository["applySort"](items, "price", "asc"); + expect(sortSpy).not.toHaveBeenCalled(); + expect(itemsSorted).toStrictEqual(items); + }); + + it("should sort items", async () => { + const items = [ + new StubEntity({ name: "b", price: 1 }), + new StubEntity({ name: "a", price: 1 }), + new StubEntity({ name: "c", price: 1 }), + ]; + + let itemsSorted = await repository["applySort"](items, "name", "asc"); + expect(itemsSorted).toStrictEqual([items[1], items[0], items[2]]); + + itemsSorted = await repository["applySort"](items, "name", "desc"); + expect(itemsSorted).toStrictEqual([items[2], items[0], items[1]]); + }); + }); + + describe("applyPaginate method tests", () => { + it("should paginate items", async () => { + const items = [ + new StubEntity({ name: "a", price: 1 }), + new StubEntity({ name: "b", price: 1 }), + new StubEntity({ name: "c", price: 1 }), + new StubEntity({ name: "d", price: 1 }), + new StubEntity({ name: "e", price: 1 }), + ]; + + let itemsPaginated = await repository["applyPaginate"](items, 1, 2); + expect(itemsPaginated).toStrictEqual([items[0], items[1]]); + + itemsPaginated = await repository["applyPaginate"](items, 2, 2); + expect(itemsPaginated).toStrictEqual([items[2], items[3]]); + + itemsPaginated = await repository["applyPaginate"](items, 3, 2); + expect(itemsPaginated).toStrictEqual([items[4]]); + + itemsPaginated = await repository["applyPaginate"](items, 4, 2); + expect(itemsPaginated).toStrictEqual([]); + }); + }); + + describe("search method tests", () => { + it("should apply only paginate when other params is null", async () => { + const entity = new StubEntity({ name: "b", price: 1 }); + const items = Array(16).fill(entity); + repository.items = items; + + const searchOutput = await repository.search(new SearchParams()); + expect(searchOutput).toStrictEqual( + new SearchResult({ + items: Array(15).fill(entity), + total: 16, + current_page: 1, + per_page: 15, + sort: null, + sort_dir: null, + filter: null, + }) + ); + }); + + it("should apply paginate and filter", async () => { + const items = [ + new StubEntity({ name: "test", price: 1 }), + new StubEntity({ name: "a", price: 1 }), + new StubEntity({ name: "TEST", price: 1 }), + new StubEntity({ name: "TeSt", price: 1 }), + ]; + + repository.items = items; + + let output = await repository.search( + new SearchParams({ page: 1, per_page: 2, filter: "TEST" }) + ); + + expect(output).toStrictEqual( + new SearchResult({ + items: [items[0], items[2]], + total: 3, + current_page: 1, + per_page: 2, + filter: "TEST", + }) + ); + + output = await repository.search( + new SearchParams({ page: 2, per_page: 2, filter: "TEST" }) + ); + + expect(output).toStrictEqual( + new SearchResult({ + items: [items[3]], + total: 3, + current_page: 2, + per_page: 2, + filter: "TEST", + }) + ); + }); + + it("should apply paginate and sort", async () => { + const items = [ + new StubEntity({ name: "b", price: 1 }), + new StubEntity({ name: "a", price: 1 }), + new StubEntity({ name: "d", price: 1 }), + new StubEntity({ name: "e", price: 1 }), + new StubEntity({ name: "c", price: 1 }), + ]; + repository.items = items; + + const arrange = [ + { + input: new SearchParams({ + per_page: 2, + sort: "name", + }), + output: new SearchResult({ + items: [items[1], items[0]], + total: 5, + current_page: 1, + per_page: 2, + sort: "name", + sort_dir: "asc", + }), + }, + { + input: new SearchParams({ + page: 2, + per_page: 2, + sort: "name", + }), + output: new SearchResult({ + items: [items[4], items[2]], + total: 5, + current_page: 2, + per_page: 2, + sort: "name", + sort_dir: "asc", + }), + }, + { + input: new SearchParams({ + per_page: 2, + sort: "name", + sort_dir: "desc", + }), + output: new SearchResult({ + items: [items[3], items[2]], + total: 5, + current_page: 1, + per_page: 2, + sort: "name", + sort_dir: "desc", + }), + }, + { + input: new SearchParams({ + page: 2, + per_page: 2, + sort: "name", + sort_dir: "desc", + }), + output: new SearchResult({ + items: [items[4], items[0]], + total: 5, + current_page: 2, + per_page: 2, + sort: "name", + sort_dir: "desc", + }), + }, + ]; + + for (const i of arrange) { + let searchOutput = await repository.search(i.input); + expect(searchOutput).toStrictEqual(i.output); + } + }); + + it("should search using filter, sort and paginate", async () => { + const items = [ + new StubEntity({ name: "test", price: 1 }), + new StubEntity({ name: "a", price: 1 }), + new StubEntity({ name: "TEST", price: 1 }), + new StubEntity({ name: "e", price: 1 }), + new StubEntity({ name: "TeSt", price: 1 }), + ]; + repository.items = items; + + let output = await repository.search( + new SearchParams({ + page: 1, + per_page: 2, + sort: "name", + sort_dir: "asc", + filter: "TEST", + }) + ); + + expect(output).toStrictEqual( + new SearchResult({ + items: [items[2], items[4]], + total: 3, + current_page: 1, + per_page: 2, + sort: "name", + sort_dir: "asc", + filter: "TEST", + }) + ); + + output = await repository.search( + new SearchParams({ + page: 2, + per_page: 2, + sort: "name", + sort_dir: "asc", + filter: "TEST", + }) + ); + + expect(output).toStrictEqual( + new SearchResult({ + items: [items[0]], + total: 3, + current_page: 2, + per_page: 2, + sort: "name", + sort_dir: "asc", + filter: "TEST", + }) + ); + }); + }); +}); diff --git a/src/@core/@seedwork/domain/repository/__tests__/in-memory.repository.spec.ts b/src/@core/@seedwork/domain/repository/__tests__/in-memory.repository.spec.ts new file mode 100644 index 0000000..51c7e44 --- /dev/null +++ b/src/@core/@seedwork/domain/repository/__tests__/in-memory.repository.spec.ts @@ -0,0 +1,97 @@ +import Entity from "../../entity/entity"; +import NotFoundError from "../../errors/not-found.error"; +import UniqueEntityId from "../../value-objects/unique-entity-id"; +import InMemoryRepository from "../in-memory.repository"; + +type StubEntityProps = { + name: string; + price: number; +}; + +class StubEntity extends Entity {} + +class StubInMemoryRepository extends InMemoryRepository {} + +describe("InMemoryRepository Unit Tests", () => { + let repository: StubInMemoryRepository; + + beforeEach(() => (repository = new StubInMemoryRepository())); + + it("should insert a new entity", async () => { + const entity = new StubEntity({ name: "test", price: 0 }); + await repository.insert(entity); + expect(entity.toJSON()).toStrictEqual(repository.items[0].toJSON()); + }); + + it("should throws error when a entity not found", () => { + expect(repository.findById("fake id")).rejects.toThrow( + new NotFoundError(`Entity Not Found using ID 'fake id'`) + ); + + const id = new UniqueEntityId("0adc23be-b196-4439-a42c-9b0c7c4d1058"); + expect(repository.findById(id)).rejects.toThrow( + new NotFoundError( + `Entity Not Found using ID '0adc23be-b196-4439-a42c-9b0c7c4d1058'` + ) + ); + }); + + it("should find a entity by id", async () => { + const entity = new StubEntity({ name: "test", price: 0 }); + await repository.insert(entity); + + let entityFound = await repository.findById(entity.id); + expect(entity.toJSON()).toStrictEqual(entityFound.toJSON()); + + entityFound = await repository.findById(new UniqueEntityId(entity.id)); + expect(entity.toJSON()).toStrictEqual(entityFound.toJSON()); + }); + + it("should returns all entities persisted", async () => { + const entity = new StubEntity({ name: "test", price: 0 }); + await repository.insert(entity); + const entities = await repository.findAll(); + expect(entities).toHaveLength(1); + expect(entities).toStrictEqual([entity]); + }); + + it("should throws error on update when a entity not found", async () => { + const entity = new StubEntity({ name: "test", price: 0 }); + expect(repository.update(entity)).rejects.toThrow( + new NotFoundError(`Entity Not Found using ID '${entity.id}'`) + ); + }); + + it("should update a entity", async () => { + const entity = new StubEntity({ name: "test", price: 0 }); + await repository.insert(entity); + + const entityUpdated = new StubEntity( + { name: "updated", price: 1 }, + new UniqueEntityId(entity.id) + ); + await repository.update(entityUpdated); + expect(entityUpdated.toJSON()).toStrictEqual(repository.items[0].toJSON()); + }); + + it("should throws error on delete when a entity not found", async () => { + expect(repository.delete("fake id")).rejects.toThrow( + new NotFoundError(`Entity Not Found using ID 'fake id'`) + ); + + const id = new UniqueEntityId("0adc23be-b196-4439-a42c-9b0c7c4d1058"); + expect(repository.delete(id)).rejects.toThrow( + new NotFoundError( + `Entity Not Found using ID '0adc23be-b196-4439-a42c-9b0c7c4d1058'` + ) + ); + }); + + it("should delete a entity", async () => { + const entity = new StubEntity({ name: "test", price: 0 }); + await repository.insert(entity); + + await repository.delete(entity.id); + expect(repository.items).toHaveLength(0); + }); +}); diff --git a/src/@core/@seedwork/domain/repository/__tests__/repository-contracts.spec.ts b/src/@core/@seedwork/domain/repository/__tests__/repository-contracts.spec.ts new file mode 100644 index 0000000..014c7d0 --- /dev/null +++ b/src/@core/@seedwork/domain/repository/__tests__/repository-contracts.spec.ts @@ -0,0 +1,212 @@ +import Entity from "../../entity/entity"; +import { SearchParams, SearchResult } from "../repository-contracts"; + +describe("SearchParams Unit Tests", () => { + test("page field", () => { + let input = new SearchParams(); + expect(input.page).toBe(1); + + const arrange = [ + { page: null, expected: 1 }, + { page: undefined, expected: 1 }, + { page: "", expected: 1 }, + { page: "fake", expected: 1 }, + { page: 0, expected: 1 }, + { page: -1, expected: 1 }, + { page: true, expected: 1 }, + { page: false, expected: 1 }, + { page: {}, expected: 1 }, + { page: 1, expected: 1 }, + { page: 2, expected: 2 }, + ]; + + arrange.forEach((i) => + expect(new SearchParams({ page: i.page as any }).page).toBe(i.expected) + ); + }); + + test("per_page field", () => { + let input = new SearchParams(); + expect(input.per_page).toBe(15); + + const arrange = [ + { page: null as any, expected: 15 }, + { page: undefined as any, expected: 15 }, + { per_page: "", expected: 15 }, + { per_page: "fake", expected: 15 }, + { per_page: 0, expected: 15 }, + { per_page: -1, expected: 15 }, + { per_page: true, expected: 1 }, + { per_page: false, expected: 15 }, + { per_page: {}, expected: 15 }, + { per_page: 1, expected: 1 }, + { per_page: 2, expected: 2 }, + ]; + + arrange.forEach((i) => + expect(new SearchParams({ per_page: i.per_page as any }).per_page).toBe( + i.expected + ) + ); + }); + + test("per_page field", () => { + let input = new SearchParams(); + expect(input.per_page).toBe(15); + + const arrange = [ + { per_page: null, expected: 15 }, + { per_page: undefined, expected: 15 }, + { per_page: "", expected: 15 }, + { per_page: "fake", expected: 15 }, + { per_page: 0, expected: 15 }, + { per_page: -1, expected: 15 }, + { per_page: true, expected: 1 }, + { per_page: false, expected: 15 }, + { per_page: {}, expected: 15 }, + { per_page: 1, expected: 1 }, + { per_page: 2, expected: 2 }, + ]; + + arrange.forEach((i) => + expect(new SearchParams({ per_page: i.per_page as any }).per_page).toBe( + i.expected + ) + ); + }); + + test("sort field", () => { + let input = new SearchParams(); + expect(input.sort).toBeNull(); + + const arrange = [ + { sort: null, expected: null }, + { sort: undefined, expected: null }, + { sort: "", expected: null }, + { sort: "fake", expected: "fake" }, + { sort: 0, expected: null }, + { sort: -1, expected: "-1" }, + { sort: true, expected: "true" }, + { sort: false, expected: null }, + { sort: {}, expected: "[object Object]" }, + ]; + + arrange.forEach((i) => + expect(new SearchParams({ sort: i.sort as any }).sort).toBe(i.expected) + ); + }); + + test("sort_dir field", () => { + let input = new SearchParams(); + expect(input.sort_dir).toBeNull(); + + input = new SearchParams({ sort: null }); + expect(input.sort_dir).toBeNull(); + + input = new SearchParams({ sort: undefined }); + expect(input.sort_dir).toBeNull(); + + input = new SearchParams({ sort: 0 as any }); + expect(input.sort_dir).toBeNull(); + + input = new SearchParams({ sort: "" }); + expect(input.sort_dir).toBeNull(); + + const arrange = [ + { sort_dir: null, expected: "asc" }, + { sort_dir: undefined, expected: "asc" }, + { sort_dir: "", expected: "asc" }, + { sort_dir: "fake", expected: "asc" }, + { sort_dir: "asc", expected: "asc" }, + { sort_dir: "ASC", expected: "asc" }, + { sort_dir: "desc", expected: "desc" }, + { sort_dir: "DESC", expected: "desc" }, + ]; + + arrange.forEach((i) => + expect( + new SearchParams({ sort: "name", sort_dir: i.sort_dir as any }).sort_dir + ).toBe(i.expected) + ); + }); + + test("filter field", () => { + let input = new SearchParams(); + expect(input.filter).toBeNull(); + + const arrange = [ + { filter: null, expected: null }, + { filter: undefined, expected: null }, + { filter: "", expected: null }, + { filter: "fake", expected: "fake" }, + { filter: 0, expected: null }, + { filter: -1, expected: "-1" }, + { filter: true, expected: "true" }, + { filter: false, expected: null }, + { filter: {}, expected: "[object Object]" }, + ]; + + arrange.forEach((i) => + expect(new SearchParams({ filter: i.filter as any }).filter).toBe( + i.expected + ) + ); + }); +}); + +class StubEntity extends Entity {} + +describe("SearchResult Unit Tests", () => { + test("constructor params", () => { + const entity = new StubEntity({}); + let output = new SearchResult({ + items: [entity, entity] as any, + total: 4, + current_page: 1, + per_page: 2, + }); + + expect(output.toJSON()).toStrictEqual({ + items: [entity, entity], + total: 4, + current_page: 1, + per_page: 2, + last_page: 2, + sort: null, + sort_dir: null, + filter: null + }); + + output = new SearchResult({ + items: [entity, entity] as any, + total: 4, + current_page: 1, + per_page: 2, + sort: "name", + sort_dir: "asc", + filter: "test" + }); + + expect(output.toJSON()).toStrictEqual({ + items: [entity, entity], + total: 4, + current_page: 1, + per_page: 2, + last_page: 2, + sort: "name", + sort_dir: "asc", + filter: "test", + }); + }); + + it('should set last_page field 1 when per_page field is greater than total field' , () => { + const output = new SearchResult({ + items: [], + total: 4, + current_page: 1, + per_page: 15, + }); + + expect(output.last_page).toBe(1); + }) +}); diff --git a/src/@core/@seedwork/domain/repository/in-memory.repository.ts b/src/@core/@seedwork/domain/repository/in-memory.repository.ts new file mode 100644 index 0000000..05c1c62 --- /dev/null +++ b/src/@core/@seedwork/domain/repository/in-memory.repository.ts @@ -0,0 +1,121 @@ +import Entity from "../entity/entity"; +import UniqueEntityId from "../value-objects/unique-entity-id"; +import NotFoundError from "../errors/not-found.error"; +import { + RepositoryInterface, + SearchableRepositoryInterface, + SearchParams, + SearchResult, + SortDirection, +} from "./repository-contracts"; + +export default abstract class InMemoryRepository + implements RepositoryInterface +{ + items: E[] = []; + + async insert(entity: E): Promise { + this.items.push(entity); + } + + async findById(id: string | UniqueEntityId): Promise { + const _id = `${id}`; + return this._get(_id); + } + + async findAll(): Promise { + return this.items; + } + + async update(entity: E): Promise { + await this._get(entity.id); + const index = this.items.findIndex((i) => i.id === entity.id); + this.items[index] = entity; + } + + async delete(id: string | UniqueEntityId): Promise { + const _id = `${id}`; + const item = await this._get(_id); + const indexFound = this.items.findIndex((i) => i.id === item.id); + this.items.splice(indexFound, 1); + } + + private async _get(id: string): Promise { + const item = this.items.find((i) => i.id === id); + if (!item) { + throw new NotFoundError(`Entity Not Found using ID '${id}'`); + } + return item; + } +} + +export abstract class InMemorySearchableRepository + extends InMemoryRepository + implements SearchableRepositoryInterface +{ + sortableFields: string[] = []; + + async search(input: SearchParams): Promise> { + let itemsFiltered = await this.applyFilter(this.items, input.filter); + let itemsSorted = await this.applySort( + itemsFiltered, + input.sort, + input.sort_dir + ); + let itemsPaginated = await this.applyPaginate( + itemsSorted, + input.page, + input.per_page + ); + + return new SearchResult({ + items: itemsPaginated, + total: itemsFiltered.length, + current_page: input.page, + per_page: input.per_page, + sort: input.sort, + sort_dir: input.sort_dir, + filter: input.filter + }); + } + + protected abstract applyFilter( + items: E[], + filter?: string + ): Promise; + + protected async applySort( + items: E[], + sort?: string, + sort_dir?: SortDirection + ): Promise { + if (sort && this.sortableFields.includes(sort)) { + return [...items].sort((a, b) => { + const field = sort as keyof E; + + if (a.props[field] < b.props[field]) { + return sort_dir === "asc" ? -1 : 1; + } + + if (a.props[field] > b.props[field]) { + return sort_dir === "asc" ? 1 : -1; + } + + return 0; + }); + } + + return items; + } + + protected async applyPaginate( + items: E[], + page: number, + per_page: number + ): Promise { + const offset = (page - 1) * per_page; + const limit = offset + per_page; + + return items.slice(offset, limit); + } +} diff --git a/src/@core/@seedwork/domain/repository/repository-contracts.ts b/src/@core/@seedwork/domain/repository/repository-contracts.ts new file mode 100644 index 0000000..d878796 --- /dev/null +++ b/src/@core/@seedwork/domain/repository/repository-contracts.ts @@ -0,0 +1,148 @@ +import Entity from "../entity/entity"; +import UniqueEntityId from "../value-objects/unique-entity-id"; + +export type SortDirection = "asc" | "desc"; + +export type SearchProps = { + page?: number; + per_page?: number; + sort?: string; + sort_dir?: SortDirection; + filter?: Filter; +}; + +export class SearchParams { + protected _page: number; + protected _per_page: number = 15; + protected _sort: string | null; + protected _sort_dir: SortDirection; + protected _filter: Filter | null; + + constructor(props: SearchProps = {}) { + this.page = props.page; + this.per_page = props.per_page; + this.sort = props.sort; + this.sort_dir = props.sort_dir; + this.filter = props.filter; + } + + get page() { + return this._page; + } + + private set page(value: number) { + let _page = +value; + + if (Number.isNaN(_page) || _page <= 0) { + _page = 1; + } + + this._page = _page; + } + + get per_page() { + return this._per_page; + } + + private set per_page(value: number) { + let _per_page = +value; + + if (Number.isNaN(_per_page) || _per_page < 1) { + _per_page = this._per_page; + } + + this._per_page = _per_page; + } + + get sort() { + return this._sort; + } + + private set sort(value: string) { + this._sort = !value ? null : `${value}`; + } + + get sort_dir() { + return this._sort_dir; + } + + private set sort_dir(value: SortDirection) { + const dir = `${value}`.toLowerCase(); + this._sort_dir = dir !== "asc" && dir !== "desc" ? "asc" : dir; + + if (!this.sort) { + this._sort_dir = null; + } + } + + get filter() { + return this._filter; + } + + private set filter(value: Filter) { + this._filter = !value ? null : (`${value}` as any); + } +} + +export type SearchResultProps = { + readonly items: E[]; + readonly total: number; + readonly current_page: number; + readonly per_page: number; + readonly sort?: string; + readonly sort_dir?: SortDirection; + readonly filter?: Filter; +}; + +export class SearchResult { + readonly items: E[]; + readonly total: number; + readonly current_page: number; + readonly per_page: number; + readonly last_page: number; + readonly sort?: string; + readonly sort_dir?: string; + readonly filter?: Filter; + + constructor(props: SearchResultProps) { + this.items = props.items; + this.total = props.total; + this.current_page = props.current_page; + this.per_page = props.per_page; + this.last_page = Math.ceil(props.total / props.per_page); + this.sort = props.sort ?? null; + this.sort_dir = props.sort_dir ?? null; + this.filter = props.filter ?? null; + } + + toJSON() { + return { + items: this.items, + total: this.total, + current_page: this.current_page, + last_page: this.last_page, + per_page: this.per_page, + sort: this.sort, + sort_dir: this.sort_dir, + filter: this.filter, + }; + } +} + +export interface RepositoryInterface { + insert(entity: E): Promise; + findById(id: string | UniqueEntityId): Promise; + findAll(): Promise; + update(entity: E): Promise; + delete(id: string | UniqueEntityId): Promise; +} + +export interface SearchableRepositoryInterface< + E extends Entity, + Filter = string, + Input = SearchParams, + Output = SearchResult +> extends RepositoryInterface { + sortableFields: string[]; + search(props: Input): Promise; +} diff --git a/src/@core/@seedwork/domain/tests/validation.ts b/src/@core/@seedwork/domain/tests/validation.ts index 8d57b1d..0c9c801 100644 --- a/src/@core/@seedwork/domain/tests/validation.ts +++ b/src/@core/@seedwork/domain/tests/validation.ts @@ -1,4 +1,5 @@ import { objectContaining } from "expect"; +import ValidationError from "../errors/validation.error"; import ClassValidator from "../validators/class.validator"; // declare global { @@ -28,7 +29,8 @@ expect.extend({ message: () => `The data is valid`, }; } catch (e) { - const isMatch = objectContaining(received).asymmetricMatch(e.error); + const error = e as ValidationError; + const isMatch = objectContaining(received).asymmetricMatch(error.error); return isMatch ? { pass: true, @@ -39,7 +41,7 @@ expect.extend({ message: () => `The validation errors not contains ${JSON.stringify( received - )}. Current: ${JSON.stringify(e.error)}`, + )}. Current: ${JSON.stringify(error.error)}`, }; } }, diff --git a/src/@core/@seedwork/domain/validators/__tests__/class.validator.spec.ts b/src/@core/@seedwork/domain/validators/__tests__/class.validator.spec.ts index 4e63d3d..8c78242 100644 --- a/src/@core/@seedwork/domain/validators/__tests__/class.validator.spec.ts +++ b/src/@core/@seedwork/domain/validators/__tests__/class.validator.spec.ts @@ -26,7 +26,7 @@ describe("ClassValidator Unit Tests", () => { expect(validateSyncSpy).toHaveBeenCalled(); }); - it("should validate with throw EntityValidatorError", () => { + it("should validate with throw ValidatorError", () => { const validateSyncSpy = jest .spyOn(libValidator, "validateSync") .mockReturnValue([ diff --git a/src/@core/@seedwork/domain/validators/__tests__/validator-rules.spec.ts b/src/@core/@seedwork/domain/validators/__tests__/validator-rules.spec.ts index 4bcffee..9c7feba 100644 --- a/src/@core/@seedwork/domain/validators/__tests__/validator-rules.spec.ts +++ b/src/@core/@seedwork/domain/validators/__tests__/validator-rules.spec.ts @@ -1,10 +1,14 @@ import { SimpleValidationError } from "../../errors/validation.error"; import ValidationRules from "../validator-rules"; +type PickMatching = { + [K in keyof T as T[K] extends V ? K : never]: T[K]; +}; + type ExpectedRule = { value: any; property: string; - rule: keyof ValidationRules; + rule: keyof PickMatching; messageError: SimpleValidationError; params?: any[]; }; @@ -17,8 +21,10 @@ function expectIsValid({ params = [], }: ExpectedRule) { const validator = ValidationRules.values(value, property); - //@ts-ignore - expect(() => validator[rule](...params)).not.toThrow(messageError); + expect(() => { + const method = validator[rule] as any; + method(...params); + }).not.toThrow(messageError); } function expectIsInvalid({ @@ -45,7 +51,7 @@ describe("ValidatorRules Unit Tests", () => { test("required validation rule", () => { const messageError = new SimpleValidationError("The field is required"); - let data = [ + let data: ExpectedRule[] = [ { value: "test", property: "field", rule: "required", messageError }, { value: 5, property: "field", rule: "required", messageError }, { value: 0, property: "field", rule: "required", messageError }, @@ -167,7 +173,9 @@ describe("ValidatorRules Unit Tests", () => { validator = ValidationRules.values("t".repeat(11), "field"); expect(() => validator.required().string().maxLength(10)).toThrow( - new SimpleValidationError("The field must be less or equal than 10 characters") + new SimpleValidationError( + "The field must be less or equal than 10 characters" + ) ); validator = ValidationRules.values(null, "field"); @@ -177,7 +185,9 @@ describe("ValidatorRules Unit Tests", () => { validator = ValidationRules.values(null, "field"); expect(() => validator.string().maxLength(10)).not.toThrow( - new SimpleValidationError("The field must be less or equal than 10 characters") + new SimpleValidationError( + "The field must be less or equal than 10 characters" + ) ); validator = ValidationRules.values(null, "field"); diff --git a/src/@core/@seedwork/domain/entity/__tests__/unique-entity-id.spec.ts b/src/@core/@seedwork/domain/value-objects/__tests__/unique-entity-id.spec.ts similarity index 79% rename from src/@core/@seedwork/domain/entity/__tests__/unique-entity-id.spec.ts rename to src/@core/@seedwork/domain/value-objects/__tests__/unique-entity-id.spec.ts index 5a94508..1b45daa 100644 --- a/src/@core/@seedwork/domain/entity/__tests__/unique-entity-id.spec.ts +++ b/src/@core/@seedwork/domain/value-objects/__tests__/unique-entity-id.spec.ts @@ -1,6 +1,6 @@ -import UniqueEntityId from "./../unique-entity-id"; +import UniqueEntityId from "../unique-entity-id"; import { validate as uuidValidate } from "uuid"; -import InvalidArgumentError from "../../errors/invalid-argument.error"; +import InvalidUuidError from "../../errors/invalid-uuid.error"; function mockValidateMethod() { return jest.spyOn(UniqueEntityId.prototype as any, "validate"); @@ -10,12 +10,12 @@ describe("UniqueEntityId Unit Tests", () => { it("should throw error when uuid is invalid", () => { const validateMethodMock = mockValidateMethod(); expect(() => new UniqueEntityId("fake id")).toThrow( - new InvalidArgumentError("ID must be a valid UUID") + new InvalidUuidError() ); expect(validateMethodMock).toHaveBeenCalled(); }); - it("should accept a uuid passed in contructor", () => { + it("should accept a uuid passed in constructor", () => { const validateMethodMock = mockValidateMethod(); const uuid = "5490020a-e866-4229-9adc-aa44b83234c4"; const id = new UniqueEntityId(uuid); diff --git a/src/@core/@seedwork/domain/entity/__tests__/value-object.spec.ts b/src/@core/@seedwork/domain/value-objects/__tests__/value-object.spec.ts similarity index 95% rename from src/@core/@seedwork/domain/entity/__tests__/value-object.spec.ts rename to src/@core/@seedwork/domain/value-objects/__tests__/value-object.spec.ts index da882fb..913b1ba 100644 --- a/src/@core/@seedwork/domain/entity/__tests__/value-object.spec.ts +++ b/src/@core/@seedwork/domain/value-objects/__tests__/value-object.spec.ts @@ -1,4 +1,4 @@ -import ValueObject from "../value-object"; +import ValueObject from "../../value-objects/value-object"; class StubValueObject extends ValueObject {} diff --git a/src/@core/@seedwork/domain/entity/unique-entity-id.ts b/src/@core/@seedwork/domain/value-objects/unique-entity-id.ts similarity index 72% rename from src/@core/@seedwork/domain/entity/unique-entity-id.ts rename to src/@core/@seedwork/domain/value-objects/unique-entity-id.ts index c86c625..fab3611 100644 --- a/src/@core/@seedwork/domain/entity/unique-entity-id.ts +++ b/src/@core/@seedwork/domain/value-objects/unique-entity-id.ts @@ -1,6 +1,6 @@ import ValueObject from './value-object'; import { v4 as uuidv4, validate as uuidValidate } from 'uuid'; -import InvalidArgumentError from '../errors/invalid-argument.error'; +import InvalidUuidError from '../errors/invalid-uuid.error'; export default class UniqueEntityId extends ValueObject { constructor(id?: string) { @@ -11,7 +11,7 @@ export default class UniqueEntityId extends ValueObject { private validate() { const isValid = uuidValidate(this._value); if (!isValid) { - throw new InvalidArgumentError('ID must be a valid UUID'); + throw new InvalidUuidError() } } } diff --git a/src/@core/@seedwork/domain/entity/value-object.ts b/src/@core/@seedwork/domain/value-objects/value-object.ts similarity index 100% rename from src/@core/@seedwork/domain/entity/value-object.ts rename to src/@core/@seedwork/domain/value-objects/value-object.ts diff --git a/src/@core/category/application/use-cases/__tests__/create-category.use-case.spec.ts b/src/@core/category/application/use-cases/__tests__/create-category.use-case.spec.ts new file mode 100644 index 0000000..f4b8750 --- /dev/null +++ b/src/@core/category/application/use-cases/__tests__/create-category.use-case.spec.ts @@ -0,0 +1,86 @@ +import Category from "../../../domain/entities/category"; +import CategoryInMemoryRepository from "../../../infra/repositories/category-in-memory.repository"; +import CreateCategoryUseCase, { + Input, + Output, +} from "../create-category.use-case"; + +describe("CreateCategoryUseCase Unit Tests", () => { + let useCase: CreateCategoryUseCase; + let repository: CategoryInMemoryRepository; + + beforeEach(() => { + repository = new CategoryInMemoryRepository(); + useCase = new CreateCategoryUseCase(repository); + }); + + it("should convert input to entity", () => { + let entity = Input.toEntity({ name: "test" }); + expect(entity.toJSON()).toMatchObject({ + name: "test", + description: null, + is_active: true, + }); + + entity = Input.toEntity({ + name: "test", + description: "description test", + is_active: false, + }); + expect(entity.toJSON()).toMatchObject({ + name: "test", + description: "description test", + is_active: false, + }); + }); + + it("should convert entity to output", () => { + let entity = new Category({ name: "test" }); + let output = Output.fromEntity(entity); + expect(output).toStrictEqual({ + id: entity.id, + name: "test", + description: null, + is_active: true, + created_at: entity.props.created_at, + }); + + entity = new Category({ + name: "test", + description: "description test", + is_active: false, + }); + output = Output.fromEntity(entity); + expect(output).toStrictEqual({ + id: entity.id, + name: "test", + description: "description test", + is_active: false, + created_at: entity.props.created_at, + }); + }); + + it("should create a category", async () => { + let output = await useCase.execute({ name: "test" }); + expect(output).toStrictEqual({ + id: repository.items[0].id, + name: "test", + description: null, + is_active: true, + created_at: repository.items[0].props.created_at, + }); + + output = await useCase.execute({ + name: "test", + description: "test description", + is_active: false, + }); + expect(output).toStrictEqual({ + id: repository.items[1].id, + name: "test", + description: "test description", + is_active: false, + created_at: repository.items[1].props.created_at, + }); + }); +}); diff --git a/src/@core/category/application/use-cases/__tests__/list-categories.use-case.spec.ts b/src/@core/category/application/use-cases/__tests__/list-categories.use-case.spec.ts new file mode 100644 index 0000000..41f9d56 --- /dev/null +++ b/src/@core/category/application/use-cases/__tests__/list-categories.use-case.spec.ts @@ -0,0 +1,127 @@ +import ListCategoriesUseCase, { Input } from "../list-categories.use-case"; +import CategoryInMemoryRepository from "../../../infra/repositories/category-in-memory.repository"; +import Category from "../../../domain/entities/category"; +import { + SearchParams, + SearchResult, +} from "../../../domain/repositories/category.repository"; + +describe("ListCategories Unit Tests", () => { + let useCase: ListCategoriesUseCase; + let repository: CategoryInMemoryRepository; + + beforeEach(() => { + repository = new CategoryInMemoryRepository(); + useCase = new ListCategoriesUseCase(repository); + }); + + test("toSearchParams method", () => { + let input: Input = {}; + let searchParams = useCase["toSearchParams"](input); + + expect(searchParams).toStrictEqual( + new SearchParams({ + page: 1, + per_page: 15, + sort: null, + sort_dir: null, + filter: null, + }) + ); + + input = { + page: 2, + per_page: 2, + sort: "name", + sort_dir: "asc", + filter: "test", + }; + searchParams = useCase["toSearchParams"](input); + + expect(searchParams).toStrictEqual( + new SearchParams({ + page: 2, + per_page: 2, + sort: "name", + sort_dir: "asc", + filter: "test", + }) + ); + }); + + test("toOutput method", () => { + let entity = new Category({ name: "test" }); + let searchResult = new SearchResult({ + items: [entity], + total: 1, + current_page: 1, + per_page: 2, + sort: "name", + sort_dir: "asc", + }); + let output = useCase["toOutput"](searchResult); + + expect(output).toStrictEqual({ + items: [entity.toJSON()], + total: 1, + current_page: 1, + per_page: 2, + last_page: 1, + }); + }); + + it("should returns the output with all categories when input is empty", async () => { + const items = [ + new Category({ name: "test 1" }), + new Category({ name: "test 2", created_at: new Date(new Date().getTime() + 100) }), + ]; + repository.items = items; + + const output = await useCase.execute(); + expect(output).toStrictEqual({ + items: [...items].reverse().map((i) => i.toJSON()), + total: 2, + current_page: 1, + per_page: 15, + last_page: 1, + }); + }); + + it("should returns the output with using paginate, filter and sort", async () => { + const items = [ + new Category({ name: "a" }), + new Category({ name: "AAA" }), + new Category({ name: "AaA" }), + new Category({ name: "b" }), + new Category({ name: "c" }), + ]; + repository.items = items; + + let output = await useCase.execute({page: 1, per_page: 2, sort: 'name', filter: 'a'}); + expect(output).toStrictEqual({ + items: [items[1].toJSON(), items[2].toJSON()], + total: 3, + current_page: 1, + per_page: 2, + last_page: 2, + }); + + output = await useCase.execute({page: 2, per_page: 2, sort: 'name', filter: 'a'}); + expect(output).toStrictEqual({ + items: [items[0].toJSON()], + total: 3, + current_page: 2, + per_page: 2, + last_page: 2, + }); + + output = await useCase.execute({page: 1, per_page: 2, sort: 'name', filter: 'a', sort_dir: 'desc'}); + expect(output).toStrictEqual({ + items: [items[0].toJSON(), items[2].toJSON()], + total: 3, + current_page: 1, + per_page: 2, + last_page: 2, + }); + }); +}); diff --git a/src/@core/category/application/use-cases/create-category.use-case.ts b/src/@core/category/application/use-cases/create-category.use-case.ts new file mode 100644 index 0000000..380e239 --- /dev/null +++ b/src/@core/category/application/use-cases/create-category.use-case.ts @@ -0,0 +1,33 @@ +import CategoryRepository from "../../domain/repositories/category.repository"; +import { CategoryOutputDto } from "./dto/category.dto"; +import Category from "../../domain/entities/category"; + +export class CreateCategoryUseCase { + constructor(private categoryRepository: CategoryRepository) {} + + async execute(input: Input): Promise { + const entity = Input.toEntity(input); + await this.categoryRepository.insert(entity); + return this.toOutput(entity); + } + + toOutput(entity: Category): Output { + return Output.fromEntity(entity); + } +} + +export default CreateCategoryUseCase; + +export class Input { + name: string; + description?: string; + is_active?: boolean; + + static toEntity(input: Input) { + return new Category(input); + } +} + +export class Output extends CategoryOutputDto { + +} diff --git a/src/@core/category/application/use-cases/dto/category.dto.spec.ts b/src/@core/category/application/use-cases/dto/category.dto.spec.ts new file mode 100644 index 0000000..13f8452 --- /dev/null +++ b/src/@core/category/application/use-cases/dto/category.dto.spec.ts @@ -0,0 +1,28 @@ +import UniqueEntityId from "../../../../@seedwork/domain/value-objects/unique-entity-id"; +import Category from "../../../domain/entities/category"; +import { CategoryOutputDto } from "./category.dto"; + +describe("CategoryOutputDto Unit Tests", () => { + it("should convert Category to CategoryDto", () => { + const id = new UniqueEntityId(); + const created_at = new Date(); + const category = new Category( + { + name: "name test", + description: "description test", + is_active: true, + created_at, + }, + id + ); + + const dto = CategoryOutputDto.fromEntity(category); + expect(dto).toStrictEqual({ + id: id.value, + name: 'name test', + description: 'description test', + is_active: true, + created_at + }); + }); +}); diff --git a/src/@core/category/application/use-cases/dto/category.dto.ts b/src/@core/category/application/use-cases/dto/category.dto.ts new file mode 100644 index 0000000..9ec7327 --- /dev/null +++ b/src/@core/category/application/use-cases/dto/category.dto.ts @@ -0,0 +1,13 @@ +import Category from "../../../domain/entities/category"; + +export class CategoryOutputDto { + id: string; + name: string; + description: string | null; + is_active: boolean; + created_at: Date; + + static fromEntity(entity: Category): CategoryOutputDto { + return entity.toJSON(); + } +} diff --git a/src/@core/category/application/use-cases/list-categories.use-case.ts b/src/@core/category/application/use-cases/list-categories.use-case.ts new file mode 100644 index 0000000..ac8a7bb --- /dev/null +++ b/src/@core/category/application/use-cases/list-categories.use-case.ts @@ -0,0 +1,35 @@ +import CategoryRepository, { + SearchProps, + SearchParams, + SearchResult, +} from "../../domain/repositories/category.repository"; +import { PaginationOutputDto } from "../../../@seedwork/application/dto/pagination-output.dto"; +import { CategoryOutputDto } from "./dto/category.dto"; + +export class ListCategoriesUseCase { + constructor(private categoryRepository: CategoryRepository) {} + + async execute(input: Input = null): Promise { + const searchParams = this.toSearchParams(input ?? {}); + const searchOutput = await this.categoryRepository.search(searchParams); + + return this.toOutput(searchOutput); + } + + private toSearchParams(input: Input) { + return new SearchParams(input); + } + + private toOutput(searchResult: SearchResult): Output { + return { + items: searchResult.items.map((i) => CategoryOutputDto.fromEntity(i)), + ...PaginationOutputDto.fromRepoSearchResult(searchResult), + }; + } +} + +export default ListCategoriesUseCase; + +export type Input = SearchProps; + +export type Output = PaginationOutputDto & { items: CategoryOutputDto[] }; diff --git a/src/@core/category/application/usecases/category/list-categories/list-categories.input.ts b/src/@core/category/application/usecases/category/list-categories/list-categories.input.ts deleted file mode 100644 index 63f2bcb..0000000 --- a/src/@core/category/application/usecases/category/list-categories/list-categories.input.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default interface ListCategoriesInput { - page?: number; - per_page?: number; - filter?: any; - order?: { sort: string; dir: string }; -} diff --git a/src/@core/category/application/usecases/category/list-categories/list-categories.output.ts b/src/@core/category/application/usecases/category/list-categories/list-categories.output.ts deleted file mode 100644 index 8b3688f..0000000 --- a/src/@core/category/application/usecases/category/list-categories/list-categories.output.ts +++ /dev/null @@ -1,7 +0,0 @@ -import PaginationOutput from "../../../@seedwork/dto/pagination.output"; - - - -export default interface ListCategoriesOutput { - pagination: PaginationOutput -} diff --git a/src/@core/category/application/usecases/category/list-categories/list-categories.usecase.ts b/src/@core/category/application/usecases/category/list-categories/list-categories.usecase.ts deleted file mode 100644 index c409335..0000000 --- a/src/@core/category/application/usecases/category/list-categories/list-categories.usecase.ts +++ /dev/null @@ -1,25 +0,0 @@ -import CategoryRepository from "../../../../domain/repositories/category.repository"; -import ListCategoriesInput from "./list-categories.input"; - -export default class ListCategoriesUseCase { - constructor(private categoryRepository: CategoryRepository) {} - - async execute( - input: ListCategoriesInput - ): Promise { - const categories = await this.categoryRepository.paginate(input); - const output: ListCategoriesOutput = { - items: categories.data.map((category) => ({ - id: category.id, - name: category.name, - description: category.description, - is_active: category.isActive, - })), - currentPage: categories.currentPage, - lastPage: categories.lastPage, - perPage: categories.perPage, - total: categories.total, - }; - return output; - } -} diff --git a/src/@core/category/domain/entities/__tests__/category.ispec.ts b/src/@core/category/domain/entities/__tests__/category.ispec.ts new file mode 100644 index 0000000..87fe71d --- /dev/null +++ b/src/@core/category/domain/entities/__tests__/category.ispec.ts @@ -0,0 +1,102 @@ +//import { SimpleValidationError } from "./../../@seedwork/errors/validation.error"; +import Category from "../category"; + +describe("Category Integration Tests", () => { + + it("should a valid entity", () => { + expect.assertions(0); + + let category = new Category({ + name: "Movie", + }); + category._validate(); + + category.props.description = null; + category.props.is_active = false; + + category._validate(); + }); + + it("should a invalid entity using name field", () => { + expect(() => new Category({ name: null })).containErrorMessages({ + name: [ + "name should not be empty", + "name must be a string", + "name must be shorter than or equal to 255 characters", + ], + }); + expect(() => new Category({ name: "" })).containErrorMessages({ + name: ["name should not be empty"], + }); + expect(() => new Category({ name: "t".repeat(256) })).containErrorMessages({ + name: ["name must be shorter than or equal to 255 characters"], + }); + }); + + it("should a invalid entity using description field", () => { + expect(() => new Category({ name: null, description: 5 as any })).containErrorMessages({ + description: [ + "description must be a string", + ], + }); + }); + + it("should a invalid entity using is_active field", () => { + expect(() => new Category({ name: null, is_active: 5 as any })).containErrorMessages({ + is_active: [ + "is_active must be a boolean value", + ], + }); + }); + + // it("should a invalid entity using name field", () => { + // expect(() => new Category({ name: null })).containErrorMessages({ + // name: [ + // "name should not be empty", + // "name must be a string", + // "name must be shorter than or equal to 255 characters", + // ], + // }); + // expect(() => new Category({ name: "" })).containErrorMessages({ + // name: ["name should not be empty"], + // }); + // }); + + // it("should a invalid entity", () => { + // let category = new Category({ + // name: "", + // }); + // expect(() => category._validate()).toThrow( + // new SimpleValidationError("The name is required") + // ); + // validar quando é string + // category = new Category({ + // name: "t".repeat(256), + // }); + // expect(() => category._validate()).toThrow( + // new SimpleValidationError( + // "The name must be less or equal than 255 characters" + // ) + // ); + + // category = new Category({ + // name: 'Movie', + // description: 5 as any, + // }); + // expect(() => category._validate()).toThrow( + // new SimpleValidationError( + // "The description must be a string" + // ) + // ); + + // category = new Category({ + // name: 'Movie', + // }); + // category.props.is_active = 1 as any; + // expect(() => category._validate()).toThrow( + // new SimpleValidationError( + // "The is_active must be a boolean" + // ) + // ); + // }); +}); diff --git a/src/@core/category/domain/entities/__tests__/category.spec.ts b/src/@core/category/domain/entities/__tests__/category.spec.ts index a5ff38d..391bfa3 100644 --- a/src/@core/category/domain/entities/__tests__/category.spec.ts +++ b/src/@core/category/domain/entities/__tests__/category.spec.ts @@ -2,10 +2,14 @@ import Category from "../category"; describe("Category Unit Tests", () => { + beforeEach(() => { + Category.prototype.validate = jest.fn() + }) test("constructor of Category", () => { const category1 = new Category({ name: "Movie", }); + expect(Category.prototype.validate).toBeCalled(); expect(category1.props).toMatchObject({ name: "Movie", @@ -36,108 +40,13 @@ describe("Category Unit Tests", () => { expect(category3.props.is_active).toBeTruthy(); }); - it("should a valid entity", () => { - expect.assertions(0); - - let category = new Category({ - name: "Movie", - }); - category._validate(); - - category.props.description = null; - category.props.is_active = false; - - category._validate(); - }); - - it("should a invalid entity using name field", () => { - expect(() => new Category({ name: null })).containErrorMessages({ - name: [ - "name should not be empty", - "name must be a string", - "name must be shorter than or equal to 255 characters", - ], - }); - expect(() => new Category({ name: "" })).containErrorMessages({ - name: ["name should not be empty"], - }); - expect(() => new Category({ name: "t".repeat(256) })).containErrorMessages({ - name: ["name must be shorter than or equal to 255 characters"], - }); - }); - - it("should a invalid entity using description field", () => { - expect(() => new Category({ name: null, description: 5 as any })).containErrorMessages({ - description: [ - "description must be a string", - ], - }); - }); - - it("should a invalid entity using is_active field", () => { - expect(() => new Category({ name: null, is_active: 5 as any })).containErrorMessages({ - is_active: [ - "is_active must be a boolean value", - ], - }); - }); - - // it("should a invalid entity using name field", () => { - // expect(() => new Category({ name: null })).containErrorMessages({ - // name: [ - // "name should not be empty", - // "name must be a string", - // "name must be shorter than or equal to 255 characters", - // ], - // }); - // expect(() => new Category({ name: "" })).containErrorMessages({ - // name: ["name should not be empty"], - // }); - // }); - - // it("should a invalid entity", () => { - // let category = new Category({ - // name: "", - // }); - // expect(() => category._validate()).toThrow( - // new SimpleValidationError("The name is required") - // ); - // category = new Category({ - // name: "t".repeat(256), - // }); - // expect(() => category._validate()).toThrow( - // new SimpleValidationError( - // "The name must be less or equal than 255 characters" - // ) - // ); - - // category = new Category({ - // name: 'Movie', - // description: 5 as any, - // }); - // expect(() => category._validate()).toThrow( - // new SimpleValidationError( - // "The description must be a string" - // ) - // ); - - // category = new Category({ - // name: 'Movie', - // }); - // category.props.is_active = 1 as any; - // expect(() => category._validate()).toThrow( - // new SimpleValidationError( - // "The is_active must be a boolean" - // ) - // ); - // }); - it("should active a category", () => { const category = new Category({ name: "Filmes", is_active: false, }); category.activate(); + expect(Category.prototype.validate).toBeCalled(); expect(category.is_active).toBeTruthy(); }); @@ -147,6 +56,7 @@ describe("Category Unit Tests", () => { is_active: true, }); category.deactivate(); + expect(Category.prototype.validate).toBeCalled(); expect(category.is_active).toBeFalsy(); }); @@ -155,6 +65,7 @@ describe("Category Unit Tests", () => { name: "Movie", }); category.update("Documentary", "some description"); + expect(Category.prototype.validate).toBeCalledTimes(2); expect(category.name).toBe("Documentary"); expect(category.description).toBe("some description"); }); diff --git a/src/@core/category/domain/entities/category.ts b/src/@core/category/domain/entities/category.ts index d5711dd..f2d8023 100644 --- a/src/@core/category/domain/entities/category.ts +++ b/src/@core/category/domain/entities/category.ts @@ -1,5 +1,5 @@ -import AggregateRoot from "../../../@seedwork/domain/entity/aggregate-root"; -import UniqueEntityId from "../../../@seedwork/domain/entity/unique-entity-id"; +import entity from "../../../@seedwork/domain/entity/aggregate-root"; +import UniqueEntityId from "../../../@seedwork/domain/value-objects/unique-entity-id"; import CategoryValidatorFactory from "../validators/category.validator"; import ValidatorRules from "../../../@seedwork/domain/validators/validator-rules"; @@ -9,8 +9,7 @@ export type CategoryProperties = { is_active?: boolean; created_at?: Date; }; - -export default class Category extends AggregateRoot { +export class Category extends entity { constructor(readonly props: CategoryProperties, id?: UniqueEntityId) { super(props, id); this.description = this.props.description; @@ -37,7 +36,7 @@ export default class Category extends AggregateRoot { } validate() { - CategoryValidatorFactory.create().validate(this.props); + CategoryValidatorFactory.create().validate({ id: this._id, ...this.props }); } activate(): true { @@ -66,3 +65,5 @@ export default class Category extends AggregateRoot { return this.props.is_active; } } + +export default Category; \ No newline at end of file diff --git a/src/@core/category/domain/repositories/category.repository.ts b/src/@core/category/domain/repositories/category.repository.ts index 42484d5..a46efaf 100644 --- a/src/@core/category/domain/repositories/category.repository.ts +++ b/src/@core/category/domain/repositories/category.repository.ts @@ -1,5 +1,28 @@ -import RepositoryInterface from "../../../@seedwork/domain/repository-interface"; +import { + SearchableRepositoryInterface, + SearchProps as DefaultSearchProps, + SearchParams as DefaultSearchParams, + SearchResult as DefaultSearchResult, +} from "../../../@seedwork/domain/repository/repository-contracts"; import Category from "../entities/category"; -export default interface CategoryRepository - extends RepositoryInterface {} +export type CategorySearchFilter = string; + +export interface SearchProps extends DefaultSearchProps {} +export class SearchParams extends DefaultSearchParams {} +export class SearchResult extends DefaultSearchResult< + Category, + CategorySearchFilter +> {} + +export interface CategoryRepository + extends SearchableRepositoryInterface< + Category, + CategorySearchFilter, + SearchParams, + SearchResult + > { + search(props: SearchParams): Promise; +} + +export default CategoryRepository; \ No newline at end of file diff --git a/src/@core/category/domain/validators/__tests__/category.validator.spec.ts b/src/@core/category/domain/validators/__tests__/category.validator.spec.ts index 6f3e40f..93bf669 100644 --- a/src/@core/category/domain/validators/__tests__/category.validator.spec.ts +++ b/src/@core/category/domain/validators/__tests__/category.validator.spec.ts @@ -1,3 +1,4 @@ +import UniqueEntityId from "../../../../@seedwork/domain/value-objects/unique-entity-id"; import CategoryValidatorFactory, { CategoryValidator, } from "./../category.validator"; @@ -8,6 +9,27 @@ describe("Category validators tests", () => { beforeEach(() => { validator = CategoryValidatorFactory.create(); }); + + test("invalidation cases for id field", () => { + expect({ validator, data: null }).containErrorMessages({ + id: [ + "id should not be empty", + "id must be an instance of UniqueEntityId", + ], + }); + + expect({ validator, data: { id: "" } }).containErrorMessages({ + id: [ + "id should not be empty", + "id must be an instance of UniqueEntityId", + ], + }); + + expect({ validator, data: { id: 5 } }).containErrorMessages({ + id: ["id must be an instance of UniqueEntityId"], + }); + }); + test("invalidation cases for name field", () => { expect({ validator, data: null }).containErrorMessages({ name: [ @@ -58,11 +80,27 @@ describe("Category validators tests", () => { test("validate cases for fields", () => { expect.assertions(0); - validator.validate({ name: "test" }); - validator.validate({ name: "test", description: undefined }); - validator.validate({ name: "test", description: null }); - validator.validate({ name: "test", is_active: true }); - validator.validate({ name: "test", is_active: false }); + validator.validate({ id: new UniqueEntityId(), name: "test" }); + validator.validate({ + id: new UniqueEntityId(), + name: "test", + description: undefined, + }); + validator.validate({ + id: new UniqueEntityId(), + name: "test", + description: null, + }); + validator.validate({ + id: new UniqueEntityId(), + name: "test", + is_active: true, + }); + validator.validate({ + id: new UniqueEntityId(), + name: "test", + is_active: false, + }); }); }); }); diff --git a/src/@core/category/domain/validators/category.validator.ts b/src/@core/category/domain/validators/category.validator.ts index 4305e5a..55cc673 100644 --- a/src/@core/category/domain/validators/category.validator.ts +++ b/src/@core/category/domain/validators/category.validator.ts @@ -1,14 +1,20 @@ +import domainValidators from '../../../@seedwork/domain/value-objects/unique-entity-id'; import { CategoryProperties } from "../entities/category"; import ClassValidator from "../../../@seedwork/domain/validators/class.validator"; -import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator"; +import { IsBoolean, IsInstance, IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator"; -export default class CategoryValidatorFactory { +export class CategoryValidatorFactory { static create() { return new CategoryValidator(); } } +export default CategoryValidatorFactory + export class CategoryRules { + @IsInstance(domainValidators) + @IsNotEmpty() + id: domainValidators @MaxLength(255) @IsString() @@ -23,13 +29,13 @@ export class CategoryRules { @IsOptional() is_active: boolean - constructor(data: CategoryProperties) { + constructor(data: CategoryProperties & {id: domainValidators}) { Object.assign(this, data); } } export class CategoryValidator extends ClassValidator { - validate(data: CategoryProperties): void { + validate(data: CategoryProperties & {id: domainValidators}): void { const rules = new CategoryRules(data); super._validate(rules); } diff --git a/src/@core/category/index.ts b/src/@core/category/index.ts new file mode 100644 index 0000000..2153cf3 --- /dev/null +++ b/src/@core/category/index.ts @@ -0,0 +1,58 @@ +import * as entity from "./domain/entities/category"; +import * as domainRepository from "./domain/repositories/category.repository"; +import * as domainValidators from "./domain/validators/category.validator"; +import * as infraRepository from "./infra/repositories/category-in-memory.repository"; +import * as listUseCase from "./application/use-cases/list-categories.use-case"; +import * as createUseCase from "./application/use-cases/create-category.use-case"; + +export namespace CategoryModule { + export namespace Application { + export namespace UseCases { + export namespace Category { + export namespace ListUseCase { + export import Input = listUseCase.Input; + export import Output = listUseCase.Output; + export import UseCase = listUseCase.ListCategoriesUseCase; + } + export namespace CreateUseCase { + export import Input = createUseCase.Input; + export import Output = createUseCase.Output; + export import UseCase = createUseCase.CreateCategoryUseCase; + } + } + } + } + + export namespace Domain { + export namespace Entities { + export import Category = entity.Category; + export import CategoryProperties = entity.CategoryProperties; + } + + export namespace Repositories { + export namespace Category { + export import SearchFilter = domainRepository.CategorySearchFilter; + export import SearchProps = domainRepository.SearchProps; + export import SearchParams = domainRepository.SearchParams; + export import SearchResult = domainRepository.SearchResult; + export import Repository = domainRepository.CategoryRepository; + } + } + + export namespace Validators { + export namespace Category { + export import ValidatorFactory = domainValidators.CategoryValidatorFactory; + export import Rules = domainValidators.CategoryRules; + export import Validator = domainValidators.CategoryValidator; + } + } + } + + export namespace Infra { + export namespace Repositories { + export namespace Category { + export import InMemoryRepository = infraRepository.CategoryInMemoryRepository; + } + } + } +} diff --git a/src/@core/category/infra/repositories/__tests__/category-in-memory.repository.spec.ts b/src/@core/category/infra/repositories/__tests__/category-in-memory.repository.spec.ts new file mode 100644 index 0000000..be9fe76 --- /dev/null +++ b/src/@core/category/infra/repositories/__tests__/category-in-memory.repository.spec.ts @@ -0,0 +1,61 @@ +import Category from "../../../domain/entities/category"; +import CategoryInMemoryRepository from "../category-in-memory.repository"; + +describe("CategoryInMemoryRepository", () => { + let repository: CategoryInMemoryRepository; + + beforeEach(() => (repository = new CategoryInMemoryRepository())); + it("should no filter items when filter object is null", async () => { + const items = [new Category({ name: "test" })]; + const filterSpy = jest.spyOn(items, "filter" as any); + + let itemsFiltered = await repository["applyFilter"](items); + expect(filterSpy).not.toHaveBeenCalled(); + expect(itemsFiltered).toStrictEqual(itemsFiltered); + }); + + it("should filter items using filter parameter", async () => { + const items = [ + new Category({ name: "test" }), + new Category({ name: "TEST" }), + new Category({ name: "fake" }), + ]; + const filterSpy = jest.spyOn(items, "filter" as any); + + let itemsFiltered = await repository["applyFilter"](items, "TEST"); + expect(filterSpy).toHaveBeenCalledTimes(1); + expect(itemsFiltered).toStrictEqual([items[0], items[1]]); + }); + + it("should sort by created_at when sort param is null", async () => { + const created_at = new Date(); + const items = [ + new Category({ name: "test", created_at: created_at }), + new Category({ + name: "TEST", + created_at: new Date(created_at.getTime() + 100), + }), + new Category({ + name: "fake", + created_at: new Date(created_at.getTime() + 200), + }), + ]; + + let itemsSorted = await repository["applySort"](items); + expect(itemsSorted).toStrictEqual([items[2], items[1], items[0]]); + }); + + it("should sort by name", async () => { + const items = [ + new Category({ name: "c" }), + new Category({ name: "b" }), + new Category({ name: "a" }), + ]; + + let itemsSorted = await repository["applySort"](items, "name", "asc"); + expect(itemsSorted).toStrictEqual([items[2], items[1], items[0]]); + + itemsSorted = await repository["applySort"](items, "name", "desc"); + expect(itemsSorted).toStrictEqual([items[0], items[1], items[2]]); + }); +}); diff --git a/src/@core/category/infra/repositories/category-in-memory.repository.ts b/src/@core/category/infra/repositories/category-in-memory.repository.ts new file mode 100644 index 0000000..1abbf53 --- /dev/null +++ b/src/@core/category/infra/repositories/category-in-memory.repository.ts @@ -0,0 +1,35 @@ +import Category from "../../domain/entities/category"; +import { InMemorySearchableRepository } from "../../../@seedwork/domain/repository/in-memory.repository"; +import CategoryRepository from "../../domain/repositories/category.repository"; +import { SortDirection } from "../../../@seedwork/domain/repository/repository-contracts"; + +export class CategoryInMemoryRepository + extends InMemorySearchableRepository + implements CategoryRepository +{ + sortableFields: string[] = ["name", "created_at"]; + + protected async applyFilter( + items: Category[], + filter?: string + ): Promise { + if (filter) { + return items.filter((i) => { + return i.props.name.toLowerCase().includes(filter.toLowerCase()); + }); + } + return items; + } + + protected async applySort( + items: Category[], + sort?: string, + sort_dir?: SortDirection + ): Promise { + return !sort + ? super.applySort(items, "created_at", "desc") + : super.applySort(items, sort, sort_dir); + } +} + +export default CategoryInMemoryRepository; \ No newline at end of file diff --git a/src/@core/category/infra/repositories/in-memory/category-in-memory.repository.ts b/src/@core/category/infra/repositories/in-memory/category-in-memory.repository.ts deleted file mode 100644 index b3d8eed..0000000 --- a/src/@core/category/infra/repositories/in-memory/category-in-memory.repository.ts +++ /dev/null @@ -1,14 +0,0 @@ -import UniqueEntityId from "../../../../@seedwork/domain/entity/unique-entity-id"; -import Category, { - CategoryProperties, -} from "../../../domain/entities/category"; -import { InMemoryRepository } from "./in-memory.repository"; - -export default class CategoryInMemoryRepository extends InMemoryRepository< - Category, - CategoryProperties -> { - toEntity({ id, ...props }: CategoryProperties & { id: string }): Category { - return new Category(props, new UniqueEntityId(id)); - } -} diff --git a/src/@core/category/infra/repositories/in-memory/in-memory.repository.ts b/src/@core/category/infra/repositories/in-memory/in-memory.repository.ts deleted file mode 100644 index 5168814..0000000 --- a/src/@core/category/infra/repositories/in-memory/in-memory.repository.ts +++ /dev/null @@ -1,50 +0,0 @@ -import Entity from "../../../../@seedwork/domain/entity/entity"; -import UniqueEntityId from "../../../../@seedwork/domain/entity/unique-entity-id"; -import NotFoundError from "../../../../@seedwork/domain/errors/not-found.error"; -import RepositoryInterface from "../../../../@seedwork/domain/repository-interface"; - -type Model = { id: string } & ModelProps; - -export abstract class InMemoryRepository, MP> - implements RepositoryInterface -{ - public items: Model[] = []; - - async insert(entity: E): Promise { - this.items.push(entity.toJSON()); - return entity; - } - - async findById(id: string | UniqueEntityId): Promise { - const _id = `${id}`; - const item = await this._get(_id); - return this.toEntity(item); - } - - async findAll(): Promise { - throw this.items.map((i) => this.toEntity(i)); - } - - async update(entity: E): Promise { - await this._get(entity.id); - this.items.push(entity.toJSON()); - return entity; - } - - async delete(id: string | UniqueEntityId): Promise { - const _id = `${id}`; - const item = await this._get(_id); - const indexFound = this.items.findIndex((i) => i.id === item.id); - this.items.splice(indexFound, 1); - } - - private async _get(id: string): Promise { - const item = this.items.find((i) => i.id === id); - if (!item) { - throw new NotFoundError(`Entity Not Found using ID ${id}`); - } - return item; - } - - abstract toEntity(model: any): E; -}