Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 29s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 2m42s
Deploy / Build Web Image (push) Failing after 27s
Deploy / Build AI Services Image (push) Failing after 29s
E2E Tests / Playwright E2E (push) Failing after 43s
Deploy / Build API Image (push) Failing after 1m31s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Security Scanning / Trivy Scan — API Image (push) Failing after 5m35s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 3m45s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Scan — Web Image (push) Failing after 13m51s
Security Scanning / Trivy Filesystem Scan (push) Failing after 14m46s
Security Scanning / Security Gate (push) Has been cancelled
517 lines
16 KiB
Markdown
517 lines
16 KiB
Markdown
# So Sánh Handler Chi Tiết & Các Mẫu Code
|
|
|
|
## So Sánh Cấu Trúc Tệp
|
|
|
|
### Mẫu Handler Đã Được Kiểm Thử: approve-listing
|
|
```
|
|
approve-listing/
|
|
├── approve-listing.command.ts (lớp đơn giản)
|
|
├── approve-listing.handler.ts (handler cần kiểm thử)
|
|
└── (không có query.ts - đây là Command, không phải Query)
|
|
|
|
Tệp kiểm thử:
|
|
└── approve-listing.handler.spec.ts
|
|
```
|
|
|
|
### Handler Chưa Được Kiểm Thử: reject-listing
|
|
```
|
|
reject-listing/
|
|
├── reject-listing.command.ts (lớp đơn giản)
|
|
├── reject-listing.handler.ts (CẦN KIỂM THỬ)
|
|
└── (không có query.ts - đây là Command, không phải Query)
|
|
|
|
Tệp kiểm thử:
|
|
└── ❌ THIẾU: reject-listing.handler.spec.ts
|
|
```
|
|
|
|
---
|
|
|
|
## So Sánh Handler Đặt Cạnh Nhau
|
|
|
|
### Handler APPROVE Listing:
|
|
```typescript
|
|
@CommandHandler(ApproveListingCommand)
|
|
export class ApproveListingHandler implements ICommandHandler<ApproveListingCommand> {
|
|
constructor(
|
|
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
|
private readonly eventBus: EventBus,
|
|
) {}
|
|
|
|
async execute(command: ApproveListingCommand): Promise<ApproveLis tingResult> {
|
|
// 1. Find listing
|
|
const listing = await this.listingRepo.findById(command.listingId);
|
|
if (!listing) {
|
|
throw new NotFoundException('Listing không tồn tại');
|
|
}
|
|
|
|
// 2. Check status
|
|
if (listing.status !== 'PENDING_REVIEW') {
|
|
throw new ValidationException(
|
|
`Listing đang ở trạng thái ${listing.status}, chỉ có thể phê duyệt listing đang chờ duyệt`,
|
|
{ currentStatus: listing.status },
|
|
);
|
|
}
|
|
|
|
// 3. Apply domain logic
|
|
listing.approve(command.notes);
|
|
|
|
// 4. Persist
|
|
await this.listingRepo.update(listing);
|
|
|
|
// 5. Publish event
|
|
this.eventBus.publish(
|
|
new ListingApprovedEvent(listing.id, command.adminId, command.notes),
|
|
);
|
|
|
|
// 6. Return result
|
|
return {
|
|
listingId: listing.id,
|
|
status: 'ACTIVE',
|
|
message: 'Listing đã được phê duyệt',
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### Handler REJECT Listing (mẫu gần như giống hệt):
|
|
```typescript
|
|
@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> {
|
|
// 1. Find listing
|
|
const listing = await this.listingRepo.findById(command.listingId);
|
|
if (!listing) {
|
|
throw new NotFoundException('Listing không tồn tại');
|
|
}
|
|
|
|
// 2. Check status (giống như approve!)
|
|
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 },
|
|
);
|
|
}
|
|
|
|
// 3. Apply domain logic (phương thức khác: reject thay vì approve)
|
|
listing.reject(command.reason);
|
|
|
|
// 4. Persist
|
|
await this.listingRepo.update(listing);
|
|
|
|
// 5. Publish event (loại event khác)
|
|
this.eventBus.publish(
|
|
new ListingRejectedEvent(listing.id, command.adminId, command.reason),
|
|
);
|
|
|
|
// 6. Return result (trạng thái khác)
|
|
return {
|
|
listingId: listing.id,
|
|
status: 'REJECTED',
|
|
message: 'Listing đã bị từ chối',
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### Sự Khác Biệt:
|
|
| Khía cạnh | Approve | Reject |
|
|
|--------|---------|--------|
|
|
| Phương thức domain | `listing.approve()` | `listing.reject()` |
|
|
| Event | `ListingApprovedEvent` | `ListingRejectedEvent` |
|
|
| Trạng thái kết quả | `'ACTIVE'` | `'REJECTED'` |
|
|
| Thông báo kết quả | `'Listing đã được phê duyệt'` | `'Listing đã bị từ chối'` |
|
|
|
|
---
|
|
|
|
## Hướng Dẫn Code Kiểm Thử
|
|
|
|
### Kiểm Thử ApproveListingHandler:
|
|
|
|
```typescript
|
|
describe('ApproveListingHandler', () => {
|
|
let handler: ApproveListingHandler;
|
|
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
|
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
|
|
|
// THIẾT LẬP: Tạo mock mới cho mỗi test
|
|
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() };
|
|
|
|
// Khởi tạo handler với các mock
|
|
handler = new ApproveListingHandler(
|
|
mockListingRepo as any,
|
|
mockEventBus as any,
|
|
);
|
|
});
|
|
|
|
// TEST 1: Luồng bình thường - Phê duyệt thành công
|
|
it('approves a pending listing successfully', async () => {
|
|
// Arrange: Tạo một thực thể listing ở trạng thái PENDING_REVIEW
|
|
const listing = createPendingListing();
|
|
mockListingRepo.findById.mockResolvedValue(listing);
|
|
mockListingRepo.update.mockResolvedValue(undefined);
|
|
|
|
// Act: Thực thi lệnh
|
|
const command = new ApproveListingCommand('listing-1', 'admin-1', 'Looks good');
|
|
const result = await handler.execute(command);
|
|
|
|
// Assert: Kiểm tra kết quả
|
|
expect(result.status).toBe('ACTIVE');
|
|
expect(result.listingId).toBe('listing-1');
|
|
|
|
// Assert: Kiểm tra các hiệu ứng phụ
|
|
expect(mockListingRepo.update).toHaveBeenCalledTimes(1);
|
|
expect(mockEventBus.publish).toHaveBeenCalled();
|
|
});
|
|
|
|
// TEST 2: Lỗi - Không tìm thấy listing
|
|
it('throws NotFoundException when listing does not exist', async () => {
|
|
// Arrange: Mock trả về null (không tìm thấy)
|
|
mockListingRepo.findById.mockResolvedValue(null);
|
|
|
|
// Act & Assert: Kỳ vọng ngoại lệ
|
|
const command = new ApproveListingCommand('nonexistent', 'admin-1');
|
|
await expect(handler.execute(command)).rejects.toThrow('Listing không tồn tại');
|
|
});
|
|
|
|
// TEST 3: Lỗi - Sai trạng thái
|
|
it('throws ValidationException when listing is not pending review', async () => {
|
|
// Arrange: Tạo listing KHÔNG ở trạng thái PENDING_REVIEW
|
|
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);
|
|
|
|
// Act & Assert: Kỳ vọng ngoại lệ
|
|
const command = new ApproveListingCommand('listing-1', 'admin-1');
|
|
await expect(handler.execute(command)).rejects.toThrow(/trạng thái/);
|
|
});
|
|
});
|
|
```
|
|
|
|
### Cách điều chỉnh cho RejectListingHandler:
|
|
|
|
1. **Thay đổi import:**
|
|
```typescript
|
|
import { RejectListingCommand } from '../commands/reject-listing/reject-listing.command';
|
|
import { RejectListingHandler } from '../commands/reject-listing/reject-listing.handler';
|
|
// Giữ nguyên tất cả phần còn lại
|
|
```
|
|
|
|
2. **Thay đổi Test 1 (Luồng bình thường):**
|
|
```typescript
|
|
it('rejects a pending listing successfully', async () => {
|
|
const listing = createPendingListing();
|
|
mockListingRepo.findById.mockResolvedValue(listing);
|
|
mockListingRepo.update.mockResolvedValue(undefined);
|
|
|
|
const command = new RejectListingCommand('listing-1', 'admin-1', 'Too many issues');
|
|
const result = await handler.execute(command);
|
|
|
|
expect(result.status).toBe('REJECTED'); // Đổi từ 'ACTIVE'
|
|
expect(result.message).toContain('từ chối'); // Thay đổi câu kiểm tra
|
|
expect(mockListingRepo.update).toHaveBeenCalledTimes(1);
|
|
expect(mockEventBus.publish).toHaveBeenCalled();
|
|
});
|
|
```
|
|
|
|
3. **Test 2 & 3 gần như giữ nguyên** (chỉ thay đổi tên import)
|
|
|
|
---
|
|
|
|
## So Sánh Query Handler
|
|
|
|
### Query Handler Đã Được Kiểm Thử: get-dashboard-stats
|
|
```typescript
|
|
@QueryHandler(GetDashboardStatsQuery)
|
|
export class GetDashboardStatsHandler implements IQueryHandler<GetDashboardStatsQuery> {
|
|
constructor(
|
|
@Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository,
|
|
) {}
|
|
|
|
async execute(query: GetDashboardStatsQuery): Promise<DashboardStats> {
|
|
return this.adminQueryRepo.getDashboardStats();
|
|
}
|
|
}
|
|
```
|
|
|
|
### Query Handler Chưa Được Kiểm Thử: get-revenue-stats
|
|
```typescript
|
|
@QueryHandler(GetRevenueStatsQuery)
|
|
export class GetRevenueStatsHandler implements IQueryHandler<GetRevenueStatsQuery> {
|
|
constructor(
|
|
@Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository,
|
|
) {}
|
|
|
|
async execute(query: GetRevenueStatsQuery): Promise<RevenueStatsItem[]> {
|
|
// SỰ KHÁC BIỆT CHÍNH: Truyền tham số query vào phương thức repo
|
|
return this.adminQueryRepo.getRevenueStats(query.startDate, query.endDate, query.groupBy);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Mẫu Kiểm Thử Query Handler:
|
|
```typescript
|
|
describe('GetRevenueStatsHandler', () => {
|
|
let handler: GetRevenueStatsHandler;
|
|
let mockAdminQueryRepo: { [K in keyof IAdminQueryRepository]: ReturnType<typeof vi.fn> };
|
|
|
|
beforeEach(() => {
|
|
mockAdminQueryRepo = {
|
|
getModerationQueue: vi.fn(),
|
|
getDashboardStats: vi.fn(),
|
|
getRevenueStats: vi.fn(), // Cái này sẽ được kiểm thử
|
|
getUsers: vi.fn(),
|
|
};
|
|
|
|
handler = new GetRevenueStatsHandler(mockAdminQueryRepo as any);
|
|
});
|
|
|
|
it('returns revenue stats for date range', async () => {
|
|
// Arrange: Dữ liệu mock
|
|
const mockStats: RevenueStatsItem[] = [
|
|
{
|
|
period: '2024-04',
|
|
totalRevenue: 50000000n,
|
|
subscriptionRevenue: 30000000n,
|
|
listingFeeRevenue: 15000000n,
|
|
featuredListingRevenue: 5000000n,
|
|
transactionCount: 125,
|
|
},
|
|
];
|
|
mockAdminQueryRepo.getRevenueStats.mockResolvedValue(mockStats);
|
|
|
|
// Act
|
|
const startDate = new Date('2024-04-01');
|
|
const endDate = new Date('2024-04-30');
|
|
const query = new GetRevenueStatsQuery(startDate, endDate, 'month');
|
|
const result = await handler.execute(query);
|
|
|
|
// Assert: Kiểm tra kết quả
|
|
expect(result).toEqual(mockStats);
|
|
|
|
// Assert: Kiểm tra các tham số được truyền đúng
|
|
expect(mockAdminQueryRepo.getRevenueStats).toHaveBeenCalledWith(
|
|
startDate,
|
|
endDate,
|
|
'month'
|
|
);
|
|
expect(mockAdminQueryRepo.getRevenueStats).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('supports day grouping', async () => {
|
|
mockAdminQueryRepo.getRevenueStats.mockResolvedValue([]);
|
|
|
|
const query = new GetRevenueStatsQuery(
|
|
new Date('2024-04-01'),
|
|
new Date('2024-04-30'),
|
|
'day' // Tham số đã thay đổi
|
|
);
|
|
await handler.execute(query);
|
|
|
|
expect(mockAdminQueryRepo.getRevenueStats).toHaveBeenCalledWith(
|
|
expect.any(Date),
|
|
expect.any(Date),
|
|
'day' // Kiểm tra 'day' đã được truyền vào
|
|
);
|
|
});
|
|
|
|
it('returns empty array when no data', async () => {
|
|
mockAdminQueryRepo.getRevenueStats.mockResolvedValue([]);
|
|
|
|
const query = new GetRevenueStatsQuery(
|
|
new Date('2024-01-01'),
|
|
new Date('2024-01-01')
|
|
);
|
|
const result = await handler.execute(query);
|
|
|
|
expect(result).toEqual([]);
|
|
expect(result.length).toBe(0);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## So Sánh Listener
|
|
|
|
### UserBannedListener (Đã Được Kiểm Thử):
|
|
```typescript
|
|
@Injectable()
|
|
export class UserBannedListener {
|
|
constructor(
|
|
private readonly commandBus: CommandBus,
|
|
private readonly prisma: PrismaService,
|
|
private readonly logger: LoggerService,
|
|
) {}
|
|
|
|
@OnEvent('user.banned', { async: true })
|
|
async handle(event: UserBannedEvent): Promise<void> {
|
|
this.logger.log(`Handling user.banned for user ${event.aggregateId}`, 'UserBannedListener');
|
|
|
|
// Vô hiệu hóa các listing
|
|
const deactivated = await this.prisma.listing.updateMany({
|
|
where: {
|
|
sellerId: event.aggregateId,
|
|
status: { in: ['ACTIVE', 'PENDING_REVIEW', 'DRAFT'] },
|
|
},
|
|
data: { status: 'EXPIRED' },
|
|
});
|
|
|
|
this.logger.log(
|
|
`Deactivated ${deactivated.count} listings for banned user ${event.aggregateId}`,
|
|
'UserBannedListener',
|
|
);
|
|
|
|
// Gửi thông báo email
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: event.aggregateId },
|
|
select: { id: true, email: true },
|
|
});
|
|
|
|
if (user?.email) {
|
|
await this.commandBus.execute(
|
|
new SendNotificationCommand(
|
|
user.id,
|
|
'EMAIL',
|
|
'user.banned',
|
|
{ reason: event.reason },
|
|
user.email,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### UserDeactivatedListener (Chưa Được Kiểm Thử):
|
|
```typescript
|
|
@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');
|
|
|
|
// Tương tự UserBannedListener nhưng:
|
|
// 1. KHÔNG có CommandBus (đơn giản hơn)
|
|
// 2. KHÔNG có thông báo email
|
|
// 3. Danh sách trạng thái khác: ['ACTIVE', 'PENDING_REVIEW'] (không có DRAFT)
|
|
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',
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Sự Khác Biệt Chính:
|
|
| Khía cạnh | UserBanned | UserDeactivated |
|
|
|--------|-----------|-----------------|
|
|
| Tên event | `'user.banned'` | `'user.deactivated'` |
|
|
| Có CommandBus | ✅ Có (để gửi email) | ❌ Không |
|
|
| Danh sách trạng thái | `['ACTIVE', 'PENDING_REVIEW', 'DRAFT']` | `['ACTIVE', 'PENDING_REVIEW']` |
|
|
| Gửi thông báo | ✅ Có (email) | ❌ Không |
|
|
| Độ phức tạp | Cao hơn | Thấp hơn |
|
|
|
|
### Mẫu Kiểm Thử Listener (đơn giản hóa cho UserDeactivated):
|
|
```typescript
|
|
describe('UserDeactivatedListener', () => {
|
|
let listener: UserDeactivatedListener;
|
|
let mockPrisma: {
|
|
listing: { updateMany: ReturnType<typeof vi.fn> };
|
|
};
|
|
let mockLogger: { log: ReturnType<typeof vi.fn> };
|
|
|
|
beforeEach(() => {
|
|
mockPrisma = {
|
|
listing: { updateMany: vi.fn().mockResolvedValue({ count: 5 }) },
|
|
};
|
|
mockLogger = { log: vi.fn() };
|
|
|
|
listener = new UserDeactivatedListener(mockPrisma as any, mockLogger as any);
|
|
});
|
|
|
|
it('expires all active and pending review listings for deactivated user', async () => {
|
|
await listener.handle({
|
|
aggregateId: 'user-123',
|
|
eventName: 'user.deactivated',
|
|
occurredAt: new Date(),
|
|
});
|
|
|
|
expect(mockPrisma.listing.updateMany).toHaveBeenCalledWith({
|
|
where: {
|
|
sellerId: 'user-123',
|
|
status: { in: ['ACTIVE', 'PENDING_REVIEW'] },
|
|
},
|
|
data: { status: 'EXPIRED' },
|
|
});
|
|
});
|
|
|
|
it('logs handling start and result', async () => {
|
|
await listener.handle({
|
|
aggregateId: 'user-123',
|
|
eventName: 'user.deactivated',
|
|
occurredAt: new Date(),
|
|
});
|
|
|
|
expect(mockLogger.log).toHaveBeenCalledTimes(2);
|
|
expect(mockLogger.log).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.stringContaining('user-123'),
|
|
'UserDeactivatedListener'
|
|
);
|
|
expect(mockLogger.log).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.stringContaining('Expired 5 listings'),
|
|
'UserDeactivatedListener'
|
|
);
|
|
});
|
|
|
|
it('handles zero listings case', async () => {
|
|
mockPrisma.listing.updateMany.mockResolvedValue({ count: 0 });
|
|
|
|
await listener.handle({
|
|
aggregateId: 'user-xyz',
|
|
eventName: 'user.deactivated',
|
|
occurredAt: new Date(),
|
|
});
|
|
|
|
expect(mockLogger.log).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.stringContaining('Expired 0 listings'),
|
|
'UserDeactivatedListener'
|
|
);
|
|
});
|
|
});
|
|
```
|