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:
Ho Ngoc Hai
2026-04-23 20:56:56 +07:00
parent 2788b35108
commit ef867f5773
11 changed files with 1420 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
});
});

View File

@@ -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
});
});

View File

@@ -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);
});
});

View File

@@ -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([]);
});
});
});

View File

@@ -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);
});
});
});

View File

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