diff --git a/apps/api/src/modules/reviews/application/commands/create-review/create-review.handler.ts b/apps/api/src/modules/reviews/application/commands/create-review/create-review.handler.ts index cb4a828..df7fb7b 100644 --- a/apps/api/src/modules/reviews/application/commands/create-review/create-review.handler.ts +++ b/apps/api/src/modules/reviews/application/commands/create-review/create-review.handler.ts @@ -1,7 +1,9 @@ import { Inject } from '@nestjs/common'; -import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata +import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { createId } from '@paralleldrive/cuid2'; -import { ConflictException, ValidationException, type LoggerService } from '@modules/shared'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata +import { ConflictException, ValidationException, LoggerService } from '@modules/shared'; import { ReviewEntity } from '../../../domain/entities/review.entity'; import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository'; import { Rating } from '../../../domain/value-objects/rating.vo'; diff --git a/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.handler.ts b/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.handler.ts index 35a87a0..9922a47 100644 --- a/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.handler.ts +++ b/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.handler.ts @@ -1,6 +1,8 @@ import { Inject } from '@nestjs/common'; -import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; -import { ForbiddenException, NotFoundException, type LoggerService } from '@modules/shared'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata +import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata +import { ForbiddenException, NotFoundException, LoggerService } from '@modules/shared'; import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository'; import { DeleteReviewCommand } from './delete-review.command'; diff --git a/apps/api/src/modules/reviews/application/listeners/review-deleted.listener.ts b/apps/api/src/modules/reviews/application/listeners/review-deleted.listener.ts index 9a3dbc7..b4d44a1 100644 --- a/apps/api/src/modules/reviews/application/listeners/review-deleted.listener.ts +++ b/apps/api/src/modules/reviews/application/listeners/review-deleted.listener.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; -import { type LoggerService, type PrismaService } from '@modules/shared'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata +import { LoggerService, PrismaService } from '@modules/shared'; import { type ReviewDeletedEvent } from '../../domain/events/review-deleted.event'; @Injectable() diff --git a/apps/api/src/modules/reviews/infrastructure/repositories/prisma-review.repository.ts b/apps/api/src/modules/reviews/infrastructure/repositories/prisma-review.repository.ts index 6359369..9df6393 100644 --- a/apps/api/src/modules/reviews/infrastructure/repositories/prisma-review.repository.ts +++ b/apps/api/src/modules/reviews/infrastructure/repositories/prisma-review.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import type { Review as PrismaReview } from '@prisma/client'; -import { type PrismaService } from '@modules/shared'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata +import { PrismaService } from '@modules/shared'; import { ReviewEntity } from '../../domain/entities/review.entity'; import type { ReviewItemData, ReviewStatsData } from '../../domain/repositories/review-read.dto'; import type { IReviewRepository, PaginatedResult } from '../../domain/repositories/review.repository'; diff --git a/apps/api/src/modules/reviews/presentation/__tests__/reviews.controller.spec.ts b/apps/api/src/modules/reviews/presentation/__tests__/reviews.controller.spec.ts new file mode 100644 index 0000000..df60e8b --- /dev/null +++ b/apps/api/src/modules/reviews/presentation/__tests__/reviews.controller.spec.ts @@ -0,0 +1,134 @@ +import { CreateReviewCommand } from '../../application/commands/create-review/create-review.command'; +import { DeleteReviewCommand } from '../../application/commands/delete-review/delete-review.command'; +import { GetAverageRatingQuery } from '../../application/queries/get-average-rating/get-average-rating.query'; +import { GetReviewsByTargetQuery } from '../../application/queries/get-reviews-by-target/get-reviews-by-target.query'; +import { GetReviewsByUserQuery } from '../../application/queries/get-reviews-by-user/get-reviews-by-user.query'; +import { ReviewsController } from '../controllers/reviews.controller'; + +describe('ReviewsController', () => { + let controller: ReviewsController; + let mockCommandBus: { execute: ReturnType }; + let mockQueryBus: { execute: ReturnType }; + + const mockUser = { sub: 'user-1', phone: '0901234567', role: 'BUYER' }; + + beforeEach(() => { + mockCommandBus = { execute: vi.fn() }; + mockQueryBus = { execute: vi.fn() }; + controller = new ReviewsController(mockCommandBus as any, mockQueryBus as any); + }); + + describe('POST /reviews — createReview', () => { + it('dispatches CreateReviewCommand with correct parameters', async () => { + const dto = { targetType: 'agent', targetId: 'agent-1', rating: 5, comment: 'Tuyệt vời!' }; + const expected = { id: 'rev-1', rating: 5, targetType: 'agent', targetId: 'agent-1', createdAt: '2026-01-01T00:00:00.000Z' }; + mockCommandBus.execute.mockResolvedValue(expected); + + const result = await controller.createReview(dto as any, mockUser as any); + + expect(mockCommandBus.execute).toHaveBeenCalledWith( + expect.any(CreateReviewCommand), + ); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateReviewCommand; + expect(cmd.userId).toBe('user-1'); + expect(cmd.targetType).toBe('agent'); + expect(cmd.targetId).toBe('agent-1'); + expect(cmd.rating).toBe(5); + expect(cmd.comment).toBe('Tuyệt vời!'); + expect(result).toEqual(expected); + }); + + it('passes null comment when not provided', async () => { + const dto = { targetType: 'property', targetId: 'prop-1', rating: 3 }; + mockCommandBus.execute.mockResolvedValue({ id: 'rev-2' }); + + await controller.createReview(dto as any, mockUser as any); + + const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateReviewCommand; + expect(cmd.comment).toBeNull(); + }); + }); + + describe('GET /reviews — getReviewsByTarget', () => { + it('dispatches GetReviewsByTargetQuery with defaults', async () => { + const dto = { targetType: 'agent', targetId: 'agent-1' }; + const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getReviewsByTarget(dto as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + expect.any(GetReviewsByTargetQuery), + ); + const query = mockQueryBus.execute.mock.calls[0]![0] as GetReviewsByTargetQuery; + expect(query.targetType).toBe('agent'); + expect(query.targetId).toBe('agent-1'); + expect(query.page).toBe(1); + expect(query.limit).toBe(20); + expect(result).toEqual(expected); + }); + + it('passes custom page and limit', async () => { + const dto = { targetType: 'agent', targetId: 'agent-1', page: 3, limit: 10 }; + mockQueryBus.execute.mockResolvedValue({ data: [] }); + + await controller.getReviewsByTarget(dto as any); + + const query = mockQueryBus.execute.mock.calls[0]![0] as GetReviewsByTargetQuery; + expect(query.page).toBe(3); + expect(query.limit).toBe(10); + }); + }); + + describe('GET /reviews/stats — getStats', () => { + it('dispatches GetAverageRatingQuery', async () => { + const dto = { targetType: 'agent', targetId: 'agent-1' }; + const expected = { targetType: 'agent', targetId: 'agent-1', averageRating: 4.5, totalReviews: 10, distribution: {} }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getStats(dto as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + expect.any(GetAverageRatingQuery), + ); + const query = mockQueryBus.execute.mock.calls[0]![0] as GetAverageRatingQuery; + expect(query.targetType).toBe('agent'); + expect(query.targetId).toBe('agent-1'); + expect(result).toEqual(expected); + }); + }); + + describe('GET /reviews/me — getMyReviews', () => { + it('dispatches GetReviewsByUserQuery with defaults', async () => { + const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getMyReviews(mockUser as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + expect.any(GetReviewsByUserQuery), + ); + const query = mockQueryBus.execute.mock.calls[0]![0] as GetReviewsByUserQuery; + expect(query.userId).toBe('user-1'); + expect(query.page).toBe(1); + expect(query.limit).toBe(20); + expect(result).toEqual(expected); + }); + }); + + describe('DELETE /reviews/:id — deleteReview', () => { + it('dispatches DeleteReviewCommand and returns { deleted: true }', async () => { + mockCommandBus.execute.mockResolvedValue(undefined); + + const result = await controller.deleteReview('rev-1', mockUser as any); + + expect(mockCommandBus.execute).toHaveBeenCalledWith( + expect.any(DeleteReviewCommand), + ); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as DeleteReviewCommand; + expect(cmd.reviewId).toBe('rev-1'); + expect(cmd.userId).toBe('user-1'); + expect(result).toEqual({ deleted: true }); + }); + }); +}); diff --git a/apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts b/apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts index 13755a1..26eb372 100644 --- a/apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts +++ b/apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts @@ -8,7 +8,8 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata +import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation,