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:
Ho Ngoc Hai
2026-04-09 10:22:20 +07:00
parent 35feccb529
commit 8179f1c16e
37 changed files with 613 additions and 36 deletions

View File

@@ -40,4 +40,56 @@ describe('AggregateRoot', () => {
});
expect(agg.domainEvents).toHaveLength(1);
});
it('should return uncommitted events without clearing via getUncommittedEvents()', () => {
const agg = new TestAggregate('agg-1');
agg.doSomething();
agg.doSomething();
const events = agg.getUncommittedEvents();
expect(events).toHaveLength(2);
// Should not clear events
expect(agg.domainEvents).toHaveLength(2);
});
it('should return defensive copy from getUncommittedEvents()', () => {
const agg = new TestAggregate('agg-1');
agg.doSomething();
const events = agg.getUncommittedEvents();
events.push({ eventName: 'Fake', occurredAt: new Date(), aggregateId: 'x' });
expect(agg.getUncommittedEvents()).toHaveLength(1);
});
it('should clear and return events via commit()', () => {
const agg = new TestAggregate('agg-1');
agg.doSomething();
agg.doSomething();
agg.doSomething();
const events = agg.commit();
expect(events).toHaveLength(3);
expect(agg.domainEvents).toHaveLength(0);
expect(agg.getUncommittedEvents()).toHaveLength(0);
});
it('commit() should behave identically to clearDomainEvents()', () => {
const agg1 = new TestAggregate('agg-1');
const agg2 = new TestAggregate('agg-2');
agg1.doSomething();
agg2.doSomething();
const cleared = agg1.clearDomainEvents();
const committed = agg2.commit();
expect(cleared).toHaveLength(committed.length);
expect(agg1.domainEvents).toHaveLength(0);
expect(agg2.domainEvents).toHaveLength(0);
});
it('should return empty arrays when no events exist', () => {
const agg = new TestAggregate('agg-1');
expect(agg.getUncommittedEvents()).toHaveLength(0);
expect(agg.commit()).toHaveLength(0);
});
});

View File

@@ -8,13 +8,33 @@ export abstract class AggregateRoot<TId = string> extends BaseEntity<TId> {
return [...this._domainEvents];
}
/**
* Returns all domain events that have not yet been published.
* Use this to inspect pending events before committing.
*/
getUncommittedEvents(): DomainEvent[] {
return [...this._domainEvents];
}
protected addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
/**
* Clears and returns all uncommitted domain events.
* Call this after persisting the aggregate and before publishing events.
*/
clearDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
/**
* Alias for clearDomainEvents(). Marks all pending events as committed
* by clearing the internal event list and returning them for publishing.
*/
commit(): DomainEvent[] {
return this.clearDomainEvents();
}
}