diff --git a/apps/api/src/modules/admin/application/__tests__/get-revenue-stats.handler.spec.ts b/apps/api/src/modules/admin/application/__tests__/get-revenue-stats.handler.spec.ts new file mode 100644 index 0000000..4b944d8 --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/get-revenue-stats.handler.spec.ts @@ -0,0 +1,99 @@ +import { type IAdminQueryRepository } from '../../domain/repositories/admin-query.repository'; +import { GetRevenueStatsHandler } from '../queries/get-revenue-stats/get-revenue-stats.handler'; +import { GetRevenueStatsQuery } from '../queries/get-revenue-stats/get-revenue-stats.query'; + +describe('GetRevenueStatsHandler', () => { + let handler: GetRevenueStatsHandler; + let mockRepo: { [K in keyof IAdminQueryRepository]: ReturnType }; + + beforeEach(() => { + mockRepo = { + getModerationQueue: vi.fn(), + getDashboardStats: vi.fn(), + getRevenueStats: vi.fn(), + getUsers: vi.fn(), + getUserDetail: vi.fn(), + getKycQueue: vi.fn(), + }; + + handler = new GetRevenueStatsHandler(mockRepo as any); + }); + + it('returns revenue stats grouped by month', async () => { + const mockStats = [ + { + period: '2026-01', + totalRevenue: 100_000_000n, + subscriptionRevenue: 60_000_000n, + listingFeeRevenue: 30_000_000n, + featuredListingRevenue: 10_000_000n, + transactionCount: 42, + }, + { + period: '2026-02', + totalRevenue: 150_000_000n, + subscriptionRevenue: 80_000_000n, + listingFeeRevenue: 50_000_000n, + featuredListingRevenue: 20_000_000n, + transactionCount: 58, + }, + ]; + + mockRepo.getRevenueStats.mockResolvedValue(mockStats); + + const startDate = new Date('2026-01-01'); + const endDate = new Date('2026-02-28'); + const query = new GetRevenueStatsQuery(startDate, endDate, 'month'); + const result = await handler.execute(query); + + expect(result).toEqual(mockStats); + expect(result).toHaveLength(2); + expect(mockRepo.getRevenueStats).toHaveBeenCalledWith(startDate, endDate, 'month'); + }); + + it('returns revenue stats grouped by day', async () => { + const mockStats = [ + { + period: '2026-03-01', + totalRevenue: 5_000_000n, + subscriptionRevenue: 3_000_000n, + listingFeeRevenue: 1_500_000n, + featuredListingRevenue: 500_000n, + transactionCount: 3, + }, + ]; + + mockRepo.getRevenueStats.mockResolvedValue(mockStats); + + const startDate = new Date('2026-03-01'); + const endDate = new Date('2026-03-01'); + const query = new GetRevenueStatsQuery(startDate, endDate, 'day'); + const result = await handler.execute(query); + + expect(result).toEqual(mockStats); + expect(mockRepo.getRevenueStats).toHaveBeenCalledWith(startDate, endDate, 'day'); + }); + + it('returns empty array when no revenue data', async () => { + mockRepo.getRevenueStats.mockResolvedValue([]); + + const query = new GetRevenueStatsQuery( + new Date('2020-01-01'), + new Date('2020-12-31'), + 'month', + ); + const result = await handler.execute(query); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); + + it('uses default groupBy of month in query', () => { + const query = new GetRevenueStatsQuery( + new Date('2026-01-01'), + new Date('2026-12-31'), + ); + + expect(query.groupBy).toBe('month'); + }); +}); diff --git a/apps/api/src/modules/admin/presentation/controllers/__tests__/admin-moderation.controller.spec.ts b/apps/api/src/modules/admin/presentation/controllers/__tests__/admin-moderation.controller.spec.ts new file mode 100644 index 0000000..94dc925 --- /dev/null +++ b/apps/api/src/modules/admin/presentation/controllers/__tests__/admin-moderation.controller.spec.ts @@ -0,0 +1,155 @@ +import { ApproveKycCommand } from '../../../application/commands/approve-kyc/approve-kyc.command'; +import { ApproveListingCommand } from '../../../application/commands/approve-listing/approve-listing.command'; +import { BulkModerateListingsCommand } from '../../../application/commands/bulk-moderate-listings/bulk-moderate-listings.command'; +import { RejectKycCommand } from '../../../application/commands/reject-kyc/reject-kyc.command'; +import { RejectListingCommand } from '../../../application/commands/reject-listing/reject-listing.command'; +import { GetKycQueueQuery } from '../../../application/queries/get-kyc-queue/get-kyc-queue.query'; +import { GetModerationQueueQuery } from '../../../application/queries/get-moderation-queue/get-moderation-queue.query'; +import { AdminModerationController } from '../admin-moderation.controller'; + +describe('AdminModerationController', () => { + let controller: AdminModerationController; + let mockCommandBus: { execute: ReturnType }; + let mockQueryBus: { execute: ReturnType }; + + const mockAdmin = { sub: 'admin-1', phone: '0901234567', role: 'ADMIN' }; + + beforeEach(() => { + mockCommandBus = { execute: vi.fn() }; + mockQueryBus = { execute: vi.fn() }; + controller = new AdminModerationController(mockCommandBus as any, mockQueryBus as any); + }); + + describe('GET /admin/moderation — getModerationQueue', () => { + it('dispatches GetModerationQueueQuery with defaults', async () => { + const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getModerationQueue(); + + expect(mockQueryBus.execute).toHaveBeenCalledWith(expect.any(GetModerationQueueQuery)); + const query = mockQueryBus.execute.mock.calls[0]![0] as GetModerationQueueQuery; + expect(query.page).toBe(1); + expect(query.limit).toBe(20); + expect(result).toEqual(expected); + }); + + it('parses page and limit from strings', async () => { + mockQueryBus.execute.mockResolvedValue({ data: [] }); + + await controller.getModerationQueue('3', '50'); + + const query = mockQueryBus.execute.mock.calls[0]![0] as GetModerationQueueQuery; + expect(query.page).toBe(3); + expect(query.limit).toBe(50); + }); + }); + + describe('POST /admin/moderation/approve — approveListing', () => { + it('dispatches ApproveListingCommand', async () => { + const expected = { listingId: 'l-1', status: 'ACTIVE' }; + mockCommandBus.execute.mockResolvedValue(expected); + + const result = await controller.approveListing( + { listingId: 'l-1', moderationNotes: 'OK' } as any, + mockAdmin as any, + ); + + expect(mockCommandBus.execute).toHaveBeenCalledWith(expect.any(ApproveListingCommand)); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as ApproveListingCommand; + expect(cmd.listingId).toBe('l-1'); + expect(cmd.adminId).toBe('admin-1'); + expect(result).toEqual(expected); + }); + }); + + describe('POST /admin/moderation/reject — rejectListing', () => { + it('dispatches RejectListingCommand', async () => { + const expected = { listingId: 'l-1', status: 'REJECTED' }; + mockCommandBus.execute.mockResolvedValue(expected); + + const result = await controller.rejectListing( + { listingId: 'l-1', reason: 'Spam' } as any, + mockAdmin as any, + ); + + expect(mockCommandBus.execute).toHaveBeenCalledWith(expect.any(RejectListingCommand)); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as RejectListingCommand; + expect(cmd.listingId).toBe('l-1'); + expect(cmd.adminId).toBe('admin-1'); + expect(cmd.reason).toBe('Spam'); + expect(result).toEqual(expected); + }); + }); + + describe('POST /admin/moderation/bulk — bulkModerate', () => { + it('dispatches BulkModerateListingsCommand', async () => { + const expected = { processed: 3, failed: 0 }; + mockCommandBus.execute.mockResolvedValue(expected); + + const result = await controller.bulkModerate( + { listingIds: ['l-1', 'l-2', 'l-3'], action: 'approve', reason: 'Batch' } as any, + mockAdmin as any, + ); + + expect(mockCommandBus.execute).toHaveBeenCalledWith(expect.any(BulkModerateListingsCommand)); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as BulkModerateListingsCommand; + expect(cmd.listingIds).toEqual(['l-1', 'l-2', 'l-3']); + expect(cmd.adminId).toBe('admin-1'); + expect(cmd.action).toBe('approve'); + expect(result).toEqual(expected); + }); + }); + + describe('GET /admin/kyc — getKycQueue', () => { + it('dispatches GetKycQueueQuery with defaults', async () => { + const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getKycQueue(); + + expect(mockQueryBus.execute).toHaveBeenCalledWith(expect.any(GetKycQueueQuery)); + const query = mockQueryBus.execute.mock.calls[0]![0] as GetKycQueueQuery; + expect(query.page).toBe(1); + expect(query.limit).toBe(20); + expect(result).toEqual(expected); + }); + }); + + describe('POST /admin/kyc/approve — approveKyc', () => { + it('dispatches ApproveKycCommand', async () => { + const expected = { userId: 'u-1', kycStatus: 'VERIFIED' }; + mockCommandBus.execute.mockResolvedValue(expected); + + const result = await controller.approveKyc( + { userId: 'u-1', comments: 'Good docs' } as any, + mockAdmin as any, + ); + + expect(mockCommandBus.execute).toHaveBeenCalledWith(expect.any(ApproveKycCommand)); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as ApproveKycCommand; + expect(cmd.userId).toBe('u-1'); + expect(cmd.adminId).toBe('admin-1'); + expect(result).toEqual(expected); + }); + }); + + describe('POST /admin/kyc/reject — rejectKyc', () => { + it('dispatches RejectKycCommand', async () => { + const expected = { userId: 'u-1', kycStatus: 'REJECTED' }; + mockCommandBus.execute.mockResolvedValue(expected); + + const result = await controller.rejectKyc( + { userId: 'u-1', reason: 'Giấy tờ không hợp lệ' } as any, + mockAdmin as any, + ); + + expect(mockCommandBus.execute).toHaveBeenCalledWith(expect.any(RejectKycCommand)); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as RejectKycCommand; + expect(cmd.userId).toBe('u-1'); + expect(cmd.adminId).toBe('admin-1'); + expect(cmd.reason).toBe('Giấy tờ không hợp lệ'); + expect(result).toEqual(expected); + }); + }); +}); diff --git a/apps/api/src/modules/admin/presentation/controllers/__tests__/admin.controller.spec.ts b/apps/api/src/modules/admin/presentation/controllers/__tests__/admin.controller.spec.ts new file mode 100644 index 0000000..1b4d564 --- /dev/null +++ b/apps/api/src/modules/admin/presentation/controllers/__tests__/admin.controller.spec.ts @@ -0,0 +1,226 @@ +import { AdjustSubscriptionCommand } from '../../../application/commands/adjust-subscription/adjust-subscription.command'; +import { BanUserCommand } from '../../../application/commands/ban-user/ban-user.command'; +import { UpdateUserStatusCommand } from '../../../application/commands/update-user-status/update-user-status.command'; +import { GetAuditLogsQuery } from '../../../application/queries/get-audit-logs/get-audit-logs.query'; +import { GetDashboardStatsQuery } from '../../../application/queries/get-dashboard-stats/get-dashboard-stats.query'; +import { GetRevenueStatsQuery } from '../../../application/queries/get-revenue-stats/get-revenue-stats.query'; +import { GetUserDetailQuery } from '../../../application/queries/get-user-detail/get-user-detail.query'; +import { GetUsersQuery } from '../../../application/queries/get-users/get-users.query'; +import { AdminController } from '../admin.controller'; + +describe('AdminController', () => { + let controller: AdminController; + let mockCommandBus: { execute: ReturnType }; + let mockQueryBus: { execute: ReturnType }; + + const mockAdmin = { sub: 'admin-1', phone: '0901234567', role: 'ADMIN' }; + + beforeEach(() => { + mockCommandBus = { execute: vi.fn() }; + mockQueryBus = { execute: vi.fn() }; + controller = new AdminController(mockCommandBus as any, mockQueryBus as any); + }); + + describe('GET /admin/users — getUsers', () => { + it('dispatches GetUsersQuery with defaults', async () => { + const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getUsers({} as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith(expect.any(GetUsersQuery)); + const query = mockQueryBus.execute.mock.calls[0]![0] as GetUsersQuery; + expect(query.page).toBe(1); + expect(query.limit).toBe(20); + expect(result).toEqual(expected); + }); + + it('passes custom filters', async () => { + mockQueryBus.execute.mockResolvedValue({ data: [] }); + + await controller.getUsers({ + page: 2, + limit: 10, + role: 'AGENT', + isActive: true, + search: 'test', + } as any); + + const query = mockQueryBus.execute.mock.calls[0]![0] as GetUsersQuery; + expect(query.page).toBe(2); + expect(query.limit).toBe(10); + expect(query.role).toBe('AGENT'); + expect(query.isActive).toBe(true); + expect(query.search).toBe('test'); + }); + }); + + describe('GET /admin/users/:id — getUserDetail', () => { + it('dispatches GetUserDetailQuery with user id', async () => { + const expected = { id: 'user-1', fullName: 'Test User' }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getUserDetail('user-1'); + + expect(mockQueryBus.execute).toHaveBeenCalledWith(expect.any(GetUserDetailQuery)); + const query = mockQueryBus.execute.mock.calls[0]![0] as GetUserDetailQuery; + expect(query.userId).toBe('user-1'); + expect(result).toEqual(expected); + }); + }); + + describe('PATCH /admin/users/status — updateUserStatus', () => { + it('dispatches UpdateUserStatusCommand', async () => { + const expected = { userId: 'user-1', isActive: false }; + mockCommandBus.execute.mockResolvedValue(expected); + + const result = await controller.updateUserStatus( + { userId: 'user-1', isActive: false, reason: 'Vi phạm' } as any, + mockAdmin as any, + ); + + expect(mockCommandBus.execute).toHaveBeenCalledWith(expect.any(UpdateUserStatusCommand)); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as UpdateUserStatusCommand; + expect(cmd.userId).toBe('user-1'); + expect(cmd.adminId).toBe('admin-1'); + expect(cmd.isActive).toBe(false); + expect(cmd.reason).toBe('Vi phạm'); + expect(result).toEqual(expected); + }); + }); + + describe('POST /admin/users/ban — banUser', () => { + it('dispatches BanUserCommand', async () => { + const expected = { userId: 'user-1', isBanned: true }; + mockCommandBus.execute.mockResolvedValue(expected); + + const result = await controller.banUser( + { userId: 'user-1', reason: 'Spam' } as any, + mockAdmin as any, + ); + + expect(mockCommandBus.execute).toHaveBeenCalledWith(expect.any(BanUserCommand)); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as BanUserCommand; + expect(cmd.userId).toBe('user-1'); + expect(cmd.adminId).toBe('admin-1'); + expect(cmd.reason).toBe('Spam'); + expect(cmd.unban).toBe(false); + expect(result).toEqual(expected); + }); + + it('passes unban flag when set', async () => { + mockCommandBus.execute.mockResolvedValue({ userId: 'user-1', isBanned: false }); + + await controller.banUser( + { userId: 'user-1', reason: 'Ân xá', unban: true } as any, + mockAdmin as any, + ); + + const cmd = mockCommandBus.execute.mock.calls[0]![0] as BanUserCommand; + expect(cmd.unban).toBe(true); + }); + }); + + describe('POST /admin/subscriptions/adjust — adjustSubscription', () => { + it('dispatches AdjustSubscriptionCommand', async () => { + const expected = { userId: 'user-1', newPlanTier: 'PREMIUM' }; + mockCommandBus.execute.mockResolvedValue(expected); + + const result = await controller.adjustSubscription( + { userId: 'user-1', newPlanTier: 'PREMIUM', reason: 'Upgrade' } as any, + mockAdmin as any, + ); + + expect(mockCommandBus.execute).toHaveBeenCalledWith(expect.any(AdjustSubscriptionCommand)); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as AdjustSubscriptionCommand; + expect(cmd.userId).toBe('user-1'); + expect(cmd.adminId).toBe('admin-1'); + expect(cmd.newPlanTier).toBe('PREMIUM'); + expect(result).toEqual(expected); + }); + }); + + describe('GET /admin/dashboard — getDashboardStats', () => { + it('dispatches GetDashboardStatsQuery', async () => { + const expected = { totalUsers: 100, totalListings: 50 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getDashboardStats(); + + expect(mockQueryBus.execute).toHaveBeenCalledWith(expect.any(GetDashboardStatsQuery)); + expect(result).toEqual(expected); + }); + }); + + describe('GET /admin/revenue — getRevenueStats', () => { + it('dispatches GetRevenueStatsQuery with parsed dates', async () => { + const expected = [{ period: '2026-01', totalRevenue: 100_000_000n }]; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getRevenueStats({ + startDate: '2026-01-01', + endDate: '2026-01-31', + groupBy: 'day', + } as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith(expect.any(GetRevenueStatsQuery)); + const query = mockQueryBus.execute.mock.calls[0]![0] as GetRevenueStatsQuery; + expect(query.startDate).toEqual(new Date('2026-01-01')); + expect(query.endDate).toEqual(new Date('2026-01-31')); + expect(query.groupBy).toBe('day'); + expect(result).toEqual(expected); + }); + + it('defaults groupBy to month', async () => { + mockQueryBus.execute.mockResolvedValue([]); + + await controller.getRevenueStats({ + startDate: '2026-01-01', + endDate: '2026-12-31', + } as any); + + const query = mockQueryBus.execute.mock.calls[0]![0] as GetRevenueStatsQuery; + expect(query.groupBy).toBe('month'); + }); + }); + + describe('GET /admin/audit-logs — getAuditLogs', () => { + it('dispatches GetAuditLogsQuery with defaults', async () => { + const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getAuditLogs({} as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith(expect.any(GetAuditLogsQuery)); + const query = mockQueryBus.execute.mock.calls[0]![0] as GetAuditLogsQuery; + expect(query.page).toBe(1); + expect(query.limit).toBe(20); + expect(result).toEqual(expected); + }); + + it('passes all filter parameters', async () => { + mockQueryBus.execute.mockResolvedValue({ data: [] }); + + await controller.getAuditLogs({ + page: 3, + limit: 50, + action: 'user.banned', + actorId: 'admin-1', + targetId: 'user-1', + targetType: 'user', + startDate: '2026-01-01', + endDate: '2026-12-31', + } as any); + + const query = mockQueryBus.execute.mock.calls[0]![0] as GetAuditLogsQuery; + expect(query.page).toBe(3); + expect(query.limit).toBe(50); + expect(query.action).toBe('user.banned'); + expect(query.actorId).toBe('admin-1'); + expect(query.targetId).toBe('user-1'); + expect(query.targetType).toBe('user'); + expect(query.startDate).toEqual(new Date('2026-01-01')); + expect(query.endDate).toEqual(new Date('2026-12-31')); + }); + }); +}); diff --git a/apps/api/src/modules/inquiries/presentation/__tests__/inquiries.controller.spec.ts b/apps/api/src/modules/inquiries/presentation/__tests__/inquiries.controller.spec.ts new file mode 100644 index 0000000..a489a9a --- /dev/null +++ b/apps/api/src/modules/inquiries/presentation/__tests__/inquiries.controller.spec.ts @@ -0,0 +1,104 @@ +import { CreateInquiryCommand } from '../../application/commands/create-inquiry/create-inquiry.command'; +import { MarkInquiryReadCommand } from '../../application/commands/mark-inquiry-read/mark-inquiry-read.command'; +import { GetInquiriesByAgentQuery } from '../../application/queries/get-inquiries-by-agent/get-inquiries-by-agent.query'; +import { GetInquiriesByListingQuery } from '../../application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query'; +import { InquiriesController } from '../controllers/inquiries.controller'; + +describe('InquiriesController', () => { + let controller: InquiriesController; + let mockCommandBus: { execute: ReturnType }; + let mockQueryBus: { execute: ReturnType }; + + const mockBuyer = { sub: 'buyer-1', phone: '0901234567', role: 'BUYER' }; + const mockAgent = { sub: 'agent-1', phone: '0901234568', role: 'AGENT' }; + + beforeEach(() => { + mockCommandBus = { execute: vi.fn() }; + mockQueryBus = { execute: vi.fn() }; + controller = new InquiriesController(mockCommandBus as any, mockQueryBus as any); + }); + + describe('POST /inquiries — createInquiry', () => { + it('dispatches CreateInquiryCommand with correct parameters', async () => { + const dto = { listingId: 'listing-1', message: 'Tôi muốn xem nhà', phone: '0909999999' }; + const expected = { id: 'inq-1', listingId: 'listing-1', createdAt: '2026-01-01T00:00:00.000Z' }; + mockCommandBus.execute.mockResolvedValue(expected); + + const result = await controller.createInquiry(dto as any, mockBuyer as any); + + expect(mockCommandBus.execute).toHaveBeenCalledWith(expect.any(CreateInquiryCommand)); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateInquiryCommand; + expect(cmd.userId).toBe('buyer-1'); + expect(cmd.listingId).toBe('listing-1'); + expect(cmd.message).toBe('Tôi muốn xem nhà'); + expect(cmd.phone).toBe('0909999999'); + expect(result).toEqual(expected); + }); + + it('passes null phone when not provided', async () => { + const dto = { listingId: 'listing-1', message: 'Xin chào' }; + mockCommandBus.execute.mockResolvedValue({ id: 'inq-2' }); + + await controller.createInquiry(dto as any, mockBuyer as any); + + const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateInquiryCommand; + expect(cmd.phone).toBeNull(); + }); + }); + + describe('GET /inquiries/listing/:listingId — getByListing', () => { + it('dispatches GetInquiriesByListingQuery with defaults', async () => { + const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getByListing('listing-1', {} as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith(expect.any(GetInquiriesByListingQuery)); + const query = mockQueryBus.execute.mock.calls[0]![0] as GetInquiriesByListingQuery; + expect(query.listingId).toBe('listing-1'); + expect(query.page).toBe(1); + expect(query.limit).toBe(20); + expect(result).toEqual(expected); + }); + + it('passes custom pagination', async () => { + mockQueryBus.execute.mockResolvedValue({ data: [] }); + + await controller.getByListing('listing-1', { page: 3, limit: 10 } as any); + + const query = mockQueryBus.execute.mock.calls[0]![0] as GetInquiriesByListingQuery; + expect(query.page).toBe(3); + expect(query.limit).toBe(10); + }); + }); + + describe('GET /inquiries/agent/me — getMyInquiries', () => { + it('dispatches GetInquiriesByAgentQuery with current user', async () => { + const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getMyInquiries(mockAgent as any, {} as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith(expect.any(GetInquiriesByAgentQuery)); + const query = mockQueryBus.execute.mock.calls[0]![0] as GetInquiriesByAgentQuery; + expect(query.agentUserId).toBe('agent-1'); + expect(query.page).toBe(1); + expect(query.limit).toBe(20); + expect(result).toEqual(expected); + }); + }); + + describe('PATCH /inquiries/:id/read — markAsRead', () => { + it('dispatches MarkInquiryReadCommand and returns success', async () => { + mockCommandBus.execute.mockResolvedValue(undefined); + + const result = await controller.markAsRead('inq-1', mockAgent as any); + + expect(mockCommandBus.execute).toHaveBeenCalledWith(expect.any(MarkInquiryReadCommand)); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as MarkInquiryReadCommand; + expect(cmd.inquiryId).toBe('inq-1'); + expect(cmd.agentUserId).toBe('agent-1'); + expect(result).toEqual({ success: true }); + }); + }); +}); diff --git a/apps/api/src/modules/leads/presentation/__tests__/leads.controller.spec.ts b/apps/api/src/modules/leads/presentation/__tests__/leads.controller.spec.ts new file mode 100644 index 0000000..a2b4850 --- /dev/null +++ b/apps/api/src/modules/leads/presentation/__tests__/leads.controller.spec.ts @@ -0,0 +1,135 @@ +import { CreateLeadCommand } from '../../application/commands/create-lead/create-lead.command'; +import { DeleteLeadCommand } from '../../application/commands/delete-lead/delete-lead.command'; +import { UpdateLeadStatusCommand } from '../../application/commands/update-lead-status/update-lead-status.command'; +import { GetLeadStatsQuery } from '../../application/queries/get-lead-stats/get-lead-stats.query'; +import { GetLeadsByAgentQuery } from '../../application/queries/get-leads-by-agent/get-leads-by-agent.query'; +import { LeadsController } from '../controllers/leads.controller'; + +describe('LeadsController', () => { + let controller: LeadsController; + let mockCommandBus: { execute: ReturnType }; + let mockQueryBus: { execute: ReturnType }; + + const mockAgent = { sub: 'agent-1', phone: '0901234567', role: 'AGENT' }; + + beforeEach(() => { + mockCommandBus = { execute: vi.fn() }; + mockQueryBus = { execute: vi.fn() }; + controller = new LeadsController(mockCommandBus as any, mockQueryBus as any); + }); + + describe('POST /leads — createLead', () => { + it('dispatches CreateLeadCommand with correct parameters', async () => { + const dto = { + name: 'Nguyễn Văn A', + phone: '0909123456', + email: 'a@example.com', + source: 'website', + score: 80, + notes: 'Quan tâm căn hộ', + }; + const expected = { id: 'lead-1', name: 'Nguyễn Văn A' }; + mockCommandBus.execute.mockResolvedValue(expected); + + const result = await controller.createLead(dto as any, mockAgent as any); + + expect(mockCommandBus.execute).toHaveBeenCalledWith(expect.any(CreateLeadCommand)); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateLeadCommand; + expect(cmd.agentUserId).toBe('agent-1'); + expect(cmd.name).toBe('Nguyễn Văn A'); + expect(cmd.phone).toBe('0909123456'); + expect(cmd.email).toBe('a@example.com'); + expect(cmd.source).toBe('website'); + expect(cmd.score).toBe(80); + expect(cmd.notes).toBe('Quan tâm căn hộ'); + expect(result).toEqual(expected); + }); + + it('passes null for optional fields', async () => { + const dto = { name: 'Test', phone: '0909000000', source: 'referral' }; + mockCommandBus.execute.mockResolvedValue({ id: 'lead-2' }); + + await controller.createLead(dto as any, mockAgent as any); + + const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateLeadCommand; + expect(cmd.email).toBeNull(); + expect(cmd.score).toBeNull(); + expect(cmd.notes).toBeNull(); + }); + }); + + describe('GET /leads — getLeads', () => { + it('dispatches GetLeadsByAgentQuery with defaults', async () => { + const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getLeads({} as any, mockAgent as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith(expect.any(GetLeadsByAgentQuery)); + const query = mockQueryBus.execute.mock.calls[0]![0] as GetLeadsByAgentQuery; + expect(query.agentUserId).toBe('agent-1'); + expect(query.status).toBeNull(); + expect(query.page).toBe(1); + expect(query.limit).toBe(20); + expect(result).toEqual(expected); + }); + + it('passes custom filters', async () => { + mockQueryBus.execute.mockResolvedValue({ data: [] }); + + await controller.getLeads({ status: 'qualified', page: 2, limit: 10 } as any, mockAgent as any); + + const query = mockQueryBus.execute.mock.calls[0]![0] as GetLeadsByAgentQuery; + expect(query.status).toBe('qualified'); + expect(query.page).toBe(2); + expect(query.limit).toBe(10); + }); + }); + + describe('GET /leads/stats — getStats', () => { + it('dispatches GetLeadStatsQuery', async () => { + const expected = { total: 50, qualified: 10, converted: 5 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getStats(mockAgent as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith(expect.any(GetLeadStatsQuery)); + const query = mockQueryBus.execute.mock.calls[0]![0] as GetLeadStatsQuery; + expect(query.agentUserId).toBe('agent-1'); + expect(result).toEqual(expected); + }); + }); + + describe('PATCH /leads/:id/status — updateStatus', () => { + it('dispatches UpdateLeadStatusCommand and returns updated true', async () => { + mockCommandBus.execute.mockResolvedValue(undefined); + + const result = await controller.updateStatus( + 'lead-1', + { status: 'qualified' } as any, + mockAgent as any, + ); + + expect(mockCommandBus.execute).toHaveBeenCalledWith(expect.any(UpdateLeadStatusCommand)); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as UpdateLeadStatusCommand; + expect(cmd.leadId).toBe('lead-1'); + expect(cmd.agentUserId).toBe('agent-1'); + expect(cmd.newStatus).toBe('qualified'); + expect(result).toEqual({ updated: true }); + }); + }); + + describe('DELETE /leads/:id — deleteLead', () => { + it('dispatches DeleteLeadCommand and returns deleted true', async () => { + mockCommandBus.execute.mockResolvedValue(undefined); + + const result = await controller.deleteLead('lead-1', mockAgent as any); + + expect(mockCommandBus.execute).toHaveBeenCalledWith(expect.any(DeleteLeadCommand)); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as DeleteLeadCommand; + expect(cmd.leadId).toBe('lead-1'); + expect(cmd.agentUserId).toBe('agent-1'); + expect(result).toEqual({ deleted: true }); + }); + }); +}); diff --git a/apps/web/app/[locale]/(dashboard)/analytics/__tests__/analytics.spec.tsx b/apps/web/app/[locale]/(dashboard)/analytics/__tests__/analytics.spec.tsx index b07e14c..5d0876a 100644 --- a/apps/web/app/[locale]/(dashboard)/analytics/__tests__/analytics.spec.tsx +++ b/apps/web/app/[locale]/(dashboard)/analytics/__tests__/analytics.spec.tsx @@ -1,5 +1,5 @@ /* eslint-disable import-x/order */ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('next/dynamic', () => ({ diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/dashboard.spec.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/dashboard.spec.tsx index 7d3b5e5..218b7d4 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/dashboard.spec.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/dashboard.spec.tsx @@ -1,5 +1,5 @@ /* eslint-disable import-x/order */ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('next/dynamic', () => ({ diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/kyc.spec.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/kyc.spec.tsx index 48e4386..81a4469 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/kyc.spec.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/kyc.spec.tsx @@ -2,8 +2,6 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useAuthStore } from '@/lib/auth-store'; - vi.mock('@/lib/auth-store', () => { const store = { user: { diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/payments.spec.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/payments.spec.tsx index 1de181f..48e3343 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/payments.spec.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/payments.spec.tsx @@ -1,5 +1,5 @@ /* eslint-disable import-x/order */ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const mockTransactions = { diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/profile.spec.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/profile.spec.tsx index 32813bd..2883e6e 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/profile.spec.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/profile.spec.tsx @@ -2,8 +2,6 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useAuthStore } from '@/lib/auth-store'; - vi.mock('@/lib/auth-store', () => { const store = { user: { diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/subscription.spec.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/subscription.spec.tsx index 89fcf2d..2d922ca 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/subscription.spec.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/subscription.spec.tsx @@ -1,5 +1,5 @@ /* eslint-disable import-x/order */ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const mockPlans = [ diff --git a/apps/web/lib/__tests__/comparison-store.spec.ts b/apps/web/lib/__tests__/comparison-store.spec.ts index e8aabd1..21a23cd 100644 --- a/apps/web/lib/__tests__/comparison-store.spec.ts +++ b/apps/web/lib/__tests__/comparison-store.spec.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { ListingDetail } from '../listings-api'; // Provide a minimal localStorage mock before importing the store module // (Zustand persist needs getItem/setItem) @@ -17,7 +18,6 @@ vi.stubGlobal('localStorage', { // Now import after mocks are in place const { useComparisonStore, computeComparisonStats, MAX_COMPARE, MIN_COMPARE } = await import('../comparison-store'); -type ListingDetail = import('../listings-api').ListingDetail; // Reset Zustand store between tests beforeEach(() => { diff --git a/e2e/fixtures/auth.fixture.ts b/e2e/fixtures/auth.fixture.ts index b80fec5..bb619d5 100644 --- a/e2e/fixtures/auth.fixture.ts +++ b/e2e/fixtures/auth.fixture.ts @@ -6,10 +6,14 @@ export interface TokenPair { refreshToken: string; } +let _counter = 0; + /** Generates a unique test user payload for each test run. */ -export function createTestUser(suffix = Date.now()) { +export function createTestUser(suffix = `${Date.now()}${(++_counter).toString().padStart(4, '0')}${Math.random().toString(36).slice(2, 6)}`) { + // Use last 8 digits of the combined suffix for the phone number + const phoneSuffix = suffix.replace(/\D/g, '').slice(-8).padStart(8, '0'); return { - phone: `09${String(suffix).slice(-8).padStart(8, '0')}`, + phone: `09${phoneSuffix}`, password: 'Test@1234!', fullName: `Test User ${suffix}`, email: `testuser${suffix}@goodgo.test`, @@ -21,7 +25,7 @@ export async function registerUser( request: APIRequestContext, user = createTestUser(), ): Promise }> { - const res = await request.post('/auth/register', { data: user }); + const res = await request.post('auth/register', { data: user }); if (!res.ok()) { const body = await res.text(); throw new Error(`Register failed (${res.status()}): ${body}`); @@ -36,7 +40,7 @@ export async function loginUser( phone: string, password: string, ): Promise { - const res = await request.post('/auth/login', { + const res = await request.post('auth/login', { data: { phone, password }, }); if (!res.ok()) { @@ -58,7 +62,7 @@ export const test = base.extend<{ testTokens: TokenPair; authedRequest: APIRequestContext; }>({ - testUser: async (_fixtures, use) => { + testUser: async (_deps, use) => { await use(createTestUser()); }, diff --git a/eslint.config.mjs b/eslint.config.mjs index d8841e8..71c6d9c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,6 +17,7 @@ export default tseslint.config( '**/*.cjs', '**/*.mjs', '!eslint.config.mjs', + '**/next-env.d.ts', ], },