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..9c2d264 --- /dev/null +++ b/apps/api/src/modules/projects/application/__tests__/create-project.handler.spec.ts @@ -0,0 +1,97 @@ +import { ConflictException } from '@modules/shared'; +import { CreateProjectCommand } from '../commands/create-project/create-project.command'; +import { CreateProjectHandler } from '../commands/create-project/create-project.handler'; + +function makeCommand(overrides: Partial> = {}): CreateProjectCommand { + return new CreateProjectCommand( + (overrides.name as string) ?? 'Test Project', + (overrides.slug as string) ?? 'test-project', + (overrides.developer as string) ?? 'DevCorp', + (overrides.developerLogo as string | null) ?? null, + (overrides.totalUnits as number) ?? 100, + (overrides.status as 'PLANNING') ?? 'PLANNING', + (overrides.latitude as number) ?? 10.82, + (overrides.longitude as number) ?? 106.83, + (overrides.address as string) ?? 'addr', + (overrides.ward as string) ?? 'ward', + (overrides.district as string) ?? 'dist', + (overrides.city as string) ?? 'HCM', + null, null, null, null, null, null, null, null, null, null, + [], + null, null, + (overrides.suitableFor as string[]) ?? [], + (overrides.whyThisLocation as string | null) ?? null, + (overrides.ownerId as string | null) ?? null, + ); +} + +describe('CreateProjectHandler', () => { + let handler: CreateProjectHandler; + let mockRepo: { + findBySlug: ReturnType; + save: ReturnType; + }; + + beforeEach(() => { + mockRepo = { + findBySlug: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(undefined), + }; + handler = new CreateProjectHandler(mockRepo as any); + }); + + it('should create project and return id + slug', async () => { + const cmd = makeCommand(); + const result = await handler.execute(cmd); + + expect(result.id).toBeDefined(); + expect(result.slug).toBe('test-project'); + expect(mockRepo.save).toHaveBeenCalledTimes(1); + }); + + it('should generate unique id via cuid2', async () => { + const r1 = await handler.execute(makeCommand({ slug: 'slug-1' })); + mockRepo.findBySlug.mockResolvedValue(null); + const r2 = await handler.execute(makeCommand({ slug: 'slug-2' })); + + expect(r1.id).not.toBe(r2.id); + }); + + it('should throw ConflictException when slug already exists', async () => { + mockRepo.findBySlug.mockResolvedValue({ id: '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: ['family'], whyThisLocation: 'Near metro' }); + await handler.execute(cmd); + + const saved = mockRepo.save.mock.calls[0][0]; + expect(saved.suitableFor).toEqual(['family']); + expect(saved.whyThisLocation).toBe('Near metro'); + }); + + it('should pass ownerId to entity', async () => { + const cmd = makeCommand({ ownerId: 'dev-1' }); + await handler.execute(cmd); + + const saved = mockRepo.save.mock.calls[0][0]; + expect(saved.ownerId).toBe('dev-1'); + }); + + it('should default completedUnits to 0', async () => { + await handler.execute(makeCommand()); + + const saved = mockRepo.save.mock.calls[0][0]; + expect(saved.completedUnits).toBe(0); + }); + + it('should default isVerified to false', async () => { + await handler.execute(makeCommand()); + + const saved = mockRepo.save.mock.calls[0][0]; + expect(saved.isVerified).toBe(false); + }); +}); 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..a5e8e86 --- /dev/null +++ b/apps/api/src/modules/projects/application/__tests__/delete-project.handler.spec.ts @@ -0,0 +1,89 @@ +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 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('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()); + + const cmd = new DeleteProjectCommand('proj-1', 'admin-1', 'ADMIN'); + await handler.execute(cmd); + + expect(mockRepo.delete).toHaveBeenCalledWith('proj-1'); + }); + + it('should throw NotFoundException when project not found', async () => { + mockRepo.findById.mockResolvedValue(null); + + const cmd = new DeleteProjectCommand('missing', 'admin-1', 'ADMIN'); + await expect(handler.execute(cmd)).rejects.toThrow(NotFoundException); + expect(mockRepo.delete).not.toHaveBeenCalled(); + }); + + it('should allow DEVELOPER to delete own project', async () => { + mockRepo.findById.mockResolvedValue(makeEntity('proj-1', { ownerId: 'dev-user-1' })); + + const cmd = new DeleteProjectCommand('proj-1', 'dev-user-1', 'DEVELOPER'); + await handler.execute(cmd); + + expect(mockRepo.delete).toHaveBeenCalledWith('proj-1'); + }); + + it('should deny DEVELOPER deleting another owners project', async () => { + mockRepo.findById.mockResolvedValue(makeEntity('proj-1', { ownerId: 'other-dev' })); + + const cmd = new DeleteProjectCommand('proj-1', 'dev-user-1', 'DEVELOPER'); + await expect(handler.execute(cmd)).rejects.toThrow(ForbiddenException); + expect(mockRepo.delete).not.toHaveBeenCalled(); + }); + + it('should deny USER role from deleting', async () => { + mockRepo.findById.mockResolvedValue(makeEntity()); + + const cmd = new DeleteProjectCommand('proj-1', 'user-1', 'USER'); + await expect(handler.execute(cmd)).rejects.toThrow(ForbiddenException); + }); + + it('should deny AGENT role from deleting', async () => { + mockRepo.findById.mockResolvedValue(makeEntity()); + + const cmd = new DeleteProjectCommand('proj-1', 'agent-1', 'AGENT'); + await expect(handler.execute(cmd)).rejects.toThrow(ForbiddenException); + }); +}); 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..082af5c --- /dev/null +++ b/apps/api/src/modules/projects/application/__tests__/get-project-stats.handler.spec.ts @@ -0,0 +1,106 @@ +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 makeProps(overrides: Partial = {}): ProjectDevelopmentProps { + return { + name: 'Test', slug: 'test', developer: 'Dev', developerLogo: null, + totalUnits: 100, 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: '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-1', ...overrides, + }; +} + +function makeEntity(id = 'proj-1', overrides: Partial = {}): ProjectDevelopmentEntity { + return new ProjectDevelopmentEntity(id, makeProps(overrides), new Date(), new Date()); +} + +describe('GetProjectStatsHandler', () => { + let handler: GetProjectStatsHandler; + let mockRepo: { findById: ReturnType }; + let mockPrisma: { $queryRaw: ReturnType }; + + const defaultStatsRow = [{ + linked: BigInt(5), active: BigInt(3), + inquiries: BigInt(10), unread: BigInt(2), saves: BigInt(7), + }]; + + beforeEach(() => { + mockRepo = { findById: vi.fn() }; + mockPrisma = { $queryRaw: vi.fn().mockResolvedValue(defaultStatsRow) }; + handler = new GetProjectStatsHandler(mockRepo as any, mockPrisma as any); + }); + + it('should return stats for ADMIN', async () => { + mockRepo.findById.mockResolvedValue(makeEntity()); + + const query = new GetProjectStatsQuery('proj-1', 'admin-1', 'ADMIN'); + const result = await handler.execute(query); + + expect(result.projectId).toBe('proj-1'); + expect(result.linkedListingCount).toBe(5); + expect(result.activeListingCount).toBe(3); + expect(result.totalInquiries).toBe(10); + expect(result.unreadInquiries).toBe(2); + expect(result.savedByUsers).toBe(7); + }); + + it('should return stats for DEVELOPER who owns the project', async () => { + mockRepo.findById.mockResolvedValue(makeEntity('proj-1', { ownerId: 'dev-1' })); + + const query = new GetProjectStatsQuery('proj-1', 'dev-1', 'DEVELOPER'); + const result = await handler.execute(query); + + expect(result.projectId).toBe('proj-1'); + }); + + it('should throw NotFoundException when project not found', async () => { + mockRepo.findById.mockResolvedValue(null); + + const query = new GetProjectStatsQuery('missing', 'admin-1', 'ADMIN'); + await expect(handler.execute(query)).rejects.toThrow(NotFoundException); + }); + + it('should deny DEVELOPER viewing stats of another owners project', async () => { + mockRepo.findById.mockResolvedValue(makeEntity('proj-1', { ownerId: 'other-dev' })); + + const query = new GetProjectStatsQuery('proj-1', 'dev-1', 'DEVELOPER'); + await expect(handler.execute(query)).rejects.toThrow(ForbiddenException); + }); + + it('should deny USER role', async () => { + mockRepo.findById.mockResolvedValue(makeEntity()); + + const query = new GetProjectStatsQuery('proj-1', 'user-1', 'USER'); + await expect(handler.execute(query)).rejects.toThrow(ForbiddenException); + }); + + it('should handle empty stats row', async () => { + mockRepo.findById.mockResolvedValue(makeEntity()); + mockPrisma.$queryRaw.mockResolvedValue([]); + + const query = new GetProjectStatsQuery('proj-1', 'admin-1', 'ADMIN'); + const result = await handler.execute(query); + + expect(result.linkedListingCount).toBe(0); + expect(result.activeListingCount).toBe(0); + expect(result.totalInquiries).toBe(0); + expect(result.unreadInquiries).toBe(0); + expect(result.savedByUsers).toBe(0); + }); + + it('should construct query DTO correctly', () => { + const q = new GetProjectStatsQuery('p1', 'u1', 'ADMIN'); + expect(q.projectId).toBe('p1'); + expect(q.requesterUserId).toBe('u1'); + 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..e1f0524 --- /dev/null +++ b/apps/api/src/modules/projects/application/__tests__/queries.spec.ts @@ -0,0 +1,105 @@ +import { GetProjectQuery } from '../queries/get-project/get-project.query'; +import { GetProjectHandler } from '../queries/get-project/get-project.handler'; +import { ListProjectsQuery } from '../queries/list-projects/list-projects.query'; +import { ListProjectsHandler } from '../queries/list-projects/list-projects.handler'; + +describe('GetProjectQuery', () => { + it('should store slugOrId', () => { + const q = new GetProjectQuery('my-slug'); + expect(q.slugOrId).toBe('my-slug'); + }); +}); + +describe('GetProjectHandler', () => { + let handler: GetProjectHandler; + let mockRepo: { + findDetailBySlug: ReturnType; + findDetailById: ReturnType; + }; + + beforeEach(() => { + mockRepo = { + findDetailBySlug: vi.fn().mockResolvedValue(null), + findDetailById: vi.fn().mockResolvedValue(null), + }; + handler = new GetProjectHandler(mockRepo as any); + }); + + it('should try slug first', async () => { + const detail = { id: 'p1', name: 'Project' }; + mockRepo.findDetailBySlug.mockResolvedValue(detail); + + const result = await handler.execute(new GetProjectQuery('my-slug')); + + expect(result).toBe(detail); + expect(mockRepo.findDetailBySlug).toHaveBeenCalledWith('my-slug'); + expect(mockRepo.findDetailById).not.toHaveBeenCalled(); + }); + + it('should fall back to id when slug returns null', async () => { + const detail = { id: 'p1', name: 'Project' }; + mockRepo.findDetailById.mockResolvedValue(detail); + + const result = await handler.execute(new GetProjectQuery('some-id')); + + expect(result).toBe(detail); + expect(mockRepo.findDetailBySlug).toHaveBeenCalledWith('some-id'); + expect(mockRepo.findDetailById).toHaveBeenCalledWith('some-id'); + }); + + it('should return null when neither slug nor id match', async () => { + const result = await handler.execute(new GetProjectQuery('nope')); + expect(result).toBeNull(); + }); +}); + +describe('ListProjectsQuery', () => { + it('should store all params', () => { + const q = new ListProjectsQuery('search', 'PLANNING', 'HCM', 'Q1', 'Dev', true, 2, 10, 'owner-1'); + expect(q.query).toBe('search'); + expect(q.status).toBe('PLANNING'); + expect(q.city).toBe('HCM'); + expect(q.district).toBe('Q1'); + expect(q.developer).toBe('Dev'); + expect(q.isVerified).toBe(true); + expect(q.page).toBe(2); + expect(q.limit).toBe(10); + expect(q.ownerId).toBe('owner-1'); + }); +}); + +describe('ListProjectsHandler', () => { + let handler: ListProjectsHandler; + let mockRepo: { search: ReturnType }; + + beforeEach(() => { + mockRepo = { search: vi.fn() }; + handler = new ListProjectsHandler(mockRepo as any); + }); + + it('should delegate to repo.search with correct params', async () => { + const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 }; + mockRepo.search.mockResolvedValue(expected); + + const q = new ListProjectsQuery('q', 'COMPLETED', 'HCM', 'Q2', 'VG', false, 1, 20, 'own-1'); + const result = await handler.execute(q); + + expect(mockRepo.search).toHaveBeenCalledWith({ + query: 'q', status: 'COMPLETED', city: 'HCM', district: 'Q2', + developer: 'VG', isVerified: false, ownerId: 'own-1', page: 1, limit: 20, + }); + expect(result).toBe(expected); + }); + + it('should return paginated result from repo', async () => { + const data = { data: [{ id: 'p1' }], total: 1, page: 1, limit: 10, totalPages: 1 }; + mockRepo.search.mockResolvedValue(data); + + const result = await handler.execute( + new ListProjectsQuery(undefined, undefined, undefined, undefined, undefined, undefined, 1, 10), + ); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + }); +}); 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__/project-development.entity.spec.ts b/apps/api/src/modules/projects/domain/__tests__/project-development.entity.spec.ts new file mode 100644 index 0000000..c0467ab --- /dev/null +++ b/apps/api/src/modules/projects/domain/__tests__/project-development.entity.spec.ts @@ -0,0 +1,155 @@ +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: 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: null, ...overrides, + }; +} + +describe('ProjectDevelopmentEntity', () => { + const now = new Date(); + + it('should expose all getters correctly', () => { + const entity = new ProjectDevelopmentEntity('id-1', makeProps(), now, now); + + expect(entity.id).toBe('id-1'); + expect(entity.name).toBe('Vinhomes Grand Park'); + expect(entity.slug).toBe('vinhomes-grand-park'); + expect(entity.developer).toBe('Vingroup'); + expect(entity.developerLogo).toBeNull(); + expect(entity.totalUnits).toBe(10000); + expect(entity.completedUnits).toBe(5000); + expect(entity.status).toBe('UNDER_CONSTRUCTION'); + expect(entity.latitude).toBe(10.8231); + expect(entity.longitude).toBe(106.8368); + expect(entity.address).toBe('addr'); + expect(entity.city).toBe('HCM'); + expect(entity.tags).toEqual([]); + expect(entity.suitableFor).toEqual([]); + expect(entity.isVerified).toBe(false); + expect(entity.ownerId).toBeNull(); + expect(entity.createdAt).toBe(now); + expect(entity.updatedAt).toBe(now); + }); + + it('should support nullable fields', () => { + const entity = new ProjectDevelopmentEntity('id-1', makeProps({ + startDate: new Date('2024-01-01'), + completionDate: new Date('2026-12-31'), + description: 'A project', + amenities: { pool: true }, + masterPlanUrl: 'https://example.com/plan.pdf', + minPrice: BigInt(1_000_000_000), + maxPrice: BigInt(5_000_000_000), + pricePerM2Range: { min: 50, max: 80 }, + totalArea: 250.5, + buildingCount: 10, + floorCount: 30, + unitTypes: { studio: 100 }, + media: [{ url: 'img.jpg' }], + documents: [{ url: 'doc.pdf' }], + whyThisLocation: 'Near metro', + ownerId: 'dev-1', + }), now, now); + + expect(entity.startDate).toEqual(new Date('2024-01-01')); + expect(entity.minPrice).toBe(BigInt(1_000_000_000)); + expect(entity.media).toEqual([{ url: 'img.jpg' }]); + expect(entity.ownerId).toBe('dev-1'); + expect(entity.whyThisLocation).toBe('Near metro'); + }); + + it('should update only provided fields via updateDetails', () => { + const entity = new ProjectDevelopmentEntity('id-1', makeProps(), now, now); + + entity.updateDetails({ name: 'New Name', status: 'COMPLETED' }); + + expect(entity.name).toBe('New Name'); + expect(entity.status).toBe('COMPLETED'); + expect(entity.developer).toBe('Vingroup'); // unchanged + }); + + it('should allow setting fields to null via updateDetails', () => { + const entity = new ProjectDevelopmentEntity('id-1', makeProps({ + description: 'desc', masterPlanUrl: 'url', + }), now, now); + + entity.updateDetails({ description: null, masterPlanUrl: null }); + + expect(entity.description).toBeNull(); + expect(entity.masterPlanUrl).toBeNull(); + }); + + it('should update ownerId via updateDetails', () => { + const entity = new ProjectDevelopmentEntity('id-1', makeProps({ ownerId: 'old' }), now, now); + + entity.updateDetails({ ownerId: 'new-owner' }); + + expect(entity.ownerId).toBe('new-owner'); + }); + + it('should update suitableFor and whyThisLocation', () => { + const entity = new ProjectDevelopmentEntity('id-1', makeProps(), now, now); + + entity.updateDetails({ suitableFor: ['family', 'investor'], whyThisLocation: 'Prime location' }); + + expect(entity.suitableFor).toEqual(['family', 'investor']); + expect(entity.whyThisLocation).toBe('Prime location'); + }); + + it('should update updatedAt when updateDetails is called', () => { + const oldDate = new Date('2020-01-01'); + const entity = new ProjectDevelopmentEntity('id-1', makeProps(), oldDate, oldDate); + + entity.updateDetails({ name: 'X' }); + + expect(entity.updatedAt.getTime()).toBeGreaterThan(oldDate.getTime()); + }); + + it('should implement equals from BaseEntity', () => { + const a = new ProjectDevelopmentEntity('id-1', makeProps(), now, now); + const b = new ProjectDevelopmentEntity('id-1', makeProps({ name: 'Other' }), now, now); + const c = new ProjectDevelopmentEntity('id-2', makeProps(), now, now); + + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }); + + it('should support domain events (AggregateRoot)', () => { + const entity = new ProjectDevelopmentEntity('id-1', makeProps(), now, now); + + expect(entity.domainEvents).toEqual([]); + expect(entity.commit()).toEqual([]); + }); + + it('should update media and documents', () => { + const entity = new ProjectDevelopmentEntity('id-1', makeProps(), now, now); + + entity.updateDetails({ + media: [{ url: 'new.jpg', type: 'image' }], + documents: [{ url: 'doc.pdf' }], + }); + + expect(entity.media).toEqual([{ url: 'new.jpg', type: 'image' }]); + expect(entity.documents).toEqual([{ url: 'doc.pdf' }]); + }); + + it('should update tags and isVerified', () => { + const entity = new ProjectDevelopmentEntity('id-1', makeProps(), now, now); + + entity.updateDetails({ tags: ['luxury', 'new'], isVerified: true }); + + expect(entity.tags).toEqual(['luxury', 'new']); + expect(entity.isVerified).toBe(true); + }); +}); 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..cefbe17 --- /dev/null +++ b/apps/api/src/modules/projects/infrastructure/__tests__/prisma-project-development.repository.spec.ts @@ -0,0 +1,164 @@ +import { PrismaProjectDevelopmentRepository } from '../repositories/prisma-project-development.repository'; +import { ProjectDevelopmentEntity, type ProjectDevelopmentProps } from '../../domain/entities/project-development.entity'; + +function makeProps(overrides: Partial = {}): ProjectDevelopmentProps { + return { + name: 'Test', slug: 'test', developer: 'Dev', developerLogo: null, + totalUnits: 100, 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: '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: null, ...overrides, + }; +} + +function makeRawRow(overrides: Record = {}) { + return { + id: 'p1', 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, lng: 106, address: 'a', 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: [], suitableFor: [], whyThisLocation: null, + isVerified: false, ownerId: null, + createdAt: new Date(), updatedAt: new Date(), + ...overrides, + }; +} + +describe('PrismaProjectDevelopmentRepository', () => { + let repo: PrismaProjectDevelopmentRepository; + let mockPrisma: { + $queryRaw: ReturnType; + $executeRaw: ReturnType; + projectDevelopment: { delete: ReturnType }; + }; + + beforeEach(() => { + mockPrisma = { + $queryRaw: vi.fn(), + $executeRaw: vi.fn().mockResolvedValue(1), + projectDevelopment: { delete: vi.fn().mockResolvedValue({}) }, + }; + repo = new PrismaProjectDevelopmentRepository(mockPrisma as any); + }); + + it('findById should return entity when found', async () => { + mockPrisma.$queryRaw.mockResolvedValue([makeRawRow()]); + + const result = await repo.findById('p1'); + + expect(result).toBeInstanceOf(ProjectDevelopmentEntity); + expect(result!.id).toBe('p1'); + expect(result!.name).toBe('Test'); + }); + + it('findById should return null when not found', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + + const result = await repo.findById('missing'); + expect(result).toBeNull(); + }); + + it('findBySlug should return entity when found', 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('findBySlug should return null when not found', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + expect(await repo.findBySlug('nope')).toBeNull(); + }); + + it('findDetailBySlug should return detail data when found', async () => { + mockPrisma.$queryRaw.mockResolvedValue([makeRawRow({ propertyCount: 5 })]); + + const result = await repo.findDetailBySlug('test'); + expect(result).not.toBeNull(); + expect(result!.id).toBe('p1'); + expect(result!.propertyCount).toBe(5); + }); + + it('findDetailBySlug should return null when not found', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + expect(await repo.findDetailBySlug('nope')).toBeNull(); + }); + + it('findDetailById should return detail data when found', async () => { + mockPrisma.$queryRaw.mockResolvedValue([makeRawRow({ propertyCount: 3 })]); + + const result = await repo.findDetailById('p1'); + expect(result).not.toBeNull(); + expect(result!.propertyCount).toBe(3); + }); + + it('findDetailById should return null when not found', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + expect(await repo.findDetailById('nope')).toBeNull(); + }); + + it('save should call $executeRaw', async () => { + const entity = new ProjectDevelopmentEntity('p1', makeProps(), new Date(), new Date()); + await repo.save(entity); + expect(mockPrisma.$executeRaw).toHaveBeenCalledTimes(1); + }); + + it('update should call $executeRaw', async () => { + const entity = new ProjectDevelopmentEntity('p1', makeProps(), new Date(), new Date()); + await repo.update(entity); + expect(mockPrisma.$executeRaw).toHaveBeenCalledTimes(1); + }); + + it('delete should call prisma.projectDevelopment.delete', async () => { + await repo.delete('p1'); + expect(mockPrisma.projectDevelopment.delete).toHaveBeenCalledWith({ where: { id: 'p1' } }); + }); + + it('search should return paginated results', async () => { + // First call: count query; second call: data query + mockPrisma.$queryRaw + .mockResolvedValueOnce([{ count: BigInt(1) }]) + .mockResolvedValueOnce([makeRawRow({ propertyCount: 2 })]); + + const result = await repo.search({ page: 1, limit: 10 }); + + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + expect(result.totalPages).toBe(1); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe('p1'); + }); + + it('search should handle null tags/suitableFor in raw rows', async () => { + mockPrisma.$queryRaw + .mockResolvedValueOnce([{ count: BigInt(1) }]) + .mockResolvedValueOnce([makeRawRow({ tags: null, suitableFor: null, propertyCount: 0 })]); + + const result = await repo.search({ page: 1, limit: 20 }); + + expect(result.data[0].tags).toEqual([]); + expect(result.data[0].suitableFor).toEqual([]); + }); + + it('search should default page=1 and limit=20', async () => { + mockPrisma.$queryRaw + .mockResolvedValueOnce([{ count: BigInt(0) }]) + .mockResolvedValueOnce([]); + + const result = await repo.search({}); + + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); +}); 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..7e4fec0 --- /dev/null +++ b/apps/api/src/modules/projects/presentation/__tests__/projects.controller.spec.ts @@ -0,0 +1,254 @@ +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); + }); + + // ── listProjects ────────────────────────────────────────────────── + + describe('listProjects', () => { + it('should shape projects with developer object and thumbnailUrl', async () => { + mockQueryBus.execute.mockResolvedValue({ + data: [{ + id: 'p1', name: 'Test', slug: 'test', + developer: 'Vingroup', developerLogo: 'logo.png', + media: [{ url: 'img.jpg', type: 'image' }], + }], + total: 1, page: 1, limit: 20, totalPages: 1, + }); + + const result = await controller.listProjects({ page: 1, limit: 20 } as any); + + expect(result.data[0].developer).toEqual({ + id: 'Vingroup', name: 'Vingroup', logo: 'logo.png', + }); + expect(result.data[0].thumbnailUrl).toBe('img.jpg'); + expect(result.data[0]).not.toHaveProperty('media'); + }); + + it('should handle null media', async () => { + mockQueryBus.execute.mockResolvedValue({ + data: [{ id: 'p1', name: 'T', slug: 's', developer: 'D', developerLogo: null, 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: [{ id: 'p1', name: 'T', slug: 's', developer: 'D', developerLogo: null, 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-1', role: 'DEVELOPER' }; + await controller.listMyProjects(user as any, {} as any); + + const query = mockQueryBus.execute.mock.calls[0][0]; + expect(query.ownerId).toBe('dev-1'); + }); + }); + + // ── getProjectStats ─────────────────────────────────────────────── + + describe('getProjectStats', () => { + it('should pass id, user.sub, and user.role', async () => { + const stats = { projectId: 'p1', linkedListingCount: 5 }; + mockQueryBus.execute.mockResolvedValue(stats); + + const user = { sub: 'admin-1', role: 'ADMIN' }; + const result = await controller.getProjectStats(user as any, 'p1'); + + expect(result).toBe(stats); + const query = mockQueryBus.execute.mock.calls[0][0]; + expect(query.projectId).toBe('p1'); + expect(query.requesterUserId).toBe('admin-1'); + expect(query.requesterRole).toBe('ADMIN'); + }); + }); + + // ── getProject ──────────────────────────────────────────────────── + + describe('getProject', () => { + it('should shape detail with media array preserved', async () => { + mockQueryBus.execute.mockResolvedValue({ + id: 'p1', name: 'T', slug: 's', developer: 'D', developerLogo: null, + media: [{ url: 'img.jpg' }], + }); + + const result = await controller.getProject('s'); + expect(result.media).toEqual([{ url: 'img.jpg' }]); + expect(result.developer).toEqual({ id: 'D', name: 'D', logo: null }); + }); + + it('should throw NotFoundException when result is null', async () => { + mockQueryBus.execute.mockResolvedValue(null); + + await expect(controller.getProject('nope')).rejects.toThrow(NotFoundException); + }); + + it('should handle null media in detail', async () => { + mockQueryBus.execute.mockResolvedValue({ + id: 'p1', name: 'T', slug: 's', developer: 'D', developerLogo: null, + media: null, + }); + + const result = await controller.getProject('s'); + expect(result.media).toEqual([]); + }); + }); + + // ── createProject ───────────────────────────────────────────────── + + describe('createProject', () => { + it('should set ownerId to user.sub for DEVELOPER', async () => { + mockCommandBus.execute.mockResolvedValue({ id: 'new-1', slug: 'test' }); + + const user = { sub: 'dev-1', role: 'DEVELOPER' }; + const dto = { + name: 'P', slug: 'p', developer: 'D', totalUnits: 10, + status: 'PLANNING', latitude: 10, longitude: 106, + address: 'a', ward: 'w', district: 'd', city: 'HCM', + }; + + 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', async () => { + mockCommandBus.execute.mockResolvedValue({ id: 'new-1', slug: 'test' }); + + const user = { sub: 'admin-1', role: 'ADMIN' }; + const dto = { + name: 'P', slug: 'p', developer: 'D', totalUnits: 10, + status: 'PLANNING', latitude: 10, longitude: 106, + address: 'a', ward: 'w', district: 'd', city: 'HCM', + }; + + await controller.createProject(user as any, dto as any); + + const cmd = mockCommandBus.execute.mock.calls[0][0]; + expect(cmd.ownerId).toBeNull(); + }); + + it('should convert minPrice/maxPrice to BigInt', async () => { + mockCommandBus.execute.mockResolvedValue({ id: 'new-1', slug: 'test' }); + + const user = { sub: 'dev-1', role: 'DEVELOPER' }; + const dto = { + name: 'P', slug: 'p', developer: 'D', totalUnits: 10, + status: 'PLANNING', latitude: 10, longitude: 106, + address: 'a', ward: 'w', district: 'd', city: 'HCM', + minPrice: '1000000000', maxPrice: '5000000000', + }; + + await controller.createProject(user as any, dto as any); + + const cmd = mockCommandBus.execute.mock.calls[0][0]; + expect(cmd.minPrice).toBe(BigInt('1000000000')); + expect(cmd.maxPrice).toBe(BigInt('5000000000')); + }); + + it('should convert startDate/completionDate to Date', async () => { + mockCommandBus.execute.mockResolvedValue({ id: 'new-1', slug: 'test' }); + + const user = { sub: 'dev-1', role: 'DEVELOPER' }; + const dto = { + name: 'P', slug: 'p', developer: 'D', totalUnits: 10, + status: 'PLANNING', latitude: 10, longitude: 106, + address: 'a', ward: 'w', district: 'd', city: 'HCM', + startDate: '2024-01-01', completionDate: '2026-12-31', + }; + + await controller.createProject(user as any, dto as any); + + const cmd = mockCommandBus.execute.mock.calls[0][0]; + expect(cmd.startDate).toBeInstanceOf(Date); + expect(cmd.completionDate).toBeInstanceOf(Date); + }); + }); + + // ── updateProject ───────────────────────────────────────────────── + + describe('updateProject', () => { + it('should pass user info and dto params to UpdateProjectCommand', async () => { + mockCommandBus.execute.mockResolvedValue({ id: 'p1' }); + + const user = { sub: 'admin-1', role: 'ADMIN' }; + const dto = { name: 'Updated', status: 'COMPLETED' }; + + await controller.updateProject(user as any, 'p1', dto as any); + + const cmd = mockCommandBus.execute.mock.calls[0][0]; + expect(cmd.id).toBe('p1'); + expect(cmd.requesterUserId).toBe('admin-1'); + expect(cmd.requesterRole).toBe('ADMIN'); + expect(cmd.name).toBe('Updated'); + expect(cmd.status).toBe('COMPLETED'); + }); + + it('should convert minPrice to BigInt when provided', async () => { + mockCommandBus.execute.mockResolvedValue({ id: 'p1' }); + + const user = { sub: 'admin-1', role: 'ADMIN' }; + const dto = { minPrice: '2000000000' }; + + await controller.updateProject(user as any, 'p1', dto as any); + + const cmd = mockCommandBus.execute.mock.calls[0][0]; + expect(cmd.minPrice).toBe(BigInt('2000000000')); + }); + + it('should leave minPrice as undefined when not in dto', async () => { + mockCommandBus.execute.mockResolvedValue({ id: 'p1' }); + + const user = { sub: 'admin-1', role: 'ADMIN' }; + await controller.updateProject(user as any, 'p1', {} as any); + + const cmd = mockCommandBus.execute.mock.calls[0][0]; + expect(cmd.minPrice).toBeUndefined(); + }); + }); + + // ── deleteProject ───────────────────────────────────────────────── + + describe('deleteProject', () => { + it('should return { success: true }', async () => { + mockCommandBus.execute.mockResolvedValue(undefined); + + const user = { sub: 'admin-1', role: 'ADMIN' }; + const result = await controller.deleteProject(user as any, 'p1'); + + expect(result).toEqual({ success: true }); + + const cmd = mockCommandBus.execute.mock.calls[0][0]; + expect(cmd.id).toBe('p1'); + expect(cmd.requesterUserId).toBe('admin-1'); + expect(cmd.requesterRole).toBe('ADMIN'); + }); + }); +});