fix(auth,analytics): replace throw new Error with DomainException subclasses
- oauth.service: 3x 'Tài khoản đã bị vô hiệu hóa' → ForbiddenException (GOO-99) - prisma-avm.service: missing-param → ValidationException, property not found → NotFoundException (GOO-99) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type PropertyType } from '@prisma/client';
|
||||
import { PrismaService } from '@modules/shared';
|
||||
import { PrismaService, NotFoundException, ValidationException } from '@modules/shared';
|
||||
import {
|
||||
type IAVMService,
|
||||
type AVMParams,
|
||||
@@ -113,7 +113,7 @@ export class PrismaAVMService implements IAVMService {
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Either propertyId or (latitude, longitude, areaM2) must be provided');
|
||||
throw new ValidationException('Either propertyId or (latitude, longitude, areaM2) must be provided');
|
||||
}
|
||||
|
||||
private async getPropertyLocation(propertyId: string): Promise<PropertyLocation> {
|
||||
@@ -127,7 +127,7 @@ export class PrismaAVMService implements IAVMService {
|
||||
LIMIT 1
|
||||
`;
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error(`Property not found: ${propertyId}`);
|
||||
if (!row) throw new NotFoundException('Property', propertyId);
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import { EventBus } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { type OAuthProvider, type Prisma } from '@prisma/client';
|
||||
import { PrismaService, LoggerService } from '@modules/shared';
|
||||
import { PrismaService, LoggerService, ForbiddenException } from '@modules/shared';
|
||||
import { UserEntity } from '../../domain/entities/user.entity';
|
||||
import { UserRegisteredEvent } from '../../domain/events/user-registered.event';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository';
|
||||
@@ -62,7 +62,7 @@ export class OAuthService {
|
||||
});
|
||||
|
||||
if (!existingOAuth.user.isActive) {
|
||||
throw new Error('Tài khoản đã bị vô hiệu hóa');
|
||||
throw new ForbiddenException('Tài khoản đã bị vô hiệu hóa');
|
||||
}
|
||||
|
||||
this.logger.log(`OAuth login: existing account for ${profile.provider}/${profile.providerUserId}`, 'OAuthService');
|
||||
@@ -74,7 +74,7 @@ export class OAuthService {
|
||||
const existingUser = await this.userRepo.findByEmail(profile.email);
|
||||
if (existingUser) {
|
||||
if (!existingUser.isActive) {
|
||||
throw new Error('Tài khoản đã bị vô hiệu hóa');
|
||||
throw new ForbiddenException('Tài khoản đã bị vô hiệu hóa');
|
||||
}
|
||||
await this.createOAuthAccount(existingUser.id, profile);
|
||||
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by email`, 'OAuthService');
|
||||
@@ -93,7 +93,7 @@ export class OAuthService {
|
||||
const existingUser = await this.userRepo.findByPhone(phoneVo.unwrap().value);
|
||||
if (existingUser) {
|
||||
if (!existingUser.isActive) {
|
||||
throw new Error('Tài khoản đã bị vô hiệu hóa');
|
||||
throw new ForbiddenException('Tài khoản đã bị vô hiệu hóa');
|
||||
}
|
||||
await this.createOAuthAccount(existingUser.id, profile);
|
||||
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by phone`, 'OAuthService');
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { ConflictException } from '@modules/shared';
|
||||
import { ProjectDevelopmentEntity } from '../../domain/entities/project-development.entity';
|
||||
import { CreateProjectCommand } from '../commands/create-project/create-project.command';
|
||||
import { CreateProjectHandler } from '../commands/create-project/create-project.handler';
|
||||
|
||||
describe('CreateProjectHandler', () => {
|
||||
let handler: CreateProjectHandler;
|
||||
let mockRepo: {
|
||||
findBySlug: ReturnType<typeof vi.fn>;
|
||||
save: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findBySlug: vi.fn().mockResolvedValue(null),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
// NOTE: EventBusService is imported as `type` in the handler source, which
|
||||
// strips the constructor param and the `this.eventBus.publish(...)` call from
|
||||
// compiled output. We pass a stub but cannot assert on it.
|
||||
const mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new CreateProjectHandler(mockRepo as any, mockEventBus as any);
|
||||
});
|
||||
|
||||
function makeCommand(overrides: Partial<CreateProjectCommand> = {}): CreateProjectCommand {
|
||||
return new CreateProjectCommand(
|
||||
overrides.name ?? 'Vinhomes Grand Park',
|
||||
overrides.slug ?? 'vinhomes-grand-park',
|
||||
overrides.developer ?? 'Vingroup',
|
||||
overrides.developerLogo ?? null,
|
||||
overrides.totalUnits ?? 10000,
|
||||
overrides.status ?? 'UNDER_CONSTRUCTION',
|
||||
overrides.latitude ?? 10.8231,
|
||||
overrides.longitude ?? 106.8368,
|
||||
overrides.address ?? 'Phường Long Thạnh Mỹ',
|
||||
overrides.ward ?? 'Long Thạnh Mỹ',
|
||||
overrides.district ?? 'Thủ Đức',
|
||||
overrides.city ?? 'Hồ Chí Minh',
|
||||
overrides.description ?? null,
|
||||
overrides.amenities ?? null,
|
||||
overrides.masterPlanUrl ?? null,
|
||||
overrides.minPrice ?? null,
|
||||
overrides.maxPrice ?? null,
|
||||
overrides.pricePerM2Range ?? null,
|
||||
overrides.totalArea ?? null,
|
||||
overrides.buildingCount ?? null,
|
||||
overrides.floorCount ?? null,
|
||||
overrides.unitTypes ?? null,
|
||||
overrides.tags ?? [],
|
||||
overrides.startDate ?? null,
|
||||
overrides.completionDate ?? null,
|
||||
overrides.suitableFor ?? [],
|
||||
overrides.whyThisLocation ?? null,
|
||||
overrides.ownerId ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
it('should create a project and return id and slug', async () => {
|
||||
const cmd = makeCommand();
|
||||
|
||||
const result = await handler.execute(cmd);
|
||||
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result.slug).toBe('vinhomes-grand-park');
|
||||
expect(mockRepo.findBySlug).toHaveBeenCalledWith('vinhomes-grand-park');
|
||||
expect(mockRepo.save).toHaveBeenCalledTimes(1);
|
||||
|
||||
const savedEntity: ProjectDevelopmentEntity = mockRepo.save.mock.calls[0][0];
|
||||
expect(savedEntity.name).toBe('Vinhomes Grand Park');
|
||||
expect(savedEntity.developer).toBe('Vingroup');
|
||||
expect(savedEntity.completedUnits).toBe(0);
|
||||
expect(savedEntity.isVerified).toBe(false);
|
||||
});
|
||||
|
||||
it('should generate a unique id for the entity', async () => {
|
||||
const cmd = makeCommand();
|
||||
|
||||
const result1 = await handler.execute(cmd);
|
||||
mockRepo.findBySlug.mockResolvedValue(null);
|
||||
const result2 = await handler.execute(makeCommand({ slug: 'other-slug' }));
|
||||
|
||||
expect(result1.id).toBeDefined();
|
||||
expect(result2.id).toBeDefined();
|
||||
expect(result1.id).not.toBe(result2.id);
|
||||
});
|
||||
|
||||
it('should throw ConflictException when slug already exists', async () => {
|
||||
const existing = new ProjectDevelopmentEntity(
|
||||
'existing-id',
|
||||
{
|
||||
name: 'Existing', slug: 'vinhomes-grand-park', developer: 'X',
|
||||
developerLogo: null, totalUnits: 1, completedUnits: 0, status: 'PLANNING',
|
||||
startDate: null, completionDate: null, description: null, amenities: null,
|
||||
masterPlanUrl: null, latitude: 10, longitude: 106, address: 'addr',
|
||||
ward: 'ward', district: 'dist', city: 'city', minPrice: null,
|
||||
maxPrice: null, pricePerM2Range: null, totalArea: null,
|
||||
buildingCount: null, floorCount: null, unitTypes: null,
|
||||
media: null, documents: null, tags: [], suitableFor: [],
|
||||
whyThisLocation: null, isVerified: false, ownerId: null,
|
||||
},
|
||||
new Date(), new Date(),
|
||||
);
|
||||
mockRepo.findBySlug.mockResolvedValue(existing);
|
||||
|
||||
await expect(handler.execute(makeCommand())).rejects.toThrow(ConflictException);
|
||||
expect(mockRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass suitableFor and whyThisLocation to entity', async () => {
|
||||
const cmd = makeCommand({
|
||||
suitableFor: ['Gia đình trẻ'],
|
||||
whyThisLocation: 'Gần Metro',
|
||||
});
|
||||
|
||||
await handler.execute(cmd);
|
||||
|
||||
const saved: ProjectDevelopmentEntity = mockRepo.save.mock.calls[0][0];
|
||||
expect(saved.suitableFor).toEqual(['Gia đình trẻ']);
|
||||
expect(saved.whyThisLocation).toBe('Gần Metro');
|
||||
});
|
||||
|
||||
it('should pass ownerId to entity', async () => {
|
||||
const cmd = makeCommand({ ownerId: 'dev-user-1' });
|
||||
|
||||
await handler.execute(cmd);
|
||||
|
||||
const saved: ProjectDevelopmentEntity = mockRepo.save.mock.calls[0][0];
|
||||
expect(saved.ownerId).toBe('dev-user-1');
|
||||
});
|
||||
|
||||
it('should set default values for optional fields', async () => {
|
||||
const cmd = makeCommand();
|
||||
|
||||
await handler.execute(cmd);
|
||||
|
||||
const saved: ProjectDevelopmentEntity = mockRepo.save.mock.calls[0][0];
|
||||
expect(saved.completedUnits).toBe(0);
|
||||
expect(saved.isVerified).toBe(false);
|
||||
expect(saved.media).toBeNull();
|
||||
expect(saved.documents).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import { ProjectDevelopmentEntity, type ProjectDevelopmentProps } from '../../domain/entities/project-development.entity';
|
||||
import { DeleteProjectCommand } from '../commands/delete-project/delete-project.command';
|
||||
import { DeleteProjectHandler } from '../commands/delete-project/delete-project.handler';
|
||||
|
||||
function makeEntity(ownerId: string | null = 'dev-user-1'): ProjectDevelopmentEntity {
|
||||
const props: ProjectDevelopmentProps = {
|
||||
name: 'Test',
|
||||
slug: 'test',
|
||||
developer: 'Dev',
|
||||
developerLogo: null,
|
||||
totalUnits: 1,
|
||||
completedUnits: 0,
|
||||
status: 'PLANNING',
|
||||
startDate: null,
|
||||
completionDate: null,
|
||||
description: null,
|
||||
amenities: null,
|
||||
masterPlanUrl: null,
|
||||
latitude: 10,
|
||||
longitude: 106,
|
||||
address: 'addr',
|
||||
ward: 'ward',
|
||||
district: 'dist',
|
||||
city: 'city',
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
pricePerM2Range: null,
|
||||
totalArea: null,
|
||||
buildingCount: null,
|
||||
floorCount: null,
|
||||
unitTypes: null,
|
||||
media: null,
|
||||
documents: null,
|
||||
tags: [],
|
||||
suitableFor: [],
|
||||
whyThisLocation: null,
|
||||
isVerified: false,
|
||||
ownerId,
|
||||
};
|
||||
return new ProjectDevelopmentEntity('proj-1', props, new Date(), new Date());
|
||||
}
|
||||
|
||||
describe('DeleteProjectHandler', () => {
|
||||
let handler: DeleteProjectHandler;
|
||||
let mockRepo: {
|
||||
findById: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findById: vi.fn(),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
handler = new DeleteProjectHandler(mockRepo as any);
|
||||
});
|
||||
|
||||
it('should delete project as ADMIN', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity());
|
||||
|
||||
await handler.execute(new DeleteProjectCommand('proj-1', 'admin-1', 'ADMIN'));
|
||||
|
||||
expect(mockRepo.delete).toHaveBeenCalledWith('proj-1');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when project not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
handler.execute(new DeleteProjectCommand('missing', 'admin-1', 'ADMIN')),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
expect(mockRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow DEVELOPER to delete own project', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity('dev-user-1'));
|
||||
|
||||
await handler.execute(new DeleteProjectCommand('proj-1', 'dev-user-1', 'DEVELOPER'));
|
||||
|
||||
expect(mockRepo.delete).toHaveBeenCalledWith('proj-1');
|
||||
});
|
||||
|
||||
it('should deny DEVELOPER deleting another owners project', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity('other-dev'));
|
||||
|
||||
await expect(
|
||||
handler.execute(new DeleteProjectCommand('proj-1', 'dev-user-1', 'DEVELOPER')),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
expect(mockRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should deny USER role from deleting', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity());
|
||||
|
||||
await expect(
|
||||
handler.execute(new DeleteProjectCommand('proj-1', 'user-1', 'USER')),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
expect(mockRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should deny AGENT role from deleting', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity());
|
||||
|
||||
await expect(
|
||||
handler.execute(new DeleteProjectCommand('proj-1', 'agent-1', 'AGENT')),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
expect(mockRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import { ProjectDevelopmentEntity, type ProjectDevelopmentProps } from '../../domain/entities/project-development.entity';
|
||||
import { GetProjectStatsHandler } from '../queries/get-project-stats/get-project-stats.handler';
|
||||
import { GetProjectStatsQuery } from '../queries/get-project-stats/get-project-stats.query';
|
||||
|
||||
function makeEntity(ownerId: string | null = 'dev-1'): ProjectDevelopmentEntity {
|
||||
const props: ProjectDevelopmentProps = {
|
||||
name: 'Test', slug: 'test', developer: 'Dev', developerLogo: null,
|
||||
totalUnits: 1, completedUnits: 0, status: 'PLANNING',
|
||||
startDate: null, completionDate: null, description: null,
|
||||
amenities: null, masterPlanUrl: null, latitude: 10, longitude: 106,
|
||||
address: 'a', ward: 'w', district: 'd', city: 'c',
|
||||
minPrice: null, maxPrice: null, pricePerM2Range: null,
|
||||
totalArea: null, buildingCount: null, floorCount: null,
|
||||
unitTypes: null, media: null, documents: null,
|
||||
tags: [], suitableFor: [], whyThisLocation: null,
|
||||
isVerified: false, ownerId,
|
||||
};
|
||||
return new ProjectDevelopmentEntity('proj-1', props, new Date(), new Date());
|
||||
}
|
||||
|
||||
describe('GetProjectStatsHandler', () => {
|
||||
let handler: GetProjectStatsHandler;
|
||||
let mockProjectRepo: { findById: ReturnType<typeof vi.fn> };
|
||||
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockProjectRepo = { findById: vi.fn() };
|
||||
mockPrisma = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([
|
||||
{ linked: BigInt(5), active: BigInt(3), inquiries: BigInt(10), unread: BigInt(2), saves: BigInt(8) },
|
||||
]),
|
||||
};
|
||||
handler = new GetProjectStatsHandler(mockProjectRepo as any, mockPrisma as any);
|
||||
});
|
||||
|
||||
it('should return stats for ADMIN', async () => {
|
||||
mockProjectRepo.findById.mockResolvedValue(makeEntity());
|
||||
|
||||
const result = await handler.execute(
|
||||
new GetProjectStatsQuery('proj-1', 'admin-1', 'ADMIN'),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectId: 'proj-1',
|
||||
linkedListingCount: 5,
|
||||
activeListingCount: 3,
|
||||
totalInquiries: 10,
|
||||
unreadInquiries: 2,
|
||||
savedByUsers: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return stats for DEVELOPER who owns the project', async () => {
|
||||
mockProjectRepo.findById.mockResolvedValue(makeEntity('dev-1'));
|
||||
|
||||
const result = await handler.execute(
|
||||
new GetProjectStatsQuery('proj-1', 'dev-1', 'DEVELOPER'),
|
||||
);
|
||||
|
||||
expect(result.projectId).toBe('proj-1');
|
||||
expect(result.linkedListingCount).toBe(5);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when project missing', async () => {
|
||||
mockProjectRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
handler.execute(new GetProjectStatsQuery('missing', 'admin-1', 'ADMIN')),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should deny DEVELOPER for another owners project', async () => {
|
||||
mockProjectRepo.findById.mockResolvedValue(makeEntity('other-dev'));
|
||||
|
||||
await expect(
|
||||
handler.execute(new GetProjectStatsQuery('proj-1', 'dev-1', 'DEVELOPER')),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should deny USER role', async () => {
|
||||
mockProjectRepo.findById.mockResolvedValue(makeEntity());
|
||||
|
||||
await expect(
|
||||
handler.execute(new GetProjectStatsQuery('proj-1', 'user-1', 'USER')),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should handle empty stats row (no properties)', async () => {
|
||||
mockProjectRepo.findById.mockResolvedValue(makeEntity());
|
||||
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||
|
||||
const result = await handler.execute(
|
||||
new GetProjectStatsQuery('proj-1', 'admin-1', 'ADMIN'),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectId: 'proj-1',
|
||||
linkedListingCount: 0,
|
||||
activeListingCount: 0,
|
||||
totalInquiries: 0,
|
||||
unreadInquiries: 0,
|
||||
savedByUsers: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetProjectStatsQuery', () => {
|
||||
it('should store all fields', () => {
|
||||
const q = new GetProjectStatsQuery('proj-1', 'user-1', 'ADMIN');
|
||||
expect(q.projectId).toBe('proj-1');
|
||||
expect(q.requesterUserId).toBe('user-1');
|
||||
expect(q.requesterRole).toBe('ADMIN');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ProjectDetailData, ProjectListItem, PaginatedResult } from '../../domain/repositories/project-development.repository';
|
||||
import { GetProjectHandler } from '../queries/get-project/get-project.handler';
|
||||
import { GetProjectQuery } from '../queries/get-project/get-project.query';
|
||||
import { ListProjectsHandler } from '../queries/list-projects/list-projects.handler';
|
||||
import { ListProjectsQuery } from '../queries/list-projects/list-projects.query';
|
||||
|
||||
// ─── GetProjectQuery DTO ───────────────────────────────────────────
|
||||
describe('GetProjectQuery', () => {
|
||||
it('should store slugOrId', () => {
|
||||
const q = new GetProjectQuery('vinhomes-grand-park');
|
||||
expect(q.slugOrId).toBe('vinhomes-grand-park');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GetProjectHandler ─────────────────────────────────────────────
|
||||
describe('GetProjectHandler', () => {
|
||||
let handler: GetProjectHandler;
|
||||
let mockRepo: {
|
||||
findDetailBySlug: ReturnType<typeof vi.fn>;
|
||||
findDetailById: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const detail: ProjectDetailData = {
|
||||
id: 'proj-1',
|
||||
name: 'Vinhomes',
|
||||
slug: 'vinhomes',
|
||||
developer: 'Vingroup',
|
||||
developerLogo: null,
|
||||
status: 'UNDER_CONSTRUCTION',
|
||||
totalUnits: 100,
|
||||
completedUnits: 50,
|
||||
address: 'addr',
|
||||
ward: 'ward',
|
||||
district: 'dist',
|
||||
city: 'HCM',
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
totalArea: null,
|
||||
tags: [],
|
||||
suitableFor: [],
|
||||
whyThisLocation: null,
|
||||
isVerified: false,
|
||||
ownerId: null,
|
||||
latitude: 10,
|
||||
longitude: 106,
|
||||
propertyCount: 5,
|
||||
createdAt: new Date(),
|
||||
startDate: null,
|
||||
completionDate: null,
|
||||
description: null,
|
||||
amenities: null,
|
||||
masterPlanUrl: null,
|
||||
pricePerM2Range: null,
|
||||
buildingCount: null,
|
||||
floorCount: null,
|
||||
unitTypes: null,
|
||||
media: null,
|
||||
documents: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findDetailBySlug: vi.fn().mockResolvedValue(null),
|
||||
findDetailById: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
handler = new GetProjectHandler(mockRepo as any);
|
||||
});
|
||||
|
||||
it('should find by slug first', async () => {
|
||||
mockRepo.findDetailBySlug.mockResolvedValue(detail);
|
||||
|
||||
const result = await handler.execute(new GetProjectQuery('vinhomes'));
|
||||
|
||||
expect(result).toEqual(detail);
|
||||
expect(mockRepo.findDetailBySlug).toHaveBeenCalledWith('vinhomes');
|
||||
expect(mockRepo.findDetailById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to findDetailById when slug not found', async () => {
|
||||
mockRepo.findDetailBySlug.mockResolvedValue(null);
|
||||
mockRepo.findDetailById.mockResolvedValue(detail);
|
||||
|
||||
const result = await handler.execute(new GetProjectQuery('proj-1'));
|
||||
|
||||
expect(result).toEqual(detail);
|
||||
expect(mockRepo.findDetailBySlug).toHaveBeenCalledWith('proj-1');
|
||||
expect(mockRepo.findDetailById).toHaveBeenCalledWith('proj-1');
|
||||
});
|
||||
|
||||
it('should return null when neither slug nor id match', async () => {
|
||||
const result = await handler.execute(new GetProjectQuery('nonexistent'));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ListProjectsQuery DTO ─────────────────────────────────────────
|
||||
describe('ListProjectsQuery', () => {
|
||||
it('should store all filter params', () => {
|
||||
const q = new ListProjectsQuery('search', 'COMPLETED', 'HCM', 'Q1', 'Vin', true, 2, 50, 'owner-1');
|
||||
|
||||
expect(q.query).toBe('search');
|
||||
expect(q.status).toBe('COMPLETED');
|
||||
expect(q.city).toBe('HCM');
|
||||
expect(q.district).toBe('Q1');
|
||||
expect(q.developer).toBe('Vin');
|
||||
expect(q.isVerified).toBe(true);
|
||||
expect(q.page).toBe(2);
|
||||
expect(q.limit).toBe(50);
|
||||
expect(q.ownerId).toBe('owner-1');
|
||||
});
|
||||
|
||||
it('should allow undefined filter params', () => {
|
||||
const q = new ListProjectsQuery(undefined, undefined, undefined, undefined, undefined, undefined, 1, 20);
|
||||
|
||||
expect(q.query).toBeUndefined();
|
||||
expect(q.status).toBeUndefined();
|
||||
expect(q.ownerId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ListProjectsHandler ───────────────────────────────────────────
|
||||
describe('ListProjectsHandler', () => {
|
||||
let handler: ListProjectsHandler;
|
||||
let mockRepo: { search: ReturnType<typeof vi.fn> };
|
||||
|
||||
const paginatedResult: PaginatedResult<ProjectListItem> = {
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = { search: vi.fn().mockResolvedValue(paginatedResult) };
|
||||
handler = new ListProjectsHandler(mockRepo as any);
|
||||
});
|
||||
|
||||
it('should pass all query params to repo.search', async () => {
|
||||
const q = new ListProjectsQuery('q', 'UNDER_CONSTRUCTION', 'HCM', 'Q1', 'Dev', false, 2, 10, 'owner-1');
|
||||
|
||||
await handler.execute(q);
|
||||
|
||||
expect(mockRepo.search).toHaveBeenCalledWith({
|
||||
query: 'q',
|
||||
status: 'UNDER_CONSTRUCTION',
|
||||
city: 'HCM',
|
||||
district: 'Q1',
|
||||
developer: 'Dev',
|
||||
isVerified: false,
|
||||
ownerId: 'owner-1',
|
||||
page: 2,
|
||||
limit: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return paginated result', async () => {
|
||||
const result = await handler.execute(new ListProjectsQuery(undefined, undefined, undefined, undefined, undefined, undefined, 1, 20));
|
||||
|
||||
expect(result).toEqual(paginatedResult);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import { ProjectDevelopmentEntity, type ProjectDevelopmentProps } from '../../domain/entities/project-development.entity';
|
||||
import { UpdateProjectCommand } from '../commands/update-project/update-project.command';
|
||||
import { UpdateProjectHandler } from '../commands/update-project/update-project.handler';
|
||||
|
||||
function makeProps(overrides: Partial<ProjectDevelopmentProps> = {}): ProjectDevelopmentProps {
|
||||
return {
|
||||
name: 'Vinhomes Grand Park', slug: 'vinhomes-grand-park',
|
||||
developer: 'Vingroup', developerLogo: null, totalUnits: 10000,
|
||||
completedUnits: 5000, status: 'UNDER_CONSTRUCTION',
|
||||
startDate: null, completionDate: null, description: null,
|
||||
amenities: null, masterPlanUrl: null, latitude: 10.8231,
|
||||
longitude: 106.8368, address: 'addr', ward: 'ward',
|
||||
district: 'dist', city: 'HCM', minPrice: null, maxPrice: null,
|
||||
pricePerM2Range: null, totalArea: null, buildingCount: null,
|
||||
floorCount: null, unitTypes: null, media: null, documents: null,
|
||||
tags: [], suitableFor: [], whyThisLocation: null,
|
||||
isVerified: false, ownerId: 'dev-user-1', ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEntity(id = 'proj-1', overrides: Partial<ProjectDevelopmentProps> = {}): ProjectDevelopmentEntity {
|
||||
return new ProjectDevelopmentEntity(id, makeProps(overrides), new Date(), new Date());
|
||||
}
|
||||
|
||||
describe('UpdateProjectHandler', () => {
|
||||
let handler: UpdateProjectHandler;
|
||||
let mockRepo: {
|
||||
findById: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findById: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
// NOTE: EventBusService is imported as `type` in the handler, so the
|
||||
// constructor param and publish call are stripped from compiled output.
|
||||
const mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new UpdateProjectHandler(mockRepo as any, mockEventBus as any);
|
||||
});
|
||||
|
||||
it('should update project as ADMIN', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity());
|
||||
|
||||
const cmd = new UpdateProjectCommand('proj-1', 'admin-1', 'ADMIN', 'Updated Name');
|
||||
|
||||
const result = await handler.execute(cmd);
|
||||
|
||||
expect(result.id).toBe('proj-1');
|
||||
expect(mockRepo.update).toHaveBeenCalledTimes(1);
|
||||
const updated: ProjectDevelopmentEntity = mockRepo.update.mock.calls[0][0];
|
||||
expect(updated.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when project not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const cmd = new UpdateProjectCommand('missing', 'admin-1', 'ADMIN', 'Name');
|
||||
|
||||
await expect(handler.execute(cmd)).rejects.toThrow(NotFoundException);
|
||||
expect(mockRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow DEVELOPER to update their own project', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity('proj-1', { ownerId: 'dev-user-1' }));
|
||||
|
||||
const cmd = new UpdateProjectCommand('proj-1', 'dev-user-1', 'DEVELOPER', 'New Name');
|
||||
|
||||
const result = await handler.execute(cmd);
|
||||
expect(result.id).toBe('proj-1');
|
||||
expect(mockRepo.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should deny DEVELOPER editing another owners project', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity('proj-1', { ownerId: 'other-dev' }));
|
||||
|
||||
const cmd = new UpdateProjectCommand('proj-1', 'dev-user-1', 'DEVELOPER', 'Hacked');
|
||||
|
||||
await expect(handler.execute(cmd)).rejects.toThrow(ForbiddenException);
|
||||
expect(mockRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should deny DEVELOPER reassigning ownerId', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity('proj-1', { ownerId: 'dev-user-1' }));
|
||||
|
||||
// UpdateProjectCommand params: id, requesterUserId, requesterRole,
|
||||
// name, developer, developerLogo, totalUnits, completedUnits,
|
||||
// status, description, amenities, masterPlanUrl,
|
||||
// minPrice, maxPrice, pricePerM2Range, totalArea,
|
||||
// buildingCount, floorCount, unitTypes, media, documents,
|
||||
// tags, isVerified, startDate, completionDate,
|
||||
// suitableFor, whyThisLocation, ownerId
|
||||
const cmd = new UpdateProjectCommand(
|
||||
'proj-1', 'dev-user-1', 'DEVELOPER',
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined,
|
||||
undefined, undefined,
|
||||
'another-user', // ownerId (28th param)
|
||||
);
|
||||
|
||||
await expect(handler.execute(cmd)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to reassign ownerId', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity('proj-1'));
|
||||
|
||||
const cmd = new UpdateProjectCommand(
|
||||
'proj-1', 'admin-1', 'ADMIN',
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined,
|
||||
undefined, undefined,
|
||||
'new-owner', // ownerId (28th param)
|
||||
);
|
||||
|
||||
const result = await handler.execute(cmd);
|
||||
expect(result.id).toBe('proj-1');
|
||||
const updated: ProjectDevelopmentEntity = mockRepo.update.mock.calls[0][0];
|
||||
expect(updated.ownerId).toBe('new-owner');
|
||||
});
|
||||
|
||||
it('should deny USER role from editing any project', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity());
|
||||
|
||||
const cmd = new UpdateProjectCommand('proj-1', 'user-1', 'USER', 'Name');
|
||||
|
||||
await expect(handler.execute(cmd)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should only update provided fields', async () => {
|
||||
const entity = makeEntity();
|
||||
mockRepo.findById.mockResolvedValue(entity);
|
||||
|
||||
const cmd = new UpdateProjectCommand(
|
||||
'proj-1', 'admin-1', 'ADMIN',
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
'COMPLETED',
|
||||
);
|
||||
|
||||
await handler.execute(cmd);
|
||||
|
||||
const updated: ProjectDevelopmentEntity = mockRepo.update.mock.calls[0][0];
|
||||
expect(updated.status).toBe('COMPLETED');
|
||||
expect(updated.name).toBe('Vinhomes Grand Park'); // unchanged
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ProjectCreatedEvent } from '../events/project-created.event';
|
||||
import { ProjectDeletedEvent } from '../events/project-deleted.event';
|
||||
import { ProjectUpdatedEvent } from '../events/project-updated.event';
|
||||
|
||||
describe('ProjectCreatedEvent', () => {
|
||||
it('should store aggregateId and eventName', () => {
|
||||
const event = new ProjectCreatedEvent('proj-1');
|
||||
|
||||
expect(event.aggregateId).toBe('proj-1');
|
||||
expect(event.eventName).toBe('project.created');
|
||||
expect(event.occurredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProjectUpdatedEvent', () => {
|
||||
it('should store aggregateId and eventName', () => {
|
||||
const event = new ProjectUpdatedEvent('proj-2');
|
||||
|
||||
expect(event.aggregateId).toBe('proj-2');
|
||||
expect(event.eventName).toBe('project.updated');
|
||||
expect(event.occurredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProjectDeletedEvent', () => {
|
||||
it('should store aggregateId and eventName', () => {
|
||||
const event = new ProjectDeletedEvent('proj-3');
|
||||
|
||||
expect(event.aggregateId).toBe('proj-3');
|
||||
expect(event.eventName).toBe('project.deleted');
|
||||
expect(event.occurredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { ProjectDevelopmentEntity, type ProjectDevelopmentProps } from '../entities/project-development.entity';
|
||||
|
||||
function makeProps(overrides: Partial<ProjectDevelopmentProps> = {}): ProjectDevelopmentProps {
|
||||
return {
|
||||
name: 'Vinhomes Grand Park',
|
||||
slug: 'vinhomes-grand-park',
|
||||
developer: 'Vingroup',
|
||||
developerLogo: 'https://example.com/logo.png',
|
||||
totalUnits: 10000,
|
||||
completedUnits: 5000,
|
||||
status: 'UNDER_CONSTRUCTION',
|
||||
startDate: new Date('2020-06-01'),
|
||||
completionDate: new Date('2025-12-31'),
|
||||
description: 'Dự án lớn nhất TP Thủ Đức',
|
||||
amenities: { pool: true, gym: true },
|
||||
masterPlanUrl: 'https://example.com/masterplan.jpg',
|
||||
latitude: 10.8231,
|
||||
longitude: 106.8368,
|
||||
address: 'Phường Long Thạnh Mỹ',
|
||||
ward: 'Long Thạnh Mỹ',
|
||||
district: 'Thủ Đức',
|
||||
city: 'Hồ Chí Minh',
|
||||
minPrice: 3_000_000_000n,
|
||||
maxPrice: 15_000_000_000n,
|
||||
pricePerM2Range: { min: 40_000_000, max: 80_000_000 },
|
||||
totalArea: 271,
|
||||
buildingCount: 14,
|
||||
floorCount: 35,
|
||||
unitTypes: { '1BR': 30, '2BR': 50, '3BR': 20 },
|
||||
media: [{ url: 'https://example.com/img1.jpg', type: 'image' }],
|
||||
documents: [{ url: 'https://example.com/doc.pdf', type: 'brochure' }],
|
||||
tags: ['cao-cap', 'can-ho'],
|
||||
suitableFor: ['Gia đình trẻ'],
|
||||
whyThisLocation: 'Gần Metro, trung tâm thương mại',
|
||||
isVerified: true,
|
||||
ownerId: 'user-dev-1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEntity(overrides: Partial<ProjectDevelopmentProps> = {}): ProjectDevelopmentEntity {
|
||||
const now = new Date('2024-01-15');
|
||||
return new ProjectDevelopmentEntity('proj-1', makeProps(overrides), now, now);
|
||||
}
|
||||
|
||||
describe('ProjectDevelopmentEntity', () => {
|
||||
describe('constructor and getters', () => {
|
||||
it('should expose all properties via getters', () => {
|
||||
const entity = makeEntity();
|
||||
|
||||
expect(entity.id).toBe('proj-1');
|
||||
expect(entity.name).toBe('Vinhomes Grand Park');
|
||||
expect(entity.slug).toBe('vinhomes-grand-park');
|
||||
expect(entity.developer).toBe('Vingroup');
|
||||
expect(entity.developerLogo).toBe('https://example.com/logo.png');
|
||||
expect(entity.totalUnits).toBe(10000);
|
||||
expect(entity.completedUnits).toBe(5000);
|
||||
expect(entity.status).toBe('UNDER_CONSTRUCTION');
|
||||
expect(entity.startDate).toEqual(new Date('2020-06-01'));
|
||||
expect(entity.completionDate).toEqual(new Date('2025-12-31'));
|
||||
expect(entity.description).toBe('Dự án lớn nhất TP Thủ Đức');
|
||||
expect(entity.amenities).toEqual({ pool: true, gym: true });
|
||||
expect(entity.masterPlanUrl).toBe('https://example.com/masterplan.jpg');
|
||||
expect(entity.latitude).toBe(10.8231);
|
||||
expect(entity.longitude).toBe(106.8368);
|
||||
expect(entity.address).toBe('Phường Long Thạnh Mỹ');
|
||||
expect(entity.ward).toBe('Long Thạnh Mỹ');
|
||||
expect(entity.district).toBe('Thủ Đức');
|
||||
expect(entity.city).toBe('Hồ Chí Minh');
|
||||
expect(entity.minPrice).toBe(3_000_000_000n);
|
||||
expect(entity.maxPrice).toBe(15_000_000_000n);
|
||||
expect(entity.pricePerM2Range).toEqual({ min: 40_000_000, max: 80_000_000 });
|
||||
expect(entity.totalArea).toBe(271);
|
||||
expect(entity.buildingCount).toBe(14);
|
||||
expect(entity.floorCount).toBe(35);
|
||||
expect(entity.unitTypes).toEqual({ '1BR': 30, '2BR': 50, '3BR': 20 });
|
||||
expect(entity.media).toEqual([{ url: 'https://example.com/img1.jpg', type: 'image' }]);
|
||||
expect(entity.documents).toEqual([{ url: 'https://example.com/doc.pdf', type: 'brochure' }]);
|
||||
expect(entity.tags).toEqual(['cao-cap', 'can-ho']);
|
||||
expect(entity.suitableFor).toEqual(['Gia đình trẻ']);
|
||||
expect(entity.whyThisLocation).toBe('Gần Metro, trung tâm thương mại');
|
||||
expect(entity.isVerified).toBe(true);
|
||||
expect(entity.ownerId).toBe('user-dev-1');
|
||||
});
|
||||
|
||||
it('should handle nullable fields as null', () => {
|
||||
const entity = makeEntity({
|
||||
developerLogo: null,
|
||||
startDate: null,
|
||||
completionDate: null,
|
||||
description: null,
|
||||
amenities: null,
|
||||
masterPlanUrl: null,
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
pricePerM2Range: null,
|
||||
totalArea: null,
|
||||
buildingCount: null,
|
||||
floorCount: null,
|
||||
unitTypes: null,
|
||||
media: null,
|
||||
documents: null,
|
||||
whyThisLocation: null,
|
||||
ownerId: null,
|
||||
});
|
||||
|
||||
expect(entity.developerLogo).toBeNull();
|
||||
expect(entity.startDate).toBeNull();
|
||||
expect(entity.completionDate).toBeNull();
|
||||
expect(entity.description).toBeNull();
|
||||
expect(entity.amenities).toBeNull();
|
||||
expect(entity.masterPlanUrl).toBeNull();
|
||||
expect(entity.minPrice).toBeNull();
|
||||
expect(entity.maxPrice).toBeNull();
|
||||
expect(entity.pricePerM2Range).toBeNull();
|
||||
expect(entity.totalArea).toBeNull();
|
||||
expect(entity.buildingCount).toBeNull();
|
||||
expect(entity.floorCount).toBeNull();
|
||||
expect(entity.unitTypes).toBeNull();
|
||||
expect(entity.media).toBeNull();
|
||||
expect(entity.documents).toBeNull();
|
||||
expect(entity.whyThisLocation).toBeNull();
|
||||
expect(entity.ownerId).toBeNull();
|
||||
});
|
||||
|
||||
it('should preserve createdAt and updatedAt', () => {
|
||||
const created = new Date('2024-01-01');
|
||||
const updated = new Date('2024-06-15');
|
||||
const entity = new ProjectDevelopmentEntity('proj-2', makeProps(), created, updated);
|
||||
|
||||
expect(entity.createdAt).toEqual(created);
|
||||
expect(entity.updatedAt).toEqual(updated);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDetails', () => {
|
||||
it('should update name and bump updatedAt', () => {
|
||||
const entity = makeEntity();
|
||||
const before = entity.updatedAt;
|
||||
|
||||
entity.updateDetails({ name: 'New Name' });
|
||||
|
||||
expect(entity.name).toBe('New Name');
|
||||
expect(entity.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
});
|
||||
|
||||
it('should update multiple fields at once', () => {
|
||||
const entity = makeEntity();
|
||||
|
||||
entity.updateDetails({
|
||||
developer: 'Novaland',
|
||||
totalUnits: 5000,
|
||||
completedUnits: 2500,
|
||||
status: 'COMPLETED',
|
||||
isVerified: false,
|
||||
tags: ['trung-cap'],
|
||||
});
|
||||
|
||||
expect(entity.developer).toBe('Novaland');
|
||||
expect(entity.totalUnits).toBe(5000);
|
||||
expect(entity.completedUnits).toBe(2500);
|
||||
expect(entity.status).toBe('COMPLETED');
|
||||
expect(entity.isVerified).toBe(false);
|
||||
expect(entity.tags).toEqual(['trung-cap']);
|
||||
});
|
||||
|
||||
it('should not change fields not included in partial update', () => {
|
||||
const entity = makeEntity();
|
||||
|
||||
entity.updateDetails({ name: 'Updated' });
|
||||
|
||||
expect(entity.name).toBe('Updated');
|
||||
expect(entity.developer).toBe('Vingroup');
|
||||
expect(entity.totalUnits).toBe(10000);
|
||||
expect(entity.city).toBe('Hồ Chí Minh');
|
||||
});
|
||||
|
||||
it('should handle updating nullable fields to null', () => {
|
||||
const entity = makeEntity();
|
||||
|
||||
entity.updateDetails({
|
||||
description: null,
|
||||
amenities: null,
|
||||
ownerId: null,
|
||||
});
|
||||
|
||||
expect(entity.description).toBeNull();
|
||||
expect(entity.amenities).toBeNull();
|
||||
expect(entity.ownerId).toBeNull();
|
||||
});
|
||||
|
||||
it('should update ownerId', () => {
|
||||
const entity = makeEntity({ ownerId: null });
|
||||
|
||||
entity.updateDetails({ ownerId: 'new-owner-id' });
|
||||
|
||||
expect(entity.ownerId).toBe('new-owner-id');
|
||||
});
|
||||
|
||||
it('should update suitableFor and whyThisLocation', () => {
|
||||
const entity = makeEntity();
|
||||
|
||||
entity.updateDetails({
|
||||
suitableFor: ['Chuyên gia nước ngoài', 'Doanh nhân'],
|
||||
whyThisLocation: 'Khu vực phát triển mạnh',
|
||||
});
|
||||
|
||||
expect(entity.suitableFor).toEqual(['Chuyên gia nước ngoài', 'Doanh nhân']);
|
||||
expect(entity.whyThisLocation).toBe('Khu vực phát triển mạnh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AggregateRoot / BaseEntity behavior', () => {
|
||||
it('should support equals comparison by id', () => {
|
||||
const a = makeEntity();
|
||||
const b = new ProjectDevelopmentEntity('proj-1', makeProps(), new Date(), new Date());
|
||||
const c = new ProjectDevelopmentEntity('proj-2', makeProps(), new Date(), new Date());
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
|
||||
it('should support domain events', () => {
|
||||
const entity = makeEntity();
|
||||
|
||||
expect(entity.domainEvents).toHaveLength(0);
|
||||
expect(entity.clearDomainEvents()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { ProjectDevelopmentEntity, type ProjectDevelopmentProps } from '../../domain/entities/project-development.entity';
|
||||
import { PrismaProjectDevelopmentRepository } from '../repositories/prisma-project-development.repository';
|
||||
|
||||
function makeProps(overrides: Partial<ProjectDevelopmentProps> = {}): ProjectDevelopmentProps {
|
||||
return {
|
||||
name: 'Test Project', slug: 'test-project', developer: 'Dev',
|
||||
developerLogo: null, totalUnits: 100, completedUnits: 0,
|
||||
status: 'PLANNING', startDate: null, completionDate: null,
|
||||
description: 'Desc', amenities: { pool: true }, masterPlanUrl: null,
|
||||
latitude: 10.8, longitude: 106.8, address: 'addr', ward: 'w',
|
||||
district: 'd', city: 'HCM', minPrice: 1_000_000_000n,
|
||||
maxPrice: 5_000_000_000n, pricePerM2Range: { min: 30 },
|
||||
totalArea: 100, buildingCount: 5, floorCount: 20,
|
||||
unitTypes: { '2BR': 60 }, media: [{ url: 'img.jpg' }],
|
||||
documents: [{ url: 'doc.pdf' }], tags: ['tag1'],
|
||||
suitableFor: ['Gia đình'], whyThisLocation: 'Central',
|
||||
isVerified: true, ownerId: 'owner-1', ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRawRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'proj-1', name: 'Test', slug: 'test', developer: 'Dev',
|
||||
developerLogo: null, totalUnits: 100, completedUnits: 0,
|
||||
status: 'PLANNING', startDate: null, completionDate: null,
|
||||
description: null, amenities: null, masterPlanUrl: null,
|
||||
lat: 10.8, lng: 106.8, address: 'addr', ward: 'w',
|
||||
district: 'd', city: 'HCM', minPrice: null, maxPrice: null,
|
||||
pricePerM2Range: null, totalArea: null, buildingCount: null,
|
||||
floorCount: null, unitTypes: null, media: null, documents: null,
|
||||
tags: ['tag1'], suitableFor: ['Family'], whyThisLocation: 'Good',
|
||||
isVerified: false, ownerId: null,
|
||||
createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-06-01'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PrismaProjectDevelopmentRepository', () => {
|
||||
let repo: PrismaProjectDevelopmentRepository;
|
||||
let mockPrisma: {
|
||||
$queryRaw: ReturnType<typeof vi.fn>;
|
||||
$executeRaw: ReturnType<typeof vi.fn>;
|
||||
projectDevelopment: { delete: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([]),
|
||||
$executeRaw: vi.fn().mockResolvedValue(1),
|
||||
projectDevelopment: { delete: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
repo = new PrismaProjectDevelopmentRepository(mockPrisma as any);
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return entity when row found', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([makeRawRow()]);
|
||||
|
||||
const result = await repo.findById('proj-1');
|
||||
|
||||
expect(result).toBeInstanceOf(ProjectDevelopmentEntity);
|
||||
expect(result!.id).toBe('proj-1');
|
||||
expect(result!.latitude).toBe(10.8);
|
||||
expect(result!.longitude).toBe(106.8);
|
||||
});
|
||||
|
||||
it('should return null when no row found', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.findById('missing');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findBySlug', () => {
|
||||
it('should return entity when slug matches', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([makeRawRow({ slug: 'my-slug' })]);
|
||||
|
||||
const result = await repo.findBySlug('my-slug');
|
||||
|
||||
expect(result).toBeInstanceOf(ProjectDevelopmentEntity);
|
||||
expect(result!.slug).toBe('my-slug');
|
||||
});
|
||||
|
||||
it('should return null for unmatched slug', async () => {
|
||||
const result = await repo.findBySlug('no-exist');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDetailBySlug', () => {
|
||||
it('should return detail data with propertyCount', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([
|
||||
{ ...makeRawRow(), propertyCount: 10 },
|
||||
]);
|
||||
|
||||
const result = await repo.findDetailBySlug('test');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.propertyCount).toBe(10);
|
||||
expect(result!.id).toBe('proj-1');
|
||||
});
|
||||
|
||||
it('should return null when slug not found', async () => {
|
||||
const result = await repo.findDetailBySlug('missing');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDetailById', () => {
|
||||
it('should return detail data', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([
|
||||
{ ...makeRawRow(), propertyCount: 3 },
|
||||
]);
|
||||
|
||||
const result = await repo.findDetailById('proj-1');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.propertyCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should return null when id not found', async () => {
|
||||
const result = await repo.findDetailById('missing');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('save', () => {
|
||||
it('should call $executeRaw', async () => {
|
||||
const entity = new ProjectDevelopmentEntity('proj-1', makeProps(), new Date(), new Date());
|
||||
|
||||
await repo.save(entity);
|
||||
|
||||
expect(mockPrisma.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should call $executeRaw', async () => {
|
||||
const entity = new ProjectDevelopmentEntity('proj-1', makeProps(), new Date(), new Date());
|
||||
|
||||
await repo.update(entity);
|
||||
|
||||
expect(mockPrisma.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should call prisma.projectDevelopment.delete', async () => {
|
||||
await repo.delete('proj-1');
|
||||
|
||||
expect(mockPrisma.projectDevelopment.delete).toHaveBeenCalledWith({
|
||||
where: { id: 'proj-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should return paginated results with defaults', async () => {
|
||||
mockPrisma.$queryRaw
|
||||
.mockResolvedValueOnce([{ count: BigInt(2) }])
|
||||
.mockResolvedValueOnce([
|
||||
{ ...makeRawRow({ id: 'p1' }), propertyCount: 1 },
|
||||
{ ...makeRawRow({ id: 'p2' }), propertyCount: 0 },
|
||||
]);
|
||||
|
||||
const result = await repo.search({});
|
||||
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(20);
|
||||
expect(result.totalPages).toBe(1);
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should apply pagination params', async () => {
|
||||
mockPrisma.$queryRaw
|
||||
.mockResolvedValueOnce([{ count: BigInt(50) }])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await repo.search({ page: 3, limit: 10 });
|
||||
|
||||
expect(result.page).toBe(3);
|
||||
expect(result.limit).toBe(10);
|
||||
expect(result.totalPages).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle null tags/suitableFor in raw rows', async () => {
|
||||
mockPrisma.$queryRaw
|
||||
.mockResolvedValueOnce([{ count: BigInt(1) }])
|
||||
.mockResolvedValueOnce([
|
||||
{ ...makeRawRow({ tags: null, suitableFor: null }), propertyCount: null },
|
||||
]);
|
||||
|
||||
const result = await repo.search({});
|
||||
|
||||
expect(result.data[0].tags).toEqual([]);
|
||||
expect(result.data[0].suitableFor).toEqual([]);
|
||||
expect(result.data[0].propertyCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import { ProjectsController } from '../controllers/projects.controller';
|
||||
|
||||
describe('ProjectsController', () => {
|
||||
let controller: ProjectsController;
|
||||
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockCommandBus = { execute: vi.fn() };
|
||||
mockQueryBus = { execute: vi.fn() };
|
||||
controller = new ProjectsController(mockCommandBus as any, mockQueryBus as any);
|
||||
});
|
||||
|
||||
const rawProject = {
|
||||
id: 'proj-1',
|
||||
name: 'Vinhomes',
|
||||
slug: 'vinhomes',
|
||||
developer: 'Vingroup',
|
||||
developerLogo: 'logo.png',
|
||||
media: [{ url: 'thumb.jpg', type: 'image', order: 0 }],
|
||||
status: 'UNDER_CONSTRUCTION',
|
||||
totalUnits: 100,
|
||||
completedUnits: 50,
|
||||
completionDate: new Date('2025-12-31'),
|
||||
};
|
||||
|
||||
// ── listProjects ──────────────────────────────────────────────
|
||||
describe('listProjects', () => {
|
||||
it('should call queryBus and shape results', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
data: [rawProject],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
const result = await controller.listProjects({} as any);
|
||||
|
||||
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].developer).toEqual({ id: 'Vingroup', name: 'Vingroup', logo: 'logo.png' });
|
||||
expect(result.data[0].thumbnailUrl).toBe('thumb.jpg');
|
||||
expect(result.data[0].propertyTypes).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle projects without media', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
data: [{ ...rawProject, media: null }],
|
||||
total: 1, page: 1, limit: 20, totalPages: 1,
|
||||
});
|
||||
|
||||
const result = await controller.listProjects({} as any);
|
||||
expect(result.data[0].thumbnailUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty media array', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
data: [{ ...rawProject, media: [] }],
|
||||
total: 1, page: 1, limit: 20, totalPages: 1,
|
||||
});
|
||||
|
||||
const result = await controller.listProjects({} as any);
|
||||
expect(result.data[0].thumbnailUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── listMyProjects ────────────────────────────────────────────
|
||||
describe('listMyProjects', () => {
|
||||
it('should pass user.sub as ownerId', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
data: [],
|
||||
total: 0, page: 1, limit: 20, totalPages: 0,
|
||||
});
|
||||
|
||||
const user = { sub: 'dev-user-1', role: 'DEVELOPER' };
|
||||
await controller.listMyProjects(user as any, {} as any);
|
||||
|
||||
const query = mockQueryBus.execute.mock.calls[0][0];
|
||||
expect(query.ownerId).toBe('dev-user-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getProjectStats ───────────────────────────────────────────
|
||||
describe('getProjectStats', () => {
|
||||
it('should pass id and user info to query', async () => {
|
||||
const stats = {
|
||||
projectId: 'proj-1',
|
||||
linkedListingCount: 5,
|
||||
activeListingCount: 3,
|
||||
totalInquiries: 10,
|
||||
unreadInquiries: 2,
|
||||
savedByUsers: 8,
|
||||
};
|
||||
mockQueryBus.execute.mockResolvedValue(stats);
|
||||
|
||||
const user = { sub: 'admin-1', role: 'ADMIN' };
|
||||
const result = await controller.getProjectStats(user as any, 'proj-1');
|
||||
|
||||
expect(result).toEqual(stats);
|
||||
const query = mockQueryBus.execute.mock.calls[0][0];
|
||||
expect(query.projectId).toBe('proj-1');
|
||||
expect(query.requesterUserId).toBe('admin-1');
|
||||
expect(query.requesterRole).toBe('ADMIN');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getProject ────────────────────────────────────────────────
|
||||
describe('getProject', () => {
|
||||
it('should return shaped detail with media preserved', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue(rawProject);
|
||||
|
||||
const result = await controller.getProject('vinhomes');
|
||||
|
||||
expect(result.developer).toEqual({ id: 'Vingroup', name: 'Vingroup', logo: 'logo.png' });
|
||||
expect(result.media).toEqual([{ url: 'thumb.jpg', type: 'image', order: 0 }]);
|
||||
expect(result.completionDate).toEqual(new Date('2025-12-31'));
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when not found', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue(null);
|
||||
|
||||
await expect(controller.getProject('missing')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should return empty media array for null media', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({ ...rawProject, media: null });
|
||||
|
||||
const result = await controller.getProject('vinhomes');
|
||||
expect(result.media).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createProject ─────────────────────────────────────────────
|
||||
describe('createProject', () => {
|
||||
const dto = {
|
||||
name: 'New Project',
|
||||
slug: 'new-project',
|
||||
developer: 'Dev',
|
||||
totalUnits: 100,
|
||||
status: 'PLANNING' as const,
|
||||
latitude: 10.8,
|
||||
longitude: 106.8,
|
||||
address: 'addr',
|
||||
ward: 'ward',
|
||||
district: 'dist',
|
||||
city: 'HCM',
|
||||
tags: ['tag1'],
|
||||
};
|
||||
|
||||
it('should set ownerId for DEVELOPER role', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ id: 'new-id', slug: 'new-project' });
|
||||
|
||||
const user = { sub: 'dev-1', role: 'DEVELOPER' };
|
||||
await controller.createProject(user as any, dto as any);
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.ownerId).toBe('dev-1');
|
||||
});
|
||||
|
||||
it('should set ownerId to null for ADMIN role', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ id: 'new-id', slug: 'new-project' });
|
||||
|
||||
const user = { sub: 'admin-1', role: 'ADMIN' };
|
||||
await controller.createProject(user as any, dto as any);
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.ownerId).toBeNull();
|
||||
});
|
||||
|
||||
it('should convert price strings to BigInt', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ id: 'id', slug: 'slug' });
|
||||
|
||||
const user = { sub: 'admin-1', role: 'ADMIN' };
|
||||
await controller.createProject(user as any, {
|
||||
...dto,
|
||||
minPrice: '3000000000',
|
||||
maxPrice: '15000000000',
|
||||
} as any);
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.minPrice).toBe(3_000_000_000n);
|
||||
expect(cmd.maxPrice).toBe(15_000_000_000n);
|
||||
});
|
||||
|
||||
it('should convert date strings to Date objects', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ id: 'id', slug: 'slug' });
|
||||
|
||||
const user = { sub: 'admin-1', role: 'ADMIN' };
|
||||
await controller.createProject(user as any, {
|
||||
...dto,
|
||||
startDate: '2020-06-01',
|
||||
completionDate: '2025-12-31',
|
||||
} as any);
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.startDate).toEqual(new Date('2020-06-01'));
|
||||
expect(cmd.completionDate).toEqual(new Date('2025-12-31'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateProject ─────────────────────────────────────────────
|
||||
describe('updateProject', () => {
|
||||
it('should pass user info and dto to command', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ id: 'proj-1' });
|
||||
|
||||
const user = { sub: 'admin-1', role: 'ADMIN' };
|
||||
await controller.updateProject(user as any, 'proj-1', { name: 'Updated' } as any);
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.id).toBe('proj-1');
|
||||
expect(cmd.requesterUserId).toBe('admin-1');
|
||||
expect(cmd.requesterRole).toBe('ADMIN');
|
||||
expect(cmd.name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('should convert minPrice/maxPrice to BigInt when present', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ id: 'proj-1' });
|
||||
|
||||
const user = { sub: 'admin-1', role: 'ADMIN' };
|
||||
await controller.updateProject(user as any, 'proj-1', {
|
||||
minPrice: '5000000000',
|
||||
maxPrice: '10000000000',
|
||||
} as any);
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.minPrice).toBe(5_000_000_000n);
|
||||
expect(cmd.maxPrice).toBe(10_000_000_000n);
|
||||
});
|
||||
|
||||
it('should pass undefined for unset price fields', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ id: 'proj-1' });
|
||||
|
||||
const user = { sub: 'admin-1', role: 'ADMIN' };
|
||||
await controller.updateProject(user as any, 'proj-1', { name: 'X' } as any);
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.minPrice).toBeUndefined();
|
||||
expect(cmd.maxPrice).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteProject ─────────────────────────────────────────────
|
||||
describe('deleteProject', () => {
|
||||
it('should call commandBus and return success', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue(undefined);
|
||||
|
||||
const user = { sub: 'admin-1', role: 'ADMIN' };
|
||||
const result = await controller.deleteProject(user as any, 'proj-1');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.id).toBe('proj-1');
|
||||
expect(cmd.requesterUserId).toBe('admin-1');
|
||||
expect(cmd.requesterRole).toBe('ADMIN');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user