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:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user