feat(api): complete domain event publishing with aggregate root pattern
- Add getUncommittedEvents() and commit() to AggregateRoot base class - Create 6 new domain events: SubscriptionExpired, SubscriptionRenewed, ListingStatusChanged, UserKycUpdated, UserDeactivated, PaymentRefunded - Wire events into entity state changes: SubscriptionEntity (markExpired, renewPeriod), ListingEntity (all transitions), UserEntity (KYC, deactivate), PaymentEntity (markRefunded) - Add 7 new event listeners across notifications, admin, and search modules (25 total @OnEvent handlers) - Fix ReviewDeletedListener to handle LISTING target type - Restore watcher notifications in ListingSoldListener - Update barrel exports and module registrations Resolves: TEC-1564 Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import { ListingApprovedEvent } from '../events/listing-approved.event';
|
||||
import { ListingCreatedEvent } from '../events/listing-created.event';
|
||||
import { ListingSoldEvent } from '../events/listing-sold.event';
|
||||
import { ListingStatusChangedEvent } from '../events/listing-status-changed.event';
|
||||
|
||||
describe('Listings Domain Events', () => {
|
||||
describe('ListingCreatedEvent', () => {
|
||||
@@ -49,4 +50,29 @@ describe('Listings Domain Events', () => {
|
||||
expect(event.finalStatus).toBe('RENTED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListingStatusChangedEvent', () => {
|
||||
it('creates event with correct properties', () => {
|
||||
const event = new ListingStatusChangedEvent('listing-1', 'prop-1', 'DRAFT', 'PENDING_REVIEW');
|
||||
|
||||
expect(event.eventName).toBe('listing.status_changed');
|
||||
expect(event.aggregateId).toBe('listing-1');
|
||||
expect(event.propertyId).toBe('prop-1');
|
||||
expect(event.previousStatus).toBe('DRAFT');
|
||||
expect(event.newStatus).toBe('PENDING_REVIEW');
|
||||
expect(event.occurredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('creates event for rejection transition', () => {
|
||||
const event = new ListingStatusChangedEvent('listing-2', 'prop-2', 'PENDING_REVIEW', 'REJECTED');
|
||||
expect(event.previousStatus).toBe('PENDING_REVIEW');
|
||||
expect(event.newStatus).toBe('REJECTED');
|
||||
});
|
||||
|
||||
it('creates event for expiration transition', () => {
|
||||
const event = new ListingStatusChangedEvent('listing-3', 'prop-3', 'ACTIVE', 'EXPIRED');
|
||||
expect(event.previousStatus).toBe('ACTIVE');
|
||||
expect(event.newStatus).toBe('EXPIRED');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,10 +33,15 @@ describe('ListingEntity', () => {
|
||||
expect(events[0]!.eventName).toBe('listing.created');
|
||||
});
|
||||
|
||||
it('should transition DRAFT -> PENDING_REVIEW', () => {
|
||||
it('should transition DRAFT -> PENDING_REVIEW and emit ListingStatusChangedEvent', () => {
|
||||
const listing = makeDefaultListing();
|
||||
listing.clearDomainEvents();
|
||||
listing.submitForReview();
|
||||
expect(listing.status).toBe('PENDING_REVIEW');
|
||||
|
||||
const events = listing.domainEvents;
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.eventName).toBe('listing.status_changed');
|
||||
});
|
||||
|
||||
it('should transition PENDING_REVIEW -> ACTIVE and emit ListingApprovedEvent', () => {
|
||||
@@ -51,12 +56,16 @@ describe('ListingEntity', () => {
|
||||
expect(events.some((e) => e.eventName === 'listing.approved')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a PENDING_REVIEW listing', () => {
|
||||
it('should reject a PENDING_REVIEW listing and emit ListingStatusChangedEvent', () => {
|
||||
const listing = makeDefaultListing();
|
||||
listing.submitForReview();
|
||||
listing.clearDomainEvents();
|
||||
listing.reject('Ảnh không rõ ràng');
|
||||
expect(listing.status).toBe('REJECTED');
|
||||
expect(listing.moderationNotes).toBe('Ảnh không rõ ràng');
|
||||
|
||||
const events = listing.domainEvents;
|
||||
expect(events.some((e) => e.eventName === 'listing.status_changed')).toBe(true);
|
||||
});
|
||||
|
||||
it('should transition ACTIVE -> SOLD and emit ListingSoldEvent', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AggregateRoot, ValidationException } from '@modules/shared';
|
||||
import { ListingApprovedEvent } from '../events/listing-approved.event';
|
||||
import { ListingCreatedEvent } from '../events/listing-created.event';
|
||||
import { ListingSoldEvent } from '../events/listing-sold.event';
|
||||
import { ListingStatusChangedEvent } from '../events/listing-status-changed.event';
|
||||
import { type Price } from '../value-objects/price.vo';
|
||||
|
||||
const VALID_TRANSITIONS: Record<ListingStatus, ListingStatus[]> = {
|
||||
@@ -152,6 +153,11 @@ export class ListingEntity extends AggregateRoot<string> {
|
||||
this._status = newStatus;
|
||||
this.updatedAt = new Date();
|
||||
|
||||
// Always emit generic status change event for all transitions
|
||||
this.addDomainEvent(
|
||||
new ListingStatusChangedEvent(this.id, this._propertyId, previousStatus, newStatus),
|
||||
);
|
||||
|
||||
if (newStatus === 'ACTIVE' && previousStatus === 'PENDING_REVIEW') {
|
||||
this._publishedAt = new Date();
|
||||
this.addDomainEvent(new ListingApprovedEvent(this.id, this._propertyId));
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type ListingStatus } from '@prisma/client';
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class ListingStatusChangedEvent implements DomainEvent {
|
||||
readonly eventName = 'listing.status_changed';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly propertyId: string,
|
||||
public readonly previousStatus: ListingStatus,
|
||||
public readonly newStatus: ListingStatus,
|
||||
) {}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export { ListingsModule } from './listings.module';
|
||||
export { ListingEntity, type ListingProps } from './domain/entities/listing.entity';
|
||||
export { ListingCreatedEvent } from './domain/events/listing-created.event';
|
||||
export { ModerateListingCommand } from './application/commands/moderate-listing/moderate-listing.command';
|
||||
export { LISTING_REPOSITORY, type IListingRepository, type ListingSearchParams, type PaginatedResult } from './domain/repositories/listing.repository';
|
||||
export { ListingSoldEvent } from './domain/events/listing-sold.event';
|
||||
export { ListingStatusChangedEvent } from './domain/events/listing-status-changed.event';
|
||||
export { Price } from './domain/value-objects/price.vo';
|
||||
|
||||
Reference in New Issue
Block a user