# Admin Module - Untested Handlers/Source Files Analysis ## Executive Summary This document provides a comprehensive analysis of untested source files in the admin module (`apps/api/src/modules/admin/`), with detailed code listings and testing patterns for reference. **Analysis Date:** 2026-04-11 --- ## 1. UNTESTED HANDLER FILES ### 1.1 reject-listing.handler.ts (Commands) **Path:** `apps/api/src/modules/admin/application/commands/reject-listing/reject-listing.handler.ts` **Status:** ❌ NO TEST FILE FOUND #### Source Code: ```typescript import { Inject } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings'; import { NotFoundException, ValidationException } from '@modules/shared'; import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event'; import { RejectListingCommand } from './reject-listing.command'; export interface RejectListingResult { listingId: string; status: string; message: string; } @CommandHandler(RejectListingCommand) export class RejectListingHandler implements ICommandHandler { constructor( @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, private readonly eventBus: EventBus, ) {} async execute(command: RejectListingCommand): Promise { const listing = await this.listingRepo.findById(command.listingId); if (!listing) { throw new NotFoundException('Listing không tồn tại'); } if (listing.status !== 'PENDING_REVIEW') { throw new ValidationException( `Listing đang ở trạng thái ${listing.status}, chỉ có thể từ chối listing đang chờ duyệt`, { currentStatus: listing.status }, ); } listing.reject(command.reason); await this.listingRepo.update(listing); this.eventBus.publish( new ListingRejectedEvent(listing.id, command.adminId, command.reason), ); return { listingId: listing.id, status: 'REJECTED', message: 'Listing đã bị từ chối', }; } } ``` #### Associated Command Class: ```typescript export class RejectListingCommand { constructor( public readonly listingId: string, public readonly adminId: string, public readonly reason: string, ) {} } ``` #### Key Testing Points: - ✅ Successfully rejects a PENDING_REVIEW listing - ✅ Throws NotFoundException when listing doesn't exist - ✅ Throws ValidationException when listing is not in PENDING_REVIEW status - ✅ Calls listingRepo.update() exactly once - ✅ Publishes ListingRejectedEvent with correct data - ✅ Returns correct RejectListingResult with status 'REJECTED' --- ### 1.2 get-revenue-stats.handler.ts (Queries) **Path:** `apps/api/src/modules/admin/application/queries/get-revenue-stats/get-revenue-stats.handler.ts` **Status:** ❌ NO TEST FILE FOUND #### Source Code: ```typescript import { Inject } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type RevenueStatsItem } from '../../../domain/repositories/admin-query.repository'; import { GetRevenueStatsQuery } from './get-revenue-stats.query'; @QueryHandler(GetRevenueStatsQuery) export class GetRevenueStatsHandler implements IQueryHandler { constructor( @Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository, ) {} async execute(query: GetRevenueStatsQuery): Promise { return this.adminQueryRepo.getRevenueStats(query.startDate, query.endDate, query.groupBy); } } ``` #### Associated Query Class: ```typescript export class GetRevenueStatsQuery { constructor( public readonly startDate: Date, public readonly endDate: Date, public readonly groupBy: 'day' | 'month' = 'month', ) {} } ``` #### RevenueStatsItem Interface: ```typescript export interface RevenueStatsItem { period: string; totalRevenue: bigint; subscriptionRevenue: bigint; listingFeeRevenue: bigint; featuredListingRevenue: bigint; transactionCount: number; } ``` #### Key Testing Points: - ✅ Returns RevenueStatsItem[] from repository - ✅ Passes correct startDate, endDate, and groupBy parameters - ✅ Calls adminQueryRepo.getRevenueStats() exactly once - ✅ Supports both 'day' and 'month' groupBy values - ✅ Default groupBy is 'month' - ✅ Handles empty results (empty array) --- ### 1.3 user-deactivated.listener.ts (Listeners) **Path:** `apps/api/src/modules/admin/application/listeners/user-deactivated.listener.ts` **Status:** ❌ NO TEST FILE FOUND #### Source Code: ```typescript import { Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { type UserDeactivatedEvent } from '@modules/auth'; import { type LoggerService, type PrismaService } from '@modules/shared'; @Injectable() export class UserDeactivatedListener { constructor( private readonly prisma: PrismaService, private readonly logger: LoggerService, ) {} @OnEvent('user.deactivated', { async: true }) async handle(event: UserDeactivatedEvent): Promise { this.logger.log(`Handling user.deactivated for user ${event.aggregateId}`, 'UserDeactivatedListener'); const deactivated = await this.prisma.listing.updateMany({ where: { sellerId: event.aggregateId, status: { in: ['ACTIVE', 'PENDING_REVIEW'] }, }, data: { status: 'EXPIRED' }, }); this.logger.log( `Expired ${deactivated.count} listings for deactivated user ${event.aggregateId}`, 'UserDeactivatedListener', ); } } ``` #### Key Testing Points: - ✅ Handles 'user.deactivated' event (async: true) - ✅ Updates listings with status ACTIVE or PENDING_REVIEW to EXPIRED - ✅ Only updates listings for the deactivated seller - ✅ Logs initial event handling - ✅ Logs number of expired listings after operation - ✅ Returns void (Promise) - ✅ Handles case with 0 listings updated - ✅ Handles case with multiple listings updated --- ## 2. EXISTING TEST FILES STRUCTURE & PATTERNS ### 2.1 Test File for Reference: approve-listing.handler.spec.ts **Path:** `apps/api/src/modules/admin/application/__tests__/approve-listing.handler.spec.ts` #### Complete Source Code: ```typescript import { ListingEntity } from '@modules/listings/domain/entities/listing.entity'; import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository'; import { Price } from '@modules/listings/domain/value-objects/price.vo'; import { ApproveListingCommand } from '../commands/approve-listing/approve-listing.command'; import { ApproveListingHandler } from '../commands/approve-listing/approve-listing.handler'; function createPendingListing(id = 'listing-1'): ListingEntity { const price = Price.create(1_000_000_000n).unwrap(); const listing = ListingEntity.createNew( id, 'prop-1', 'seller-1', 'SALE', price, 100, 'agent-1', ); listing.submitForReview(); listing.clearDomainEvents(); return listing; } describe('ApproveListingHandler', () => { let handler: ApproveListingHandler; let mockListingRepo: { [K in keyof IListingRepository]: ReturnType }; let mockEventBus: { publish: ReturnType }; beforeEach(() => { mockListingRepo = { findById: vi.fn(), findByIdWithProperty: vi.fn(), save: vi.fn(), update: vi.fn(), search: vi.fn(), findByStatus: vi.fn(), findBySellerId: vi.fn(), }; mockEventBus = { publish: vi.fn() }; handler = new ApproveListingHandler( mockListingRepo as any, mockEventBus as any, ); }); it('approves a pending listing successfully', async () => { const listing = createPendingListing(); mockListingRepo.findById.mockResolvedValue(listing); mockListingRepo.update.mockResolvedValue(undefined); const command = new ApproveListingCommand('listing-1', 'admin-1', 'Looks good'); const result = await handler.execute(command); expect(result.status).toBe('ACTIVE'); expect(result.listingId).toBe('listing-1'); expect(mockListingRepo.update).toHaveBeenCalledTimes(1); expect(mockEventBus.publish).toHaveBeenCalled(); }); it('throws NotFoundException when listing does not exist', async () => { mockListingRepo.findById.mockResolvedValue(null); const command = new ApproveListingCommand('nonexistent', 'admin-1'); await expect(handler.execute(command)).rejects.toThrow('Listing không tồn tại'); }); it('throws ValidationException when listing is not pending review', async () => { const price = Price.create(500_000_000n).unwrap(); const listing = ListingEntity.createNew( 'listing-1', 'prop-1', 'seller-1', 'SALE', price, 80, ); listing.clearDomainEvents(); mockListingRepo.findById.mockResolvedValue(listing); const command = new ApproveListingCommand('listing-1', 'admin-1'); await expect(handler.execute(command)).rejects.toThrow(/trạng thái/); }); }); ``` #### Key Patterns Used: 1. **Factory Function:** `createPendingListing()` creates test entities 2. **Mock Setup:** Typed mocks using `ReturnType` 3. **Initialization:** beforeEach() sets up fresh mocks for each test 4. **Entity Testing:** Uses actual domain entities with state transitions 5. **Domain Events:** Clears domain events after setup with `.clearDomainEvents()` 6. **Test Structure:** - Happy path (success case) - Error cases (NotFoundException, ValidationException) - Side effect verification (repository calls, events published) --- ### 2.2 Listener Test Pattern: user-banned.listener.spec.ts **Path:** `apps/api/src/modules/admin/application/__tests__/user-banned.listener.spec.ts` #### Complete Source Code: ```typescript import { UserBannedListener } from '../listeners/user-banned.listener'; describe('UserBannedListener', () => { let listener: UserBannedListener; let mockCommandBus: { execute: ReturnType }; let mockPrisma: { listing: { updateMany: ReturnType }; user: { findUnique: ReturnType }; }; let mockLogger: { log: ReturnType; warn: ReturnType }; beforeEach(() => { mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) }; mockPrisma = { listing: { updateMany: vi.fn().mockResolvedValue({ count: 3 }) }, user: { findUnique: vi.fn() }, }; mockLogger = { log: vi.fn(), warn: vi.fn() }; listener = new UserBannedListener( mockCommandBus as any, mockPrisma as any, mockLogger as any, ); }); it('deactivates all user listings when banned', async () => { mockPrisma.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' }); await listener.handle({ aggregateId: 'user-1', adminId: 'admin-1', reason: 'Vi phạm chính sách', eventName: 'user.banned', occurredAt: new Date(), }); expect(mockPrisma.listing.updateMany).toHaveBeenCalledWith({ where: { sellerId: 'user-1', status: { in: ['ACTIVE', 'PENDING_REVIEW', 'DRAFT'] }, }, data: { status: 'EXPIRED' }, }); }); it('notifies banned user via email', async () => { mockPrisma.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' }); await listener.handle({ aggregateId: 'user-1', adminId: 'admin-1', reason: 'Spam', eventName: 'user.banned', occurredAt: new Date(), }); expect(mockCommandBus.execute).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1', channel: 'EMAIL', templateKey: 'user.banned', templateData: { reason: 'Spam' }, }), ); }); it('skips email notification when user has no email', async () => { mockPrisma.user.findUnique.mockResolvedValue({ id: 'user-1', email: null }); await listener.handle({ aggregateId: 'user-1', adminId: 'admin-1', reason: 'Vi phạm', eventName: 'user.banned', occurredAt: new Date(), }); expect(mockPrisma.listing.updateMany).toHaveBeenCalled(); expect(mockCommandBus.execute).not.toHaveBeenCalled(); }); }); ``` #### Listener Test Patterns: 1. **Event Object Construction:** Manually create event with all properties 2. **Multiple Dependencies:** Mock CommandBus, Prisma, Logger 3. **Side Effects Testing:** Verify database calls and command execution 4. **Conditional Logic:** Test both happy path and edge cases (no email) 5. **Chained Operations:** Test interactions between multiple services --- ### 2.3 Query Handler Pattern: get-dashboard-stats.handler.spec.ts **Path:** `apps/api/src/modules/admin/application/__tests__/get-dashboard-stats.handler.spec.ts` #### Complete Source Code: ```typescript import { type IAdminQueryRepository } from '../../domain/repositories/admin-query.repository'; import { GetDashboardStatsHandler } from '../queries/get-dashboard-stats/get-dashboard-stats.handler'; import { GetDashboardStatsQuery } from '../queries/get-dashboard-stats/get-dashboard-stats.query'; describe('GetDashboardStatsHandler', () => { let handler: GetDashboardStatsHandler; let mockAdminQueryRepo: { [K in keyof IAdminQueryRepository]: ReturnType }; beforeEach(() => { mockAdminQueryRepo = { getModerationQueue: vi.fn(), getDashboardStats: vi.fn(), getRevenueStats: vi.fn(), getUsers: vi.fn(), }; handler = new GetDashboardStatsHandler(mockAdminQueryRepo as any); }); it('returns dashboard stats', async () => { const stats = { totalUsers: 150, totalListings: 500, activeListings: 200, pendingModerationCount: 15, totalAgents: 30, verifiedAgents: 20, totalTransactions: 50, newUsersLast30Days: 25, newListingsLast30Days: 40, }; mockAdminQueryRepo.getDashboardStats.mockResolvedValue(stats); const result = await handler.execute(new GetDashboardStatsQuery()); expect(result).toEqual(stats); expect(mockAdminQueryRepo.getDashboardStats).toHaveBeenCalledTimes(1); }); }); ``` #### Query Handler Test Patterns: 1. **Simple Delegation:** Query handlers typically just delegate to repository 2. **Minimal Setup:** Mock only the repository 3. **Result Verification:** Check the returned data matches expectations 4. **Call Count:** Verify the repository method was called exactly once --- ## 3. HANDLER CODE FOR REFERENCE ### 3.1 Similar Handler: ban-user.handler.spec.ts **Path:** `apps/api/src/modules/admin/application/__tests__/ban-user.handler.spec.ts` #### Complete Source Code (for comparison to reject-listing): ```typescript import { UserEntity } from '@modules/auth/domain/entities/user.entity'; import { type IUserRepository } from '@modules/auth/domain/repositories/user.repository'; import { HashedPassword } from '@modules/auth/domain/value-objects/hashed-password.vo'; import { Phone } from '@modules/auth/domain/value-objects/phone.vo'; import { BanUserCommand } from '../commands/ban-user/ban-user.command'; import { BanUserHandler } from '../commands/ban-user/ban-user.handler'; async function createUser(role = 'BUYER' as any, isActive = true): Promise { const phone = Phone.create('0901234567').unwrap(); const hash = await HashedPassword.fromPlain('Password123'); const passwordHash = hash.isOk ? hash.unwrap() : null; const user = new UserEntity('user-1', { email: null, phone, passwordHash, fullName: 'Test User', avatarUrl: null, role, kycStatus: 'NONE', kycData: null, isActive, }); return user; } describe('BanUserHandler', () => { let handler: BanUserHandler; let mockUserRepo: { [K in keyof IUserRepository]: ReturnType }; let mockEventBus: { publish: ReturnType }; beforeEach(() => { mockUserRepo = { findById: vi.fn(), findByPhone: vi.fn(), findByEmail: vi.fn(), save: vi.fn(), update: vi.fn(), }; mockEventBus = { publish: vi.fn() }; handler = new BanUserHandler( mockUserRepo as any, mockEventBus as any, ); }); it('bans an active user successfully', async () => { const user = await createUser('BUYER', true); mockUserRepo.findById.mockResolvedValue(user); mockUserRepo.update.mockResolvedValue(undefined); const command = new BanUserCommand('user-1', 'admin-1', 'Spam activity'); const result = await handler.execute(command); expect(result.isActive).toBe(false); expect(result.message).toContain('bị ban'); expect(mockUserRepo.update).toHaveBeenCalledTimes(1); expect(mockEventBus.publish).toHaveBeenCalled(); }); it('unbans a banned user successfully', async () => { const user = await createUser('BUYER', false); mockUserRepo.findById.mockResolvedValue(user); mockUserRepo.update.mockResolvedValue(undefined); const command = new BanUserCommand('user-1', 'admin-1', 'Resolved', true); const result = await handler.execute(command); expect(result.isActive).toBe(true); expect(result.message).toContain('gỡ ban'); }); it('throws NotFoundException when user does not exist', async () => { mockUserRepo.findById.mockResolvedValue(null); const command = new BanUserCommand('nonexistent', 'admin-1', 'test'); await expect(handler.execute(command)).rejects.toThrow('Người dùng không tồn tại'); }); it('prevents banning an admin user', async () => { const admin = await createUser('ADMIN', true); mockUserRepo.findById.mockResolvedValue(admin); const command = new BanUserCommand('user-1', 'admin-1', 'test'); await expect(handler.execute(command)).rejects.toThrow(/admin/i); }); it('throws when trying to ban already banned user', async () => { const user = await createUser('BUYER', false); mockUserRepo.findById.mockResolvedValue(user); const command = new BanUserCommand('user-1', 'admin-1', 'test'); await expect(handler.execute(command)).rejects.toThrow('Người dùng đã bị ban'); }); }); ``` --- ## 4. INFRASTRUCTURE & REPOSITORY FILES (No tests required but provided for context) ### 4.1 prisma-admin-query.repository.ts **Path:** `apps/api/src/modules/admin/infrastructure/repositories/prisma-admin-query.repository.ts` **Status:** ℹ️ Infrastructure layer - Integration tests may be appropriate instead **Note:** This is an implementation detail that delegates to more granular query functions. Consider integration tests instead of unit tests. --- ## 5. PRESENTATION LAYER FILES (No tests typically required) ### 5.1 admin.controller.ts **Path:** `apps/api/src/modules/admin/presentation/controllers/admin.controller.ts` **Status:** ℹ️ Controllers typically use E2E tests, not unit tests **Note:** Controllers are tested via E2E/integration tests, not unit tests in this architecture. --- ## 6. TEST WRITING RECOMMENDATIONS ### For reject-listing.handler.spec.ts Follow the pattern from `approve-listing.handler.spec.ts`: 1. Create helper function to build listing entities 2. Mock `IListingRepository` and `EventBus` 3. Test 3 scenarios: - Successfully rejects pending listing → publishes event - Throws when listing doesn't exist - Throws when listing not in PENDING_REVIEW status ### For get-revenue-stats.handler.spec.ts Follow the pattern from `get-dashboard-stats.handler.spec.ts`: 1. Mock `IAdminQueryRepository` 2. Test single scenario: - Returns results from repository.getRevenueStats() call 3. Verify correct parameters passed: - startDate, endDate, groupBy ### For user-deactivated.listener.spec.ts Follow the pattern from `user-banned.listener.spec.ts`: 1. Mock `PrismaService` and `LoggerService` 2. Test scenarios: - Successfully expires listings for deactivated user - Updates both ACTIVE and PENDING_REVIEW listings - Logs correct messages - Handles case with 0 listings updated - Handles case with multiple listings updated --- ## 7. COMPLETE FILE STRUCTURE ``` admin/ ├── application/ │ ├── __tests__/ │ │ ├── adjust-subscription.handler.spec.ts ✅ │ │ ├── admin-audit.listener.spec.ts ✅ │ │ ├── approve-kyc.handler.spec.ts ✅ │ │ ├── approve-listing.handler.spec.ts ✅ (REFERENCE) │ │ ├── ban-user.handler.spec.ts ✅ │ │ ├── bulk-moderate-listings.handler.spec.ts ✅ │ │ ├── get-audit-logs.handler.spec.ts ✅ │ │ ├── get-dashboard-stats.handler.spec.ts ✅ (REFERENCE) │ │ ├── get-kyc-queue.handler.spec.ts ✅ │ │ ├── get-moderation-queue.handler.spec.ts ✅ │ │ ├── get-user-detail.handler.spec.ts ✅ │ │ ├── get-users.handler.spec.ts ✅ │ │ ├── reject-kyc.handler.spec.ts ✅ │ │ ├── update-user-status.handler.spec.ts ✅ │ │ ├── user-banned.listener.spec.ts ✅ (REFERENCE) │ │ └── [MISSING] reject-listing.handler.spec.ts ❌ │ │ └── [MISSING] get-revenue-stats.handler.spec.ts ❌ │ │ └── [MISSING] user-deactivated.listener.spec.ts ❌ │ │ │ ├── commands/ │ │ ├── adjust-subscription/ │ │ ├── approve-kyc/ │ │ ├── approve-listing/ │ │ ├── ban-user/ │ │ ├── bulk-moderate-listings/ │ │ ├── reject-kyc/ │ │ ├── reject-listing/ ❌ NO TEST │ │ ├── update-user-status/ │ │ └── index.ts │ │ │ ├── listeners/ │ │ ├── admin-audit.listener.ts ✅ │ │ ├── user-banned.listener.ts ✅ │ │ ├── user-deactivated.listener.ts ❌ NO TEST │ │ └── (no index.ts) │ │ │ ├── queries/ │ │ ├── get-audit-logs/ │ │ ├── get-dashboard-stats/ │ │ ├── get-kyc-queue/ │ │ ├── get-moderation-queue/ │ │ ├── get-revenue-stats/ ❌ NO TEST │ │ ├── get-user-detail/ │ │ ├── get-users/ │ │ └── index.ts │ │ │ ├── index.ts │ └── application.module.ts │ ├── domain/ │ ├── __tests__/ │ │ └── admin-events.spec.ts ✅ │ │ │ ├── events/ │ │ ├── kyc-approved.event.ts │ │ ├── kyc-rejected.event.ts │ │ ├── listing-approved.event.ts │ │ ├── listing-rejected.event.ts │ │ ├── subscription-adjusted.event.ts │ │ ├── user-banned.event.ts │ │ ├── user-unbanned.event.ts │ │ └── index.ts │ │ │ ├── repositories/ │ │ ├── admin-query.repository.ts │ │ ├── audit-log.repository.ts │ │ └── index.ts │ │ │ ├── domain.module.ts │ └── index.ts │ ├── infrastructure/ │ ├── repositories/ │ │ ├── admin-stats.queries.ts │ │ ├── admin-user.queries.ts │ │ ├── prisma-admin-query.repository.ts (ℹ️ Integration test candidate) │ │ ├── prisma-audit-log.repository.ts (ℹ️ Integration test candidate) │ │ └── index.ts │ │ │ ├── infrastructure.module.ts │ └── index.ts │ ├── presentation/ │ ├── controllers/ │ │ ├── admin.controller.ts (ℹ️ E2E test - not unit test) │ │ ├── admin-moderation.controller.ts (ℹ️ E2E test - not unit test) │ │ └── index.ts │ │ │ ├── dto/ │ │ ├── adjust-subscription.dto.ts │ │ ├── approve-kyc.dto.ts │ │ ├── approve-listing.dto.ts │ │ ├── ban-user.dto.ts │ │ ├── bulk-moderate.dto.ts │ │ ├── get-audit-logs-query.dto.ts │ │ ├── get-users-query.dto.ts │ │ ├── reject-kyc.dto.ts │ │ ├── reject-listing.dto.ts │ │ ├── revenue-stats.dto.ts │ │ ├── update-user-status.dto.ts │ │ └── index.ts │ │ │ ├── presentation.module.ts │ └── index.ts │ ├── admin.module.ts └── index.ts ``` Legend: - ✅ Has test file - ❌ Missing test file - ℹ️ Not a unit test candidate (integration/E2E) --- ## Summary: Critical Files Requiring Tests | File | Type | Priority | Pattern to Follow | |------|------|----------|-------------------| | reject-listing.handler.ts | Command Handler | HIGH | approve-listing.handler.spec.ts | | get-revenue-stats.handler.ts | Query Handler | HIGH | get-dashboard-stats.handler.spec.ts | | user-deactivated.listener.ts | Event Listener | HIGH | user-banned.listener.spec.ts | ---