fix(reviews): resolve 404 on /reviews/* routes — type-only imports broke NestJS DI metadata

The ReviewsModule routes returned 404 because TypeScript `type` imports
(`import { type CommandBus }`) are erased at compile time, causing
`emitDecoratorMetadata` to emit `Function` instead of the actual class
reference. NestJS DI relies on `design:paramtypes` metadata to resolve
constructor dependencies; with `Function` as the token, it cannot match
providers and the module fails to initialize silently.

Changed all DI-injected classes (CommandBus, QueryBus, EventBus,
LoggerService, PrismaService) from `type` imports to value imports
across the reviews module. Added eslint-disable comments to suppress
the `consistent-type-imports` rule on those lines, since NestJS DI
fundamentally requires runtime class references.

Also added ReviewsController unit tests covering all 5 endpoints.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 20:44:36 +07:00
parent 50c5168529
commit d36a13d536
6 changed files with 148 additions and 7 deletions

View File

@@ -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';

View File

@@ -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';

View File

@@ -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()

View File

@@ -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';

View File

@@ -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<typeof vi.fn> };
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
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 });
});
});
});

View File

@@ -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,