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>
758 lines
24 KiB
Markdown
758 lines
24 KiB
Markdown
# 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<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:
|
||
```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<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:
|
||
```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<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<void>)
|
||
- ✅ 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<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:
|
||
1. **Factory Function:** `createPendingListing()` creates test entities
|
||
2. **Mock Setup:** Typed mocks using `ReturnType<typeof vi.fn>`
|
||
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<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:
|
||
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<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:
|
||
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<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`:
|
||
|
||
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 |
|
||
|
||
---
|
||
|