feat(admin): add Admin module with moderation, user mgmt, and dashboard

- Commands: ApproveListing, RejectListing, BanUser, AdjustSubscription
- Queries: GetModerationQueue, GetDashboardStats, GetRevenueStats
- Admin-only guards via @Roles('ADMIN') on all endpoints
- Prisma-based admin query repository for dashboard aggregations
- 14 unit tests covering all command handlers and query handlers
- Added activate() method to UserEntity for unban support

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 02:17:09 +07:00
parent ac3947b42d
commit dafed32e11
49 changed files with 1485 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ import { SearchModule } from '@modules/search';
import { NotificationsModule } from '@modules/notifications';
import { PaymentsModule } from '@modules/payments';
import { SubscriptionsModule } from '@modules/subscriptions';
import { AdminModule } from '@modules/admin';
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { CqrsModule } from '@nestjs/cqrs';
@@ -22,6 +23,7 @@ import { AppController } from './app.controller';
NotificationsModule,
PaymentsModule,
SubscriptionsModule,
AdminModule,
// ── Rate Limiting ──
// Default: 60 requests per 60 seconds per IP

View File

@@ -0,0 +1,52 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { AuthModule } from '@modules/auth';
import { ListingsModule } from '@modules/listings';
import { SubscriptionsModule } from '@modules/subscriptions';
// Domain
import { ADMIN_QUERY_REPOSITORY } from './domain/repositories/admin-query.repository';
// Infrastructure
import { PrismaAdminQueryRepository } from './infrastructure/repositories/prisma-admin-query.repository';
// Application — Commands
import { ApproveListingHandler } from './application/commands/approve-listing/approve-listing.handler';
import { RejectListingHandler } from './application/commands/reject-listing/reject-listing.handler';
import { BanUserHandler } from './application/commands/ban-user/ban-user.handler';
import { AdjustSubscriptionHandler } from './application/commands/adjust-subscription/adjust-subscription.handler';
// Application — Queries
import { GetModerationQueueHandler } from './application/queries/get-moderation-queue/get-moderation-queue.handler';
import { GetDashboardStatsHandler } from './application/queries/get-dashboard-stats/get-dashboard-stats.handler';
import { GetRevenueStatsHandler } from './application/queries/get-revenue-stats/get-revenue-stats.handler';
// Presentation
import { AdminController } from './presentation/controllers/admin.controller';
const CommandHandlers = [
ApproveListingHandler,
RejectListingHandler,
BanUserHandler,
AdjustSubscriptionHandler,
];
const QueryHandlers = [
GetModerationQueueHandler,
GetDashboardStatsHandler,
GetRevenueStatsHandler,
];
@Module({
imports: [CqrsModule, AuthModule, ListingsModule, SubscriptionsModule],
controllers: [AdminController],
providers: [
// Repositories
{ provide: ADMIN_QUERY_REPOSITORY, useClass: PrismaAdminQueryRepository },
// CQRS
...CommandHandlers,
...QueryHandlers,
],
})
export class AdminModule {}

View File

@@ -0,0 +1,88 @@
import { AdjustSubscriptionHandler } from '../commands/adjust-subscription/adjust-subscription.handler';
import { AdjustSubscriptionCommand } from '../commands/adjust-subscription/adjust-subscription.command';
import { type ISubscriptionRepository } from '@modules/subscriptions/domain/repositories/subscription.repository';
import { SubscriptionEntity } from '@modules/subscriptions/domain/entities/subscription.entity';
describe('AdjustSubscriptionHandler', () => {
let handler: AdjustSubscriptionHandler;
let mockSubRepo: { [K in keyof ISubscriptionRepository]: ReturnType<typeof vi.fn> };
let mockPrisma: any;
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockSubRepo = {
findById: vi.fn(),
findByUserId: vi.fn(),
save: vi.fn(),
update: vi.fn(),
};
mockPrisma = {
plan: {
findUnique: vi.fn(),
},
};
mockEventBus = { publish: vi.fn() };
handler = new AdjustSubscriptionHandler(
mockSubRepo as any,
mockPrisma,
mockEventBus as any,
);
});
it('adjusts subscription to a new plan successfully', async () => {
const sub = SubscriptionEntity.createNew(
'sub-1', 'user-1', 'plan-free', 'FREE',
new Date(), new Date(Date.now() + 30 * 86400000),
);
sub.clearDomainEvents();
mockSubRepo.findByUserId.mockResolvedValue(sub);
mockPrisma.plan.findUnique.mockResolvedValue({
id: 'plan-pro',
tier: 'AGENT_PRO',
});
mockSubRepo.update.mockResolvedValue(undefined);
const command = new AdjustSubscriptionCommand('user-1', 'admin-1', 'AGENT_PRO', 'Courtesy upgrade');
const result = await handler.execute(command);
expect(result.newPlanTier).toBe('AGENT_PRO');
expect(mockSubRepo.update).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('throws NotFoundException when user has no subscription', async () => {
mockSubRepo.findByUserId.mockResolvedValue(null);
const command = new AdjustSubscriptionCommand('user-1', 'admin-1', 'AGENT_PRO', 'test');
await expect(handler.execute(command)).rejects.toThrow('Người dùng chưa có subscription');
});
it('throws ValidationException for invalid plan tier', async () => {
const command = new AdjustSubscriptionCommand('user-1', 'admin-1', 'INVALID_TIER', 'test');
await expect(handler.execute(command)).rejects.toThrow(/không hợp lệ/);
});
it('throws ValidationException when user is already on the same plan', async () => {
const sub = SubscriptionEntity.createNew(
'sub-1', 'user-1', 'plan-pro', 'AGENT_PRO',
new Date(), new Date(Date.now() + 30 * 86400000),
);
sub.clearDomainEvents();
mockSubRepo.findByUserId.mockResolvedValue(sub);
mockPrisma.plan.findUnique.mockResolvedValue({
id: 'plan-pro',
tier: 'AGENT_PRO',
});
const command = new AdjustSubscriptionCommand('user-1', 'admin-1', 'AGENT_PRO', 'test');
await expect(handler.execute(command)).rejects.toThrow(/đã đang sử dụng/);
});
});

View File

@@ -0,0 +1,75 @@
import { ApproveListingHandler } from '../commands/approve-listing/approve-listing.handler';
import { ApproveListingCommand } from '../commands/approve-listing/approve-listing.command';
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { ListingEntity } from '@modules/listings/domain/entities/listing.entity';
import { Price } from '@modules/listings/domain/value-objects/price.vo';
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/);
});
});

View File

@@ -0,0 +1,99 @@
import { BanUserHandler } from '../commands/ban-user/ban-user.handler';
import { BanUserCommand } from '../commands/ban-user/ban-user.command';
import { type IUserRepository } from '@modules/auth/domain/repositories/user.repository';
import { UserEntity } from '@modules/auth/domain/entities/user.entity';
import { Phone } from '@modules/auth/domain/value-objects/phone.vo';
import { HashedPassword } from '@modules/auth/domain/value-objects/hashed-password.vo';
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');
});
});

View File

@@ -0,0 +1,40 @@
import { GetDashboardStatsHandler } from '../queries/get-dashboard-stats/get-dashboard-stats.handler';
import { GetDashboardStatsQuery } from '../queries/get-dashboard-stats/get-dashboard-stats.query';
import { type IAdminQueryRepository } from '../../domain/repositories/admin-query.repository';
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);
});
});

View File

@@ -0,0 +1,49 @@
import { GetModerationQueueHandler } from '../queries/get-moderation-queue/get-moderation-queue.handler';
import { GetModerationQueueQuery } from '../queries/get-moderation-queue/get-moderation-queue.query';
import { type IAdminQueryRepository } from '../../domain/repositories/admin-query.repository';
describe('GetModerationQueueHandler', () => {
let handler: GetModerationQueueHandler;
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 GetModerationQueueHandler(mockAdminQueryRepo as any);
});
it('returns moderation queue with pagination', async () => {
const queueResult = {
data: [
{
listingId: 'listing-1',
propertyTitle: 'Căn hộ 2PN quận 7',
propertyType: 'APARTMENT',
transactionType: 'SALE',
priceVND: 3_000_000_000n,
sellerName: 'Nguyễn Văn A',
sellerId: 'seller-1',
moderationScore: null,
createdAt: new Date(),
},
],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
};
mockAdminQueryRepo.getModerationQueue.mockResolvedValue(queueResult);
const result = await handler.execute(new GetModerationQueueQuery(1, 20));
expect(result.data).toHaveLength(1);
expect(result.total).toBe(1);
expect(mockAdminQueryRepo.getModerationQueue).toHaveBeenCalledWith(1, 20);
});
});

View File

@@ -0,0 +1,8 @@
export class AdjustSubscriptionCommand {
constructor(
public readonly userId: string,
public readonly adminId: string,
public readonly newPlanTier: string,
public readonly reason: string,
) {}
}

View File

@@ -0,0 +1,65 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { NotFoundException, ValidationException } from '@modules/shared';
import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository } from '@modules/subscriptions/domain/repositories/subscription.repository';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type PlanTier } from '@prisma/client';
import { SubscriptionAdjustedEvent } from '../../../domain/events/subscription-adjusted.event';
import { AdjustSubscriptionCommand } from './adjust-subscription.command';
const VALID_TIERS: PlanTier[] = ['FREE', 'AGENT_PRO', 'INVESTOR', 'ENTERPRISE'];
export interface AdjustSubscriptionResult {
subscriptionId: string;
newPlanTier: string;
message: string;
}
@CommandHandler(AdjustSubscriptionCommand)
export class AdjustSubscriptionHandler implements ICommandHandler<AdjustSubscriptionCommand> {
constructor(
@Inject(SUBSCRIPTION_REPOSITORY) private readonly subscriptionRepo: ISubscriptionRepository,
private readonly prisma: PrismaService,
private readonly eventBus: EventBus,
) {}
async execute(command: AdjustSubscriptionCommand): Promise<AdjustSubscriptionResult> {
const tier = command.newPlanTier as PlanTier;
if (!VALID_TIERS.includes(tier)) {
throw new ValidationException(`Gói không hợp lệ: ${command.newPlanTier}`, {
validTiers: VALID_TIERS,
});
}
const subscription = await this.subscriptionRepo.findByUserId(command.userId);
if (!subscription) {
throw new NotFoundException('Người dùng chưa có subscription');
}
const newPlan = await this.prisma.plan.findUnique({
where: { tier },
});
if (!newPlan) {
throw new NotFoundException(`Gói ${tier} không tồn tại trong hệ thống`);
}
if (subscription.planId === newPlan.id) {
throw new ValidationException('Người dùng đã đang sử dụng gói này', {
currentPlanId: subscription.planId,
});
}
subscription.upgrade(newPlan.id, tier);
await this.subscriptionRepo.update(subscription);
this.eventBus.publish(
new SubscriptionAdjustedEvent(subscription.id, command.adminId, newPlan.id, command.reason),
);
return {
subscriptionId: subscription.id,
newPlanTier: tier,
message: `Subscription đã được chuyển sang gói ${tier}`,
};
}
}

View File

@@ -0,0 +1,7 @@
export class ApproveListingCommand {
constructor(
public readonly listingId: string,
public readonly adminId: string,
public readonly moderationNotes?: string,
) {}
}

View File

@@ -0,0 +1,53 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { NotFoundException, ValidationException } from '@modules/shared';
import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
import { ApproveListingCommand } from './approve-listing.command';
export interface ApproveListingResult {
listingId: string;
status: string;
message: string;
}
@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<ApproveListingResult> {
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ể duyệt listing đang chờ duyệt`,
{ currentStatus: listing.status },
);
}
listing.approve();
if (command.moderationNotes) {
listing.setModerationScore(1.0, command.moderationNotes);
}
await this.listingRepo.update(listing);
this.eventBus.publish(
new ListingApprovedEvent(listing.id, command.adminId, command.moderationNotes),
);
return {
listingId: listing.id,
status: 'ACTIVE',
message: 'Listing đã được duyệt thành công',
};
}
}

View File

@@ -0,0 +1,8 @@
export class BanUserCommand {
constructor(
public readonly userId: string,
public readonly adminId: string,
public readonly reason: string,
public readonly unban: boolean = false,
) {}
}

View File

@@ -0,0 +1,70 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { NotFoundException, ValidationException } from '@modules/shared';
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth/domain/repositories/user.repository';
import { UserBannedEvent } from '../../../domain/events/user-banned.event';
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
import { BanUserCommand } from './ban-user.command';
export interface BanUserResult {
userId: string;
isActive: boolean;
message: string;
}
@CommandHandler(BanUserCommand)
export class BanUserHandler implements ICommandHandler<BanUserCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: BanUserCommand): Promise<BanUserResult> {
const user = await this.userRepo.findById(command.userId);
if (!user) {
throw new NotFoundException('Người dùng không tồn tại');
}
if (user.role === 'ADMIN') {
throw new ValidationException('Không thể ban/unban tài khoản admin', {
userId: command.userId,
});
}
if (command.unban) {
if (user.isActive) {
throw new ValidationException('Người dùng chưa bị ban', {
userId: command.userId,
});
}
user.activate();
await this.userRepo.update(user);
this.eventBus.publish(new UserUnbannedEvent(user.id, command.adminId));
return {
userId: user.id,
isActive: true,
message: 'Người dùng đã được gỡ ban',
};
}
if (!user.isActive) {
throw new ValidationException('Người dùng đã bị ban', {
userId: command.userId,
});
}
user.deactivate();
await this.userRepo.update(user);
this.eventBus.publish(new UserBannedEvent(user.id, command.adminId, command.reason));
return {
userId: user.id,
isActive: false,
message: 'Người dùng đã bị ban',
};
}
}

View File

@@ -0,0 +1,8 @@
export { ApproveListingCommand } from './approve-listing/approve-listing.command';
export { ApproveListingHandler } from './approve-listing/approve-listing.handler';
export { RejectListingCommand } from './reject-listing/reject-listing.command';
export { RejectListingHandler } from './reject-listing/reject-listing.handler';
export { BanUserCommand } from './ban-user/ban-user.command';
export { BanUserHandler } from './ban-user/ban-user.handler';
export { AdjustSubscriptionCommand } from './adjust-subscription/adjust-subscription.command';
export { AdjustSubscriptionHandler } from './adjust-subscription/adjust-subscription.handler';

View File

@@ -0,0 +1,7 @@
export class RejectListingCommand {
constructor(
public readonly listingId: string,
public readonly adminId: string,
public readonly reason: string,
) {}
}

View File

@@ -0,0 +1,47 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { NotFoundException, ValidationException } from '@modules/shared';
import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
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',
};
}
}

View File

@@ -0,0 +1,2 @@
export * from './commands';
export * from './queries';

View File

@@ -0,0 +1,15 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type DashboardStats } from '../../../domain/repositories/admin-query.repository';
import { GetDashboardStatsQuery } from './get-dashboard-stats.query';
@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();
}
}

View File

@@ -0,0 +1,3 @@
export class GetDashboardStatsQuery {
constructor() {}
}

View File

@@ -0,0 +1,15 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type ModerationQueueResult } from '../../../domain/repositories/admin-query.repository';
import { GetModerationQueueQuery } from './get-moderation-queue.query';
@QueryHandler(GetModerationQueueQuery)
export class GetModerationQueueHandler implements IQueryHandler<GetModerationQueueQuery> {
constructor(
@Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository,
) {}
async execute(query: GetModerationQueueQuery): Promise<ModerationQueueResult> {
return this.adminQueryRepo.getModerationQueue(query.page, query.limit);
}
}

View File

@@ -0,0 +1,6 @@
export class GetModerationQueueQuery {
constructor(
public readonly page: number = 1,
public readonly limit: number = 20,
) {}
}

View File

@@ -0,0 +1,15 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
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);
}
}

View File

@@ -0,0 +1,7 @@
export class GetRevenueStatsQuery {
constructor(
public readonly startDate: Date,
public readonly endDate: Date,
public readonly groupBy: 'day' | 'month' = 'month',
) {}
}

View File

@@ -0,0 +1,6 @@
export { GetModerationQueueQuery } from './get-moderation-queue/get-moderation-queue.query';
export { GetModerationQueueHandler } from './get-moderation-queue/get-moderation-queue.handler';
export { GetDashboardStatsQuery } from './get-dashboard-stats/get-dashboard-stats.query';
export { GetDashboardStatsHandler } from './get-dashboard-stats/get-dashboard-stats.handler';
export { GetRevenueStatsQuery } from './get-revenue-stats/get-revenue-stats.query';
export { GetRevenueStatsHandler } from './get-revenue-stats/get-revenue-stats.handler';

View File

@@ -0,0 +1,5 @@
export { ListingApprovedEvent } from './listing-approved.event';
export { ListingRejectedEvent } from './listing-rejected.event';
export { UserBannedEvent } from './user-banned.event';
export { UserUnbannedEvent } from './user-unbanned.event';
export { SubscriptionAdjustedEvent } from './subscription-adjusted.event';

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class ListingApprovedEvent implements DomainEvent {
readonly eventName = 'listing.approved_by_admin';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly adminId: string,
public readonly moderationNotes?: string,
) {}
}

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class ListingRejectedEvent implements DomainEvent {
readonly eventName = 'listing.rejected_by_admin';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly adminId: string,
public readonly reason: string,
) {}
}

View File

@@ -0,0 +1,13 @@
import { type DomainEvent } from '@modules/shared';
export class SubscriptionAdjustedEvent implements DomainEvent {
readonly eventName = 'subscription.adjusted_by_admin';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly adminId: string,
public readonly newPlanId: string,
public readonly reason: string,
) {}
}

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class UserBannedEvent implements DomainEvent {
readonly eventName = 'user.banned';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly adminId: string,
public readonly reason: string,
) {}
}

View File

@@ -0,0 +1,11 @@
import { type DomainEvent } from '@modules/shared';
export class UserUnbannedEvent implements DomainEvent {
readonly eventName = 'user.unbanned';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly adminId: string,
) {}
}

View File

@@ -0,0 +1,2 @@
export * from './events';
export * from './repositories';

View File

@@ -0,0 +1,68 @@
export const ADMIN_QUERY_REPOSITORY = Symbol('ADMIN_QUERY_REPOSITORY');
export interface ModerationQueueItem {
listingId: string;
propertyTitle: string;
propertyType: string;
transactionType: string;
priceVND: bigint;
sellerName: string;
sellerId: string;
moderationScore: number | null;
createdAt: Date;
}
export interface ModerationQueueResult {
data: ModerationQueueItem[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface DashboardStats {
totalUsers: number;
totalListings: number;
activeListings: number;
pendingModerationCount: number;
totalAgents: number;
verifiedAgents: number;
totalTransactions: number;
newUsersLast30Days: number;
newListingsLast30Days: number;
}
export interface RevenueStatsItem {
period: string;
totalRevenue: bigint;
subscriptionRevenue: bigint;
listingFeeRevenue: bigint;
featuredListingRevenue: bigint;
transactionCount: number;
}
export interface UserListItem {
id: string;
email: string | null;
phone: string;
fullName: string;
role: string;
kycStatus: string;
isActive: boolean;
createdAt: Date;
}
export interface UserListResult {
data: UserListItem[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface IAdminQueryRepository {
getModerationQueue(page: number, limit: number): Promise<ModerationQueueResult>;
getDashboardStats(): Promise<DashboardStats>;
getRevenueStats(startDate: Date, endDate: Date, groupBy: 'day' | 'month'): Promise<RevenueStatsItem[]>;
getUsers(params: { page: number; limit: number; role?: string; isActive?: boolean; search?: string }): Promise<UserListResult>;
}

View File

@@ -0,0 +1,9 @@
export { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository } from './admin-query.repository';
export type {
ModerationQueueItem,
ModerationQueueResult,
DashboardStats,
RevenueStatsItem,
UserListItem,
UserListResult,
} from './admin-query.repository';

View File

@@ -0,0 +1 @@
export { AdminModule } from './admin.module';

View File

@@ -0,0 +1 @@
export * from './repositories';

View File

@@ -0,0 +1 @@
export { PrismaAdminQueryRepository } from './prisma-admin-query.repository';

View File

@@ -0,0 +1,203 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import {
type IAdminQueryRepository,
type ModerationQueueResult,
type DashboardStats,
type RevenueStatsItem,
type UserListResult,
} from '../../domain/repositories/admin-query.repository';
@Injectable()
export class PrismaAdminQueryRepository implements IAdminQueryRepository {
constructor(private readonly prisma: PrismaService) {}
async getModerationQueue(page: number, limit: number): Promise<ModerationQueueResult> {
const skip = (page - 1) * limit;
const [listings, total] = await Promise.all([
this.prisma.listing.findMany({
where: { status: 'PENDING_REVIEW' },
include: {
property: { select: { title: true, propertyType: true } },
seller: { select: { fullName: true } },
},
orderBy: { createdAt: 'asc' },
skip,
take: limit,
}),
this.prisma.listing.count({ where: { status: 'PENDING_REVIEW' } }),
]);
return {
data: listings.map((l) => ({
listingId: l.id,
propertyTitle: l.property.title,
propertyType: l.property.propertyType,
transactionType: l.transactionType,
priceVND: l.priceVND,
sellerName: l.seller.fullName,
sellerId: l.sellerId,
moderationScore: l.moderationScore,
createdAt: l.createdAt,
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async getDashboardStats(): Promise<DashboardStats> {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [
totalUsers,
totalListings,
activeListings,
pendingModerationCount,
totalAgents,
verifiedAgents,
totalTransactions,
newUsersLast30Days,
newListingsLast30Days,
] = await Promise.all([
this.prisma.user.count(),
this.prisma.listing.count(),
this.prisma.listing.count({ where: { status: 'ACTIVE' } }),
this.prisma.listing.count({ where: { status: 'PENDING_REVIEW' } }),
this.prisma.agent.count(),
this.prisma.agent.count({ where: { isVerified: true } }),
this.prisma.transaction.count(),
this.prisma.user.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
this.prisma.listing.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
]);
return {
totalUsers,
totalListings,
activeListings,
pendingModerationCount,
totalAgents,
verifiedAgents,
totalTransactions,
newUsersLast30Days,
newListingsLast30Days,
};
}
async getRevenueStats(
startDate: Date,
endDate: Date,
groupBy: 'day' | 'month',
): Promise<RevenueStatsItem[]> {
const payments = await this.prisma.payment.findMany({
where: {
status: 'COMPLETED',
createdAt: { gte: startDate, lte: endDate },
},
select: {
type: true,
amountVND: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
});
const grouped = new Map<string, {
totalRevenue: bigint;
subscriptionRevenue: bigint;
listingFeeRevenue: bigint;
featuredListingRevenue: bigint;
transactionCount: number;
}>();
for (const payment of payments) {
const period = groupBy === 'day'
? payment.createdAt.toISOString().slice(0, 10)
: payment.createdAt.toISOString().slice(0, 7);
if (!grouped.has(period)) {
grouped.set(period, {
totalRevenue: 0n,
subscriptionRevenue: 0n,
listingFeeRevenue: 0n,
featuredListingRevenue: 0n,
transactionCount: 0,
});
}
const stats = grouped.get(period)!;
stats.totalRevenue += payment.amountVND;
stats.transactionCount++;
switch (payment.type) {
case 'SUBSCRIPTION':
stats.subscriptionRevenue += payment.amountVND;
break;
case 'LISTING_FEE':
stats.listingFeeRevenue += payment.amountVND;
break;
case 'FEATURED_LISTING':
stats.featuredListingRevenue += payment.amountVND;
break;
}
}
return Array.from(grouped.entries()).map(([period, stats]) => ({
period,
...stats,
}));
}
async getUsers(params: {
page: number;
limit: number;
role?: string;
isActive?: boolean;
search?: string;
}): Promise<UserListResult> {
const { page, limit, role, isActive, search } = params;
const skip = (page - 1) * limit;
const where: any = {};
if (role) where.role = role;
if (isActive !== undefined) where.isActive = isActive;
if (search) {
where.OR = [
{ fullName: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search } },
];
}
const [users, total] = await Promise.all([
this.prisma.user.findMany({
where,
select: {
id: true,
email: true,
phone: true,
fullName: true,
role: true,
kycStatus: true,
isActive: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
this.prisma.user.count({ where }),
]);
return {
data: users,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
}

View File

@@ -0,0 +1,124 @@
import {
Body,
Controller,
Get,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard';
import { Roles } from '@modules/auth/presentation/decorators/roles.decorator';
import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator';
import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service';
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
import { RejectListingCommand } from '../../application/commands/reject-listing/reject-listing.command';
import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command';
import { AdjustSubscriptionCommand } from '../../application/commands/adjust-subscription/adjust-subscription.command';
import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query';
import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query';
import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
import { ApproveListingDto } from '../dto/approve-listing.dto';
import { RejectListingDto } from '../dto/reject-listing.dto';
import { BanUserDto } from '../dto/ban-user.dto';
import { AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
import { RevenueStatsDto } from '../dto/revenue-stats.dto';
import { type ApproveListingResult } from '../../application/commands/approve-listing/approve-listing.handler';
import { type RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler';
import { type BanUserResult } from '../../application/commands/ban-user/ban-user.handler';
import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler';
import { type ModerationQueueResult } from '../../domain/repositories/admin-query.repository';
import { type DashboardStats, type RevenueStatsItem } from '../../domain/repositories/admin-query.repository';
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
export class AdminController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
// ── Moderation ──
@Get('moderation')
async getModerationQueue(
@Query('page') page?: string,
@Query('limit') limit?: string,
): Promise<ModerationQueueResult> {
return this.queryBus.execute(
new GetModerationQueueQuery(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
),
);
}
@Post('moderation/approve')
async approveListing(
@Body() dto: ApproveListingDto,
@CurrentUser() user: JwtPayload,
): Promise<ApproveListingResult> {
return this.commandBus.execute(
new ApproveListingCommand(dto.listingId, user.sub, dto.moderationNotes),
);
}
@Post('moderation/reject')
async rejectListing(
@Body() dto: RejectListingDto,
@CurrentUser() user: JwtPayload,
): Promise<RejectListingResult> {
return this.commandBus.execute(
new RejectListingCommand(dto.listingId, user.sub, dto.reason),
);
}
// ── User Management ──
@Post('users/ban')
async banUser(
@Body() dto: BanUserDto,
@CurrentUser() user: JwtPayload,
): Promise<BanUserResult> {
return this.commandBus.execute(
new BanUserCommand(dto.userId, user.sub, dto.reason, dto.unban ?? false),
);
}
// ── Subscription Management ──
@Post('subscriptions/adjust')
async adjustSubscription(
@Body() dto: AdjustSubscriptionDto,
@CurrentUser() user: JwtPayload,
): Promise<AdjustSubscriptionResult> {
return this.commandBus.execute(
new AdjustSubscriptionCommand(dto.userId, user.sub, dto.newPlanTier, dto.reason),
);
}
// ── Dashboard ──
@Get('dashboard')
async getDashboardStats(): Promise<DashboardStats> {
return this.queryBus.execute(new GetDashboardStatsQuery());
}
@Get('revenue')
async getRevenueStats(
@Query() dto: RevenueStatsDto,
): Promise<RevenueStatsItem[]> {
return this.queryBus.execute(
new GetRevenueStatsQuery(
new Date(dto.startDate),
new Date(dto.endDate),
dto.groupBy ?? 'month',
),
);
}
}

View File

@@ -0,0 +1 @@
export { AdminController } from './admin.controller';

View File

@@ -0,0 +1,13 @@
import { IsString, MinLength } from 'class-validator';
export class AdjustSubscriptionDto {
@IsString()
userId!: string;
@IsString()
newPlanTier!: string;
@IsString()
@MinLength(5)
reason!: string;
}

View File

@@ -0,0 +1,10 @@
import { IsOptional, IsString } from 'class-validator';
export class ApproveListingDto {
@IsString()
listingId!: string;
@IsOptional()
@IsString()
moderationNotes?: string;
}

View File

@@ -0,0 +1,14 @@
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
export class BanUserDto {
@IsString()
userId!: string;
@IsString()
@MinLength(5)
reason!: string;
@IsOptional()
@IsBoolean()
unban?: boolean;
}

View File

@@ -0,0 +1,5 @@
export { ApproveListingDto } from './approve-listing.dto';
export { RejectListingDto } from './reject-listing.dto';
export { BanUserDto } from './ban-user.dto';
export { AdjustSubscriptionDto } from './adjust-subscription.dto';
export { RevenueStatsDto } from './revenue-stats.dto';

View File

@@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class RejectListingDto {
@IsString()
listingId!: string;
@IsString()
@MinLength(5)
reason!: string;
}

View File

@@ -0,0 +1,13 @@
import { IsDateString, IsIn, IsOptional } from 'class-validator';
export class RevenueStatsDto {
@IsDateString()
startDate!: string;
@IsDateString()
endDate!: string;
@IsOptional()
@IsIn(['day', 'month'])
groupBy?: 'day' | 'month';
}

View File

@@ -0,0 +1,2 @@
export * from './controllers';
export * from './dto';

View File

@@ -85,4 +85,9 @@ export class UserEntity extends AggregateRoot<string> {
this._isActive = false;
this.updatedAt = new Date();
}
activate(): void {
this._isActive = true;
this.updatedAt = new Date();
}
}

View File

@@ -0,0 +1,95 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { EventBus } from '@nestjs/cqrs';
import { CreateSubscriptionHandler } from '../commands/create-subscription/create-subscription.handler';
import { CreateSubscriptionCommand } from '../commands/create-subscription/create-subscription.command';
import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository';
import { SubscriptionEntity } from '../../domain/entities/subscription.entity';
describe('CreateSubscriptionHandler', () => {
let handler: CreateSubscriptionHandler;
let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType<typeof vi.fn> };
let mockPrisma: any;
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockRepo = {
findById: vi.fn(),
findByUserId: vi.fn(),
save: vi.fn(),
update: vi.fn(),
};
mockPrisma = {
plan: {
findFirst: vi.fn(),
},
};
mockEventBus = {
publish: vi.fn(),
};
handler = new CreateSubscriptionHandler(
mockRepo as any,
mockPrisma,
mockEventBus as any,
);
});
it('creates subscription successfully', async () => {
mockRepo.findByUserId.mockResolvedValue(null);
mockPrisma.plan.findFirst.mockResolvedValue({
id: 'plan-1',
tier: 'AGENT_PRO',
isActive: true,
});
mockRepo.save.mockResolvedValue(undefined);
const command = new CreateSubscriptionCommand('user-1', 'AGENT_PRO', 'monthly');
const result = await handler.execute(command);
expect(result.planTier).toBe('AGENT_PRO');
expect(result.status).toBe('ACTIVE');
expect(result.subscriptionId).toBeDefined();
expect(mockRepo.save).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('throws ConflictException when user already has active subscription', async () => {
const existing = SubscriptionEntity.createNew(
'sub-1', 'user-1', 'plan-1', 'FREE',
new Date(), new Date(),
);
mockRepo.findByUserId.mockResolvedValue(existing);
const command = new CreateSubscriptionCommand('user-1', 'AGENT_PRO', 'monthly');
await expect(handler.execute(command)).rejects.toThrow(ConflictException);
});
it('throws NotFoundException when plan does not exist', async () => {
mockRepo.findByUserId.mockResolvedValue(null);
mockPrisma.plan.findFirst.mockResolvedValue(null);
const command = new CreateSubscriptionCommand('user-1', 'AGENT_PRO', 'monthly');
await expect(handler.execute(command)).rejects.toThrow(NotFoundException);
});
it('creates yearly subscription with correct period', async () => {
mockRepo.findByUserId.mockResolvedValue(null);
mockPrisma.plan.findFirst.mockResolvedValue({
id: 'plan-1',
tier: 'INVESTOR',
isActive: true,
});
mockRepo.save.mockResolvedValue(undefined);
const command = new CreateSubscriptionCommand('user-1', 'INVESTOR', 'yearly');
const result = await handler.execute(command);
const startYear = result.currentPeriodStart.getFullYear();
const endYear = result.currentPeriodEnd.getFullYear();
expect(endYear - startYear).toBe(1);
});
});

View File

@@ -0,0 +1,96 @@
import { SubscriptionEntity } from '../entities/subscription.entity';
describe('SubscriptionEntity', () => {
const makeSub = (overrides?: Partial<Parameters<typeof SubscriptionEntity.createNew>[0]>) => {
return SubscriptionEntity.createNew(
'sub-1',
'user-1',
'plan-1',
'AGENT_PRO',
new Date('2026-01-01'),
new Date('2026-02-01'),
);
};
it('creates a new subscription with ACTIVE status', () => {
const sub = makeSub();
expect(sub.id).toBe('sub-1');
expect(sub.userId).toBe('user-1');
expect(sub.planTier).toBe('AGENT_PRO');
expect(sub.status).toBe('ACTIVE');
expect(sub.isActive()).toBe(true);
expect(sub.cancelledAt).toBeNull();
});
it('emits SubscriptionCreatedEvent on creation', () => {
const sub = makeSub();
const events = sub.domainEvents;
expect(events).toHaveLength(1);
expect(events[0].eventName).toBe('subscription.created');
});
it('upgrades plan tier', () => {
const sub = makeSub();
sub.clearDomainEvents();
sub.upgrade('plan-2', 'ENTERPRISE');
expect(sub.planId).toBe('plan-2');
expect(sub.planTier).toBe('ENTERPRISE');
const events = sub.domainEvents;
expect(events).toHaveLength(1);
expect(events[0].eventName).toBe('subscription.upgraded');
});
it('throws when upgrading non-active subscription', () => {
const sub = makeSub();
sub.cancel();
expect(() => sub.upgrade('plan-2', 'ENTERPRISE')).toThrow();
});
it('cancels subscription', () => {
const sub = makeSub();
sub.clearDomainEvents();
sub.cancel();
expect(sub.status).toBe('CANCELLED');
expect(sub.cancelledAt).not.toBeNull();
const events = sub.domainEvents;
expect(events).toHaveLength(1);
expect(events[0].eventName).toBe('subscription.cancelled');
});
it('throws when cancelling already cancelled subscription', () => {
const sub = makeSub();
sub.cancel();
expect(() => sub.cancel()).toThrow('Subscription đã bị hủy');
});
it('marks past due', () => {
const sub = makeSub();
sub.markPastDue();
expect(sub.status).toBe('PAST_DUE');
});
it('marks expired', () => {
const sub = makeSub();
sub.markExpired();
expect(sub.status).toBe('EXPIRED');
});
it('renews period', () => {
const sub = makeSub();
sub.markPastDue();
const newStart = new Date('2026-02-01');
const newEnd = new Date('2026-03-01');
sub.renewPeriod(newStart, newEnd);
expect(sub.status).toBe('ACTIVE');
expect(sub.currentPeriodStart).toEqual(newStart);
expect(sub.currentPeriodEnd).toEqual(newEnd);
});
});