chore: remediate CI blockers for production readiness

This commit is contained in:
Ho Ngoc Hai
2026-05-07 13:08:20 +07:00
parent f82806e06d
commit b35ec55126
32 changed files with 401 additions and 113 deletions

View File

@@ -146,12 +146,14 @@ describe('UpdateListingStatusCommand', () => {
'listing-1',
'ACTIVE',
'user-1',
'ADMIN',
'Đã xác minh thông tin',
);
expect(command.listingId).toBe('listing-1');
expect(command.newStatus).toBe('ACTIVE');
expect(command.userId).toBe('user-1');
expect(command.userRole).toBe('ADMIN');
expect(command.moderationNotes).toBe('Đã xác minh thông tin');
});

View File

@@ -52,7 +52,7 @@ describe('UpdateListingStatusHandler', () => {
const listing = createListing('listing-1', 'PENDING_REVIEW');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'ACTIVE', 'admin-1');
const command = new UpdateListingStatusCommand('listing-1', 'ACTIVE', 'admin-1', 'ADMIN');
const result = await handler.execute(command);
expect(result.status).toBe('ACTIVE');
@@ -64,7 +64,7 @@ describe('UpdateListingStatusHandler', () => {
const listing = createListing('listing-1', 'PENDING_REVIEW');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'REJECTED', 'admin-1', 'Vi phạm chính sách');
const command = new UpdateListingStatusCommand('listing-1', 'REJECTED', 'admin-1', 'ADMIN', 'Vi phạm chính sách');
const result = await handler.execute(command);
expect(result.status).toBe('REJECTED');
@@ -74,7 +74,7 @@ describe('UpdateListingStatusHandler', () => {
const listing = createListing('listing-1', 'ACTIVE');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1');
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1', 'SELLER');
const result = await handler.execute(command);
expect(result.status).toBe('SOLD');
@@ -83,7 +83,7 @@ describe('UpdateListingStatusHandler', () => {
it('throws NotFoundException for non-existent listing', async () => {
mockListingRepo.findById.mockResolvedValue(null);
const command = new UpdateListingStatusCommand('nonexistent', 'ACTIVE', 'admin-1');
const command = new UpdateListingStatusCommand('nonexistent', 'ACTIVE', 'admin-1', 'ADMIN');
await expect(handler.execute(command)).rejects.toThrow('Listing');
});
@@ -92,8 +92,28 @@ describe('UpdateListingStatusHandler', () => {
const listing = createListing('listing-1', 'DRAFT');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1');
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1', 'SELLER');
await expect(handler.execute(command)).rejects.toThrow(/trạng thái/);
});
it('rejects moderation transitions from non-admin users', async () => {
const listing = createListing('listing-1', 'PENDING_REVIEW');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'ACTIVE', 'seller-1', 'SELLER');
await expect(handler.execute(command)).rejects.toThrow(/quản trị viên/);
expect(mockListingRepo.update).not.toHaveBeenCalled();
});
it('rejects status updates from non-owner users', async () => {
const listing = createListing('listing-1', 'ACTIVE');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'other-user', 'SELLER');
await expect(handler.execute(command)).rejects.toThrow(/người bán/);
expect(mockListingRepo.update).not.toHaveBeenCalled();
});
});

View File

@@ -5,6 +5,7 @@ export class UpdateListingStatusCommand {
public readonly listingId: string,
public readonly newStatus: ListingStatus,
public readonly userId: string,
public readonly userRole?: string,
public readonly moderationNotes?: string,
) {}
}

View File

@@ -1,6 +1,6 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, NotFoundException, CacheService, CachePrefix, LoggerService } from '@modules/shared';
import { DomainException, ForbiddenException, NotFoundException, CacheService, CachePrefix, LoggerService } from '@modules/shared';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
import { ModerationService } from '../../../domain/services/moderation.service';
import { UpdateListingStatusCommand } from './update-listing-status.command';
@@ -22,6 +22,23 @@ export class UpdateListingStatusHandler implements ICommandHandler<UpdateListing
throw new NotFoundException('Listing', command.listingId);
}
const isAdmin = command.userRole === 'ADMIN';
const isOwner = listing.sellerId === command.userId;
const isAssignedAgent = listing.agentId !== null && listing.agentId === command.userId;
const isModerationTransition =
(listing.status === 'PENDING_REVIEW' && command.newStatus === 'ACTIVE') ||
command.newStatus === 'REJECTED';
if (isModerationTransition && !isAdmin) {
throw new ForbiddenException('Chỉ quản trị viên mới có thể duyệt hoặc từ chối tin đăng');
}
if (!isAdmin && !isOwner && !isAssignedAgent) {
throw new ForbiddenException(
'Chỉ người bán, môi giới được giao hoặc quản trị viên mới có thể cập nhật trạng thái tin đăng',
);
}
this.moderationService.applyStatusTransition(
listing,
command.newStatus,

View File

@@ -387,7 +387,7 @@ export class ListingsController {
@CurrentUser() user: JwtPayload,
): Promise<{ status: string }> {
return this.commandBus.execute(
new UpdateListingStatusCommand(id, dto.status, user.sub, dto.moderationNotes),
new UpdateListingStatusCommand(id, dto.status, user.sub, user.role, dto.moderationNotes),
);
}