Move 36 root-level audit/analysis documents and 7 web app audit documents into docs/audits/ directory to declutter the project root. Remove stale EXPLORATION_SUMMARY.txt. Co-Authored-By: Paperclip <noreply@paperclip.ing>
24 KiB
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:
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<RejectListingCommand> {
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: RejectListingCommand): Promise<RejectListingResult> {
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:
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:
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<GetRevenueStatsQuery> {
constructor(
@Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository,
) {}
async execute(query: GetRevenueStatsQuery): Promise<RevenueStatsItem[]> {
return this.adminQueryRepo.getRevenueStats(query.startDate, query.endDate, query.groupBy);
}
}
Associated Query Class:
export class GetRevenueStatsQuery {
constructor(
public readonly startDate: Date,
public readonly endDate: Date,
public readonly groupBy: 'day' | 'month' = 'month',
) {}
}
RevenueStatsItem Interface:
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:
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<void> {
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:
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<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
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:
- Factory Function:
createPendingListing()creates test entities - Mock Setup: Typed mocks using
ReturnType<typeof vi.fn> - Initialization: beforeEach() sets up fresh mocks for each test
- Entity Testing: Uses actual domain entities with state transitions
- Domain Events: Clears domain events after setup with
.clearDomainEvents() - 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:
import { UserBannedListener } from '../listeners/user-banned.listener';
describe('UserBannedListener', () => {
let listener: UserBannedListener;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockPrisma: {
listing: { updateMany: ReturnType<typeof vi.fn> };
user: { findUnique: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
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:
- Event Object Construction: Manually create event with all properties
- Multiple Dependencies: Mock CommandBus, Prisma, Logger
- Side Effects Testing: Verify database calls and command execution
- Conditional Logic: Test both happy path and edge cases (no email)
- 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:
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<typeof vi.fn> };
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:
- Simple Delegation: Query handlers typically just delegate to repository
- Minimal Setup: Mock only the repository
- Result Verification: Check the returned data matches expectations
- 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):
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<UserEntity> {
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<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
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:
- Create helper function to build listing entities
- Mock
IListingRepositoryandEventBus - 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:
- Mock
IAdminQueryRepository - Test single scenario:
- Returns results from repository.getRevenueStats() call
- Verify correct parameters passed:
- startDate, endDate, groupBy
For user-deactivated.listener.spec.ts
Follow the pattern from user-banned.listener.spec.ts:
- Mock
PrismaServiceandLoggerService - 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 |