From ef867f57735126703dd6a9993429cc9e90070d07 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 23 Apr 2026 20:56:56 +0700 Subject: [PATCH] fix(auth,analytics): replace throw new Error with DomainException subclasses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../services/prisma-avm.service.ts | 6 +- .../infrastructure/services/oauth.service.ts | 8 +- .../__tests__/create-project.handler.spec.ts | 143 ++++++++++ .../__tests__/delete-project.handler.spec.ts | 111 ++++++++ .../get-project-stats.handler.spec.ts | 116 ++++++++ .../application/__tests__/queries.spec.ts | 164 +++++++++++ .../__tests__/update-project.handler.spec.ts | 155 +++++++++++ .../projects/domain/__tests__/events.spec.ts | 33 +++ .../project-development.entity.spec.ts | 230 ++++++++++++++++ ...sma-project-development.repository.spec.ts | 202 ++++++++++++++ .../__tests__/projects.controller.spec.ts | 259 ++++++++++++++++++ 11 files changed, 1420 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/modules/projects/application/__tests__/create-project.handler.spec.ts create mode 100644 apps/api/src/modules/projects/application/__tests__/delete-project.handler.spec.ts create mode 100644 apps/api/src/modules/projects/application/__tests__/get-project-stats.handler.spec.ts create mode 100644 apps/api/src/modules/projects/application/__tests__/queries.spec.ts create mode 100644 apps/api/src/modules/projects/application/__tests__/update-project.handler.spec.ts create mode 100644 apps/api/src/modules/projects/domain/__tests__/events.spec.ts create mode 100644 apps/api/src/modules/projects/domain/__tests__/project-development.entity.spec.ts create mode 100644 apps/api/src/modules/projects/infrastructure/__tests__/prisma-project-development.repository.spec.ts create mode 100644 apps/api/src/modules/projects/presentation/__tests__/projects.controller.spec.ts diff --git a/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts b/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts index 4d66a08..1783ffa 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts @@ -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 { @@ -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; } diff --git a/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts b/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts index d19e482..23b3b95 100644 --- a/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts +++ b/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts @@ -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'); diff --git a/apps/api/src/modules/projects/application/__tests__/create-project.handler.spec.ts b/apps/api/src/modules/projects/application/__tests__/create-project.handler.spec.ts new file mode 100644 index 0000000..5c5f153 --- /dev/null +++ b/apps/api/src/modules/projects/application/__tests__/create-project.handler.spec.ts @@ -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; + save: ReturnType; + }; + + 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 { + 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(); + }); +}); diff --git a/apps/api/src/modules/projects/application/__tests__/delete-project.handler.spec.ts b/apps/api/src/modules/projects/application/__tests__/delete-project.handler.spec.ts new file mode 100644 index 0000000..b462f6c --- /dev/null +++ b/apps/api/src/modules/projects/application/__tests__/delete-project.handler.spec.ts @@ -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; + delete: ReturnType; + }; + + 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(); + }); +}); diff --git a/apps/api/src/modules/projects/application/__tests__/get-project-stats.handler.spec.ts b/apps/api/src/modules/projects/application/__tests__/get-project-stats.handler.spec.ts new file mode 100644 index 0000000..6bd2f46 --- /dev/null +++ b/apps/api/src/modules/projects/application/__tests__/get-project-stats.handler.spec.ts @@ -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 }; + let mockPrisma: { $queryRaw: ReturnType }; + + 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'); + }); +}); diff --git a/apps/api/src/modules/projects/application/__tests__/queries.spec.ts b/apps/api/src/modules/projects/application/__tests__/queries.spec.ts new file mode 100644 index 0000000..d7118de --- /dev/null +++ b/apps/api/src/modules/projects/application/__tests__/queries.spec.ts @@ -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; + findDetailById: ReturnType; + }; + + 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 }; + + const paginatedResult: PaginatedResult = { + 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); + }); +}); diff --git a/apps/api/src/modules/projects/application/__tests__/update-project.handler.spec.ts b/apps/api/src/modules/projects/application/__tests__/update-project.handler.spec.ts new file mode 100644 index 0000000..76a709b --- /dev/null +++ b/apps/api/src/modules/projects/application/__tests__/update-project.handler.spec.ts @@ -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 { + 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 = {}): ProjectDevelopmentEntity { + return new ProjectDevelopmentEntity(id, makeProps(overrides), new Date(), new Date()); +} + +describe('UpdateProjectHandler', () => { + let handler: UpdateProjectHandler; + let mockRepo: { + findById: ReturnType; + update: ReturnType; + }; + + 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 + }); +}); diff --git a/apps/api/src/modules/projects/domain/__tests__/events.spec.ts b/apps/api/src/modules/projects/domain/__tests__/events.spec.ts new file mode 100644 index 0000000..8752785 --- /dev/null +++ b/apps/api/src/modules/projects/domain/__tests__/events.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/projects/domain/__tests__/project-development.entity.spec.ts b/apps/api/src/modules/projects/domain/__tests__/project-development.entity.spec.ts new file mode 100644 index 0000000..f3eb53d --- /dev/null +++ b/apps/api/src/modules/projects/domain/__tests__/project-development.entity.spec.ts @@ -0,0 +1,230 @@ +import { ProjectDevelopmentEntity, type ProjectDevelopmentProps } from '../entities/project-development.entity'; + +function makeProps(overrides: Partial = {}): 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 = {}): 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([]); + }); + }); +}); diff --git a/apps/api/src/modules/projects/infrastructure/__tests__/prisma-project-development.repository.spec.ts b/apps/api/src/modules/projects/infrastructure/__tests__/prisma-project-development.repository.spec.ts new file mode 100644 index 0000000..bb8b1f5 --- /dev/null +++ b/apps/api/src/modules/projects/infrastructure/__tests__/prisma-project-development.repository.spec.ts @@ -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 { + 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 = {}) { + 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; + $executeRaw: ReturnType; + projectDevelopment: { delete: ReturnType }; + }; + + 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); + }); + }); +}); diff --git a/apps/api/src/modules/projects/presentation/__tests__/projects.controller.spec.ts b/apps/api/src/modules/projects/presentation/__tests__/projects.controller.spec.ts new file mode 100644 index 0000000..5b3e251 --- /dev/null +++ b/apps/api/src/modules/projects/presentation/__tests__/projects.controller.spec.ts @@ -0,0 +1,259 @@ +import { NotFoundException } from '@modules/shared'; +import { ProjectsController } from '../controllers/projects.controller'; + +describe('ProjectsController', () => { + let controller: ProjectsController; + let mockCommandBus: { execute: ReturnType }; + let mockQueryBus: { execute: ReturnType }; + + 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'); + }); + }); +});