test(e2e): add 14 new web E2E test files for critical user flows
Cover auth (login, register, OAuth callbacks), search with filters, listing detail, dashboard, analytics, create listing form, admin dashboard/users/moderation/KYC, navigation routing, and responsive design. Total 91 test cases using Playwright with API route mocking. Also fix mcp-servers tsconfig deprecation warning for TS 7.x compat. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { ListingEntity } from '@modules/listings/domain/entities/listing.entity';
|
||||
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
|
||||
import { type IPropertyRepository } from '@modules/listings/domain/repositories/property.repository';
|
||||
import { CreateListingCommand } from '../commands/create-listing/create-listing.command';
|
||||
import { CreateListingHandler } from '../commands/create-listing/create-listing.handler';
|
||||
|
||||
describe('CreateListingHandler', () => {
|
||||
let handler: CreateListingHandler;
|
||||
let mockPropertyRepo: { [K in keyof IPropertyRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
let mockCache: { invalidateByPrefix: ReturnType<typeof vi.fn>; invalidate: ReturnType<typeof vi.fn>; getOrSet: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPropertyRepo = {
|
||||
findById: vi.fn(),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn(),
|
||||
addMedia: vi.fn(),
|
||||
findMediaByPropertyId: vi.fn(),
|
||||
deleteMedia: vi.fn(),
|
||||
countMediaByPropertyId: vi.fn(),
|
||||
};
|
||||
|
||||
mockListingRepo = {
|
||||
findById: vi.fn(),
|
||||
findByIdWithProperty: vi.fn(),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn(),
|
||||
search: vi.fn(),
|
||||
findByStatus: vi.fn(),
|
||||
findBySellerId: vi.fn(),
|
||||
};
|
||||
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
mockCache = {
|
||||
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
|
||||
invalidate: vi.fn(),
|
||||
getOrSet: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new CreateListingHandler(
|
||||
mockPropertyRepo as any,
|
||||
mockListingRepo as any,
|
||||
mockEventBus as any,
|
||||
mockCache as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates listing and property successfully', async () => {
|
||||
const command = new CreateListingCommand(
|
||||
'seller-1', 'SALE', 5_000_000_000n,
|
||||
'APARTMENT', 'Căn hộ đẹp', 'Mô tả chi tiết',
|
||||
'123 Nguyễn Huệ', 'Phường Bến Nghé', 'Quận 1', 'TP. Hồ Chí Minh',
|
||||
10.7769, 106.7009, 80,
|
||||
);
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.listingId).toBeDefined();
|
||||
expect(result.propertyId).toBeDefined();
|
||||
expect(result.status).toBe('DRAFT');
|
||||
expect(mockPropertyRepo.save).toHaveBeenCalledTimes(1);
|
||||
expect(mockListingRepo.save).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
expect(mockCache.invalidateByPrefix).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates listing with optional fields', async () => {
|
||||
const command = new CreateListingCommand(
|
||||
'seller-1', 'SALE', 3_000_000_000n,
|
||||
'HOUSE', 'Nhà phố', 'Mô tả',
|
||||
'456 Lê Lợi', 'Phường 1', 'Quận 3', 'TP. Hồ Chí Minh',
|
||||
10.78, 106.69, 120,
|
||||
100, 3, 2, 3, undefined, undefined, 'EAST', 2020, 'SỔ HỒNG',
|
||||
);
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.listingId).toBeDefined();
|
||||
expect(result.status).toBe('DRAFT');
|
||||
});
|
||||
|
||||
it('throws ValidationException for invalid address', async () => {
|
||||
const command = new CreateListingCommand(
|
||||
'seller-1', 'SALE', 1_000_000_000n,
|
||||
'APARTMENT', 'Test', 'Test',
|
||||
'', '', '', '',
|
||||
10.77, 106.70, 50,
|
||||
);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws ValidationException for invalid price', async () => {
|
||||
const command = new CreateListingCommand(
|
||||
'seller-1', 'SALE', -100n,
|
||||
'APARTMENT', 'Test', 'Test',
|
||||
'123 ABC', 'Ward', 'District', 'City',
|
||||
10.77, 106.70, 50,
|
||||
);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws ValidationException for invalid geo coordinates', async () => {
|
||||
const command = new CreateListingCommand(
|
||||
'seller-1', 'SALE', 1_000_000_000n,
|
||||
'APARTMENT', 'Test', 'Test',
|
||||
'123 ABC', 'Ward', 'District', 'City',
|
||||
999, 999, 50,
|
||||
);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
|
||||
import { GetListingQuery } from '../queries/get-listing/get-listing.query';
|
||||
import { GetListingHandler } from '../queries/get-listing/get-listing.handler';
|
||||
|
||||
describe('GetListingHandler', () => {
|
||||
let handler: GetListingHandler;
|
||||
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockCache: { getOrSet: ReturnType<typeof vi.fn>; invalidate: ReturnType<typeof vi.fn>; invalidateByPrefix: ReturnType<typeof vi.fn> };
|
||||
|
||||
const mockListingDetail = {
|
||||
id: 'listing-1',
|
||||
status: 'ACTIVE',
|
||||
price: 5_000_000_000n,
|
||||
property: { id: 'prop-1', title: 'Căn hộ Q1' },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockListingRepo = {
|
||||
findById: vi.fn(),
|
||||
findByIdWithProperty: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
search: vi.fn(),
|
||||
findByStatus: vi.fn(),
|
||||
findBySellerId: vi.fn(),
|
||||
};
|
||||
|
||||
mockCache = {
|
||||
getOrSet: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
invalidateByPrefix: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetListingHandler(
|
||||
mockListingRepo as any,
|
||||
mockCache as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns listing detail via cache', async () => {
|
||||
mockCache.getOrSet.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => fn());
|
||||
mockListingRepo.findByIdWithProperty.mockResolvedValue(mockListingDetail);
|
||||
|
||||
const query = new GetListingQuery('listing-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toEqual(mockListingDetail);
|
||||
expect(mockCache.getOrSet).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when listing not found', async () => {
|
||||
mockCache.getOrSet.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => fn());
|
||||
mockListingRepo.findByIdWithProperty.mockResolvedValue(null);
|
||||
|
||||
const query = new GetListingQuery('nonexistent');
|
||||
|
||||
await expect(handler.execute(query)).rejects.toThrow('Listing');
|
||||
});
|
||||
|
||||
it('uses cache key with listing id', async () => {
|
||||
mockCache.getOrSet.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => fn());
|
||||
mockListingRepo.findByIdWithProperty.mockResolvedValue(mockListingDetail);
|
||||
|
||||
await handler.execute(new GetListingQuery('listing-1'));
|
||||
|
||||
expect(mockCache.getOrSet).toHaveBeenCalledWith(
|
||||
expect.stringContaining('listing-1'),
|
||||
expect.any(Function),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
|
||||
import { GetPendingModerationQuery } from '../queries/get-pending-moderation/get-pending-moderation.query';
|
||||
import { GetPendingModerationHandler } from '../queries/get-pending-moderation/get-pending-moderation.handler';
|
||||
|
||||
describe('GetPendingModerationHandler', () => {
|
||||
let handler: GetPendingModerationHandler;
|
||||
let mockListingRepo: { [K in keyof IListingRepository]: 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(),
|
||||
};
|
||||
|
||||
handler = new GetPendingModerationHandler(mockListingRepo as any);
|
||||
});
|
||||
|
||||
it('returns paginated pending listings', async () => {
|
||||
const mockResult = {
|
||||
data: [{ id: 'listing-1', status: 'PENDING_REVIEW' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
};
|
||||
mockListingRepo.findByStatus.mockResolvedValue(mockResult);
|
||||
|
||||
const query = new GetPendingModerationQuery(1, 20);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(mockListingRepo.findByStatus).toHaveBeenCalledWith('PENDING_REVIEW', 1, 20);
|
||||
});
|
||||
|
||||
it('uses default pagination values', async () => {
|
||||
const mockResult = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
||||
mockListingRepo.findByStatus.mockResolvedValue(mockResult);
|
||||
|
||||
const query = new GetPendingModerationQuery();
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockListingRepo.findByStatus).toHaveBeenCalledWith('PENDING_REVIEW', 1, 20);
|
||||
});
|
||||
|
||||
it('passes custom page and limit', async () => {
|
||||
const mockResult = { data: [], total: 50, page: 3, limit: 10, totalPages: 5 };
|
||||
mockListingRepo.findByStatus.mockResolvedValue(mockResult);
|
||||
|
||||
const query = new GetPendingModerationQuery(3, 10);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.page).toBe(3);
|
||||
expect(result.limit).toBe(10);
|
||||
expect(mockListingRepo.findByStatus).toHaveBeenCalledWith('PENDING_REVIEW', 3, 10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
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 { ModerateListingCommand } from '../commands/moderate-listing/moderate-listing.command';
|
||||
import { ModerateListingHandler } from '../commands/moderate-listing/moderate-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);
|
||||
listing.submitForReview();
|
||||
listing.clearDomainEvents();
|
||||
return listing;
|
||||
}
|
||||
|
||||
describe('ModerateListingHandler', () => {
|
||||
let handler: ModerateListingHandler;
|
||||
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
let mockCache: { invalidate: ReturnType<typeof vi.fn>; invalidateByPrefix: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockListingRepo = {
|
||||
findById: vi.fn(),
|
||||
findByIdWithProperty: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
search: vi.fn(),
|
||||
findByStatus: vi.fn(),
|
||||
findBySellerId: vi.fn(),
|
||||
};
|
||||
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
mockCache = {
|
||||
invalidate: vi.fn().mockResolvedValue(undefined),
|
||||
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
handler = new ModerateListingHandler(
|
||||
mockListingRepo as any,
|
||||
mockEventBus as any,
|
||||
mockCache as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('approves a pending listing', async () => {
|
||||
const listing = createPendingListing();
|
||||
mockListingRepo.findById.mockResolvedValue(listing);
|
||||
|
||||
const command = new ModerateListingCommand('listing-1', 'mod-1', 'approve');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.status).toBe('ACTIVE');
|
||||
expect(mockListingRepo.update).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a pending listing', async () => {
|
||||
const listing = createPendingListing();
|
||||
mockListingRepo.findById.mockResolvedValue(listing);
|
||||
|
||||
const command = new ModerateListingCommand('listing-1', 'mod-1', 'reject', undefined, 'Nội dung không phù hợp');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.status).toBe('REJECTED');
|
||||
expect(mockListingRepo.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sets moderation score before action', async () => {
|
||||
const listing = createPendingListing();
|
||||
mockListingRepo.findById.mockResolvedValue(listing);
|
||||
|
||||
const command = new ModerateListingCommand('listing-1', 'mod-1', 'approve', 95, 'Chất lượng tốt');
|
||||
await handler.execute(command);
|
||||
|
||||
expect(listing.moderationScore).toBe(95);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when listing does not exist', async () => {
|
||||
mockListingRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new ModerateListingCommand('nonexistent', 'mod-1', 'approve');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Listing');
|
||||
});
|
||||
|
||||
it('invalidates cache after moderation', async () => {
|
||||
const listing = createPendingListing();
|
||||
mockListingRepo.findById.mockResolvedValue(listing);
|
||||
|
||||
const command = new ModerateListingCommand('listing-1', 'mod-1', 'approve');
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockCache.invalidate).toHaveBeenCalled();
|
||||
expect(mockCache.invalidateByPrefix).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
|
||||
import { SearchListingsQuery } from '../queries/search-listings/search-listings.query';
|
||||
import { SearchListingsHandler } from '../queries/search-listings/search-listings.handler';
|
||||
|
||||
describe('SearchListingsHandler', () => {
|
||||
let handler: SearchListingsHandler;
|
||||
let mockListingRepo: { [K in keyof IListingRepository]: 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(),
|
||||
};
|
||||
|
||||
handler = new SearchListingsHandler(mockListingRepo as any);
|
||||
});
|
||||
|
||||
it('searches with all filters', async () => {
|
||||
const mockResult = {
|
||||
data: [{ id: 'listing-1', status: 'ACTIVE' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
};
|
||||
mockListingRepo.search.mockResolvedValue(mockResult);
|
||||
|
||||
const query = new SearchListingsQuery(
|
||||
'ACTIVE', 'SALE', 'APARTMENT', 'TP. Hồ Chí Minh', 'Quận 1',
|
||||
1_000_000_000n, 10_000_000_000n, 50, 200, 2,
|
||||
);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(mockListingRepo.search).toHaveBeenCalledWith({
|
||||
status: 'ACTIVE',
|
||||
transactionType: 'SALE',
|
||||
propertyType: 'APARTMENT',
|
||||
city: 'TP. Hồ Chí Minh',
|
||||
district: 'Quận 1',
|
||||
minPrice: 1_000_000_000n,
|
||||
maxPrice: 10_000_000_000n,
|
||||
minArea: 50,
|
||||
maxArea: 200,
|
||||
bedrooms: 2,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('searches with no filters (defaults)', async () => {
|
||||
const mockResult = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
||||
mockListingRepo.search.mockResolvedValue(mockResult);
|
||||
|
||||
const query = new SearchListingsQuery();
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(mockListingRepo.search).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 1, limit: 20 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes custom pagination', async () => {
|
||||
const mockResult = { data: [], total: 100, page: 5, limit: 10, totalPages: 10 };
|
||||
mockListingRepo.search.mockResolvedValue(mockResult);
|
||||
|
||||
const query = new SearchListingsQuery(
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
5, 10,
|
||||
);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.page).toBe(5);
|
||||
expect(mockListingRepo.search).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 5, limit: 10 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
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 { UpdateListingStatusCommand } from '../commands/update-listing-status/update-listing-status.command';
|
||||
import { UpdateListingStatusHandler } from '../commands/update-listing-status/update-listing-status.handler';
|
||||
|
||||
function createListing(id = 'listing-1', status: 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' = 'DRAFT'): ListingEntity {
|
||||
const price = Price.create(2_000_000_000n).unwrap();
|
||||
const listing = ListingEntity.createNew(id, 'prop-1', 'seller-1', 'SALE', price, 80);
|
||||
if (status === 'PENDING_REVIEW') listing.submitForReview();
|
||||
if (status === 'ACTIVE') {
|
||||
listing.submitForReview();
|
||||
listing.approve();
|
||||
}
|
||||
listing.clearDomainEvents();
|
||||
return listing;
|
||||
}
|
||||
|
||||
describe('UpdateListingStatusHandler', () => {
|
||||
let handler: UpdateListingStatusHandler;
|
||||
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
let mockCache: { invalidate: ReturnType<typeof vi.fn>; invalidateByPrefix: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockListingRepo = {
|
||||
findById: vi.fn(),
|
||||
findByIdWithProperty: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
search: vi.fn(),
|
||||
findByStatus: vi.fn(),
|
||||
findBySellerId: vi.fn(),
|
||||
};
|
||||
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
mockCache = {
|
||||
invalidate: vi.fn().mockResolvedValue(undefined),
|
||||
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
handler = new UpdateListingStatusHandler(
|
||||
mockListingRepo as any,
|
||||
mockEventBus as any,
|
||||
mockCache as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('approves a pending listing via ACTIVE status', async () => {
|
||||
const listing = createListing('listing-1', 'PENDING_REVIEW');
|
||||
mockListingRepo.findById.mockResolvedValue(listing);
|
||||
|
||||
const command = new UpdateListingStatusCommand('listing-1', 'ACTIVE', 'admin-1');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.status).toBe('ACTIVE');
|
||||
expect(mockListingRepo.update).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a listing with moderation notes', async () => {
|
||||
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 result = await handler.execute(command);
|
||||
|
||||
expect(result.status).toBe('REJECTED');
|
||||
});
|
||||
|
||||
it('transitions active listing to SOLD', async () => {
|
||||
const listing = createListing('listing-1', 'ACTIVE');
|
||||
mockListingRepo.findById.mockResolvedValue(listing);
|
||||
|
||||
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.status).toBe('SOLD');
|
||||
});
|
||||
|
||||
it('throws NotFoundException for non-existent listing', async () => {
|
||||
mockListingRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new UpdateListingStatusCommand('nonexistent', 'ACTIVE', 'admin-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Listing');
|
||||
});
|
||||
|
||||
it('throws ValidationException for invalid status transition', async () => {
|
||||
const listing = createListing('listing-1', 'DRAFT');
|
||||
mockListingRepo.findById.mockResolvedValue(listing);
|
||||
|
||||
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/trạng thái/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { type IPropertyRepository } from '@modules/listings/domain/repositories/property.repository';
|
||||
import { type IMediaStorageService } from '@modules/listings/infrastructure/services/media-storage.service';
|
||||
import { UploadMediaCommand } from '../commands/upload-media/upload-media.command';
|
||||
import { UploadMediaHandler } from '../commands/upload-media/upload-media.handler';
|
||||
|
||||
describe('UploadMediaHandler', () => {
|
||||
let handler: UploadMediaHandler;
|
||||
let mockPropertyRepo: { [K in keyof IPropertyRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockMediaStorage: { [K in keyof IMediaStorageService]: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPropertyRepo = {
|
||||
findById: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addMedia: vi.fn().mockResolvedValue(undefined),
|
||||
findMediaByPropertyId: vi.fn(),
|
||||
deleteMedia: vi.fn(),
|
||||
countMediaByPropertyId: vi.fn(),
|
||||
};
|
||||
|
||||
mockMediaStorage = {
|
||||
upload: vi.fn().mockResolvedValue('http://storage.local/media/test.jpg'),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new UploadMediaHandler(
|
||||
mockPropertyRepo as any,
|
||||
mockMediaStorage as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads image media successfully', async () => {
|
||||
mockPropertyRepo.findById.mockResolvedValue({ id: 'prop-1' });
|
||||
mockPropertyRepo.countMediaByPropertyId.mockResolvedValue(5);
|
||||
|
||||
const command = new UploadMediaCommand('prop-1', 'user-1', {
|
||||
buffer: Buffer.from('fake-image'),
|
||||
mimetype: 'image/jpeg',
|
||||
originalname: 'photo.jpg',
|
||||
size: 1024,
|
||||
}, 'Phòng khách');
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.mediaId).toBeDefined();
|
||||
expect(result.url).toBe('http://storage.local/media/test.jpg');
|
||||
expect(mockMediaStorage.upload).toHaveBeenCalledWith(
|
||||
expect.any(Buffer), 'photo.jpg', 'image/jpeg', 'properties/prop-1',
|
||||
);
|
||||
expect(mockPropertyRepo.addMedia).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uploads video media with correct type', async () => {
|
||||
mockPropertyRepo.findById.mockResolvedValue({ id: 'prop-1' });
|
||||
mockPropertyRepo.countMediaByPropertyId.mockResolvedValue(0);
|
||||
mockMediaStorage.upload.mockResolvedValue('http://storage.local/media/video.mp4');
|
||||
|
||||
const command = new UploadMediaCommand('prop-1', 'user-1', {
|
||||
buffer: Buffer.from('fake-video'),
|
||||
mimetype: 'video/mp4',
|
||||
originalname: 'tour.mp4',
|
||||
size: 10240,
|
||||
});
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.mediaId).toBeDefined();
|
||||
expect(result.url).toBe('http://storage.local/media/video.mp4');
|
||||
});
|
||||
|
||||
it('throws NotFoundException when property does not exist', async () => {
|
||||
mockPropertyRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new UploadMediaCommand('nonexistent', 'user-1', {
|
||||
buffer: Buffer.from('data'),
|
||||
mimetype: 'image/png',
|
||||
originalname: 'pic.png',
|
||||
size: 512,
|
||||
});
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Property');
|
||||
});
|
||||
|
||||
it('throws ValidationException when media limit exceeded', async () => {
|
||||
mockPropertyRepo.findById.mockResolvedValue({ id: 'prop-1' });
|
||||
mockPropertyRepo.countMediaByPropertyId.mockResolvedValue(20);
|
||||
|
||||
const command = new UploadMediaCommand('prop-1', 'user-1', {
|
||||
buffer: Buffer.from('data'),
|
||||
mimetype: 'image/jpeg',
|
||||
originalname: 'pic.jpg',
|
||||
size: 512,
|
||||
});
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/20/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { type IPaymentRepository } from '../../domain/repositories/payment.repository';
|
||||
import { Money } from '../../domain/value-objects/money.vo';
|
||||
import { type IPaymentGatewayFactory } from '../../infrastructure/services/payment-gateway.interface';
|
||||
import { CreatePaymentCommand } from '../commands/create-payment/create-payment.command';
|
||||
import { CreatePaymentHandler } from '../commands/create-payment/create-payment.handler';
|
||||
|
||||
describe('CreatePaymentHandler', () => {
|
||||
let handler: CreatePaymentHandler;
|
||||
let mockPaymentRepo: { [K in keyof IPaymentRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockGatewayFactory: { getGateway: ReturnType<typeof vi.fn> };
|
||||
let mockGateway: { createPaymentUrl: ReturnType<typeof vi.fn>; verifyCallback: ReturnType<typeof vi.fn>; refund: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPaymentRepo = {
|
||||
findById: vi.fn(),
|
||||
findByProviderTxId: vi.fn(),
|
||||
findByIdempotencyKey: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn(),
|
||||
updateIfStatus: vi.fn(),
|
||||
};
|
||||
|
||||
mockGateway = {
|
||||
createPaymentUrl: vi.fn().mockResolvedValue({
|
||||
paymentUrl: 'https://vnpay.vn/pay/123',
|
||||
providerTxId: 'vnpay-tx-1',
|
||||
}),
|
||||
verifyCallback: vi.fn(),
|
||||
refund: vi.fn(),
|
||||
};
|
||||
|
||||
mockGatewayFactory = {
|
||||
getGateway: vi.fn().mockReturnValue(mockGateway),
|
||||
};
|
||||
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new CreatePaymentHandler(
|
||||
mockPaymentRepo as any,
|
||||
mockGatewayFactory as any,
|
||||
mockEventBus as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates payment successfully', async () => {
|
||||
mockPaymentRepo.findByIdempotencyKey.mockResolvedValue(null);
|
||||
|
||||
const command = new CreatePaymentCommand(
|
||||
'user-1', 'VNPAY', 'SUBSCRIPTION', 500_000n,
|
||||
'Thanh toán gói Pro', 'https://goodgo.vn/return', '127.0.0.1',
|
||||
undefined, 'idem-key-1',
|
||||
);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.paymentId).toBeDefined();
|
||||
expect(result.paymentUrl).toBe('https://vnpay.vn/pay/123');
|
||||
expect(result.providerTxId).toBe('vnpay-tx-1');
|
||||
expect(mockPaymentRepo.save).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
expect(mockGatewayFactory.getGateway).toHaveBeenCalledWith('VNPAY');
|
||||
});
|
||||
|
||||
it('throws ConflictException for duplicate idempotency key (pending)', async () => {
|
||||
mockPaymentRepo.findByIdempotencyKey.mockResolvedValue({ status: 'PENDING' });
|
||||
|
||||
const command = new CreatePaymentCommand(
|
||||
'user-1', 'VNPAY', 'SUBSCRIPTION', 500_000n,
|
||||
'desc', 'https://goodgo.vn/return', '127.0.0.1',
|
||||
undefined, 'existing-key',
|
||||
);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/idempotency/);
|
||||
});
|
||||
|
||||
it('throws ConflictException for already processed idempotency key', async () => {
|
||||
mockPaymentRepo.findByIdempotencyKey.mockResolvedValue({ status: 'COMPLETED' });
|
||||
|
||||
const command = new CreatePaymentCommand(
|
||||
'user-1', 'VNPAY', 'SUBSCRIPTION', 500_000n,
|
||||
'desc', 'https://goodgo.vn/return', '127.0.0.1',
|
||||
undefined, 'completed-key',
|
||||
);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/xử lý/);
|
||||
});
|
||||
|
||||
it('throws ValidationException for invalid amount', async () => {
|
||||
const command = new CreatePaymentCommand(
|
||||
'user-1', 'VNPAY', 'SUBSCRIPTION', -100n,
|
||||
'desc', 'https://goodgo.vn/return', '127.0.0.1',
|
||||
);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('creates payment without idempotency key', async () => {
|
||||
const command = new CreatePaymentCommand(
|
||||
'user-1', 'VNPAY', 'DEPOSIT', 1_000_000n,
|
||||
'Nạp tiền', 'https://goodgo.vn/return', '127.0.0.1',
|
||||
);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.paymentId).toBeDefined();
|
||||
expect(mockPaymentRepo.findByIdempotencyKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { PaymentEntity } from '../../domain/entities/payment.entity';
|
||||
import { type IPaymentRepository } from '../../domain/repositories/payment.repository';
|
||||
import { Money } from '../../domain/value-objects/money.vo';
|
||||
import { GetPaymentStatusQuery } from '../queries/get-payment-status/get-payment-status.query';
|
||||
import { GetPaymentStatusHandler } from '../queries/get-payment-status/get-payment-status.handler';
|
||||
|
||||
function createPayment(): PaymentEntity {
|
||||
const money = Money.create(500_000n).unwrap();
|
||||
const payment = PaymentEntity.createNew('pay-1', 'user-1', 'VNPAY', 'SUBSCRIPTION', money);
|
||||
payment.markProcessing('vnpay-tx-1');
|
||||
payment.clearDomainEvents();
|
||||
return payment;
|
||||
}
|
||||
|
||||
describe('GetPaymentStatusHandler', () => {
|
||||
let handler: GetPaymentStatusHandler;
|
||||
let mockPaymentRepo: { [K in keyof IPaymentRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPaymentRepo = {
|
||||
findById: vi.fn(),
|
||||
findByProviderTxId: vi.fn(),
|
||||
findByIdempotencyKey: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateIfStatus: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetPaymentStatusHandler(mockPaymentRepo as any);
|
||||
});
|
||||
|
||||
it('returns payment status for owner', async () => {
|
||||
const payment = createPayment();
|
||||
mockPaymentRepo.findById.mockResolvedValue(payment);
|
||||
|
||||
const query = new GetPaymentStatusQuery('pay-1', 'user-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.id).toBe('pay-1');
|
||||
expect(result.provider).toBe('VNPAY');
|
||||
expect(result.status).toBe('PROCESSING');
|
||||
expect(result.amountVND).toBe('500000');
|
||||
expect(result.providerTxId).toBe('vnpay-tx-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundException when payment not found', async () => {
|
||||
mockPaymentRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const query = new GetPaymentStatusQuery('nonexistent', 'user-1');
|
||||
|
||||
await expect(handler.execute(query)).rejects.toThrow('Payment');
|
||||
});
|
||||
|
||||
it('throws ForbiddenException when user is not owner', async () => {
|
||||
const payment = createPayment();
|
||||
mockPaymentRepo.findById.mockResolvedValue(payment);
|
||||
|
||||
const query = new GetPaymentStatusQuery('pay-1', 'other-user');
|
||||
|
||||
await expect(handler.execute(query)).rejects.toThrow(/quyền/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { PaymentEntity } from '../../domain/entities/payment.entity';
|
||||
import { type IPaymentRepository } from '../../domain/repositories/payment.repository';
|
||||
import { Money } from '../../domain/value-objects/money.vo';
|
||||
import { HandleCallbackCommand } from '../commands/handle-callback/handle-callback.command';
|
||||
import { HandleCallbackHandler } from '../commands/handle-callback/handle-callback.handler';
|
||||
|
||||
function createPaymentEntity(status: 'PENDING' | 'PROCESSING' | 'COMPLETED' = 'PROCESSING'): PaymentEntity {
|
||||
const money = Money.create(500_000n).unwrap();
|
||||
const payment = PaymentEntity.createNew('pay-1', 'user-1', 'VNPAY', 'SUBSCRIPTION', money);
|
||||
if (status === 'PROCESSING') payment.markProcessing('vnpay-tx-1');
|
||||
if (status === 'COMPLETED') {
|
||||
payment.markProcessing('vnpay-tx-1');
|
||||
payment.markCompleted({ verified: true });
|
||||
}
|
||||
payment.clearDomainEvents();
|
||||
return payment;
|
||||
}
|
||||
|
||||
describe('HandleCallbackHandler', () => {
|
||||
let handler: HandleCallbackHandler;
|
||||
let mockPaymentRepo: { [K in keyof IPaymentRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockGatewayFactory: { getGateway: ReturnType<typeof vi.fn> };
|
||||
let mockGateway: { verifyCallback: ReturnType<typeof vi.fn>; createPaymentUrl: ReturnType<typeof vi.fn>; refund: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPaymentRepo = {
|
||||
findById: vi.fn(),
|
||||
findByProviderTxId: vi.fn(),
|
||||
findByIdempotencyKey: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateIfStatus: vi.fn(),
|
||||
};
|
||||
|
||||
mockGateway = {
|
||||
verifyCallback: vi.fn(),
|
||||
createPaymentUrl: vi.fn(),
|
||||
refund: vi.fn(),
|
||||
};
|
||||
|
||||
mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) };
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new HandleCallbackHandler(
|
||||
mockPaymentRepo as any,
|
||||
mockGatewayFactory as any,
|
||||
mockEventBus as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles successful callback', async () => {
|
||||
const payment = createPaymentEntity('PROCESSING');
|
||||
mockGateway.verifyCallback.mockReturnValue({
|
||||
isValid: true,
|
||||
orderId: 'pay-1',
|
||||
providerTxId: 'vnpay-tx-1',
|
||||
isSuccess: true,
|
||||
rawData: { responseCode: '00' },
|
||||
});
|
||||
mockPaymentRepo.updateIfStatus.mockResolvedValue(payment);
|
||||
|
||||
const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '00' });
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.isSuccess).toBe(true);
|
||||
expect(result.paymentId).toBe('pay-1');
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles failed callback', async () => {
|
||||
const payment = createPaymentEntity('PROCESSING');
|
||||
mockGateway.verifyCallback.mockReturnValue({
|
||||
isValid: true,
|
||||
orderId: 'pay-1',
|
||||
providerTxId: 'vnpay-tx-1',
|
||||
isSuccess: false,
|
||||
rawData: { responseCode: '24' },
|
||||
});
|
||||
mockPaymentRepo.updateIfStatus.mockResolvedValue(payment);
|
||||
|
||||
const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '24' });
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.isSuccess).toBe(false);
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ValidationException for invalid callback signature', async () => {
|
||||
mockGateway.verifyCallback.mockReturnValue({
|
||||
isValid: false,
|
||||
orderId: '',
|
||||
providerTxId: '',
|
||||
isSuccess: false,
|
||||
rawData: {},
|
||||
});
|
||||
|
||||
const command = new HandleCallbackCommand('VNPAY', { tampered: 'true' });
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/callback/);
|
||||
});
|
||||
|
||||
it('returns idempotent response for already processed payment', async () => {
|
||||
const completedPayment = createPaymentEntity('COMPLETED');
|
||||
mockGateway.verifyCallback.mockReturnValue({
|
||||
isValid: true,
|
||||
orderId: 'pay-1',
|
||||
providerTxId: 'vnpay-tx-1',
|
||||
isSuccess: true,
|
||||
rawData: {},
|
||||
});
|
||||
mockPaymentRepo.updateIfStatus.mockResolvedValue(null);
|
||||
mockPaymentRepo.findById.mockResolvedValue(completedPayment);
|
||||
|
||||
const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '00' });
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.paymentId).toBe('pay-1');
|
||||
expect(result.status).toBe('COMPLETED');
|
||||
expect(result.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when payment not found after failed update', async () => {
|
||||
mockGateway.verifyCallback.mockReturnValue({
|
||||
isValid: true,
|
||||
orderId: 'nonexistent',
|
||||
providerTxId: 'tx-1',
|
||||
isSuccess: true,
|
||||
rawData: {},
|
||||
});
|
||||
mockPaymentRepo.updateIfStatus.mockResolvedValue(null);
|
||||
mockPaymentRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '00' });
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Payment');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { PaymentEntity } from '../../domain/entities/payment.entity';
|
||||
import { type IPaymentRepository } from '../../domain/repositories/payment.repository';
|
||||
import { Money } from '../../domain/value-objects/money.vo';
|
||||
import { ListTransactionsQuery } from '../queries/list-transactions/list-transactions.query';
|
||||
import { ListTransactionsHandler } from '../queries/list-transactions/list-transactions.handler';
|
||||
|
||||
function createPayment(id: string, status: 'PENDING' | 'COMPLETED' = 'COMPLETED'): PaymentEntity {
|
||||
const money = Money.create(1_000_000n).unwrap();
|
||||
const payment = PaymentEntity.createNew(id, 'user-1', 'VNPAY', 'SUBSCRIPTION', money);
|
||||
if (status === 'COMPLETED') {
|
||||
payment.markProcessing('tx-' + id);
|
||||
payment.markCompleted({ verified: true });
|
||||
}
|
||||
payment.clearDomainEvents();
|
||||
return payment;
|
||||
}
|
||||
|
||||
describe('ListTransactionsHandler', () => {
|
||||
let handler: ListTransactionsHandler;
|
||||
let mockPaymentRepo: { [K in keyof IPaymentRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPaymentRepo = {
|
||||
findById: vi.fn(),
|
||||
findByProviderTxId: vi.fn(),
|
||||
findByIdempotencyKey: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateIfStatus: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new ListTransactionsHandler(mockPaymentRepo as any);
|
||||
});
|
||||
|
||||
it('returns paginated transactions', async () => {
|
||||
const payments = [createPayment('pay-1'), createPayment('pay-2')];
|
||||
mockPaymentRepo.findByUserId.mockResolvedValue({ items: payments, total: 2 });
|
||||
|
||||
const query = new ListTransactionsQuery('user-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.items[0].amountVND).toBe('1000000');
|
||||
expect(result.limit).toBe(20);
|
||||
expect(result.offset).toBe(0);
|
||||
});
|
||||
|
||||
it('applies custom limit and offset', async () => {
|
||||
mockPaymentRepo.findByUserId.mockResolvedValue({ items: [], total: 0 });
|
||||
|
||||
const query = new ListTransactionsQuery('user-1', undefined, 10, 20);
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockPaymentRepo.findByUserId).toHaveBeenCalledWith('user-1', {
|
||||
status: undefined,
|
||||
limit: 10,
|
||||
offset: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('caps limit at 100', async () => {
|
||||
mockPaymentRepo.findByUserId.mockResolvedValue({ items: [], total: 0 });
|
||||
|
||||
const query = new ListTransactionsQuery('user-1', undefined, 500);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.limit).toBe(100);
|
||||
});
|
||||
|
||||
it('filters by status', async () => {
|
||||
mockPaymentRepo.findByUserId.mockResolvedValue({ items: [], total: 0 });
|
||||
|
||||
const query = new ListTransactionsQuery('user-1', 'COMPLETED');
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockPaymentRepo.findByUserId).toHaveBeenCalledWith('user-1',
|
||||
expect.objectContaining({ status: 'COMPLETED' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { PaymentEntity } from '../../domain/entities/payment.entity';
|
||||
import { type IPaymentRepository } from '../../domain/repositories/payment.repository';
|
||||
import { Money } from '../../domain/value-objects/money.vo';
|
||||
import { RefundPaymentCommand } from '../commands/refund-payment/refund-payment.command';
|
||||
import { RefundPaymentHandler } from '../commands/refund-payment/refund-payment.handler';
|
||||
|
||||
function createCompletedPayment(): PaymentEntity {
|
||||
const money = Money.create(1_000_000n).unwrap();
|
||||
const payment = PaymentEntity.createNew('pay-1', 'user-1', 'VNPAY', 'SUBSCRIPTION', money);
|
||||
payment.markProcessing('vnpay-tx-1');
|
||||
payment.markCompleted({ verified: true });
|
||||
payment.clearDomainEvents();
|
||||
return payment;
|
||||
}
|
||||
|
||||
describe('RefundPaymentHandler', () => {
|
||||
let handler: RefundPaymentHandler;
|
||||
let mockPaymentRepo: { [K in keyof IPaymentRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockGatewayFactory: { getGateway: ReturnType<typeof vi.fn> };
|
||||
let mockGateway: { refund: ReturnType<typeof vi.fn>; createPaymentUrl: ReturnType<typeof vi.fn>; verifyCallback: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPaymentRepo = {
|
||||
findById: vi.fn(),
|
||||
findByProviderTxId: vi.fn(),
|
||||
findByIdempotencyKey: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
updateIfStatus: vi.fn(),
|
||||
};
|
||||
|
||||
mockGateway = {
|
||||
refund: vi.fn(),
|
||||
createPaymentUrl: vi.fn(),
|
||||
verifyCallback: vi.fn(),
|
||||
};
|
||||
|
||||
mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) };
|
||||
|
||||
handler = new RefundPaymentHandler(
|
||||
mockPaymentRepo as any,
|
||||
mockGatewayFactory as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('refunds a completed payment successfully', async () => {
|
||||
const payment = createCompletedPayment();
|
||||
mockPaymentRepo.findById.mockResolvedValue(payment);
|
||||
mockGateway.refund.mockResolvedValue({ success: true, refundTxId: 'refund-tx-1' });
|
||||
|
||||
const command = new RefundPaymentCommand('pay-1', 'Yêu cầu hoàn tiền', 'admin-1');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.refundTxId).toBe('refund-tx-1');
|
||||
expect(result.paymentId).toBe('pay-1');
|
||||
expect(mockPaymentRepo.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles failed refund from gateway', async () => {
|
||||
const payment = createCompletedPayment();
|
||||
mockPaymentRepo.findById.mockResolvedValue(payment);
|
||||
mockGateway.refund.mockResolvedValue({ success: false, refundTxId: null });
|
||||
|
||||
const command = new RefundPaymentCommand('pay-1', 'Hoàn tiền', 'admin-1');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(mockPaymentRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when payment not found', async () => {
|
||||
mockPaymentRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new RefundPaymentCommand('nonexistent', 'reason', 'admin-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Payment');
|
||||
});
|
||||
|
||||
it('throws ValidationException when payment is not completed', async () => {
|
||||
const money = Money.create(500_000n).unwrap();
|
||||
const payment = PaymentEntity.createNew('pay-2', 'user-1', 'VNPAY', 'SUBSCRIPTION', money);
|
||||
payment.clearDomainEvents();
|
||||
mockPaymentRepo.findById.mockResolvedValue(payment);
|
||||
|
||||
const command = new RefundPaymentCommand('pay-2', 'reason', 'admin-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/hoàn tất/);
|
||||
});
|
||||
|
||||
it('throws ValidationException when no provider transaction id', async () => {
|
||||
const money = Money.create(500_000n).unwrap();
|
||||
const payment = PaymentEntity.createNew('pay-3', 'user-1', 'VNPAY', 'SUBSCRIPTION', money);
|
||||
// Manually mark completed without providerTxId by using internal hack
|
||||
(payment as any)._status = 'COMPLETED';
|
||||
(payment as any)._providerTxId = null;
|
||||
payment.clearDomainEvents();
|
||||
mockPaymentRepo.findById.mockResolvedValue(payment);
|
||||
|
||||
const command = new RefundPaymentCommand('pay-3', 'reason', 'admin-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/mã giao dịch/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { SubscriptionEntity } from '../../domain/entities/subscription.entity';
|
||||
import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository';
|
||||
import { CancelSubscriptionCommand } from '../commands/cancel-subscription/cancel-subscription.command';
|
||||
import { CancelSubscriptionHandler } from '../commands/cancel-subscription/cancel-subscription.handler';
|
||||
|
||||
function createActiveSubscription(userId = 'user-1'): SubscriptionEntity {
|
||||
const sub = SubscriptionEntity.createNew(
|
||||
'sub-1', userId, 'plan-1', 'AGENT_PRO',
|
||||
new Date('2026-01-01'), new Date('2026-02-01'),
|
||||
);
|
||||
sub.clearDomainEvents();
|
||||
return sub;
|
||||
}
|
||||
|
||||
describe('CancelSubscriptionHandler', () => {
|
||||
let handler: CancelSubscriptionHandler;
|
||||
let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findById: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new CancelSubscriptionHandler(
|
||||
mockRepo as any,
|
||||
mockEventBus as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels an active subscription', async () => {
|
||||
const subscription = createActiveSubscription();
|
||||
mockRepo.findByUserId.mockResolvedValue(subscription);
|
||||
|
||||
const command = new CancelSubscriptionCommand('user-1', 'Không cần nữa');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.status).toBe('CANCELLED');
|
||||
expect(result.subscriptionId).toBe('sub-1');
|
||||
expect(result.cancelledAt).toBeDefined();
|
||||
expect(mockRepo.update).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when no subscription found', async () => {
|
||||
mockRepo.findByUserId.mockResolvedValue(null);
|
||||
|
||||
const command = new CancelSubscriptionCommand('user-99');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Subscription');
|
||||
});
|
||||
|
||||
it('throws ValidationException when already cancelled', async () => {
|
||||
const subscription = createActiveSubscription();
|
||||
subscription.cancel();
|
||||
subscription.clearDomainEvents();
|
||||
mockRepo.findByUserId.mockResolvedValue(subscription);
|
||||
|
||||
const command = new CancelSubscriptionCommand('user-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/hủy/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { SubscriptionEntity } from '../../domain/entities/subscription.entity';
|
||||
import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository';
|
||||
import { CheckQuotaQuery } from '../queries/check-quota/check-quota.query';
|
||||
import { CheckQuotaHandler } from '../queries/check-quota/check-quota.handler';
|
||||
|
||||
describe('CheckQuotaHandler', () => {
|
||||
let handler: CheckQuotaHandler;
|
||||
let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPrisma: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findById: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
mockPrisma = {
|
||||
plan: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
usageRecord: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
handler = new CheckQuotaHandler(mockRepo as any, mockPrisma);
|
||||
});
|
||||
|
||||
it('returns quota for active subscription', async () => {
|
||||
const sub = SubscriptionEntity.createNew(
|
||||
'sub-1', 'user-1', 'plan-1', 'AGENT_PRO',
|
||||
new Date('2026-01-01'), new Date('2026-02-01'),
|
||||
);
|
||||
sub.clearDomainEvents();
|
||||
mockRepo.findByUserId.mockResolvedValue(sub);
|
||||
mockPrisma.plan.findUnique.mockResolvedValue({
|
||||
id: 'plan-1',
|
||||
maxListings: 50,
|
||||
maxSavedSearches: 10,
|
||||
});
|
||||
mockPrisma.usageRecord.findFirst.mockResolvedValue({ count: 15 });
|
||||
|
||||
const query = new CheckQuotaQuery('user-1', 'listings_created');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.metric).toBe('listings_created');
|
||||
expect(result.limit).toBe(50);
|
||||
expect(result.used).toBe(15);
|
||||
expect(result.remaining).toBe(35);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns not allowed when quota exceeded', async () => {
|
||||
const sub = SubscriptionEntity.createNew(
|
||||
'sub-1', 'user-1', 'plan-1', 'FREE',
|
||||
new Date('2026-01-01'), new Date('2026-02-01'),
|
||||
);
|
||||
sub.clearDomainEvents();
|
||||
mockRepo.findByUserId.mockResolvedValue(sub);
|
||||
mockPrisma.plan.findUnique.mockResolvedValue({
|
||||
id: 'plan-1',
|
||||
maxListings: 5,
|
||||
});
|
||||
mockPrisma.usageRecord.findFirst.mockResolvedValue({ count: 5 });
|
||||
|
||||
const query = new CheckQuotaQuery('user-1', 'listings_created');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.remaining).toBe(0);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to FREE tier when no subscription', async () => {
|
||||
mockRepo.findByUserId.mockResolvedValue(null);
|
||||
mockPrisma.plan.findFirst.mockResolvedValue({
|
||||
id: 'free-plan',
|
||||
maxListings: 3,
|
||||
maxSavedSearches: 1,
|
||||
});
|
||||
|
||||
const query = new CheckQuotaQuery('user-1', 'listings_created');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.limit).toBe(3);
|
||||
expect(result.used).toBe(0);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns unlimited for unknown metric', async () => {
|
||||
const sub = SubscriptionEntity.createNew(
|
||||
'sub-1', 'user-1', 'plan-1', 'AGENT_PRO',
|
||||
new Date('2026-01-01'), new Date('2026-02-01'),
|
||||
);
|
||||
sub.clearDomainEvents();
|
||||
mockRepo.findByUserId.mockResolvedValue(sub);
|
||||
mockPrisma.plan.findUnique.mockResolvedValue({ id: 'plan-1' });
|
||||
|
||||
const query = new CheckQuotaQuery('user-1', 'unknown_metric');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.limit).toBeNull();
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns zero quota when no free plan found', async () => {
|
||||
mockRepo.findByUserId.mockResolvedValue(null);
|
||||
mockPrisma.plan.findFirst.mockResolvedValue(null);
|
||||
|
||||
const query = new CheckQuotaQuery('user-1', 'listings_created');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.limit).toBe(0);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { SubscriptionEntity } from '../../domain/entities/subscription.entity';
|
||||
import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository';
|
||||
import { GetBillingHistoryQuery } from '../queries/get-billing-history/get-billing-history.query';
|
||||
import { GetBillingHistoryHandler } from '../queries/get-billing-history/get-billing-history.handler';
|
||||
|
||||
describe('GetBillingHistoryHandler', () => {
|
||||
let handler: GetBillingHistoryHandler;
|
||||
let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPrisma: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findById: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
mockPrisma = {
|
||||
payment: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
handler = new GetBillingHistoryHandler(mockRepo as any, mockPrisma);
|
||||
});
|
||||
|
||||
it('returns billing history with subscription and payments', async () => {
|
||||
const sub = SubscriptionEntity.createNew(
|
||||
'sub-1', 'user-1', 'plan-1', 'AGENT_PRO',
|
||||
new Date('2026-01-01'), new Date('2026-02-01'),
|
||||
);
|
||||
sub.clearDomainEvents();
|
||||
mockRepo.findByUserId.mockResolvedValue(sub);
|
||||
|
||||
mockPrisma.payment.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'pay-1',
|
||||
amountVND: 500000n,
|
||||
status: 'COMPLETED',
|
||||
provider: 'VNPAY',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
},
|
||||
]);
|
||||
mockPrisma.payment.count.mockResolvedValue(1);
|
||||
|
||||
const query = new GetBillingHistoryQuery('user-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.subscription).not.toBeNull();
|
||||
expect(result.subscription!.id).toBe('sub-1');
|
||||
expect(result.subscription!.planTier).toBe('AGENT_PRO');
|
||||
expect(result.payments).toHaveLength(1);
|
||||
expect(result.payments[0].amountVND).toBe('500000');
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
|
||||
it('returns null subscription when user has none', async () => {
|
||||
mockRepo.findByUserId.mockResolvedValue(null);
|
||||
mockPrisma.payment.findMany.mockResolvedValue([]);
|
||||
mockPrisma.payment.count.mockResolvedValue(0);
|
||||
|
||||
const query = new GetBillingHistoryQuery('user-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.subscription).toBeNull();
|
||||
expect(result.payments).toHaveLength(0);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
|
||||
it('applies limit and offset', async () => {
|
||||
mockRepo.findByUserId.mockResolvedValue(null);
|
||||
mockPrisma.payment.findMany.mockResolvedValue([]);
|
||||
mockPrisma.payment.count.mockResolvedValue(0);
|
||||
|
||||
const query = new GetBillingHistoryQuery('user-1', 10, 20);
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockPrisma.payment.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ take: 10, skip: 20 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { GetPlanQuery } from '../queries/get-plan/get-plan.query';
|
||||
import { GetPlanHandler } from '../queries/get-plan/get-plan.handler';
|
||||
|
||||
describe('GetPlanHandler', () => {
|
||||
let handler: GetPlanHandler;
|
||||
let mockPrisma: any;
|
||||
|
||||
const mockPlan = {
|
||||
id: 'plan-1',
|
||||
tier: 'AGENT_PRO',
|
||||
name: 'Agent Pro',
|
||||
priceMonthlyVND: 299000n,
|
||||
priceYearlyVND: 2990000n,
|
||||
maxListings: 50,
|
||||
maxSavedSearches: 10,
|
||||
features: { analytics: true },
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
plan: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
handler = new GetPlanHandler(mockPrisma);
|
||||
});
|
||||
|
||||
it('returns a single plan by tier', async () => {
|
||||
mockPrisma.plan.findFirst.mockResolvedValue(mockPlan);
|
||||
|
||||
const query = new GetPlanQuery('AGENT_PRO');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).not.toBeInstanceOf(Array);
|
||||
const plan = result as any;
|
||||
expect(plan.tier).toBe('AGENT_PRO');
|
||||
expect(plan.priceMonthlyVND).toBe('299000');
|
||||
expect(plan.priceYearlyVND).toBe('2990000');
|
||||
});
|
||||
|
||||
it('returns all active plans when no tier specified', async () => {
|
||||
mockPrisma.plan.findMany.mockResolvedValue([mockPlan]);
|
||||
|
||||
const query = new GetPlanQuery();
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect((result as any[]).length).toBe(1);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when plan not found', async () => {
|
||||
mockPrisma.plan.findFirst.mockResolvedValue(null);
|
||||
|
||||
const query = new GetPlanQuery('ENTERPRISE');
|
||||
|
||||
await expect(handler.execute(query)).rejects.toThrow('Plan');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { SubscriptionEntity } from '../../domain/entities/subscription.entity';
|
||||
import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository';
|
||||
import { MeterUsageCommand } from '../commands/meter-usage/meter-usage.command';
|
||||
import { MeterUsageHandler } from '../commands/meter-usage/meter-usage.handler';
|
||||
|
||||
function createActiveSubscription(): SubscriptionEntity {
|
||||
const sub = SubscriptionEntity.createNew(
|
||||
'sub-1', 'user-1', 'plan-1', 'AGENT_PRO',
|
||||
new Date('2026-01-01'), new Date('2026-02-01'),
|
||||
);
|
||||
sub.clearDomainEvents();
|
||||
return sub;
|
||||
}
|
||||
|
||||
describe('MeterUsageHandler', () => {
|
||||
let handler: MeterUsageHandler;
|
||||
let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPrisma: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findById: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
mockPrisma = {
|
||||
usageRecord: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
handler = new MeterUsageHandler(
|
||||
mockRepo as any,
|
||||
mockPrisma,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates new usage record when none exists', async () => {
|
||||
const subscription = createActiveSubscription();
|
||||
mockRepo.findByUserId.mockResolvedValue(subscription);
|
||||
mockPrisma.usageRecord.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.usageRecord.create.mockResolvedValue({
|
||||
id: 'usage-1',
|
||||
metric: 'listings_created',
|
||||
count: 3,
|
||||
periodStart: subscription.currentPeriodStart,
|
||||
periodEnd: subscription.currentPeriodEnd,
|
||||
});
|
||||
|
||||
const command = new MeterUsageCommand('user-1', 'listings_created', 3);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.usageRecordId).toBe('usage-1');
|
||||
expect(result.metric).toBe('listings_created');
|
||||
expect(result.count).toBe(3);
|
||||
expect(mockPrisma.usageRecord.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('increments existing usage record', async () => {
|
||||
const subscription = createActiveSubscription();
|
||||
mockRepo.findByUserId.mockResolvedValue(subscription);
|
||||
mockPrisma.usageRecord.findFirst.mockResolvedValue({
|
||||
id: 'usage-1',
|
||||
count: 5,
|
||||
});
|
||||
mockPrisma.usageRecord.update.mockResolvedValue({
|
||||
id: 'usage-1',
|
||||
metric: 'listings_created',
|
||||
count: 8,
|
||||
periodStart: subscription.currentPeriodStart,
|
||||
periodEnd: subscription.currentPeriodEnd,
|
||||
});
|
||||
|
||||
const command = new MeterUsageCommand('user-1', 'listings_created', 3);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.count).toBe(8);
|
||||
expect(mockPrisma.usageRecord.update).toHaveBeenCalledWith({
|
||||
where: { id: 'usage-1' },
|
||||
data: { count: 8 },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws ValidationException for zero count', async () => {
|
||||
const command = new MeterUsageCommand('user-1', 'listings_created', 0);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/lớn hơn 0/);
|
||||
});
|
||||
|
||||
it('throws ValidationException for negative count', async () => {
|
||||
const command = new MeterUsageCommand('user-1', 'listings_created', -1);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/lớn hơn 0/);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when no subscription found', async () => {
|
||||
mockRepo.findByUserId.mockResolvedValue(null);
|
||||
|
||||
const command = new MeterUsageCommand('user-99', 'listings_created', 1);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Subscription');
|
||||
});
|
||||
|
||||
it('throws ValidationException when subscription is not active', async () => {
|
||||
const subscription = createActiveSubscription();
|
||||
subscription.cancel();
|
||||
subscription.clearDomainEvents();
|
||||
mockRepo.findByUserId.mockResolvedValue(subscription);
|
||||
|
||||
const command = new MeterUsageCommand('user-1', 'listings_created', 1);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/hoạt động/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { SubscriptionEntity } from '../../domain/entities/subscription.entity';
|
||||
import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository';
|
||||
import { UpgradeSubscriptionCommand } from '../commands/upgrade-subscription/upgrade-subscription.command';
|
||||
import { UpgradeSubscriptionHandler } from '../commands/upgrade-subscription/upgrade-subscription.handler';
|
||||
|
||||
function createActiveSubscription(tier: 'FREE' | 'AGENT_PRO' | 'INVESTOR' | 'ENTERPRISE' = 'FREE'): SubscriptionEntity {
|
||||
const sub = SubscriptionEntity.createNew(
|
||||
'sub-1', 'user-1', 'plan-1', tier,
|
||||
new Date('2026-01-01'), new Date('2026-02-01'),
|
||||
);
|
||||
sub.clearDomainEvents();
|
||||
return sub;
|
||||
}
|
||||
|
||||
describe('UpgradeSubscriptionHandler', () => {
|
||||
let handler: UpgradeSubscriptionHandler;
|
||||
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().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockPrisma = {
|
||||
plan: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new UpgradeSubscriptionHandler(
|
||||
mockRepo as any,
|
||||
mockPrisma,
|
||||
mockEventBus as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('upgrades from FREE to AGENT_PRO', async () => {
|
||||
const subscription = createActiveSubscription('FREE');
|
||||
mockRepo.findByUserId.mockResolvedValue(subscription);
|
||||
mockPrisma.plan.findFirst.mockResolvedValue({ id: 'plan-2', tier: 'AGENT_PRO', isActive: true });
|
||||
|
||||
const command = new UpgradeSubscriptionCommand('user-1', 'AGENT_PRO');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.previousTier).toBe('FREE');
|
||||
expect(result.newTier).toBe('AGENT_PRO');
|
||||
expect(result.subscriptionId).toBe('sub-1');
|
||||
expect(mockRepo.update).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows lateral switch between AGENT_PRO and INVESTOR', async () => {
|
||||
const subscription = createActiveSubscription('AGENT_PRO');
|
||||
mockRepo.findByUserId.mockResolvedValue(subscription);
|
||||
mockPrisma.plan.findFirst.mockResolvedValue({ id: 'plan-3', tier: 'INVESTOR', isActive: true });
|
||||
|
||||
const command = new UpgradeSubscriptionCommand('user-1', 'INVESTOR');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.previousTier).toBe('AGENT_PRO');
|
||||
expect(result.newTier).toBe('INVESTOR');
|
||||
});
|
||||
|
||||
it('throws NotFoundException when no subscription found', async () => {
|
||||
mockRepo.findByUserId.mockResolvedValue(null);
|
||||
|
||||
const command = new UpgradeSubscriptionCommand('user-99', 'AGENT_PRO');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Subscription');
|
||||
});
|
||||
|
||||
it('throws ValidationException when subscription is not active', async () => {
|
||||
const subscription = createActiveSubscription('FREE');
|
||||
subscription.cancel();
|
||||
subscription.clearDomainEvents();
|
||||
mockRepo.findByUserId.mockResolvedValue(subscription);
|
||||
|
||||
const command = new UpgradeSubscriptionCommand('user-1', 'AGENT_PRO');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/hoạt động/);
|
||||
});
|
||||
|
||||
it('throws ValidationException when already on same tier', async () => {
|
||||
const subscription = createActiveSubscription('AGENT_PRO');
|
||||
mockRepo.findByUserId.mockResolvedValue(subscription);
|
||||
|
||||
const command = new UpgradeSubscriptionCommand('user-1', 'AGENT_PRO');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/gói này/);
|
||||
});
|
||||
|
||||
it('throws ValidationException when downgrading', async () => {
|
||||
const subscription = createActiveSubscription('ENTERPRISE');
|
||||
mockRepo.findByUserId.mockResolvedValue(subscription);
|
||||
|
||||
const command = new UpgradeSubscriptionCommand('user-1', 'FREE');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/nâng cấp/);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when plan does not exist', async () => {
|
||||
const subscription = createActiveSubscription('FREE');
|
||||
mockRepo.findByUserId.mockResolvedValue(subscription);
|
||||
mockPrisma.plan.findFirst.mockResolvedValue(null);
|
||||
|
||||
const command = new UpgradeSubscriptionCommand('user-1', 'AGENT_PRO');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Plan');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
|
||||
Reference in New Issue
Block a user