From 3f4eedbc031446be59940e50dc375af3acc9f8ab Mon Sep 17 00:00:00 2001 From: YgorPereira Date: Tue, 21 Apr 2026 11:07:25 -0300 Subject: [PATCH 1/4] fix: history returned in toPrimitives --- .../ticket/application/useCases/create/create.usecase.ts | 9 ++------- .../src/modules/ticket/domain/entities/ticket.entity.ts | 4 ++-- .../ticket/presentation/controllers/ticket.controller.ts | 2 -- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/backend/src/modules/ticket/application/useCases/create/create.usecase.ts b/backend/src/modules/ticket/application/useCases/create/create.usecase.ts index 8135992..a9ed312 100644 --- a/backend/src/modules/ticket/application/useCases/create/create.usecase.ts +++ b/backend/src/modules/ticket/application/useCases/create/create.usecase.ts @@ -1,8 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { - Ticket, - TicketStatus, -} from '../../../domain/entities/ticket.entity'; +import { Ticket, TicketStatus } from '../../../domain/entities/ticket.entity'; import { ITicketRepository } from '../../../domain/repository/ticket.repository.interface'; import { TriageService } from '../../../../triage/application/triage.service'; @@ -31,9 +28,7 @@ export class CreateTicketUseCase { ) {} async execute(input: CreateTicketInput): Promise { - const triageResult = await this.triageService.classify( - input.description, - ); + const triageResult = await this.triageService.classify(input.description); const ticket = Ticket.create({ ...input, diff --git a/backend/src/modules/ticket/domain/entities/ticket.entity.ts b/backend/src/modules/ticket/domain/entities/ticket.entity.ts index 9369cf8..75f86c7 100644 --- a/backend/src/modules/ticket/domain/entities/ticket.entity.ts +++ b/backend/src/modules/ticket/domain/entities/ticket.entity.ts @@ -110,6 +110,7 @@ export class Ticket { ticket._id = randomUUID(); ticket.createdAt = new Date(); + ticket.priority = TicketPriority.LOW; ticket.addHistory({ event: TicketEvents.OPEN_NEW_TICKET, @@ -148,8 +149,6 @@ export class Ticket { ticket._id = props._id; - ticket.priority = TicketPriority.LOW; - ticket._agentId = props.agentId ?? null; ticket._groupId = props.groupId ?? null; ticket.attachmentsUrls = props.fileUrls ?? []; @@ -183,6 +182,7 @@ export class Ticket { createdAt: this.createdAt, updatedAt: this.updatedAt, closedAt: this.closedAt, + history: this.history, }; } diff --git a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts index 8556567..61f0d2a 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts @@ -22,11 +22,9 @@ import { ApiBody, ApiOperation, ApiParam, - ApiQuery, ApiResponse, ApiTags, } from '@nestjs/swagger'; -import { randomUUID } from 'crypto'; @ApiTags('Ticket') @Controller('tickets') From f2f5587630c226ee82605b98c40ab7b1f9009ddd Mon Sep 17 00:00:00 2001 From: YgorPereira Date: Tue, 21 Apr 2026 13:16:37 -0300 Subject: [PATCH 2/4] feat: get history filtered use case --- backend/package-lock.json | 8 + backend/package.json | 1 + .../getHistoryFiltered.usecase.spec.ts | 157 ++++++++++++++++++ .../getHistoryFiltered.usecase.ts | 67 ++++++++ 4 files changed, 233 insertions(+) create mode 100644 backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.spec.ts create mode 100644 backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index d9d427b..366f3fe 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -39,6 +39,7 @@ "@nestjs/testing": "^11.0.1", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/mocha": "^10.0.10", "@types/morgan": "^1.9.10", "@types/node": "^22.10.7", "@types/passport-jwt": "^4.0.1", @@ -3759,6 +3760,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/morgan": { "version": "1.9.10", "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", diff --git a/backend/package.json b/backend/package.json index 9bf4ec0..2ce33a7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -51,6 +51,7 @@ "@nestjs/testing": "^11.0.1", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/mocha": "^10.0.10", "@types/morgan": "^1.9.10", "@types/node": "^22.10.7", "@types/passport-jwt": "^4.0.1", diff --git a/backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.spec.ts b/backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.spec.ts new file mode 100644 index 0000000..27bf7ab --- /dev/null +++ b/backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.spec.ts @@ -0,0 +1,157 @@ +import { randomUUID } from 'crypto'; +import { ITicketRepository } from '../../../domain/repository/ticket.repository.interface'; +import { + Ticket, + TicketEvents, + TicketStatus, +} from '../../../domain/entities/ticket.entity'; +import { + GetHistoryFilteredUseCase, +} from './getHistoryFiltered.usecase'; + +describe('GetHistoryFilteredUseCase', () => { + let repository: jest.Mocked; + let useCase: GetHistoryFilteredUseCase; + let ticket: Ticket; + + beforeEach(() => { + ticket = Ticket.create({ + title: 'titulo do ticket', + category: 'iot', + description: 'descricao do ticket', + clientId: randomUUID(), + }); + + repository = { + readById: jest.fn(), + } as unknown as jest.Mocked; + + useCase = new GetHistoryFilteredUseCase(repository); + }); + + it('should return full history when no filters are provided', async () => { + repository.readById.mockResolvedValue(ticket); + + const output = await useCase.execute(ticket.id, TicketEvents.OPEN_NEW_TICKET, {}); + + expect(output).toBeDefined(); + expect(output.id).toBe(ticket.id); + expect(Array.isArray(output.history)).toBe(true); + expect(output.history).toHaveLength(1); + expect(output.history[0].status).toBe(TicketStatus.OPEN); + + expect(repository.readById).toHaveBeenCalledTimes(1); + expect(repository.readById).toHaveBeenCalledWith(ticket.id); + }); + + it('should filter history by status', async () => { + ticket.assignToAgent(randomUUID()); + repository.readById.mockResolvedValue(ticket); + + const output = await useCase.execute(ticket.id, TicketEvents.NEW_AGENT, { + status: TicketStatus.IN_PROGRESS, + }); + + expect(output.history.every((e) => e.status === TicketStatus.IN_PROGRESS)).toBe(true); + expect(output.history).toHaveLength(1); + }); + + it('should filter history by responsibleAgent', async () => { + const agentId = randomUUID(); + ticket.assignToAgent(agentId); + repository.readById.mockResolvedValue(ticket); + + const output = await useCase.execute(ticket.id, TicketEvents.NEW_AGENT, { + responsibleAgent: agentId, + }); + + expect(output.history.every((e) => e.responsibleAgent === agentId)).toBe(true); + expect(output.history).toHaveLength(1); + }); + + it('should filter history by event', async () => { + ticket.assignToAgent(randomUUID()); + repository.readById.mockResolvedValue(ticket); + + const output = await useCase.execute(ticket.id, TicketEvents.NEW_AGENT, { + event: TicketEvents.NEW_AGENT, + }); + + expect(output.history.every((e) => e.event === TicketEvents.NEW_AGENT)).toBe(true); + expect(output.history).toHaveLength(1); + }); + + it('should filter history by fromDate', async () => { + const before = new Date(Date.now() - 10000); + ticket.assignToAgent(randomUUID()); + repository.readById.mockResolvedValue(ticket); + + const output = await useCase.execute(ticket.id, TicketEvents.NEW_AGENT, { + fromDate: before, + }); + + expect(output.history.every((e) => e.occurredAt >= before)).toBe(true); + expect(output.history.length).toBeGreaterThanOrEqual(1); + }); + + it('should return empty history when fromDate is in the future', async () => { + const future = new Date(Date.now() + 99999999); + repository.readById.mockResolvedValue(ticket); + + const output = await useCase.execute(ticket.id, TicketEvents.OPEN_NEW_TICKET, { + fromDate: future, + }); + + expect(output.history).toHaveLength(0); + }); + + it('should combine multiple filters correctly', async () => { + const agentId = randomUUID(); + const before = new Date(Date.now() - 10000); + ticket.assignToAgent(agentId); + repository.readById.mockResolvedValue(ticket); + + const output = await useCase.execute(ticket.id, TicketEvents.NEW_AGENT, { + status: TicketStatus.IN_PROGRESS, + responsibleAgent: agentId, + event: TicketEvents.NEW_AGENT, + fromDate: before, + }); + + expect(output.history).toHaveLength(1); + expect(output.history[0].status).toBe(TicketStatus.IN_PROGRESS); + expect(output.history[0].responsibleAgent).toBe(agentId); + expect(output.history[0].event).toBe(TicketEvents.NEW_AGENT); + }); + + it('should return empty history when no entries match filters', async () => { + repository.readById.mockResolvedValue(ticket); + + const output = await useCase.execute(ticket.id, TicketEvents.OPEN_NEW_TICKET, { + status: TicketStatus.CLOSED, + }); + + expect(output.history).toHaveLength(0); + }); + + it('should throw when ticket is not found', async () => { + repository.readById.mockResolvedValue(null); + + await expect( + useCase.execute('non-existent-id', TicketEvents.OPEN_NEW_TICKET, {}), + ).rejects.toThrow('Ticket not found'); + + expect(repository.readById).toHaveBeenCalledWith('non-existent-id'); + }); + + it('should not expose domain methods in output', async () => { + repository.readById.mockResolvedValue(ticket); + + const output = await useCase.execute(ticket.id, TicketEvents.OPEN_NEW_TICKET, {}); + + expect(output).not.toHaveProperty('toPrimitives'); + expect(output).not.toHaveProperty('escalate'); + expect(output).not.toHaveProperty('assignToAgent'); + expect(output).not.toHaveProperty('close'); + }); +}); \ No newline at end of file diff --git a/backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.ts b/backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.ts new file mode 100644 index 0000000..829ff44 --- /dev/null +++ b/backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { + TicketEvents, + TicketStatus, +} from '../../../domain/entities/ticket.entity'; +import { ITicketRepository } from '../../../domain/repository/ticket.repository.interface'; + +export interface TicketHistoryEntryOutput { + event: TicketEvents; + responsibleAgent: string | null; + status: TicketStatus; + message: string; + solution?: string | null; + occurredAt: Date; +} + +export interface GetHistoryTicketOutput { + id: string; + history: TicketHistoryEntryOutput[]; +} + +@Injectable() +export class GetHistoryFilteredUseCase { + constructor(private readonly repository: ITicketRepository) {} + + async execute( + id: string, + event: TicketEvents, + filters: { + status?: TicketStatus; + responsibleAgent?: string; + event?: TicketEvents; + fromDate?: Date; + }, + ): Promise { + const foundTicket = await this.repository.readById(id); + + if (!foundTicket) { + throw new Error('Ticket not found'); + } + + let history = foundTicket.history; + + if (filters.status) { + history = history.filter((entry) => entry.status === filters.status); + } + + if (filters.responsibleAgent) { + history = history.filter( + (entry) => entry.responsibleAgent === filters.responsibleAgent, + ); + } + + if (filters.event) { + history = history.filter((entry) => entry.event === filters.event); + } + + if (filters.fromDate) { + history = history.filter((entry) => entry.occurredAt >= filters.fromDate); + } + + return { + id: foundTicket.id, + history: [...history], + }; + } +} From 4ca56bcae7f801aa7d57cc554dd358008ffa7b64 Mon Sep 17 00:00:00 2001 From: YgorPereira Date: Tue, 21 Apr 2026 16:23:06 -0300 Subject: [PATCH 3/4] feat: add filters on get history route --- .../getHistoryFiltered.usecase.spec.ts | 46 +++--- .../getHistoryFiltered.usecase.ts | 4 +- .../controllers/ticket.controller.spec.ts | 147 +++++++++++++++++- .../controllers/ticket.controller.ts | 35 ++++- backend/src/modules/ticket/ticket.module.ts | 2 + 5 files changed, 205 insertions(+), 29 deletions(-) diff --git a/backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.spec.ts b/backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.spec.ts index 27bf7ab..b23610b 100644 --- a/backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.spec.ts +++ b/backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.spec.ts @@ -5,9 +5,7 @@ import { TicketEvents, TicketStatus, } from '../../../domain/entities/ticket.entity'; -import { - GetHistoryFilteredUseCase, -} from './getHistoryFiltered.usecase'; +import { GetHistoryFilteredUseCase } from './getHistoryFiltered.usecase'; describe('GetHistoryFilteredUseCase', () => { let repository: jest.Mocked; @@ -32,7 +30,11 @@ describe('GetHistoryFilteredUseCase', () => { it('should return full history when no filters are provided', async () => { repository.readById.mockResolvedValue(ticket); - const output = await useCase.execute(ticket.id, TicketEvents.OPEN_NEW_TICKET, {}); + const output = await useCase.execute( + ticket.id, + TicketEvents.OPEN_NEW_TICKET, + {}, + ); expect(output).toBeDefined(); expect(output.id).toBe(ticket.id); @@ -48,11 +50,13 @@ describe('GetHistoryFilteredUseCase', () => { ticket.assignToAgent(randomUUID()); repository.readById.mockResolvedValue(ticket); - const output = await useCase.execute(ticket.id, TicketEvents.NEW_AGENT, { + const output = await useCase.execute(ticket.id, { status: TicketStatus.IN_PROGRESS, }); - expect(output.history.every((e) => e.status === TicketStatus.IN_PROGRESS)).toBe(true); + expect( + output.history.every((e) => e.status === TicketStatus.IN_PROGRESS), + ).toBe(true); expect(output.history).toHaveLength(1); }); @@ -61,11 +65,13 @@ describe('GetHistoryFilteredUseCase', () => { ticket.assignToAgent(agentId); repository.readById.mockResolvedValue(ticket); - const output = await useCase.execute(ticket.id, TicketEvents.NEW_AGENT, { + const output = await useCase.execute(ticket.id, { responsibleAgent: agentId, }); - expect(output.history.every((e) => e.responsibleAgent === agentId)).toBe(true); + expect(output.history.every((e) => e.responsibleAgent === agentId)).toBe( + true, + ); expect(output.history).toHaveLength(1); }); @@ -73,11 +79,13 @@ describe('GetHistoryFilteredUseCase', () => { ticket.assignToAgent(randomUUID()); repository.readById.mockResolvedValue(ticket); - const output = await useCase.execute(ticket.id, TicketEvents.NEW_AGENT, { + const output = await useCase.execute(ticket.id, { event: TicketEvents.NEW_AGENT, }); - expect(output.history.every((e) => e.event === TicketEvents.NEW_AGENT)).toBe(true); + expect( + output.history.every((e) => e.event === TicketEvents.NEW_AGENT), + ).toBe(true); expect(output.history).toHaveLength(1); }); @@ -86,7 +94,7 @@ describe('GetHistoryFilteredUseCase', () => { ticket.assignToAgent(randomUUID()); repository.readById.mockResolvedValue(ticket); - const output = await useCase.execute(ticket.id, TicketEvents.NEW_AGENT, { + const output = await useCase.execute(ticket.id, { fromDate: before, }); @@ -98,7 +106,7 @@ describe('GetHistoryFilteredUseCase', () => { const future = new Date(Date.now() + 99999999); repository.readById.mockResolvedValue(ticket); - const output = await useCase.execute(ticket.id, TicketEvents.OPEN_NEW_TICKET, { + const output = await useCase.execute(ticket.id, { fromDate: future, }); @@ -111,7 +119,7 @@ describe('GetHistoryFilteredUseCase', () => { ticket.assignToAgent(agentId); repository.readById.mockResolvedValue(ticket); - const output = await useCase.execute(ticket.id, TicketEvents.NEW_AGENT, { + const output = await useCase.execute(ticket.id, { status: TicketStatus.IN_PROGRESS, responsibleAgent: agentId, event: TicketEvents.NEW_AGENT, @@ -127,7 +135,7 @@ describe('GetHistoryFilteredUseCase', () => { it('should return empty history when no entries match filters', async () => { repository.readById.mockResolvedValue(ticket); - const output = await useCase.execute(ticket.id, TicketEvents.OPEN_NEW_TICKET, { + const output = await useCase.execute(ticket.id, { status: TicketStatus.CLOSED, }); @@ -137,9 +145,9 @@ describe('GetHistoryFilteredUseCase', () => { it('should throw when ticket is not found', async () => { repository.readById.mockResolvedValue(null); - await expect( - useCase.execute('non-existent-id', TicketEvents.OPEN_NEW_TICKET, {}), - ).rejects.toThrow('Ticket not found'); + await expect(useCase.execute('non-existent-id', {})).rejects.toThrow( + 'Ticket not found', + ); expect(repository.readById).toHaveBeenCalledWith('non-existent-id'); }); @@ -147,11 +155,11 @@ describe('GetHistoryFilteredUseCase', () => { it('should not expose domain methods in output', async () => { repository.readById.mockResolvedValue(ticket); - const output = await useCase.execute(ticket.id, TicketEvents.OPEN_NEW_TICKET, {}); + const output = await useCase.execute(ticket.id, {}); expect(output).not.toHaveProperty('toPrimitives'); expect(output).not.toHaveProperty('escalate'); expect(output).not.toHaveProperty('assignToAgent'); expect(output).not.toHaveProperty('close'); }); -}); \ No newline at end of file +}); diff --git a/backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.ts b/backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.ts index 829ff44..1f6c189 100644 --- a/backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.ts +++ b/backend/src/modules/ticket/application/useCases/getHistoryFiltered/getHistoryFiltered.usecase.ts @@ -25,7 +25,6 @@ export class GetHistoryFilteredUseCase { async execute( id: string, - event: TicketEvents, filters: { status?: TicketStatus; responsibleAgent?: string; @@ -56,7 +55,8 @@ export class GetHistoryFilteredUseCase { } if (filters.fromDate) { - history = history.filter((entry) => entry.occurredAt >= filters.fromDate); + const fromDate = filters.fromDate; + history = history.filter((entry) => entry.occurredAt >= fromDate); } return { diff --git a/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts b/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts index 31c9882..5abbf45 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.spec.ts @@ -10,12 +10,10 @@ import { ReadByIdTicketUseCase } from '../../application/useCases/readById/readB import { TicketController } from './ticket.controller'; import { DeleteTicketUseCase } from '../../application/useCases/delete/delete.usecase'; import { INestApplication, ValidationPipe } from '@nestjs/common'; -import { - Ticket, - TicketStatus, -} from '../../domain/entities/ticket.entity'; +import { Ticket, TicketEvents, TicketStatus } from '../../domain/entities/ticket.entity'; import { randomUUID } from 'crypto'; import request from 'supertest'; +import { GetHistoryFilteredUseCase } from '../../application/useCases/getHistoryFiltered/getHistoryFiltered.usecase'; describe('TicketController', () => { let app: INestApplication; @@ -25,6 +23,7 @@ describe('TicketController', () => { let readAllUseCase: ReadAllTicketUseCase; let readByIdUseCase: ReadByIdTicketUseCase; let getHistoryUseCase: GetHistoryTicketUseCase; + let getHistoryFilteredUseCase: GetHistoryFilteredUseCase; let newAgentUseCase: NewAgentTicketUseCase; let escalateTicketUseCase: EscalateTicketUseCase; let deleteUseCase: DeleteTicketUseCase; @@ -35,7 +34,7 @@ describe('TicketController', () => { description: 'descricao do chamado 1', clientId: randomUUID(), }; - const ticket = Ticket.create(ticketData); + let ticket: Ticket; beforeAll(async () => { const modulesFixture: TestingModule = await Test.createTestingModule({ @@ -69,6 +68,14 @@ describe('TicketController', () => { provide: DeleteTicketUseCase, useValue: { execute: jest.fn() }, }, + { + provide: GetHistoryFilteredUseCase, + useValue: { execute: jest.fn() }, + }, + { + provide: GetHistoryTicketUseCase, + useValue: { execute: jest.fn() }, + }, ], }).compile(); @@ -88,12 +95,15 @@ describe('TicketController', () => { readAllUseCase = modulesFixture.get(ReadAllTicketUseCase); readByIdUseCase = modulesFixture.get(ReadByIdTicketUseCase); getHistoryUseCase = modulesFixture.get(GetHistoryTicketUseCase); + getHistoryFilteredUseCase = modulesFixture.get(GetHistoryFilteredUseCase); newAgentUseCase = modulesFixture.get(NewAgentTicketUseCase); escalateTicketUseCase = modulesFixture.get(EscalateTicketUseCase); deleteUseCase = modulesFixture.get(DeleteTicketUseCase); }); beforeEach(() => { + ticket = Ticket.create(ticketData); + jest.clearAllMocks(); }); @@ -345,4 +355,131 @@ describe('TicketController', () => { expect(deleteUseCase.execute).toHaveBeenCalledTimes(1); }); + + it('GET /tickets/:id/history?status= should return filtered history by status', async () => { + ticket.assignToAgent(randomUUID()); + + const filteredHistory = ticket.history.filter( + (e) => e.status === TicketStatus.IN_PROGRESS, + ); + + jest.spyOn(getHistoryFilteredUseCase, 'execute').mockResolvedValue({ + id: ticket.id, + history: filteredHistory, + }); + + const response = await request(httpServer) + .get(`/tickets/${ticket.id}/history?status=IN_PROGRESS`) + .expect(200); + + expect( + response.body.history.every((e) => e.status === TicketStatus.IN_PROGRESS), + ).toBe(true); + expect(getHistoryFilteredUseCase.execute).toHaveBeenCalledTimes(1); + expect(getHistoryFilteredUseCase.execute).toHaveBeenCalledWith( + ticket.id, + expect.objectContaining({ status: TicketStatus.IN_PROGRESS }), + ); + expect(getHistoryUseCase.execute).not.toHaveBeenCalled(); + }); + + it('GET /tickets/:id/history?event= should return filtered history by event', async () => { + const filteredHistory = ticket.history.filter( + (e) => e.event === TicketEvents.NEW_AGENT, + ); + + jest.spyOn(getHistoryFilteredUseCase, 'execute').mockResolvedValue({ + id: ticket.id, + history: filteredHistory, + }); + + const response = await request(httpServer) + .get(`/tickets/${ticket.id}/history?event=NEW_AGENT`) + .expect(200); + + expect( + response.body.history.every((e) => e.event === TicketEvents.NEW_AGENT), + ).toBe(true); + expect(getHistoryFilteredUseCase.execute).toHaveBeenCalledTimes(1); + expect(getHistoryUseCase.execute).not.toHaveBeenCalled(); + }); + + it('GET /tickets/:id/history?responsibleAgent= should return filtered history by agent', async () => { + const agentId = randomUUID(); + ticket.assignToAgent(agentId); + + const filteredHistory = ticket.history.filter( + (e) => e.responsibleAgent === agentId, + ); + + jest.spyOn(getHistoryFilteredUseCase, 'execute').mockResolvedValue({ + id: ticket.id, + history: filteredHistory, + }); + + const response = await request(httpServer) + .get(`/tickets/${ticket.id}/history?responsibleAgent=${agentId}`) + .expect(200); + + expect( + response.body.history.every((e) => e.responsibleAgent === agentId), + ).toBe(true); + expect(getHistoryFilteredUseCase.execute).toHaveBeenCalledTimes(1); + expect(getHistoryUseCase.execute).not.toHaveBeenCalled(); + }); + + it('GET /tickets/:id/history?fromDate= should return filtered history by date', async () => { + const fromDate = new Date(Date.now() - 10000).toISOString(); + + jest.spyOn(getHistoryFilteredUseCase, 'execute').mockResolvedValue({ + id: ticket.id, + history: [...ticket.history], + }); + + const response = await request(httpServer) + .get(`/tickets/${ticket.id}/history?fromDate=${fromDate}`) + .expect(200); + + expect(Array.isArray(response.body.history)).toBe(true); + expect(getHistoryFilteredUseCase.execute).toHaveBeenCalledTimes(1); + expect(getHistoryUseCase.execute).not.toHaveBeenCalled(); + }); + + it('GET /tickets/:id/history with multiple filters should call filtered use case', async () => { + const agentId = randomUUID(); + + jest.spyOn(getHistoryFilteredUseCase, 'execute').mockResolvedValue({ + id: ticket.id, + history: [], + }); + + await request(httpServer) + .get( + `/tickets/${ticket.id}/history?status=IN_PROGRESS&event=NEW_AGENT&responsibleAgent=${agentId}`, + ) + .expect(200); + + expect(getHistoryFilteredUseCase.execute).toHaveBeenCalledTimes(1); + expect(getHistoryFilteredUseCase.execute).toHaveBeenCalledWith( + ticket.id, + expect.objectContaining({ + status: TicketStatus.IN_PROGRESS, + event: TicketEvents.NEW_AGENT, + responsibleAgent: agentId, + }), + ); + expect(getHistoryUseCase.execute).not.toHaveBeenCalled(); + }); + + it('GET /tickets/:id/history without filters should call plain history use case', async () => { + jest.spyOn(getHistoryUseCase, 'execute').mockResolvedValue({ + id: ticket.id, + history: [...ticket.history], + }); + + await request(httpServer).get(`/tickets/${ticket.id}/history`).expect(200); + + expect(getHistoryUseCase.execute).toHaveBeenCalledTimes(1); + expect(getHistoryFilteredUseCase.execute).not.toHaveBeenCalled(); + }); }); diff --git a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts index 61f0d2a..ef9ec6b 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts @@ -6,11 +6,15 @@ import { Param, Post, Put, + Query, } from '@nestjs/common'; import { CreateTicketUseCase } from '../../application/useCases/create/create.usecase'; import { DeleteTicketUseCase } from '../../application/useCases/delete/delete.usecase'; import { EscalateTicketUseCase } from '../../application/useCases/escalate/escalate.usecase'; -import { GetHistoryTicketUseCase } from '../../application/useCases/getHistory/getHistory.usecase'; +import { + GetHistoryTicketOutput, + GetHistoryTicketUseCase, +} from '../../application/useCases/getHistory/getHistory.usecase'; import { NewAgentTicketUseCase } from '../../application/useCases/newAgent/newAgent.usecase'; import { ReadAllTicketUseCase } from '../../application/useCases/readAll/readAll.usecase'; import { ReadByIdTicketUseCase } from '../../application/useCases/readById/readById.usecase'; @@ -25,6 +29,11 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; +import { + TicketEvents, + TicketStatus, +} from '../../domain/entities/ticket.entity'; +import { GetHistoryFilteredUseCase } from '../../application/useCases/getHistoryFiltered/getHistoryFiltered.usecase'; @ApiTags('Ticket') @Controller('tickets') @@ -34,6 +43,7 @@ export class TicketController { private readonly readAllUseCase: ReadAllTicketUseCase, private readonly readByIdUseCase: ReadByIdTicketUseCase, private readonly getHistoryUseCase: GetHistoryTicketUseCase, + private readonly getHistoryFilteredUseCase: GetHistoryFilteredUseCase, private readonly escalateUseCase: EscalateTicketUseCase, private readonly newAgentUseCase: NewAgentTicketUseCase, private readonly deleteUseCase: DeleteTicketUseCase, @@ -77,8 +87,27 @@ export class TicketController { @ApiOperation({ summary: 'Retorna o histórico de um ticket pelo ID' }) @ApiParam({ name: 'id', example: 'uuid-do-ticket' }) @ApiResponse({ status: 200, description: 'Histórico retornado com sucesso.' }) - async getHistoryById(@Param('id') id: string) { - const response = await this.getHistoryUseCase.execute(id); + async getHistoryById( + @Param('id') id: string, + @Query('status') status?: TicketStatus, + @Query('responsibleAgent') responsibleAgent?: string, + @Query('event') event?: TicketEvents, + @Query('fromDate') fromDate?: Date, + ) { + let response: GetHistoryTicketOutput; + + const hasFilters = status || responsibleAgent || event || fromDate; + + if (hasFilters) { + response = await this.getHistoryFilteredUseCase.execute(id, { + status, + responsibleAgent, + event, + fromDate, + }); + } else { + response = await this.getHistoryUseCase.execute(id); + } return response; } diff --git a/backend/src/modules/ticket/ticket.module.ts b/backend/src/modules/ticket/ticket.module.ts index c493306..1cb9fb7 100644 --- a/backend/src/modules/ticket/ticket.module.ts +++ b/backend/src/modules/ticket/ticket.module.ts @@ -15,6 +15,7 @@ import { EscalateTicketUseCase } from './application/useCases/escalate/escalate. import { DeleteTicketUseCase } from './application/useCases/delete/delete.usecase'; import { NewAgentTicketUseCase } from './application/useCases/newAgent/newAgent.usecase'; import { TriageModule } from '../triage/triage.module'; +import { GetHistoryFilteredUseCase } from './application/useCases/getHistoryFiltered/getHistoryFiltered.usecase'; @Module({ imports: [ @@ -30,6 +31,7 @@ import { TriageModule } from '../triage/triage.module'; ReadAllTicketUseCase, ReadByIdTicketUseCase, GetHistoryTicketUseCase, + GetHistoryFilteredUseCase, EscalateTicketUseCase, DeleteTicketUseCase, NewAgentTicketUseCase, From 6f4087622bb172739ca1957fc218a72b722d52c0 Mon Sep 17 00:00:00 2001 From: YgorPereira Date: Tue, 21 Apr 2026 17:59:06 -0300 Subject: [PATCH 4/4] fix: filters as not required --- .../controllers/ticket.controller.ts | 35 ++++++++++++------- .../presentation/dtos/getHistory.dto.ts | 23 ++++++++++++ 2 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 backend/src/modules/ticket/presentation/dtos/getHistory.dto.ts diff --git a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts index ef9ec6b..f8fc732 100644 --- a/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts +++ b/backend/src/modules/ticket/presentation/controllers/ticket.controller.ts @@ -26,14 +26,13 @@ import { ApiBody, ApiOperation, ApiParam, + ApiQuery, ApiResponse, ApiTags, } from '@nestjs/swagger'; -import { - TicketEvents, - TicketStatus, -} from '../../domain/entities/ticket.entity'; import { GetHistoryFilteredUseCase } from '../../application/useCases/getHistoryFiltered/getHistoryFiltered.usecase'; +import { GetHistoryFiltersRequest } from '../dtos/getHistory.dto'; +import { TicketEvents, TicketStatus } from '../../domain/entities/ticket.entity'; @ApiTags('Ticket') @Controller('tickets') @@ -86,24 +85,34 @@ export class TicketController { @Get(':id/history') @ApiOperation({ summary: 'Retorna o histórico de um ticket pelo ID' }) @ApiParam({ name: 'id', example: 'uuid-do-ticket' }) + @ApiQuery({ name: 'status', enum: TicketStatus, required: false }) + @ApiQuery({ name: 'event', enum: TicketEvents, required: false }) + @ApiQuery({ name: 'responsibleAgent', type: String, required: false }) + @ApiQuery({ + name: 'fromDate', + type: String, + required: false, + example: '2024-01-01T00:00:00.000Z', + }) @ApiResponse({ status: 200, description: 'Histórico retornado com sucesso.' }) async getHistoryById( @Param('id') id: string, - @Query('status') status?: TicketStatus, - @Query('responsibleAgent') responsibleAgent?: string, - @Query('event') event?: TicketEvents, - @Query('fromDate') fromDate?: Date, + @Query() filters: GetHistoryFiltersRequest, ) { let response: GetHistoryTicketOutput; - const hasFilters = status || responsibleAgent || event || fromDate; + const hasFilters = + filters.status || + filters.responsibleAgent || + filters.event || + filters.fromDate; if (hasFilters) { response = await this.getHistoryFilteredUseCase.execute(id, { - status, - responsibleAgent, - event, - fromDate, + status: filters.status, + responsibleAgent: filters.responsibleAgent, + event: filters.event, + fromDate: filters.fromDate ? new Date(filters.fromDate) : undefined, }); } else { response = await this.getHistoryUseCase.execute(id); diff --git a/backend/src/modules/ticket/presentation/dtos/getHistory.dto.ts b/backend/src/modules/ticket/presentation/dtos/getHistory.dto.ts new file mode 100644 index 0000000..70e4472 --- /dev/null +++ b/backend/src/modules/ticket/presentation/dtos/getHistory.dto.ts @@ -0,0 +1,23 @@ +import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator'; +import { + TicketStatus, + TicketEvents, +} from '../../domain/entities/ticket.entity'; + +export class GetHistoryFiltersRequest { + @IsOptional() + @IsEnum(TicketStatus) + status?: TicketStatus; + + @IsOptional() + @IsString() + responsibleAgent?: string; + + @IsOptional() + @IsEnum(TicketEvents) + event?: TicketEvents; + + @IsOptional() + @IsDateString() + fromDate?: Date; +}