diff --git a/src/app/events/listeners/ExampleListener.ts b/src/app/events/listeners/ExampleListener.ts deleted file mode 100644 index 5f166aa43..000000000 --- a/src/app/events/listeners/ExampleListener.ts +++ /dev/null @@ -1,10 +0,0 @@ -import EventListener from "@src/core/domains/events/services/EventListener"; - -export class ExampleListener extends EventListener<{userId: string}> { - - // eslint-disable-next-line no-unused-vars - handle = async (payload: { userId: string}) => { - // Handle the logic - } - -} \ No newline at end of file diff --git a/src/app/events/listeners/UserCreatedListener.ts b/src/app/events/listeners/UserCreatedListener.ts new file mode 100644 index 000000000..f1cf680cc --- /dev/null +++ b/src/app/events/listeners/UserCreatedListener.ts @@ -0,0 +1,15 @@ +import BaseEventListener from "@src/core/domains/events/base/BaseEventListener"; + +export class UserCreatedListener extends BaseEventListener { + + /** + * Optional method to execute before the subscribers are dispatched. + */ + async execute(): Promise { + + // const payload = this.getPayload(); + + // Handle logic + } + +} \ No newline at end of file diff --git a/src/app/events/subscribers/ExampleSubscriber.ts b/src/app/events/subscribers/ExampleSubscriber.ts deleted file mode 100644 index fcdd2ce48..000000000 --- a/src/app/events/subscribers/ExampleSubscriber.ts +++ /dev/null @@ -1,16 +0,0 @@ -import EventSubscriber from "@src/core/domains/events/services/EventSubscriber"; - -type Payload = { - userId: string; -} - -export default class ExampleSubscriber extends EventSubscriber { - - constructor(payload: Payload) { - const eventName = 'OnExample' - const driver = 'queue'; - - super(eventName, driver, payload) - } - -} \ No newline at end of file diff --git a/src/app/events/subscribers/UserCreatedSubscriber.ts b/src/app/events/subscribers/UserCreatedSubscriber.ts new file mode 100644 index 000000000..010de7ce5 --- /dev/null +++ b/src/app/events/subscribers/UserCreatedSubscriber.ts @@ -0,0 +1,28 @@ +import BaseEvent from "@src/core/domains/events/base/BaseEvent"; +import SyncDriver from "@src/core/domains/events/drivers/SyncDriver"; + +export default class UserCreatedSubscriber extends BaseEvent { + + static readonly eventName = 'UserCreatedSubscriber'; + + protected namespace: string = 'auth'; + + constructor(payload) { + super(payload, SyncDriver); + } + + getName(): string { + return UserCreatedSubscriber.eventName; + } + + getQueueName(): string { + return 'default'; + } + + async execute(): Promise { + // const payload = this.getPayload(); + + // Handle logic + } + +} \ No newline at end of file diff --git a/src/app/observers/UserObserver.ts b/src/app/observers/UserObserver.ts index daa6ce8c2..2797c6a04 100644 --- a/src/app/observers/UserObserver.ts +++ b/src/app/observers/UserObserver.ts @@ -1,3 +1,4 @@ +import { UserCreatedListener } from "@src/app/events/listeners/UserCreatedListener"; import { IUserData } from "@src/app/models/auth/User"; import hashPassword from "@src/core/domains/auth/utils/hashPassword"; import Observer from "@src/core/domains/observer/services/Observer"; @@ -22,6 +23,16 @@ export default class UserObserver extends Observer { return data } + /** + * Called after the User model has been created. + * @param data The User data that has been created. + * @returns The processed User data. + */ + async created(data: IUserData): Promise { + await App.container('events').dispatch(new UserCreatedListener(data)) + return data + } + /** * Updates the roles of the user based on the groups they belong to. * Retrieves the roles associated with each group the user belongs to from the permissions configuration. diff --git a/src/config/events.ts b/src/config/events.ts index 1c8ea5e95..951d95b15 100644 --- a/src/config/events.ts +++ b/src/config/events.ts @@ -1,71 +1,69 @@ -import { ExampleListener } from "@src/app/events/listeners/ExampleListener"; -import QueueDriver, { QueueDriverOptions } from "@src/core/domains/events/drivers/QueueDriver"; -import SynchronousDriver from "@src/core/domains/events/drivers/SynchronousDriver"; -import { IEventDrivers, ISubscribers } from "@src/core/domains/events/interfaces/IEventConfig"; +import { UserCreatedListener } from "@src/app/events/listeners/UserCreatedListener"; +import UserCreatedSubscriber from "@src/app/events/subscribers/UserCreatedSubscriber"; +import QueueableDriver, { TQueueDriverOptions } from "@src/core/domains/events/drivers/QueableDriver"; +import SyncDriver from "@src/core/domains/events/drivers/SyncDriver"; +import { IEventConfig } from "@src/core/domains/events/interfaces/config/IEventConfig"; +import FailedWorkerModel from "@src/core/domains/events/models/FailedWorkerModel"; import WorkerModel from "@src/core/domains/events/models/WorkerModel"; -import DriverOptions from "@src/core/domains/events/services/QueueDriverOptions"; +import EventService from "@src/core/domains/events/services/EventService"; /** - * Default Event Driver Configuration - * - * This setting determines which event driver will be used by default when no specific - * driver is defined for an event. The value is read from the APP_EVENT_DRIVER - * environment variable, falling back to 'sync' if not set. - * - * Options: - * - 'sync': Events are processed immediately. - * - 'queue': Events are queued for background processing. + * Event Drivers Constants */ -export const defaultEventDriver: string = process.env.APP_EVENT_DRIVER ?? 'sync'; +export const EVENT_DRIVERS = { + SYNC: EventService.getDriverName(SyncDriver), + QUEABLE: EventService.getDriverName(QueueableDriver) +} -/** - * Event Drivers Configuration - * - * This object defines the available event drivers and their configurations. - * Each driver can have its own set of options to customize its behavior. - * - * Structure: - * { - * [driverName: string]: { - * driver: Class extending IEventDriver, - * options?: DriverOptions object - * } - * } - */ -export const eventDrivers: IEventDrivers = { - // Synchronous Driver: Processes events immediately - sync: { - driver: SynchronousDriver - }, - // Queue Driver: Saves events for background processing - queue: { - driver: QueueDriver, - options: new DriverOptions({ - queueName: 'default', // Name of the queue - retries: 3, // Number of retry attempts for failed events - failedCollection: 'failedWorkers', // Collection to store failed events - runAfterSeconds: 10, // Delay before processing queued events - workerModelCtor: WorkerModel // Constructor for the Worker model +export const eventConfig: IEventConfig = { + + /** + * Default Event Driver + */ + defaultDriver: SyncDriver, + + /** + * Event Drivers Configuration + * + * This object defines the available event drivers and their configurations. + * Each driver can have its own set of options to customize its behavior. + */ + drivers: { + + // Synchronous Driver: Processes events immediately + [EVENT_DRIVERS.SYNC]: EventService.createConfigDriver(SyncDriver, {}), + + // Queue Driver: Saves events for background processing + [EVENT_DRIVERS.QUEABLE]: EventService.createConfigDriver(QueueableDriver, { + queueName: 'default', // Name of the queue + retries: 3, // Number of retry attempts for failed events + runAfterSeconds: 10, // Delay before processing queued events + workerModelCtor: WorkerModel, // Constructor for the Worker model + failedWorkerModelCtor: FailedWorkerModel, }) - } -} as const; + + }, + + /** + * Register Events + */ + events: EventService.createConfigEvents([ + + ]), + + /** + * Event Listeners Configuration + * + * These are automatically registered with the event service + * and do not need to be added to 'events' array. + */ + listeners: EventService.createConfigListeners([ + { + listener: UserCreatedListener, + subscribers: [ + UserCreatedSubscriber + ] + } + ]), -/** - * Event Subscribers Configuration - * - * This object maps event names to arrays of listener classes. When an event - * is dispatched, all listeners registered for that event will be executed. - * - * Structure: - * { - * [eventName: string]: Array - * } - * - * Example usage: - * When an 'OnExample' event is dispatched, the ExampleListener will be triggered. - */ -export const eventSubscribers: ISubscribers = { - 'OnExample': [ - ExampleListener - ] } \ No newline at end of file diff --git a/src/core/base/BaseCastable.ts b/src/core/base/BaseCastable.ts new file mode 100644 index 000000000..f9073b217 --- /dev/null +++ b/src/core/base/BaseCastable.ts @@ -0,0 +1,8 @@ +import HasCastableConcern from "@src/core/concerns/HasCastableConcern"; +import { IHasCastableConcern } from "@src/core/interfaces/concerns/IHasCastableConcern"; +import { ICtor } from "@src/core/interfaces/ICtor"; +import compose from "@src/core/util/compose"; + +const BaseCastable: ICtor = compose(class {}, HasCastableConcern) + +export default BaseCastable \ No newline at end of file diff --git a/src/core/concerns/HasAttributesConcern.ts b/src/core/concerns/HasAttributesConcern.ts index 1a55d8bdc..1d274ca37 100644 --- a/src/core/concerns/HasAttributesConcern.ts +++ b/src/core/concerns/HasAttributesConcern.ts @@ -5,6 +5,16 @@ import { IHasAttributes, IHasAttributesSetAttributeOptions as SetAttributeOption import { ICtor } from "@src/core/interfaces/ICtor"; import IModelAttributes from "@src/core/interfaces/IModelData"; +/** + * Concern that adds the ability to set and retrieve attributes from a model, and to broadcast when attributes change. + * The concern is a mixin and can be applied to any class that implements the IBroadcaster interface. + * The concern adds the following methods to the class: setAttribute, getAttribute, getAttributes, getOriginal, isDirty, and getDirty. + * The concern also adds a constructor that subscribes to the SetAttributeBroadcastEvent and calls the onSetAttributeEvent method when the event is triggered. + * The concern is generic and can be used with any type of model attributes. + * @template Attributes The type of the model's attributes. + * @param {ICtor} Base The base class to extend with the concern. + * @returns {ICtor} A class that extends the base class with the concern. + */ const HasAttributesConcern = (Base: ICtor) => { return class HasAttributes extends Base implements IHasAttributes { diff --git a/src/core/concerns/HasCastableConcern.ts b/src/core/concerns/HasCastableConcern.ts new file mode 100644 index 000000000..b357a318e --- /dev/null +++ b/src/core/concerns/HasCastableConcern.ts @@ -0,0 +1,297 @@ +import CastException from "@src/core/exceptions/CastException"; +import { IHasCastableConcern, TCastableType, TCasts } from "@src/core/interfaces/concerns/IHasCastableConcern"; +import { ICtor } from "@src/core/interfaces/ICtor"; + +const HasCastableConcernMixin = (Base: ICtor) => { + return class HasCastableConcern extends Base implements IHasCastableConcern { + + casts: TCasts = {}; + + /** + * Casts each property of the given data object according to the types specified in the casts record. + * @template ReturnType The return type of the casted object + * @param {Record} data - The object containing data to be casted + * @returns {ReturnType} The object with its properties casted to the specified types + */ + getCastFromObject(data: Record, casts: TCasts = this.casts ): ReturnType { + for(const [key, type] of Object.entries(casts)) { + if (key in data) { + data[key] = this.getCast(data[key], type); + } + } + + return data as ReturnType; + } + + /** + * Casts the given data to the specified type. + * @template T The type to cast to + * @param {unknown} data - The data to cast + * @param {TCastableType} type - The target type for casting + * @returns {T} The casted data + * @throws {CastException} If the cast operation fails or is invalid + */ + getCast(data: unknown, type: TCastableType): T { + if (!this.isValidType(type)) { + throw new CastException(`Invalid cast type: ${type}`); + } + + if (data === null || data === undefined) { + if (type === 'null') return null as T; + if (type === 'undefined') return undefined as T; + throw new CastException(`Cannot cast null/undefined to ${type}`); + } + + try { + switch (type) { + case 'string': return this.castString(data); + case 'number': return this.castNumber(data); + case 'boolean': return this.castBoolean(data); + case 'array': return this.castArray(data); + case 'object': return this.castObject(data); + case 'date': return this.castDate(data); + case 'integer': return this.castInteger(data); + case 'float': return this.castFloat(data); + case 'bigint': return this.castBigInt(data); + case 'map': return this.castMap(data); + case 'set': return this.castSet(data); + case 'symbol': return Symbol(String(data)) as unknown as T; + default: + throw new CastException(`Unsupported cast type: ${type}`); + } + } + catch (error) { + throw new CastException(`Cast failed: ${(error as Error).message}`); + } + } + + /** + * Validates if the given type is a supported castable type + * @param {TCastableType} type - The type to validate + * @returns {boolean} True if the type is valid, false otherwise + */ + isValidType(type: TCastableType): boolean { + const validTypes: TCastableType[] = [ + 'string', 'number', 'boolean', 'array', 'object', 'date', + 'integer', 'float', 'bigint', 'null', 'undefined', 'symbol', + 'map', 'set' + ]; + return validTypes.includes(type); + } + + /** + * Validates if a string represents a valid date + * @param {string} date - The date string to validate + * @returns {boolean} True if the string is a valid date, false otherwise + * @private + */ + private isValidDate(date: string): boolean { + const timestamp = Date.parse(date); + return !isNaN(timestamp); + } + + /** + * Casts data to a string + * @template T The return type + * @param {unknown} data - The data to cast + * @returns {T} The data as a string + * @throws {CastException} If the cast operation fails + * @private + */ + private castString(data: unknown): T { + if (data instanceof Date) { + return data.toISOString() as unknown as T; + } + if (typeof data === 'object') { + return JSON.stringify(data) as unknown as T; + } + return String(data) as unknown as T; + } + + /** + * Casts data to a number + * @template T The return type + * @param {unknown} data - The data to cast + * @returns {T} The data as a number + * @throws {CastException} If the data cannot be converted to a number + * @private + */ + private castNumber(data: unknown): T { + if (typeof data === 'string') { + const num = Number(data); + if (isNaN(num)) throw new CastException('Invalid number string'); + return num as unknown as T; + } + if (data instanceof Date) { + return data.getTime() as unknown as T; + } + return Number(data) as unknown as T; + } + + /** + * Casts data to a boolean + * @template T The return type + * @param {unknown} data - The data to cast + * @returns {T} The data as a boolean + * @throws {CastException} If the data cannot be converted to a boolean + * @private + */ + private castBoolean(data: unknown): T { + if (typeof data === 'string') { + const lowercased = data.toLowerCase(); + if (['true', '1', 'yes'].includes(lowercased)) return true as unknown as T; + if (['false', '0', 'no'].includes(lowercased)) return false as unknown as T; + throw new CastException('Invalid boolean string'); + } + return Boolean(data) as unknown as T; + } + + /** + * Casts data to an array + * @template T The return type + * @param {unknown} data - The data to cast + * @returns {T} The data as an array + * @throws {CastException} If the data cannot be converted to an array + * @private + */ + private castArray(data: unknown): T { + if (typeof data === 'string') { + try { + return JSON.parse(data) as unknown as T; + } + catch { + return [data] as unknown as T; + } + } + if (data instanceof Set || data instanceof Map) { + return Array.from(data) as unknown as T; + } + if (Array.isArray(data)) return data as T; + return [data] as unknown as T; + } + + /** + * Casts data to an object + * @template T The return type + * @param {unknown} data - The data to cast + * @returns {T} The data as an object + * @throws {CastException} If the data cannot be converted to an object + * @private + */ + private castObject(data: unknown): T { + if (typeof data === 'string') { + try { + return JSON.parse(data) as T; + } + catch (error) { + throw new CastException('Invalid JSON string for object conversion'); + } + } + if (Array.isArray(data) || data instanceof Set || data instanceof Map) { + return Object.fromEntries( + Array.from(data).map((val, idx) => [idx, val]) + ) as unknown as T; + } + return Object(data) as T; + } + + /** + * Casts data to a Date object + * @template T The return type + * @param {unknown} data - The data to cast + * @returns {T} The data as a Date object + * @throws {CastException} If the data cannot be converted to a Date + * @private + */ + private castDate(data: unknown): T { + if (data instanceof Date) return data as T; + if (typeof data === 'number') { + return new Date(data) as unknown as T; + } + if (typeof data === 'string' && this.isValidDate(data)) { + return new Date(data) as unknown as T; + } + throw new CastException('Invalid date format'); + } + + /** + * Casts data to an integer + * @template T The return type + * @param {unknown} data - The data to cast + * @returns {T} The data as an integer + * @throws {CastException} If the data cannot be converted to an integer + * @private + */ + private castInteger(data: unknown): T { + const int = parseInt(String(data), 10); + if (isNaN(int)) throw new CastException('Invalid integer'); + return int as unknown as T; + } + + /** + * Casts data to a float + * @template T The return type + * @param {unknown} data - The data to cast + * @returns {T} The data as a float + * @throws {CastException} If the data cannot be converted to a float + * @private + */ + private castFloat(data: unknown): T { + const float = parseFloat(String(data)); + if (isNaN(float)) throw new CastException('Invalid float'); + return float as unknown as T; + } + + /** + * Casts data to a BigInt + * @template T The return type + * @param {unknown} data - The data to cast + * @returns {T} The data as a BigInt + * @throws {CastException} If the data cannot be converted to a BigInt + * @private + */ + private castBigInt(data: unknown): T { + if (typeof data === 'string' || typeof data === 'number') { + try { + return BigInt(data) as unknown as T; + } + catch { + throw new CastException('Cannot convert to BigInt'); + } + } + throw new CastException('Cannot convert to BigInt'); + } + + /** + * Casts data to a Map + * @template T The return type + * @param {unknown} data - The data to cast + * @returns {T} The data as a Map + * @throws {CastException} If the data cannot be converted to a Map + * @private + */ + private castMap(data: unknown): T { + if (data instanceof Map) return data as T; + throw new CastException('Cannot convert to Map'); + } + + /** + * Casts data to a Set + * @template T The return type + * @param {unknown} data - The data to cast + * @returns {T} The data as a Set + * @throws {CastException} If the data cannot be converted to a Set + * @private + */ + private castSet(data: unknown): T { + if (data instanceof Set) return data as T; + if (Array.isArray(data)) { + return new Set(data) as unknown as T; + } + return new Set([data]) as unknown as T; + } + + } +} + +export default HasCastableConcernMixin; \ No newline at end of file diff --git a/src/core/concerns/HasDatabaseConnectionConcern.ts b/src/core/concerns/HasDatabaseConnectionConcern.ts index 8181def9d..935604de3 100644 --- a/src/core/concerns/HasDatabaseConnectionConcern.ts +++ b/src/core/concerns/HasDatabaseConnectionConcern.ts @@ -4,6 +4,21 @@ import { IHasDatabaseConnection } from "@src/core/interfaces/concerns/IHasDataba import { ICtor } from "@src/core/interfaces/ICtor"; import { App } from "@src/core/services/App"; +/** + * A concern that provides database connection capabilities to a class. + * + * Automatically injects the `connection` and `table` properties, as well as + * the `getDocumentManager` and `getSchema` methods. + * + * To use this concern, simply call it in the class definition, like so: + * + * class MyModel extends HasDatabaseConnection(Base) { + * + * } + * + * @param Base The class to extend. + * @returns A class that extends Base and implements IHasDatabaseConnection. + */ const HasDatabaseConnectionConcern = (Base: ICtor) => { return class HasDatabaseConnection extends Base implements IHasDatabaseConnection { diff --git a/src/core/concerns/HasObserverConcern.ts b/src/core/concerns/HasObserverConcern.ts index 95aafac3d..2defb1cfc 100644 --- a/src/core/concerns/HasObserverConcern.ts +++ b/src/core/concerns/HasObserverConcern.ts @@ -6,6 +6,22 @@ import SetAttributeBroadcastEvent from "@src/core/events/concerns/HasAttribute/S import { ICtor } from "@src/core/interfaces/ICtor"; import IModelAttributes from "@src/core/interfaces/IModelData"; +/** + * Attaches an observer to a model. + * + * The observer is an instance of a class that implements the IObserver interface. + * The observer is responsible for handling events that are broadcasted from the model. + * The observer can also be used to handle custom events that are not part of the predefined set. + * + * The HasObserverConcern adds the following methods to the model: + * - onAttributeChange: Called when a HasAttributeBroadcastEvent is triggered from a model with the HasAttributes concern. + * - observeWith: Attatch the Observer to this instance. + * - observeData: Data has changed, pass it through the appropriate method, return the data. + * - observeDataCustom: A custom observer method. + * + * @param Broadcaster The class that implements the IBroadcaster interface. This class is responsible for broadcasting events to the observer. + * @returns A class that extends the Broadcaster class and implements the IHasObserver interface. + */ const HasObserverConcern = (Broadcaster: ICtor) => { return class HasObserver extends Broadcaster implements IHasObserver { diff --git a/src/core/concerns/HasPrepareDocumentConcern.ts b/src/core/concerns/HasPrepareDocumentConcern.ts index bca686eac..ce7a66314 100644 --- a/src/core/concerns/HasPrepareDocumentConcern.ts +++ b/src/core/concerns/HasPrepareDocumentConcern.ts @@ -1,6 +1,19 @@ import { IHasPrepareDocument } from "@src/core/interfaces/concerns/IHasPrepareDocument"; import { ICtor } from "@src/core/interfaces/ICtor"; +/** + * Concern providing a method to prepare a document for saving to the database. + * + * Automatically stringifies any fields specified in the `json` property. + * + * @example + * class MyModel extends BaseModel { + * json = ['options']; + * } + * + * @template T The type of the prepared document. + * @returns {T} The prepared document. + */ const HasPrepareDocumentConcern = (Base: ICtor) => { return class HasPrepareDocument extends Base implements IHasPrepareDocument { diff --git a/src/core/concerns/HasRegisterableConcern.ts b/src/core/concerns/HasRegisterableConcern.ts new file mode 100644 index 000000000..d2ec239b6 --- /dev/null +++ b/src/core/concerns/HasRegisterableConcern.ts @@ -0,0 +1,126 @@ +import { IHasRegisterableConcern, IRegsiterList, TRegisterMap } from "@src/core/interfaces/concerns/IHasRegisterableConcern"; +import { ICtor } from "@src/core/interfaces/ICtor"; + +/** + * Concern that allows a class to register arbitrary values by key. + * This allows for decoupling of the registration of values from the class itself. + * The registered values can then be retrieved by key. + * + * If a list name is provided, the registered values are stored in a map with that name. + * If not, the default list 'default' is used. + * The registered values can then be retrieved by list name. + * + * @example + * class MyClass extends HasRegisterableConcern(MyBaseClass) { + * constructor() { + * super(); + * this.register('myKey', 'myValue'); + * } + * } + * const myInstance = new MyClass(); + * myInstance.getRegisteredObject()['default'].get('myKey'); // returns 'myValue' + * + * @param {ICtor} Broadcaster The base class to extend. + * @returns {ICtor} The class that extends the passed in class. + */ +const HasRegisterableConcern = (Broadcaster: ICtor) => { + return class HasRegisterable extends Broadcaster implements IHasRegisterableConcern { + + protected registerObject: IRegsiterList = {} + + private static defaultList = 'default'; + + /** + * Registers a key-value pair in the default list. + * If the default list does not exist, it initializes it as a new Map. + * + * @param {string} key - The key to register the value under. + * @param {...unknown[]} args - The values to be associated with the key. + * @returns {void} + */ + register(key: string, ...args: unknown[]): void { + if(this.isRegisteredInList(HasRegisterable.defaultList, key)) { + return; + } + + if(!this.registerObject[HasRegisterable.defaultList]) { + this.registerObject[HasRegisterable.defaultList] = new Map(); + } + + this.registerObject[HasRegisterable.defaultList].set(key, args); + } + + /** + * Registers a key-value pair in the list with the given name. + * If the list does not exist, it initializes it as a new Map. + * + * @param {string} listName - The name of the list to register the value in. + * @param {string} key - The key to register the value under. + * @param {...unknown[]} args - The values to be associated with the key. + * @returns {void} + */ + registerByList(listName: string, key: string, ...args: unknown[]): void { + if(this.isRegisteredInList(listName, key)) { + return; + } + + this.registerObject[listName] = this.registerObject[listName] ?? new Map(); + this.registerObject[listName].set(key, args); + } + + /** + * Sets the registered values for the given list name. + * If the list does not exist, it initializes it as a new Map. + * @param {string} listName - The name of the list to set the values for. + * @param {Map} registered - The values to be associated with the list. + * @returns {void} + */ + setRegisteredByList(listName: string, registered: Map): void { + this.registerObject[listName] = registered + } + + /** + * Retrieves the entire register object containing all registered lists and their key-value pairs. + * + * @returns {IRegsiterList} The complete register object. + */ + getRegisteredObject(): IRegsiterList { + return this.registerObject; + } + + /** + * Retrieves the registered values from the default list. + * + * @returns {TRegisterMap} A map of key-value pairs from the default list. + */ + getRegisteredList(): T { + return this.getRegisteredByList(HasRegisterable.defaultList) + } + + /** + * Retrieves the registered values for a specific list. + * If the list does not exist, returns an empty map. + * + * @template T Type of the register map. + * @param {string} listName - The name of the list to retrieve values from. + * @returns {T} A map of key-value pairs associated with the specified list. + */ + getRegisteredByList(listName: string): T { + return this.registerObject[listName] as T ?? new Map(); + } + + /** + * Checks if a key is registered in a specific list. + * @private + * @param {string} listName - The name of the list to check for the key. + * @param {string} key - The key to check for in the list. + * @returns {boolean} True if the key is registered in the list, false otherwise. + */ + isRegisteredInList(listName: string, key: string): boolean { + return this.getRegisteredByList(listName).has(key) + } + + } +} + +export default HasRegisterableConcern \ No newline at end of file diff --git a/src/core/domains/broadcast/abstract/BroadcastEvent.ts b/src/core/domains/broadcast/abstract/BroadcastEvent.ts index cc89ffdbb..93cf66ef7 100644 --- a/src/core/domains/broadcast/abstract/BroadcastEvent.ts +++ b/src/core/domains/broadcast/abstract/BroadcastEvent.ts @@ -18,7 +18,7 @@ abstract class BroadcastEvent implements IBroadcastEvent { * * @returns The name of the event. */ - abstract getEventName(): string; + abstract getName(): string; /** * Returns the payload of the event. diff --git a/src/core/domains/broadcast/abstract/Broadcaster.ts b/src/core/domains/broadcast/abstract/Broadcaster.ts index e432f6043..1a04035c0 100644 --- a/src/core/domains/broadcast/abstract/Broadcaster.ts +++ b/src/core/domains/broadcast/abstract/Broadcaster.ts @@ -19,7 +19,7 @@ abstract class Broadcaster implements IBroadcaster { * @param args The arguments to pass to the listeners. */ async broadcast(event: IBroadcastEvent): Promise { - const eventName = event.getEventName() + const eventName = event.getName() if(!this.broadcastListeners.has(eventName)) { this.createBroadcastListener(eventName) diff --git a/src/core/domains/broadcast/interfaces/IBroadcastEvent.ts b/src/core/domains/broadcast/interfaces/IBroadcastEvent.ts index 2a165ed13..5f94b3864 100644 --- a/src/core/domains/broadcast/interfaces/IBroadcastEvent.ts +++ b/src/core/domains/broadcast/interfaces/IBroadcastEvent.ts @@ -1,6 +1,6 @@ export interface IBroadcastEvent { - getEventName(): string; + getName(): string; getPayload(): T; } \ No newline at end of file diff --git a/src/core/domains/console/commands/WorkerCommand.ts b/src/core/domains/console/commands/WorkerCommand.ts deleted file mode 100644 index febf1b1d7..000000000 --- a/src/core/domains/console/commands/WorkerCommand.ts +++ /dev/null @@ -1,54 +0,0 @@ -import BaseCommand from "@src/core/domains/console/base/BaseCommand"; -import Worker from "@src/core/domains/events/services/Worker"; -import { App } from "@src/core/services/App"; - -export default class WorkerCommand extends BaseCommand { - - /** - * The signature of the command - */ - signature: string = 'worker'; - - description = 'Run the worker to process queued event items'; - - /** - * Whether to keep the process alive after command execution - */ - public keepProcessAlive = true; - - /** - * Execute the command - */ - - async execute() { - const driver = this.getDriverName(); - const worker = Worker.getInstance() - worker.setDriver(driver) - - App.container('logger').console('Running worker...', worker.options) - - await worker.work(); - - if (worker.options.runOnce) { - return; - } - - setInterval(async () => { - await worker.work() - App.container('logger').console('Running worker again in ' + worker.options.runAfterSeconds.toString() + ' seconds') - }, worker.options.runAfterSeconds * 1000) - } - - /** - * Get the driver name based on the environment - */ - - getDriverName() { - if (App.env() === 'testing') { - return 'testing'; - } - - return process.env.APP_WORKER_DRIVER ?? 'queue'; - } - -} \ No newline at end of file diff --git a/src/core/domains/database/base/BaseQueryBuilder.ts b/src/core/domains/database/base/BaseQueryBuilder.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/core/domains/events/base/BaseDriver.ts b/src/core/domains/events/base/BaseDriver.ts new file mode 100644 index 000000000..fb7ca61ec --- /dev/null +++ b/src/core/domains/events/base/BaseDriver.ts @@ -0,0 +1,38 @@ +/* eslint-disable no-unused-vars */ +import { IBaseEvent } from "@src/core/domains/events/interfaces/IBaseEvent"; +import IEventDriver from "@src/core/domains/events/interfaces/IEventDriver"; +import { IEventService } from "@src/core/domains/events/interfaces/IEventService"; +import { IEventDriversConfigOption } from "@src/core/domains/events/interfaces/config/IEventDriversConfig"; + + +abstract class BaseDriver implements IEventDriver { + + protected eventService!: IEventService; + + constructor(eventService: IEventService) { + this.eventService = eventService + } + + /** + * @returns The name of the event driver. + */ + getName(): string { + return this.constructor.name + } + + /** + * Dispatches an event. + * @param event + */ + abstract dispatch(event: IBaseEvent): Promise; + + /** + * @returns The configuration options for this event driver, or undefined if not found. + */ + protected getOptions(): T | undefined { + return this.eventService.getDriverOptions(this)?.options as T ?? undefined + } + +} + +export default BaseDriver \ No newline at end of file diff --git a/src/core/domains/events/base/BaseEvent.ts b/src/core/domains/events/base/BaseEvent.ts new file mode 100644 index 000000000..c03190ba3 --- /dev/null +++ b/src/core/domains/events/base/BaseEvent.ts @@ -0,0 +1,123 @@ +import BaseCastable from "@src/core/base/BaseCastable"; +import EventInvalidPayloadException from "@src/core/domains/events/exceptions/EventInvalidPayloadException"; +import { IBaseEvent } from "@src/core/domains/events/interfaces/IBaseEvent"; +import IEventDriver from "@src/core/domains/events/interfaces/IEventDriver"; +import { IEventService } from "@src/core/domains/events/interfaces/IEventService"; +import { TCastableType, TCasts } from "@src/core/interfaces/concerns/IHasCastableConcern"; +import { ICtor } from "@src/core/interfaces/ICtor"; +import { App } from "@src/core/services/App"; + +abstract class BaseEvent extends BaseCastable implements IBaseEvent { + + protected payload: TPayload | null = null; + + protected driver!: ICtor; + + protected defaultDriver!: ICtor; + + protected namespace: string = ''; + + casts: TCasts = {}; + + /** + * Constructor + * @param payload The payload of the event + * @param driver The class of the event driver + */ + constructor(payload: TPayload | null = null, driver?: ICtor) { + super() + this.payload = payload; + + // Use safeContainer here to avoid errors during registering which runs during boot up. + this.defaultDriver = App.safeContainer('events')?.getDefaultDriverCtor() as ICtor; + this.driver = driver ?? this.defaultDriver; + + // Ensure the payload is valid + if(!this.validatePayload()) { + throw new EventInvalidPayloadException('Invalid payload. Must be JSON serializable.'); + } + } + + /** + * Declare HasCastableConcern methods. + */ + // eslint-disable-next-line no-unused-vars + declare getCastFromObject: (data: Record, casts: TCasts) => ReturnType; + + // eslint-disable-next-line no-unused-vars + declare getCast: (data: unknown, type: TCastableType) => T; + + // eslint-disable-next-line no-unused-vars + declare isValidType: (type: TCastableType) => boolean; + + /** + * Executes the event. + */ + async execute(): Promise {/* Nothing to execute */} + + /** + * Validates the payload of the event. Ensures that the payload is an object with types that match: + * string, number, boolean, object, array, null. + * @throws {EventInvalidPayloadException} If the payload is invalid. + */ + validatePayload(): boolean { + try { + JSON.stringify(this.payload); + } + // eslint-disable-next-line no-unused-vars + catch (err) { + return false + } + + return true + } + + /** + * Gets the event service that handles event dispatching and listener registration. + * @returns The event service. + */ + getEventService(): IEventService { + return App.container('events'); + } + + /** + * @returns The name of the queue as a string. + */ + getQueueName(): string { + return 'default'; + } + + /** + * @template T The type of the payload to return. + * @returns The payload of the event. + */ + getPayload(): T { + return this.getCastFromObject(this.payload as Record, this.casts) + } + + /** + * Sets the payload of the event. + * @param payload The payload of the event to set. + */ + setPayload(payload: TPayload): void { + this.payload = payload + } + + /** + * @returns The name of the event as a string. + */ + getName(): string { + const prefix = this.namespace === '' ? '' : (this.namespace + '/') + return prefix + this.constructor.name + } + + /** + * @returns The event driver constructor. + */ + getDriverCtor(): ICtor { + return this.driver ?? this.defaultDriver; + } + +} + +export default BaseEvent \ No newline at end of file diff --git a/src/core/domains/events/base/BaseEventListener.ts b/src/core/domains/events/base/BaseEventListener.ts new file mode 100644 index 000000000..c63511d46 --- /dev/null +++ b/src/core/domains/events/base/BaseEventListener.ts @@ -0,0 +1,27 @@ +import BaseEvent from "@src/core/domains/events/base/BaseEvent"; +import IEventDriver from "@src/core/domains/events/interfaces/IEventDriver"; +import { IEventListener } from "@src/core/domains/events/interfaces/IEventListener"; +import { ICtor } from "@src/core/interfaces/ICtor"; +import { App } from "@src/core/services/App"; + +class BaseEventListener extends BaseEvent implements IEventListener { + + /** + * Constructor + * + * Creates a new instance of the event listener and dispatches the event to + * all subscribers. + * + * @param payload The payload of the event to dispatch + */ + constructor(payload?: TPayload, driver?: ICtor) { + super(payload, driver); + + if(!App.containerReady('events')) { + return; + } + } + +} + +export default BaseEventListener \ No newline at end of file diff --git a/src/core/domains/events/base/BaseService.ts b/src/core/domains/events/base/BaseService.ts new file mode 100644 index 000000000..5faf387f1 --- /dev/null +++ b/src/core/domains/events/base/BaseService.ts @@ -0,0 +1,14 @@ +import HasRegisterableConcern from "@src/core/concerns/HasRegisterableConcern"; +import EventMockableConcern from "@src/core/domains/events/concerns/EventMockableConcern"; +import EventWorkerConcern from "@src/core/domains/events/concerns/EventWorkerConcern"; +import { ICtor } from "@src/core/interfaces/ICtor"; +import compose from "@src/core/util/compose"; + +const BaseService: ICtor = compose( + class {}, + HasRegisterableConcern, + EventWorkerConcern, + EventMockableConcern, +); + +export default BaseService \ No newline at end of file diff --git a/src/core/domains/events/commands/WorkerCommand.ts b/src/core/domains/events/commands/WorkerCommand.ts new file mode 100644 index 000000000..c4f2caae5 --- /dev/null +++ b/src/core/domains/events/commands/WorkerCommand.ts @@ -0,0 +1,87 @@ +import { EVENT_DRIVERS } from "@src/config/events"; +import BaseCommand from "@src/core/domains/console/base/BaseCommand"; +import { IEventDriversConfigOption } from "@src/core/domains/events/interfaces/config/IEventDriversConfig"; +import { IEventService } from "@src/core/domains/events/interfaces/IEventService"; +import { TEventWorkerOptions } from "@src/core/domains/events/interfaces/IEventWorkerConcern"; +import { App } from "@src/core/services/App"; +import { z } from "zod"; + +export default class WorkerCommand extends BaseCommand { + + /** + * The signature of the command + */ + signature: string = 'worker'; + + description = 'Run the worker to process queued event items. --queue=[queue]'; + + /** + * Whether to keep the process alive after command execution + */ + public keepProcessAlive = true; + + protected eventService: IEventService = App.container('events'); + + /** + * Execute the command + */ + + async execute() { + const options = this.getWorkerOptions(); + + await this.eventService.runWorker(options); + + const intervalId = setInterval(async () => { + await this.eventService.runWorker(options); + App.container('logger').console('Running worker again in '+ options.runAfterSeconds +' seconds') + }, options.runAfterSeconds * 1000) + + if(options.runOnce) { + clearInterval(intervalId); + App.container('logger').console('runOnce enabled. Quitting...'); + } + } + + /** + * Gets the worker options from the CLI arguments or the default value. + * @returns The worker options. + */ + private getWorkerOptions(): TEventWorkerOptions { + const driverName = this.getArguementByKey('driver')?.value ?? EVENT_DRIVERS.QUEABLE; + const queueName = this.getArguementByKey('queue')?.value ?? 'default'; + + const options = this.eventService.getDriverOptionsByName(driverName)?.options; + + this.validateOptions(driverName, options); + + return { ...options, queueName } as TEventWorkerOptions; + } + + /** + * Validates the options for the worker + * @param driverName The name of the driver + * @param options The options to validate + * @throws {Error} If the options are invalid + * @private + */ + private validateOptions(driverName: string, options: IEventDriversConfigOption['options'] | undefined) { + if(!options) { + throw new Error('Could not find options for driver: '+ driverName); + } + + const schema = z.object({ + retries: z.number(), + runAfterSeconds: z.number(), + runOnce: z.boolean().optional(), + workerModelCtor: z.any(), + failedWorkerModelCtor: z.any(), + }) + + const parsedResult = schema.safeParse(options) + + if(!parsedResult.success) { + throw new Error('Invalid worker options: '+ parsedResult.error.message); + } + } + +} \ No newline at end of file diff --git a/src/core/domains/events/concerns/EventMockableConcern.ts b/src/core/domains/events/concerns/EventMockableConcern.ts new file mode 100644 index 000000000..0d5f8f01e --- /dev/null +++ b/src/core/domains/events/concerns/EventMockableConcern.ts @@ -0,0 +1,95 @@ +import EventMockException from "@src/core/domains/events/exceptions/EventMockException"; +import { IBaseEvent } from "@src/core/domains/events/interfaces/IBaseEvent"; +import { IMockableConcern, TMockableEventCallback } from "@src/core/domains/events/interfaces/IMockableConcern"; +import { ICtor } from "@src/core/interfaces/ICtor"; + + +const EventMockableConcern = (Base: ICtor) => { + return class EventMockable extends Base implements IMockableConcern { + + /** Array of events to mock */ + mockEvents: ICtor[] = []; + + /** Array of events that have been dispatched */ + mockEventsDispatched: IBaseEvent[] = []; + + /** + * Mocks an event to be dispatched. + * + * The mocked event will be added to the {@link mockEvents} array. + * When the event is dispatched, the {@link mockEventDispatched} method + * will be called and the event will be added to the {@link mockEventsDispatched} array. + * + * @param event The event to mock. + */ + mockEvent(event: ICtor): void { + this.mockEvents.push(event) + this.removeMockEventDispatched(event) + } + + /** + * Removes the given event from the {@link mockEvents} array. + * + * @param event - The event to remove from the {@link mockEvents} array. + */ + removeMockEvent(event: ICtor): void { + this.mockEvents = this.mockEvents.filter(e => (new e).getName() !== (new event).getName()) + } + + /** + * This method is called when an event is dispatched. It will check if the event + * has been mocked with the {@link mockEvent} method. If it has, the event will be + * added to the {@link mockEventsDispatched} array. + * + * @param event - The event that was dispatched. + */ + mockEventDispatched(event: IBaseEvent): void { + + const shouldMock = this.mockEvents.find(eCtor => (new eCtor(null)).getName() === event.getName()) + + if(!shouldMock) { + return + } + + this.mockEventsDispatched.push(event) + } + + /** + * Removes all events from the {@link mockEventsDispatched} array that match the given event constructor. + * + * @param event - The event to remove from the {@link mockEventsDispatched} array. + */ + removeMockEventDispatched(event: ICtor): void { + this.mockEventsDispatched = this.mockEventsDispatched.filter(e => e.getName() !== (new event).getName()) + } + + + /** + * Asserts that a specific event has been dispatched and that its payload satisfies the given condition. + * + * @param eventCtor - The event to check for dispatch. + * @param callback - A function that takes the event payload and returns a boolean indicating + * whether the payload satisfies the condition. + * + * @throws Will throw an error if the event was not dispatched or if the dispatched event's + * payload does not satisfy the given condition. + */ + assertDispatched(eventCtor: ICtor, callback?: TMockableEventCallback): boolean { + const eventCtorName = (new eventCtor(null)).getName() + const dispatchedEvent = this.mockEventsDispatched.find(e => e.getName() === eventCtorName) + + if(!dispatchedEvent) { + throw new EventMockException(`Event ${eventCtorName} was not dispatched`) + } + + if(typeof callback !== 'function') { + return true; + } + + return callback(dispatchedEvent.getPayload()) + } + + } +} + +export default EventMockableConcern \ No newline at end of file diff --git a/src/core/domains/events/concerns/EventWorkerConcern.ts b/src/core/domains/events/concerns/EventWorkerConcern.ts new file mode 100644 index 000000000..53c8f5f40 --- /dev/null +++ b/src/core/domains/events/concerns/EventWorkerConcern.ts @@ -0,0 +1,132 @@ +import Repository from "@src/core/base/Repository"; +import EventWorkerException from "@src/core/domains/events/exceptions/EventWorkerException"; +import { TISerializablePayload } from "@src/core/domains/events/interfaces/IEventPayload"; +import { IEventWorkerConcern, IWorkerModel, TEventWorkerOptions } from "@src/core/domains/events/interfaces/IEventWorkerConcern"; +import { ICtor } from "@src/core/interfaces/ICtor"; +import { App } from "@src/core/services/App"; + +const EventWorkerConcern = (Base: ICtor) => { + return class EventWorkerConcern extends Base implements IEventWorkerConcern { + + /** + * Run the worker to process queued event items + * + * Fetches documents from the worker model repository and runs each + * document through the handleWorkerModel method. This method is + * responsible for processing the event contained in the document. + * + * @param options The options to use when running the worker + * @returns A promise that resolves once the worker has finished + * processing the documents. + */ + async runWorker(options: TEventWorkerOptions): Promise { + + const workerModels = await this.fetchWorkerModelDocuments(options) + + App.container('logger').console('Queued items: ', workerModels.length) + + if(workerModels.length === 0) { + App.container('logger').console("No queued items"); + return; + } + + for(const workerModel of workerModels) { + await this.handleWorkerModel(workerModel, options) + } + } + + /** + * Handles a single worker model document + * @param workerModel The worker model document to process + * @param options The options to use when processing the event + * @private + */ + private async handleWorkerModel(workerModel: IWorkerModel, options: TEventWorkerOptions): Promise { + try { + const eventName = workerModel.getAttribute('eventName'); + + if(typeof eventName !== 'string') { + throw new EventWorkerException('Event name must be a string'); + } + + const eventCtor = App.container('events').getEventCtorByName(eventName) + + if(!eventCtor) { + throw new EventWorkerException(`Event '${eventName}' not found`); + } + + const payload = workerModel.getPayload() + + const eventInstance = new eventCtor(payload); + await eventInstance.execute(); + + await workerModel.delete(); + } + catch (err) { + App.container('logger').error(err) + await this.handleUpdateWorkerModelAttempts(workerModel, options) + } + } + + /** + * Handles updating the worker model document with the number of attempts + * it has made to process the event. + * @param workerModel The worker model document to update + * @param options The options to use when updating the worker model document + * @private + */ + private async handleUpdateWorkerModelAttempts(workerModel: IWorkerModel, options: TEventWorkerOptions) { + + const attempt = workerModel.getAttribute('attempt') ?? 0 + const newAttempt = attempt + 1 + const retries = workerModel.getAttribute('retries') ?? 0 + + await workerModel.attr('attempt', newAttempt) + + if(newAttempt >= retries) { + await this.handleFailedWorkerModel(workerModel, options) + return; + } + + await workerModel.save(); + } + + /** + * Handles a worker model that has failed to process. + * + * Saves a new instance of the failed worker model to the database + * and deletes the original worker model document. + * + * @param workerModel The worker model document to handle + * @param options The options to use when handling the failed worker model + * @private + */ + private async handleFailedWorkerModel(workerModel: IWorkerModel, options: TEventWorkerOptions) { + const FailedWorkerModel = new options.failedWorkerModelCtor({ + eventName: workerModel.getAttribute('eventName'), + queueName: workerModel.getAttribute('queueName'), + payload: workerModel.getAttribute('payload') ?? '{}', + error: '', + failedAt: new Date() + }) + await FailedWorkerModel.save(); + await workerModel.delete(); + } + + /** + * Fetches worker model documents + */ + private async fetchWorkerModelDocuments(options: TEventWorkerOptions): Promise { + return await new Repository(options.workerModelCtor).findMany({ + queueName: options.queueName, + }, { + sort: { + createdAt: 'asc' + } + }) + } + + } +} + +export default EventWorkerConcern \ No newline at end of file diff --git a/src/core/domains/events/drivers/QueableDriver.ts b/src/core/domains/events/drivers/QueableDriver.ts new file mode 100644 index 000000000..0118aae87 --- /dev/null +++ b/src/core/domains/events/drivers/QueableDriver.ts @@ -0,0 +1,120 @@ +import BaseDriver from "@src/core/domains/events/base/BaseDriver"; +import EventDriverException from "@src/core/domains/events/exceptions/EventDriverException"; +import { IBaseEvent } from "@src/core/domains/events/interfaces/IBaseEvent"; +import { IWorkerModel, TFailedWorkerModelData } from "@src/core/domains/events/interfaces/IEventWorkerConcern"; +import { ICtor } from "@src/core/interfaces/ICtor"; +import { IModel } from "@src/core/interfaces/IModel"; +import { z } from "zod"; + + +export type TQueueDriverOptions = { + + /** + * Name of the queue + */ + queueName: string; + + /** + * Name of the event, defaults to the IEvent.name + */ + eventName?: string; + + /** + * Number of retry attempts for failed events + */ + retries: number; + + /** + * Delay before processing queued events + */ + runAfterSeconds: number; + + /** + * Constructor for the Worker model + */ + workerModelCtor: ICtor; + + /** + * Constructor for the Worker model for failed events + */ + failedWorkerModelCtor: ICtor>; + + /** + * Run the worker only once, defaults to false + */ + runOnce?: boolean; +} + +class QueueableDriver extends BaseDriver { + + /** + * Dispatches an event by saving it to the worker model. + * + * First, it retrieves the options for the queue driver using the getOptions method. + * Then, it validates the options using the validateOptions method. + * If the options are invalid, it throws an EventDriverException. + * Finally, it creates a new instance of the worker model using the options.workerModelCtor, + * and saves it to the database. + * + * @param event The event to dispatch. + * @throws {EventDriverException} If the options are invalid. + * @returns A promise that resolves once the event has been dispatched. + */ + async dispatch(event: IBaseEvent): Promise { + + const options = this.getOptions() + + this.validateOptions(options) + + await this.updateWorkerQueueTable(options as TQueueDriverOptions, event) + } + + /** + * Updates the worker queue table with the given event. + * + * It creates a new instance of the worker model using the options.workerModelCtor, + * and saves it to the database. This method is used by the dispatch method to + * save the event to the worker queue table. + * + * @param options The options to use when updating the worker queue table. + * @param event The event to update the worker queue table with. + * @returns A promise that resolves once the worker queue table has been updated. + * @throws {EventDriverException} If the options are invalid. + */ + private async updateWorkerQueueTable(options: TQueueDriverOptions, event: IBaseEvent) { + const workerModel = new options.workerModelCtor({ + queueName: event.getQueueName(), + eventName: event.getName(), + retries: options.retries, + payload: JSON.stringify(event.getPayload() ?? {}), + }) + await workerModel.save(); + } + + /** + * Validates the options for the queue driver + * @param options The options to validate + * @throws {EventDriverException} If the options are invalid + * @private + */ + private validateOptions(options: TQueueDriverOptions | undefined) { + const schema = z.object({ + queueName: z.string(), + eventName: z.string().optional(), + retries: z.number(), + runAfterSeconds: z.number(), + workerModelCtor: z.any(), + failedWorkerModelCtor: z.any(), + runOnce: z.boolean().optional() + }) + + const parsedResult = schema.safeParse(options) + + if(!parsedResult.success) { + throw new EventDriverException('Invalid queue driver options: '+ parsedResult.error.message); + } + } + +} + +export default QueueableDriver \ No newline at end of file diff --git a/src/core/domains/events/drivers/QueueDriver.ts b/src/core/domains/events/drivers/QueueDriver.ts deleted file mode 100644 index cea6d22f3..000000000 --- a/src/core/domains/events/drivers/QueueDriver.ts +++ /dev/null @@ -1,71 +0,0 @@ -import WorkerModelFactory from '@src/core/domains/events/factory/workerModelFactory'; -import { IEvent } from '@src/core/domains/events/interfaces/IEvent'; -import IEventDriver from '@src/core/domains/events/interfaces/IEventDriver'; -import WorkerModel from '@src/core/domains/events/models/WorkerModel'; -import { ModelConstructor } from '@src/core/interfaces/IModel'; - -/** - * QueueDriver - * - * Saves events for background processing - */ -export type WorkerModelCtor = ModelConstructor - -export type QueueDriverOptions = { - - /** - * Name of the queue - */ - queueName: string; - - /** - * Name of the event, defaults to the IEvent.name - */ - eventName?: string; - - /** - * Number of retry attempts for failed events - */ - retries: number; - - /** - * Collection to store failed events - */ - failedCollection: string; - - /** - * Delay before processing queued events - */ - runAfterSeconds: number; - - /** - * Constructor for the Worker model - */ - workerModelCtor: WMCtor; - - /** - * Run the worker only once, defaults to false - */ - runOnce?: boolean; -} - -export default class QueueDriver implements IEventDriver { - - /** - * Handle the dispatched event - * @param event - * @param options - */ - async handle(event: IEvent, options: QueueDriverOptions) { - const workerModel = (new WorkerModelFactory).create(new options.workerModelCtor().table, { - queueName: options.queueName, - eventName: event.name, - payload: JSON.stringify(event.payload), - retries: options.retries, - workerModelCtor: options.workerModelCtor - }); - - await workerModel.save(); - } - -} diff --git a/src/core/domains/events/drivers/SyncDriver.ts b/src/core/domains/events/drivers/SyncDriver.ts new file mode 100644 index 000000000..394fe54a7 --- /dev/null +++ b/src/core/domains/events/drivers/SyncDriver.ts @@ -0,0 +1,12 @@ +import BaseDriver from "@src/core/domains/events/base/BaseDriver"; +import { IBaseEvent } from "@src/core/domains/events/interfaces/IBaseEvent"; + +class SyncDriver extends BaseDriver { + + async dispatch(event: IBaseEvent): Promise { + await event.execute(); + } + +} + +export default SyncDriver \ No newline at end of file diff --git a/src/core/domains/events/drivers/SynchronousDriver.ts b/src/core/domains/events/drivers/SynchronousDriver.ts deleted file mode 100644 index 90485d862..000000000 --- a/src/core/domains/events/drivers/SynchronousDriver.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { IEvent } from '@src/core/domains/events/interfaces/IEvent'; -import IEventDriver from '@src/core/domains/events/interfaces/IEventDriver'; -import { App } from '@src/core/services/App'; - -/** - * Synchronous event driver - * - * This driver will process events synchronously as soon as they are dispatched. - */ -export default class SynchronousDriver implements IEventDriver { - - /** - * Process an event synchronously - * - * @param event The event to process - */ - async handle(event: IEvent) { - const eventName = event.name - - // Get all the listeners with this eventName - const listenerConstructors = App.container('events').getListenersByEventName(eventName) - - // Process each listener synchronously - for (const listenerCtor of listenerConstructors) { - const listener = new listenerCtor(); - await listener.handle(event.payload); - } - } - -} diff --git a/src/core/domains/events/exceptions/EventDispatchException.ts b/src/core/domains/events/exceptions/EventDispatchException.ts new file mode 100644 index 000000000..7f958c859 --- /dev/null +++ b/src/core/domains/events/exceptions/EventDispatchException.ts @@ -0,0 +1,8 @@ +export default class EventDispatchException extends Error { + + constructor(message: string = 'Event Dispatch Exception') { + super(message); + this.name = 'EventDispatchException'; + } + +} \ No newline at end of file diff --git a/src/core/domains/events/exceptions/EventInvalidPayloadException.ts b/src/core/domains/events/exceptions/EventInvalidPayloadException.ts new file mode 100644 index 000000000..2f4577c4b --- /dev/null +++ b/src/core/domains/events/exceptions/EventInvalidPayloadException.ts @@ -0,0 +1,8 @@ +export default class EventInvalidPayloadException extends Error { + + constructor(message: string = 'Invalid payload') { + super(message); + this.name = 'EventInvalidPayloadException'; + } + +} \ No newline at end of file diff --git a/src/core/domains/events/exceptions/EventMockException.ts b/src/core/domains/events/exceptions/EventMockException.ts new file mode 100644 index 000000000..5dc57092f --- /dev/null +++ b/src/core/domains/events/exceptions/EventMockException.ts @@ -0,0 +1,8 @@ +export default class EventMockException extends Error { + + constructor(message: string = 'Mock Exception') { + super(message); + this.name = 'MockException'; + } + +} \ No newline at end of file diff --git a/src/core/domains/events/exceptions/EventSubscriberException.ts b/src/core/domains/events/exceptions/EventSubscriberException.ts deleted file mode 100644 index 40b9fe19b..000000000 --- a/src/core/domains/events/exceptions/EventSubscriberException.ts +++ /dev/null @@ -1,8 +0,0 @@ -export default class EventSubscriberException extends Error { - - constructor(message: string = 'Event Subscriber Exception') { - super(message); - this.name = 'EventSubscriberException'; - } - -} \ No newline at end of file diff --git a/src/core/domains/events/exceptions/EventWorkerException.ts b/src/core/domains/events/exceptions/EventWorkerException.ts new file mode 100644 index 000000000..458c2cd07 --- /dev/null +++ b/src/core/domains/events/exceptions/EventWorkerException.ts @@ -0,0 +1,8 @@ +export default class EventWorkerException extends Error { + + constructor(message: string = 'Event Worker Exception') { + super(message); + this.name = 'EventWorkerException'; + } + +} \ No newline at end of file diff --git a/src/core/domains/events/factory/failedWorkerModelFactory.ts b/src/core/domains/events/factory/failedWorkerModelFactory.ts deleted file mode 100644 index f09c5e0bd..000000000 --- a/src/core/domains/events/factory/failedWorkerModelFactory.ts +++ /dev/null @@ -1,28 +0,0 @@ -import FailedWorkerModel, { initialFailedWorkerModalData } from "@src/core/domains/events/models/FailedWorkerModel"; - -type Params = { - eventName: string; - payload: any, - error: any; -} -export default class FailedWorkerModelFactory { - - /** - * Creates a new instance of FailedWorkerModel - * @param collection The database collection to store the model in - * @param eventName The name of the event that failed - * @param payload The payload of the event that failed - * @param error The error that caused the event to fail - * @returns A new instance of FailedWorkerModel - */ - create(collection: string, { eventName, payload, error }: Params): FailedWorkerModel { - return new FailedWorkerModel({ - ...initialFailedWorkerModalData, - eventName, - payload, - error, - failedAt: new Date(), - }) - } - -} \ No newline at end of file diff --git a/src/core/domains/events/factory/workerModelFactory.ts b/src/core/domains/events/factory/workerModelFactory.ts deleted file mode 100644 index 0c66813d4..000000000 --- a/src/core/domains/events/factory/workerModelFactory.ts +++ /dev/null @@ -1,34 +0,0 @@ -import WorkerModel, { initialWorkerModalData } from "@src/core/domains/events/models/WorkerModel"; -import { ModelConstructor } from "@src/core/interfaces/IModel"; - -type Params = { - queueName: string; - eventName: string; - payload: any, - retries: number, - workerModelCtor: ModelConstructor -} -export default class WorkerModelFactory { - - /** - * Creates a new instance of WorkerModel - * @param collection The database collection to store the model in - * @param queueName The name of the queue to store the model in - * @param eventName The name of the event to store the model with - * @param payload The payload of the event to store the model with - * @param retries The number of retries for the event to store the model with - * @param workerModelCtor The constructor for the WorkerModel to create - * @returns A new instance of WorkerModel - */ - create(collection: string, { queueName, eventName, payload, retries, workerModelCtor }: Params): WorkerModel { - return new workerModelCtor({ - ...initialWorkerModalData, - queueName, - eventName, - payload, - retries, - createdAt: new Date() - }, collection) - } - -} \ No newline at end of file diff --git a/src/core/domains/events/interfaces/IBaseEvent.ts b/src/core/domains/events/interfaces/IBaseEvent.ts new file mode 100644 index 000000000..99f6a80d4 --- /dev/null +++ b/src/core/domains/events/interfaces/IBaseEvent.ts @@ -0,0 +1,18 @@ +/* eslint-disable no-unused-vars */ + +import IEventDriver from "@src/core/domains/events/interfaces/IEventDriver"; +import { IEventService } from "@src/core/domains/events/interfaces/IEventService"; +import { IExecutable } from "@src/core/interfaces/concerns/IExecutable"; +import { IHasCastableConcern } from "@src/core/interfaces/concerns/IHasCastableConcern"; +import { INameable } from "@src/core/interfaces/concerns/INameable"; +import { ICtor } from "@src/core/interfaces/ICtor"; + + +export interface IBaseEvent extends INameable, IExecutable, IHasCastableConcern +{ + getQueueName(): string; + getEventService(): IEventService; + getDriverCtor(): ICtor; + getPayload(): T; + setPayload(payload: TPayload): void; +} \ No newline at end of file diff --git a/src/core/domains/events/interfaces/IDispatchable.ts b/src/core/domains/events/interfaces/IDispatchable.ts deleted file mode 100644 index ed49ff4ff..000000000 --- a/src/core/domains/events/interfaces/IDispatchable.ts +++ /dev/null @@ -1,4 +0,0 @@ -/* eslint-disable no-unused-vars */ -export default interface IDispatchable { - dispatch: (...args: any[]) => any; -} \ No newline at end of file diff --git a/src/core/domains/events/interfaces/IEvent.ts b/src/core/domains/events/interfaces/IEvent.ts deleted file mode 100644 index 9ea7badf5..000000000 --- a/src/core/domains/events/interfaces/IEvent.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { eventSubscribers } from "@src/config/events"; -import { IEventDrivers, ISubscribers } from "@src/core/domains/events/interfaces/IEventConfig"; -import { IEventPayload } from "@src/core/domains/events/interfaces/IEventPayload"; - -export interface IEvent< - Payload extends IEventPayload = IEventPayload, - Watchters extends ISubscribers = typeof eventSubscribers, - Drivers extends IEventDrivers = IEventDrivers -> { - name: keyof Watchters & string; - driver: keyof Drivers; - payload: Payload; -} \ No newline at end of file diff --git a/src/core/domains/events/interfaces/IEventConfig.ts b/src/core/domains/events/interfaces/IEventConfig.ts deleted file mode 100644 index 512b863d8..000000000 --- a/src/core/domains/events/interfaces/IEventConfig.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { IDriverConstructor } from '@src/core/domains/events/interfaces/IEventDriver'; -import { EventListenerConstructor } from "@src/core/domains/events/interfaces/IEventListener"; -import DriverOptions from "@src/core/domains/events/services/QueueDriverOptions"; - -export interface IDriverConfig { - driver: IDriverConstructor - options?: DriverOptions -} - -export type IEventDrivers = { - [key: string]: IDriverConfig -} - -export type ISubscribers = { - [key: string]: Array -} \ No newline at end of file diff --git a/src/core/domains/events/interfaces/IEventDispatcher.ts b/src/core/domains/events/interfaces/IEventDispatcher.ts deleted file mode 100644 index 75edb633b..000000000 --- a/src/core/domains/events/interfaces/IEventDispatcher.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* eslint-disable no-unused-vars */ -import IDispatchable from "@src/core/domains/events/interfaces/IDispatchable"; -import { IEvent } from "@src/core/domains/events/interfaces/IEvent"; - -export interface IEventDispatcher extends IDispatchable { - dispatch: (event: IEvent) => Promise; -} \ No newline at end of file diff --git a/src/core/domains/events/interfaces/IEventDriver.ts b/src/core/domains/events/interfaces/IEventDriver.ts index 9e1aa6be2..1c2bc856f 100644 --- a/src/core/domains/events/interfaces/IEventDriver.ts +++ b/src/core/domains/events/interfaces/IEventDriver.ts @@ -1,13 +1,5 @@ -/* eslint-disable no-unused-vars */ -import { IEvent } from "@src/core/domains/events/interfaces/IEvent"; -import { IEventPayload } from "@src/core/domains/events/interfaces/IEventPayload"; + +import { IDispatchable } from "@src/core/interfaces/concerns/IDispatchable"; +import { INameable } from "@src/core/interfaces/concerns/INameable"; -export type IDriverConstructor< -Payload extends IEventPayload = IEventPayload, -Options extends object = object, -Driver extends IEventDriver = IEventDriver -> = new (...args: any[]) => Driver; - -export default interface IEventDriver { - handle(event: IEvent, options?: Options): any; -} \ No newline at end of file +export default interface IEventDriver extends INameable, IDispatchable {} \ No newline at end of file diff --git a/src/core/domains/events/interfaces/IEventListener.ts b/src/core/domains/events/interfaces/IEventListener.ts index 6077bcaf1..799716a3a 100644 --- a/src/core/domains/events/interfaces/IEventListener.ts +++ b/src/core/domains/events/interfaces/IEventListener.ts @@ -1,6 +1,6 @@ -/* eslint-disable no-unused-vars */ -export type EventListenerConstructor = new (...args: any[]) => EventListener; - -export interface IEventListener { - handle: (...args: any[]) => any; +import { IBaseEvent } from "@src/core/domains/events/interfaces/IBaseEvent"; +import { INameable } from "@src/core/interfaces/concerns/INameable"; + +export interface IEventListener extends INameable, IBaseEvent { + } \ No newline at end of file diff --git a/src/core/domains/events/interfaces/IEventPayload.ts b/src/core/domains/events/interfaces/IEventPayload.ts index 53978349b..c227aef40 100644 --- a/src/core/domains/events/interfaces/IEventPayload.ts +++ b/src/core/domains/events/interfaces/IEventPayload.ts @@ -1,5 +1,3 @@ -export type TSerializableTypes = number | string | boolean | undefined; - -export interface IEventPayload { - [key: string | number | symbol]: TSerializableTypes -} \ No newline at end of file +export type TSerializableValues = number | string | boolean | undefined | null; + +export type TISerializablePayload = Record | TSerializableValues; \ No newline at end of file diff --git a/src/core/domains/events/interfaces/IEventService.ts b/src/core/domains/events/interfaces/IEventService.ts index fad5dd06d..3498bebba 100644 --- a/src/core/domains/events/interfaces/IEventService.ts +++ b/src/core/domains/events/interfaces/IEventService.ts @@ -1,18 +1,32 @@ /* eslint-disable no-unused-vars */ -import { IEvent } from "@src/core/domains/events/interfaces/IEvent"; -import { IDriverConfig, IEventDrivers, ISubscribers } from "@src/core/domains/events/interfaces/IEventConfig"; -import { EventListenerConstructor } from "@src/core/domains/events/interfaces/IEventListener"; -import { IEventPayload } from "@src/core/domains/events/interfaces/IEventPayload"; - -export interface EventServiceConfig { - defaultDriver: string; - drivers: IEventDrivers; - subscribers: ISubscribers; -} - -export interface IEventService { - config: EventServiceConfig; - dispatch(event: IEvent): Promise; - getListenersByEventName(eventName: string): EventListenerConstructor[]; - getDriver(driverName: string): IDriverConfig; +import { IEventConfig } from "@src/core/domains/events/interfaces/config/IEventConfig"; +import { IEventDriversConfigOption } from "@src/core/domains/events/interfaces/config/IEventDriversConfig"; +import { TListenersConfigOption } from "@src/core/domains/events/interfaces/config/IEventListenersConfig"; +import { IBaseEvent } from "@src/core/domains/events/interfaces/IBaseEvent"; +import IEventDriver from "@src/core/domains/events/interfaces/IEventDriver"; +import { IEventWorkerConcern } from "@src/core/domains/events/interfaces/IEventWorkerConcern"; +import { IMockableConcern } from "@src/core/domains/events/interfaces/IMockableConcern"; +import { IDispatchable } from "@src/core/interfaces/concerns/IDispatchable"; +import { IHasRegisterableConcern } from "@src/core/interfaces/concerns/IHasRegisterableConcern"; +import { ICtor } from "@src/core/interfaces/ICtor"; + +export interface IEventService extends IHasRegisterableConcern, IDispatchable, IEventWorkerConcern, IMockableConcern +{ + getConfig(): IEventConfig; + + registerEvent(event: ICtor): void; + + registerDriver(driverConfig: IEventDriversConfigOption): void; + + registerListener(listenerConfig: TListenersConfigOption): void; + + getDefaultDriverCtor(): ICtor; + + getDriverOptions(driver: IEventDriver): IEventDriversConfigOption | undefined; + + getDriverOptionsByName(driverName: string): IEventDriversConfigOption | undefined; + + getEventCtorByName(eventName: string): ICtor | undefined; + + getSubscribers(eventName: string): ICtor[]; } \ No newline at end of file diff --git a/src/core/domains/events/interfaces/IEventWorkerConcern.ts b/src/core/domains/events/interfaces/IEventWorkerConcern.ts new file mode 100644 index 000000000..e2bdeaf04 --- /dev/null +++ b/src/core/domains/events/interfaces/IEventWorkerConcern.ts @@ -0,0 +1,40 @@ +/* eslint-disable no-unused-vars */ +import { ICtor } from "@src/core/interfaces/ICtor"; +import { IModel } from "@src/core/interfaces/IModel"; + + +export type TWorkerModelData = { + queueName: string; + eventName: string; + payload: any; + attempt: number; + retries: number; + createdAt: Date; +} + +export type TFailedWorkerModel = IModel; + +export type TFailedWorkerModelData = { + eventName: string; + queueName: string; + payload: string; + error: string; + failedAt: Date; +} + +export interface IWorkerModel extends IModel { + getPayload(): T | null; +} + +export type TEventWorkerOptions = { + queueName: string; + retries: number; + runAfterSeconds: number; + workerModelCtor: ICtor + failedWorkerModelCtor: ICtor; + runOnce?: boolean; +} + +export interface IEventWorkerConcern { + runWorker(options: TEventWorkerOptions): Promise; +} \ No newline at end of file diff --git a/src/core/domains/events/interfaces/IMockableConcern.ts b/src/core/domains/events/interfaces/IMockableConcern.ts new file mode 100644 index 000000000..85b27ce2b --- /dev/null +++ b/src/core/domains/events/interfaces/IMockableConcern.ts @@ -0,0 +1,14 @@ +/* eslint-disable no-unused-vars */ +import { IBaseEvent } from "@src/core/domains/events/interfaces/IBaseEvent"; +import { ICtor } from "@src/core/interfaces/ICtor"; + + +export type TMockableEventCallback = (payload: TPayload) => boolean; + +export interface IMockableConcern { + mockEvent(event: ICtor): void; + + mockEventDispatched(event: IBaseEvent): void; + + assertDispatched(eventCtor: ICtor, callback?: TMockableEventCallback): boolean; +} \ No newline at end of file diff --git a/src/core/domains/events/interfaces/IQueableDriverOptions.ts b/src/core/domains/events/interfaces/IQueableDriverOptions.ts new file mode 100644 index 000000000..0b27c13ba --- /dev/null +++ b/src/core/domains/events/interfaces/IQueableDriverOptions.ts @@ -0,0 +1,10 @@ +import { ICtor } from "@src/core/interfaces/ICtor"; +import { IModel } from "@src/core/interfaces/IModel"; + +export interface IQueableDriverOptions { + queueName: string; + retries: number; + failedCollection: string; + runAfterSeconds: number; + workerModelCtor: ICtor; +} \ No newline at end of file diff --git a/src/core/domains/events/interfaces/config/IEventConfig.ts b/src/core/domains/events/interfaces/config/IEventConfig.ts new file mode 100644 index 000000000..542b0aa74 --- /dev/null +++ b/src/core/domains/events/interfaces/config/IEventConfig.ts @@ -0,0 +1,12 @@ +import { IBaseEvent } from "@src/core/domains/events/interfaces/IBaseEvent"; +import IEventDriver from "@src/core/domains/events/interfaces/IEventDriver"; +import { IEventDriversConfig } from "@src/core/domains/events/interfaces/config/IEventDriversConfig"; +import { IEventListenersConfig } from "@src/core/domains/events/interfaces/config/IEventListenersConfig"; +import { ICtor } from "@src/core/interfaces/ICtor"; + +export interface IEventConfig { + defaultDriver: ICtor; + drivers: IEventDriversConfig; + events: ICtor[]; + listeners: IEventListenersConfig; +} \ No newline at end of file diff --git a/src/core/domains/events/interfaces/config/IEventDriversConfig.ts b/src/core/domains/events/interfaces/config/IEventDriversConfig.ts new file mode 100644 index 000000000..3733378b1 --- /dev/null +++ b/src/core/domains/events/interfaces/config/IEventDriversConfig.ts @@ -0,0 +1,14 @@ +import IEventDriver from "@src/core/domains/events/interfaces/IEventDriver"; +import { ICtor } from "@src/core/interfaces/ICtor"; + +export interface IEventDriversConfigOption { + driverCtor: ICtor, + options?: Record; +} + +export type TEventDriversRegister = Record; + +export interface IEventDriversConfig +{ + [key: string]: IEventDriversConfigOption +} \ No newline at end of file diff --git a/src/core/domains/events/interfaces/config/IEventListenersConfig.ts b/src/core/domains/events/interfaces/config/IEventListenersConfig.ts new file mode 100644 index 000000000..497d74da2 --- /dev/null +++ b/src/core/domains/events/interfaces/config/IEventListenersConfig.ts @@ -0,0 +1,12 @@ +import { IBaseEvent } from "@src/core/domains/events/interfaces/IBaseEvent"; +import { IEventListener } from "@src/core/domains/events/interfaces/IEventListener"; +import { ICtor } from "@src/core/interfaces/ICtor"; + +export type TListenersConfigOption = { + listener: ICtor; + subscribers: ICtor[] +} + +export type TListenersMap = Map + +export type IEventListenersConfig = TListenersConfigOption[] \ No newline at end of file diff --git a/src/core/domains/events/models/FailedWorkerModel.ts b/src/core/domains/events/models/FailedWorkerModel.ts index 99e3ef778..68ba24ea9 100644 --- a/src/core/domains/events/models/FailedWorkerModel.ts +++ b/src/core/domains/events/models/FailedWorkerModel.ts @@ -1,38 +1,9 @@ import Model from "@src/core/base/Model"; -import IModelAttributes from "@src/core/interfaces/IModelData"; +import { TFailedWorkerModelData } from "@src/core/domains/events/interfaces/IEventWorkerConcern"; -/** - * Represents a failed worker model. - * - * @interface FailedWorkerModelData - * @extends IModelAttributes - */ -export interface FailedWorkerModelData extends IModelAttributes { - /** - * The name of the event that failed. - */ - eventName: string; +export interface FailedWorkerModelData extends TFailedWorkerModelData {} - /** - * The payload of the event that failed. - */ - payload: any; - - /** - * The error that caused the event to fail. - */ - error: any; - - /** - * The date when the event failed. - */ - failedAt: Date; -} - -/** - * Initial data for a failed worker model. - */ export const initialFailedWorkerModalData = { eventName: '', payload: null, @@ -69,7 +40,7 @@ export default class FailedWorkerModel extends Model { * @param {FailedWorkerModelData} data - The data for the model. */ constructor(data: FailedWorkerModelData) { - super(data); + super({ ...initialFailedWorkerModalData, ...data }); } } diff --git a/src/core/domains/events/models/WorkerModel.ts b/src/core/domains/events/models/WorkerModel.ts index bb966ab6e..e5b678855 100644 --- a/src/core/domains/events/models/WorkerModel.ts +++ b/src/core/domains/events/models/WorkerModel.ts @@ -1,14 +1,7 @@ import Model from "@src/core/base/Model"; -import IModelAttributes from "@src/core/interfaces/IModelData"; +import { IWorkerModel, TWorkerModelData } from "@src/core/domains/events/interfaces/IEventWorkerConcern"; -export interface WorkerModelData extends IModelAttributes { - queueName: string; - eventName: string; - payload: any; - attempt: number; - retries: number; - createdAt: Date; -} +export interface WorkerModelData extends TWorkerModelData {} export const initialWorkerModalData = { queueName: '', @@ -27,8 +20,9 @@ export const initialWorkerModalData = { * @class WorkerModel * @extends Model */ -export default class WorkerModel extends Model { +export default class WorkerModel extends Model implements IWorkerModel { + table: string = 'worker_queue'; /** * The list of date fields. @@ -61,13 +55,13 @@ export default class WorkerModel extends Model { ] constructor(data: WorkerModelData) { - super(data); + super({...initialWorkerModalData, ...data}); } - public getPayload(): unknown { + getPayload(): T | null { try { const payload = this.getAttribute('payload'); - return JSON.parse(payload) + return JSON.parse(payload) as T } // eslint-disable-next-line no-unused-vars catch (err) { diff --git a/src/core/domains/events/providers/EventProvider.ts b/src/core/domains/events/providers/EventProvider.ts index 8eacab1ba..f4bfbf3bd 100644 --- a/src/core/domains/events/providers/EventProvider.ts +++ b/src/core/domains/events/providers/EventProvider.ts @@ -1,35 +1,62 @@ - -import { defaultEventDriver, eventDrivers, eventSubscribers } from "@src/config/events"; -import BaseProvider from "@src/core/base/Provider"; -import { EventServiceConfig } from "@src/core/domains/events/interfaces/IEventService"; -import EventService from "@src/core/domains/events/services/EventService"; -import { App } from "@src/core/services/App"; -import WorkerCommand from "@src/core/domains/console/commands/WorkerCommand"; - -export default class EventProvider extends BaseProvider { - - protected config: EventServiceConfig = { - defaultDriver: defaultEventDriver, - drivers: eventDrivers, - subscribers: eventSubscribers - }; - - public async register(): Promise { - this.log('Registering EventProvider'); - - /** - * Register event service - */ - App.setContainer('events', new EventService(this.config)); - - /** - * Register system provided commands - */ - App.container('console').register().registerAll([ - WorkerCommand - ]) - } - - public async boot(): Promise {} - -} \ No newline at end of file +import { eventConfig } from "@src/config/events"; +import BaseProvider from "@src/core/base/Provider"; +import WorkerCommand from "@src/core/domains/events/commands/WorkerCommand"; +import { IEventConfig } from "@src/core/domains/events/interfaces/config/IEventConfig"; +import { IEventService } from "@src/core/domains/events/interfaces/IEventService"; +import EventService from "@src/core/domains/events/services/EventService"; +import { App } from "@src/core/services/App"; + +class EventProvider extends BaseProvider { + + protected config: IEventConfig = eventConfig; + + async register(): Promise { + + const eventService = new EventService(this.config); + + this.registerDrivers(eventService); + this.registerEvents(eventService); + this.registerListeners(eventService); + + App.setContainer('events', eventService); + + App.container('console').register().registerAll([ + WorkerCommand + ]) + } + + async boot(): Promise {} + + /** + * Registers all event drivers defined in the configuration with the provided event service. + * @param eventService The event service to register drivers with. + */ + private registerDrivers(eventService: IEventService) { + for(const driverKey of Object.keys(this.config.drivers)) { + eventService.registerDriver(this.config.drivers[driverKey]); + } + } + + /** + * Registers all event constructors defined in the configuration with the provided event service. + * @param eventService The event service to register events with. + */ + private registerEvents(eventService: IEventService) { + for(const event of this.config.events) { + eventService.registerEvent(event); + } + } + + /** + * Registers all event listeners defined in the configuration with the provided event service. + * @param eventService The event service to register listeners with. + */ + private registerListeners(eventService: IEventService) { + for(const listenerConfig of this.config.listeners) { + eventService.registerListener(listenerConfig) + } + } + +} + +export default EventProvider \ No newline at end of file diff --git a/src/core/domains/events/services/EventDispatcher.ts b/src/core/domains/events/services/EventDispatcher.ts deleted file mode 100644 index 4e548c3d4..000000000 --- a/src/core/domains/events/services/EventDispatcher.ts +++ /dev/null @@ -1,40 +0,0 @@ -import Singleton from "@src/core/base/Singleton"; -import { IEvent } from "@src/core/domains/events/interfaces/IEvent"; -import { IDriverConfig } from "@src/core/domains/events/interfaces/IEventConfig"; -import { IEventDispatcher } from "@src/core/domains/events/interfaces/IEventDispatcher"; -import { IEventPayload } from "@src/core/domains/events/interfaces/IEventPayload"; -import { App } from "@src/core/services/App"; - - -export default class EventDispatcher extends Singleton implements IEventDispatcher { - - /** - * Handle the dispatched event - * @param event - */ - public async dispatch(event: IEvent) { - App.container('logger').info(`[EventDispatcher:dispatch] Event '${event.name}' with driver '${event.driver}'`) - - const driverOptions = this.getDriverOptionsFromEvent(event) - const driverCtor = driverOptions.driver - - const instance = new driverCtor(); - await instance.handle(event, driverOptions.options?.getOptions()); - } - - /** - * Get the driver constructor based on the name of the worker defiend in the Event - * @param IEvent event - * @returns - */ - protected getDriverOptionsFromEvent(event: IEvent): IDriverConfig { - const driver = App.container('events').config.drivers[event.driver] - - if(!driver) { - throw new Error('Driver not found \'' + event.driver + '\'') - } - - return driver - } - -} \ No newline at end of file diff --git a/src/core/domains/events/services/EventListener.ts b/src/core/domains/events/services/EventListener.ts deleted file mode 100644 index 08574ef14..000000000 --- a/src/core/domains/events/services/EventListener.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* eslint-disable no-unused-vars */ -import { IEventListener } from "@src/core/domains/events/interfaces/IEventListener"; -import { IEventPayload } from "@src/core/domains/events/interfaces/IEventPayload"; - -export default abstract class EventListener< - Payload extends IEventPayload = IEventPayload -> implements IEventListener { - - handle!: (payload: Payload) => any; - -} \ No newline at end of file diff --git a/src/core/domains/events/services/EventService.ts b/src/core/domains/events/services/EventService.ts index 68add9430..560b6920c 100644 --- a/src/core/domains/events/services/EventService.ts +++ b/src/core/domains/events/services/EventService.ts @@ -1,61 +1,284 @@ -import Singleton from "@src/core/base/Singleton"; -import EventDriverException from "@src/core/domains/events/exceptions/EventDriverException"; -import { IEvent } from "@src/core/domains/events/interfaces/IEvent"; -import { IDriverConfig } from "@src/core/domains/events/interfaces/IEventConfig"; -import { EventListenerConstructor } from "@src/core/domains/events/interfaces/IEventListener"; -import { IEventPayload } from "@src/core/domains/events/interfaces/IEventPayload"; -import { EventServiceConfig, IEventService } from "@src/core/domains/events/interfaces/IEventService"; -import EventDispatcher from "@src/core/domains/events/services/EventDispatcher"; +/* eslint-disable no-unused-vars */ +import BaseEventListener from "@src/core/domains/events/base/BaseEventListener"; +import BaseService from "@src/core/domains/events/base/BaseService"; +import EventDispatchException from "@src/core/domains/events/exceptions/EventDispatchException"; +import { IBaseEvent } from "@src/core/domains/events/interfaces/IBaseEvent"; +import IEventDriver from "@src/core/domains/events/interfaces/IEventDriver"; +import { IEventService } from "@src/core/domains/events/interfaces/IEventService"; +import { TEventWorkerOptions } from "@src/core/domains/events/interfaces/IEventWorkerConcern"; +import { TMockableEventCallback } from "@src/core/domains/events/interfaces/IMockableConcern"; +import { IEventConfig } from "@src/core/domains/events/interfaces/config/IEventConfig"; +import { IEventDriversConfigOption } from "@src/core/domains/events/interfaces/config/IEventDriversConfig"; +import { IEventListenersConfig, TListenersConfigOption, TListenersMap } from "@src/core/domains/events/interfaces/config/IEventListenersConfig"; +import { ICtor } from "@src/core/interfaces/ICtor"; +import { IRegsiterList, TRegisterMap } from "@src/core/interfaces/concerns/IHasRegisterableConcern"; -/** - * Event Service - * - * Provides methods for dispatching events and retrieving event listeners. - */ -export default class EventService extends Singleton implements IEventService { + +class EventService extends BaseService implements IEventService { + + static readonly REGISTERED_EVENTS = "registeredEvents"; + + static readonly REGISTERED_DRIVERS = "registeredDrivers"; + + static readonly REGISTERED_LISTENERS = "registeredListeners"; + + static readonly REGISTERED_MOCK_EVENTS = "mockEvents"; + + static readonly REGISTERED_MOCK_DISPATCHED = "mockDispatched"; + + protected config!: IEventConfig; + + constructor(config: IEventConfig) { + super() + this.config = config; + } + + /** + * Retrieves the name of the event driver from its constructor. + * @param driver The constructor of the event driver. + * @returns The name of the event driver as a string. + */ + public static getDriverName(driver: ICtor): string { + return driver.name + } + + /** + * @param driverCtor The event driver class. + * @param options The event driver options. + * @returns The event driver config. + */ + public static createConfigDriver(driverCtor: ICtor, options?: T): IEventDriversConfigOption { + return { + driverCtor, + options + } + } + + /** + * @param events An array of event constructors to be registered. + * @returns The same array of event constructors. + */ + public static createConfigEvents(events: ICtor[]): ICtor[] { + return events + } + + /** + * @param config The event listeners config. + * @returns The event listeners config. + */ + public static createConfigListeners(config: IEventListenersConfig): IEventListenersConfig { + return config + } + + /** + * @returns The current event configuration as an instance of IEventConfig. + */ + getConfig(): IEventConfig { + return this.config + } + + /** + * Declare HasRegisterableConcern methods. + */ + declare register: (key: string, value: unknown) => void; + + declare registerByList: (listName: string, key: string, value: unknown) => void; + + declare setRegisteredByList: (listName: string, registered: TRegisterMap) => void; + + declare getRegisteredByList: (listName: string) => T; + + declare getRegisteredList: () => T; + + declare getRegisteredObject: () => IRegsiterList; + + declare isRegisteredInList: (listName: string, key: string) => boolean; + + /** + * Declare EventMockableConcern methods. + */ + declare mockEvent: (event: ICtor) => void; + + declare mockEventDispatched: (event: IBaseEvent) => void; + + declare assertDispatched: (eventCtor: ICtor, callback?: TMockableEventCallback) => boolean + + /** + * Delcare EventWorkerConcern methods. + */ + declare runWorker: (options: TEventWorkerOptions) => Promise; /** - * Config. + * Dispatch an event using its registered driver. + * @param event The event to be dispatched. */ - public config!: EventServiceConfig; + async dispatch(event: IBaseEvent): Promise { + + if(!this.isRegisteredEvent(event)) { + throw new EventDispatchException(`Event '${event.getName()}' not registered. The event must be added to the \`events\` array in the config. See @src/config/events.ts`) + } + + // Mock the dispatch before dispatching the event, as any errors thrown during the dispatch will not be caught + this.mockEventDispatched(event) + + const eventDriverCtor = event.getDriverCtor() + const eventDriver = new eventDriverCtor(this) + await eventDriver.dispatch(event) + + // Notify all subscribers of the event + if(event instanceof BaseEventListener) { + await this.notifySubscribers(event); + } + } + + /** + * @param event The event class to be checked + * @returns True if the event is registered, false otherwise + * @private + */ + private isRegisteredEvent(event: IBaseEvent): boolean { + return this.getRegisteredByList>>(EventService.REGISTERED_EVENTS).has(event.getName()); + } /** - * Constructor. - * @param config Event service config. + * Register an event with the event service + * @param event The event class to be registered */ - constructor(config: EventServiceConfig) { - super(config); + registerEvent(event: ICtor): void { + this.registerByList( + EventService.REGISTERED_EVENTS, + new event().getName(), + event + ) } /** - * Dispatch an event. - * @param event Event to dispatch. - * @returns + * Register a driver with the event service + * @param driverIdentifierConstant a constant string to identify the driver + * @param driverConfig the driver configuration */ - async dispatch(event: IEvent) { - return await (new EventDispatcher).dispatch(event); + registerDriver(driverConfig: IEventDriversConfigOption): void { + const driverIdentifier = EventService.getDriverName(driverConfig.driverCtor) + + this.registerByList( + EventService.REGISTERED_DRIVERS, + driverIdentifier, + driverConfig + ) } /** - * Get an array of listeners by the event name. - * @param eventName Event name. - * @returns Array of listeners. + * Register a listener with the event service + * @param listenerIdentifierConstant a constant string to identify the listener + * @param listenerConfig the listener configuration */ - getListenersByEventName(eventName: string): EventListenerConstructor[] { - return this.config.subscribers[eventName] ?? []; + registerListener(listenerConfig: TListenersConfigOption): void { + const listenerIdentifier = listenerConfig.listener.name + + // Update registered listeners + this.registerByList( + EventService.REGISTERED_LISTENERS, + listenerIdentifier, + listenerConfig + ) + + // Update the registered events from the listener and subscribers + this.registerEventsFromListenerConfig(listenerConfig) + } + + /** + * Registers the events associated with the listener configuration with the event service. + * Iterates over the subscribers and registers each subscriber event with the event service. + * @param listenerConfig The listener configuration. + */ + private registerEventsFromListenerConfig(listenerConfig: TListenersConfigOption): void { + + // Update registered events with the listener + this.registerEvent(listenerConfig.listener) + + // Update the registered events from the subscribers + for(const subscriber of listenerConfig.subscribers) { + this.registerEvent(subscriber) + } + } + + /** + * Get the default event driver constructor. + * @returns The default event driver constructor. + */ + getDefaultDriverCtor(): ICtor { + return this.config.defaultDriver + } + + /** + * Retrieves the configuration options for a given event driver constructor. + * @param driverCtor The constructor of the event driver. + * @returns The configuration options for the specified event driver, or undefined if not found. + */ + getDriverOptions(driver: IEventDriver): IEventDriversConfigOption | undefined { + const registeredDrivers = this.getRegisteredByList>(EventService.REGISTERED_DRIVERS); + const driverConfig = registeredDrivers.get(driver.getName())?.[0]; + + return driverConfig ?? undefined + } + + /** + * Retrieves the configuration options for a given event driver by name. + * @param driverName The name of the event driver. + * @returns The configuration options for the specified event driver, or undefined if not found. + */ + getDriverOptionsByName(driverName: string): IEventDriversConfigOption | undefined { + const registeredDrivers = this.getRegisteredByList>(EventService.REGISTERED_DRIVERS); + const driverConfig = registeredDrivers. get(driverName)?.[0]; + + return driverConfig ?? undefined + } + + /** + * Retrieves the event constructor for a given event name. + * @param eventName The name of the event. + * @returns The event constructor for the specified event, or undefined if not found. + */ + getEventCtorByName(eventName: string): ICtor | undefined { + const registeredEvents = this.getRegisteredByList>>(EventService.REGISTERED_EVENTS); + return registeredEvents.get(eventName)?.[0] + } + + /** + * Notifies all subscribers of this event that the event has been dispatched. + * + * Retrieves all subscribers of this event from the event service, creates + * a new instance of each subscriber, passing the payload of this event to + * the subscriber's constructor, and then dispatches the subscriber event + * using the event service. + */ + async notifySubscribers(eventListener: BaseEventListener) { + const subscribers = this.getSubscribers(eventListener.getName()); + + for (const subscriber of subscribers) { + const eventSubscriber = new subscriber(null); + eventSubscriber.setPayload(eventListener.getPayload()); + + await this.dispatch(eventSubscriber); + } } /** - * Get event driver. - * @param driverName Driver name. - * @returns Driver config. + * Returns an array of event subscriber constructors that are listening to this event. + * @returns An array of event subscriber constructors. */ - getDriver(driverName: string): IDriverConfig { - if(!this.config.drivers[driverName]) { - throw new EventDriverException(`Driver '${driverName}' not found`); + getSubscribers(eventName: string): ICtor[] { + const registeredListeners = this.getRegisteredByList(EventService.REGISTERED_LISTENERS); + + const listenerConfig = registeredListeners.get(eventName)?.[0]; + + if(!listenerConfig) { + return []; } - return this.config.drivers[driverName]; + + return listenerConfig.subscribers; } } +export default EventService \ No newline at end of file diff --git a/src/core/domains/events/services/EventSubscriber.ts b/src/core/domains/events/services/EventSubscriber.ts deleted file mode 100644 index 26ee138eb..000000000 --- a/src/core/domains/events/services/EventSubscriber.ts +++ /dev/null @@ -1,56 +0,0 @@ -import EventSubscriberException from "@src/core/domains/events/exceptions/EventSubscriberException"; -import { IEvent } from "@src/core/domains/events/interfaces/IEvent"; -import { IEventDrivers, ISubscribers } from '@src/core/domains/events/interfaces/IEventConfig'; -import { IEventPayload } from "@src/core/domains/events/interfaces/IEventPayload"; - -/** - * EventSubscriber - * - * Represents an event and its handler. - * - * @template Payload type of the event payload - * @template Watchters object with event names as keys and event listener classes as values - * @template Drivers object with driver names as keys and the driver classes as values - */ -export default class EventSubscriber< - Payload extends IEventPayload, - Watchters extends ISubscribers = ISubscribers, - Drivers extends IEventDrivers = IEventDrivers -> implements IEvent { - - /** - * Name of the event - */ - public name: keyof Watchters & string; - - /** - * Name of the event driver - */ - public driver: keyof Drivers; - - /** - * Payload of the event - */ - public payload: Payload; - - /** - * Constructor - * - * @param name name of the event - * @param driver name of the event driver - * @param payload payload of the event - */ - constructor(name: keyof Watchters & string, driver: keyof Drivers, payload: Payload) { - this.name = name; - this.driver = driver; - this.payload = payload; - - if(!this.name) { - throw new EventSubscriberException('EventSubscriber must have a \'name\' property') - } - if(!this.driver) { - throw new EventSubscriberException('EventSubscriber must have a \'driver\' property') - } - } - -} diff --git a/src/core/domains/events/services/QueueDriverOptions.ts b/src/core/domains/events/services/QueueDriverOptions.ts deleted file mode 100644 index 6fcafb242..000000000 --- a/src/core/domains/events/services/QueueDriverOptions.ts +++ /dev/null @@ -1,32 +0,0 @@ - -/** - * Class for storing driver options - * - * @template Options - Type of options object - */ -export default class DriverOptions = {}> { - - /** - * The options object - */ - protected options: Options; - - /** - * Constructor - * - * @param options - The options object to store - */ - constructor(options: Options = {} as Options) { - this.options = options; - } - - /** - * Get the options object - * - * @returns The options object - */ - getOptions(): Options { - return this.options; - } - -} diff --git a/src/core/domains/events/services/Worker.ts b/src/core/domains/events/services/Worker.ts deleted file mode 100644 index 0d792ae8c..000000000 --- a/src/core/domains/events/services/Worker.ts +++ /dev/null @@ -1,191 +0,0 @@ -import Repository from "@src/core/base/Repository"; -import Singleton from "@src/core/base/Singleton"; -import { QueueDriverOptions } from "@src/core/domains/events/drivers/QueueDriver"; -import EventDriverException from "@src/core/domains/events/exceptions/EventDriverException"; -import FailedWorkerModelFactory from "@src/core/domains/events/factory/failedWorkerModelFactory"; -import { IEventPayload } from "@src/core/domains/events/interfaces/IEventPayload"; -import WorkerModel from "@src/core/domains/events/models/WorkerModel"; -import EventSubscriber from "@src/core/domains/events/services/EventSubscriber"; -import DriverOptions from "@src/core/domains/events/services/QueueDriverOptions"; -import { App } from "@src/core/services/App"; - -/** - * Worker service - * - * This service provides methods for working with the worker table. - */ -export default class Worker extends Singleton { - - /** - * Queue driver options - */ - public options!: QueueDriverOptions; - - /** - * Sync driver for running events - */ - private syncDriver: string = 'sync'; - - /** - * Set the driver for the worker - * - * @param driver Driver to set - */ - setDriver(driver: string) { - this.options = this.getOptions(driver) - this.logToConsole(`Driver set to '${driver}'`,) - } - - /** - * Work the worker - * - * This method will fetch all queued items and process them through the - * event driver set by the setDriver method. If an error occurs, the - * worker will retry up to the number of times specified in the options. - * After the number of retries has been exceeded, the worker will move - * the item to the failed collection. - */ - async work() { - if(!this.options) { - throw new EventDriverException(`Driver not defined. Did you forget to call 'setDriver'?`) - } - - // Worker service - const worker = Worker.getInstance(); - let model: WorkerModel; - - // Fetch the current list of queued results - const workerResults: WorkerModel[] = await worker.getWorkerResults(this.options.queueName) - - this.logToConsole('collection: ' + new this.options.workerModelCtor().table) - this.logToConsole(`${workerResults.length} queued items with queue name '${this.options.queueName}'`) - - for(const workerModel of workerResults) { - // We set the model here to pass it to the failedWorkerModel method, - // but allowing the worker to continue processing - model = workerModel - - try { - App.container('logger').console('Worker processing model', model.getId()?.toString()) - await worker.processWorkerModel(model) - } - catch (err) { - if(!(err instanceof Error)) { - App.container('logger').error(err) - return; - } - - await worker.failedWorkerModel(model, err) - } - } - } - - /** - * Get the driver options based on the driver provided - * @returns - */ - getOptions(driver: string): QueueDriverOptions { - const eventDriver = App.container('events').getDriver(driver) - - if(!eventDriver) { - throw new EventDriverException(`Driver '${driver}' not found`) - } - - return (eventDriver.options as DriverOptions).getOptions() - } - - /** - * Get the worker results from oldest to newest - * @returns - */ - async getWorkerResults(queueName: string) { - const workerRepository = new Repository(this.options.workerModelCtor) - - return await workerRepository.findMany({ - queueName - }) - } - - /** - * Proces the worker by dispatching it through the event driver 'sync' - * @param model - */ - async processWorkerModel(model: WorkerModel) { - model.table = new this.options.workerModelCtor().table - const eventName = model.getAttribute('eventName') - const payload = model.getPayload() as IEventPayload - - // Use the sync driver - const event = new EventSubscriber(eventName as string, this.syncDriver, payload) - - // Dispatch the event - await App.container('events').dispatch(event) - - // Delete record as it was a success - await model.delete(); - - this.logToConsole(`Processed: ${eventName}`) - } - - /** - * Fail the worker - * @param model - * @param err - * @returns - */ - async failedWorkerModel(model: WorkerModel, err: Error) { - model.table = new this.options.workerModelCtor().table; - - // Get attempts and max retreis - const { retries } = this.options - const currentAttempt = (model.getAttribute('attempt') ?? 0) - const nextCurrentAttempt = currentAttempt + 1 - - this.logToConsole(`Failed ${model.getAttribute('eventName')} attempts ${currentAttempt + 1} out of ${retries}, ID: ${model.getId()?.toString()}`) - - // If reached max, move to failed collection - if(nextCurrentAttempt >= retries) { - await this.moveFailedWorkerModel(model, err); - return; - } - - // Otherwise, update the attempt count - model.setAttribute('attempt', currentAttempt + 1) - await model.save() - } - - /** - * Moved worker to the failed collection - * @param model - * @param err - */ - async moveFailedWorkerModel(model: WorkerModel, err: Error) { - this.logToConsole('Moved to failed') - - const failedWorkerModel = (new FailedWorkerModelFactory).create( - this.options.failedCollection, - { - eventName: model.getAttribute('eventName') as string, - payload: JSON.stringify(model.getPayload()), - error: { - name: err.name, - message: err.message, - stack: err.stack, - } - } - ) - - await failedWorkerModel.save() - await model.delete(); - } - - /** - * Logs a message to the console - * @param message The message to log - */ - protected logToConsole(message: string) { - App.container('logger').console('[Worker]: ', message) - } - -} - diff --git a/src/core/domains/make/commands/MakeListenerCommand.ts b/src/core/domains/make/commands/MakeListenerCommand.ts index 869262b79..a8ae08a26 100644 --- a/src/core/domains/make/commands/MakeListenerCommand.ts +++ b/src/core/domains/make/commands/MakeListenerCommand.ts @@ -5,7 +5,7 @@ export default class MakeListenerCommand extends BaseMakeFileCommand { constructor() { super({ signature: 'make:listener', - description: 'Create a new listener', + description: 'Create a new listener event', makeType: 'Listener', args: ['name'], endsWith: 'Listener' diff --git a/src/core/domains/make/commands/MakeSubscriberCommand.ts b/src/core/domains/make/commands/MakeSubscriberCommand.ts index f2e6917ba..00afc811a 100644 --- a/src/core/domains/make/commands/MakeSubscriberCommand.ts +++ b/src/core/domains/make/commands/MakeSubscriberCommand.ts @@ -5,7 +5,7 @@ export default class MakeSubscriberCommand extends BaseMakeFileCommand { constructor() { super({ signature: 'make:subscriber', - description: 'Create a new subscriber', + description: 'Create a new subscriber event', makeType: 'Subscriber', args: ['name'], endsWith: 'Subscriber' diff --git a/src/core/domains/make/templates/Listener.ts.template b/src/core/domains/make/templates/Listener.ts.template index 3bc95d8e7..e461d27b5 100644 --- a/src/core/domains/make/templates/Listener.ts.template +++ b/src/core/domains/make/templates/Listener.ts.template @@ -1,24 +1,18 @@ -import { IEventPayload } from "@src/core/domains/events/interfaces/IEventPayload"; -import EventListener from "@src/core/domains/events/services/EventListener"; - -export interface I#name#Data extends IEventPayload { - -} - -/** - * - */ -export class #name# extends EventListener { +import BaseEventListener from "@src/core/domains/events/base/BaseEventListener"; + +class #name# extends BaseEventListener { /** - * Handle the dispatched event - * @param payload The dispatched event payload + * Optional method to execute before the subscribers are dispatched. */ - handle = async (payload: I#name#Data) => { + async execute(): Promise { - // Handle the logic + // eslint-disable-next-line no-unused-vars + const payload = this.getPayload(); + // Handle logic } - + } +export default #name# \ No newline at end of file diff --git a/src/core/domains/make/templates/Subscriber.ts.template b/src/core/domains/make/templates/Subscriber.ts.template index 3d0ee77cd..ac1c0f8ee 100644 --- a/src/core/domains/make/templates/Subscriber.ts.template +++ b/src/core/domains/make/templates/Subscriber.ts.template @@ -1,31 +1,30 @@ -import EventSubscriber from "@src/core/domains/events/services/EventSubscriber"; +import BaseEvent from "@src/core/domains/events/base/BaseEvent"; +import SyncDriver from "@src/core/domains/events/drivers/SyncDriver"; -export type I#name#Data = { +class #name# extends BaseEvent { -} - -/** - * - * - * @param payload The payload of the event. - */ -export default class #name# extends EventSubscriber { + static readonly eventName = '#name'; + + protected namespace: string = 'default'; - /** - * Constructor - * @param payload The payload of the event. - */ - constructor(payload: I#name#Data) { + constructor(payload) { + super(payload, SyncDriver); + } - // Set the name of the event. - const eventName = 'TestSubscriber' + getName(): string { + return #name#.eventName; + } - // Set to 'queue' if you want the event to be added to the worker queue - // and processed by the worker command. - // Set to 'sync' if you want the event to be processed immediately. - const driver = 'queue'; + getQueueName(): string { + return 'default'; + } - super(eventName, driver, payload) + async execute(): Promise { + const payload = this.getPayload(); + + // Handle logic } } + +export default #name# \ No newline at end of file diff --git a/src/core/events/concerns/HasAttribute/OnAttributeChangeBroadcastEvent.ts b/src/core/events/concerns/HasAttribute/OnAttributeChangeBroadcastEvent.ts index 2c6b8f899..c998c319f 100644 --- a/src/core/events/concerns/HasAttribute/OnAttributeChangeBroadcastEvent.ts +++ b/src/core/events/concerns/HasAttribute/OnAttributeChangeBroadcastEvent.ts @@ -15,7 +15,7 @@ class OnAttributeChangeBroadcastEvent extends BroadcastEvent { super(data); } - getEventName(): string { + getName(): string { return OnAttributeChangeBroadcastEvent.eventName; } diff --git a/src/core/events/concerns/HasAttribute/SetAttributeBroadcastEvent.ts b/src/core/events/concerns/HasAttribute/SetAttributeBroadcastEvent.ts index d3630884e..e440dfcb6 100644 --- a/src/core/events/concerns/HasAttribute/SetAttributeBroadcastEvent.ts +++ b/src/core/events/concerns/HasAttribute/SetAttributeBroadcastEvent.ts @@ -13,7 +13,7 @@ class SetAttributeBroadcastEvent extends BroadcastEvent { super(data); } - getEventName(): string { + getName(): string { return SetAttributeBroadcastEvent.eventName; } diff --git a/src/core/exceptions/CastException.ts b/src/core/exceptions/CastException.ts new file mode 100644 index 000000000..e65510401 --- /dev/null +++ b/src/core/exceptions/CastException.ts @@ -0,0 +1,8 @@ +export default class CastException extends Error { + + constructor(message: string = 'Cast Exception') { + super(message); + this.name = 'CastException'; + } + +} \ No newline at end of file diff --git a/src/core/interfaces/concerns/IDispatchable.ts b/src/core/interfaces/concerns/IDispatchable.ts new file mode 100644 index 000000000..cb491410d --- /dev/null +++ b/src/core/interfaces/concerns/IDispatchable.ts @@ -0,0 +1,5 @@ +/* eslint-disable no-unused-vars */ +export interface IDispatchable +{ + dispatch(...args: unknown[]): Promise; +} \ No newline at end of file diff --git a/src/core/interfaces/concerns/IExecutable.ts b/src/core/interfaces/concerns/IExecutable.ts new file mode 100644 index 000000000..74c5fb8a5 --- /dev/null +++ b/src/core/interfaces/concerns/IExecutable.ts @@ -0,0 +1,5 @@ +/* eslint-disable no-unused-vars */ +export interface IExecutable +{ + execute(...args: any[]): Promise; +} \ No newline at end of file diff --git a/src/core/interfaces/concerns/IHasAttributes.ts b/src/core/interfaces/concerns/IHasAttributes.ts index 003ab3b8c..51ab2b34f 100644 --- a/src/core/interfaces/concerns/IHasAttributes.ts +++ b/src/core/interfaces/concerns/IHasAttributes.ts @@ -12,11 +12,11 @@ export interface IHasAttributes(key: K, value?: unknown): Attributes[K] | null | undefined; setAttribute(key: keyof Attributes, value?: unknown): Promise; - getAttribute(key: keyof Attributes): Attributes[keyof Attributes] | null + getAttribute(key: K): Attributes[K] | null getAttributes(...args: any[]): Attributes | null; diff --git a/src/core/interfaces/concerns/IHasCastableConcern.ts b/src/core/interfaces/concerns/IHasCastableConcern.ts new file mode 100644 index 000000000..8aa6796eb --- /dev/null +++ b/src/core/interfaces/concerns/IHasCastableConcern.ts @@ -0,0 +1,25 @@ +/* eslint-disable no-unused-vars */ +export type TCastableType = + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'date' + | 'integer' + | 'float' + | 'bigint' + | 'null' + | 'undefined' + | 'symbol' + | 'map' + | 'set'; + +export interface IHasCastableConcern { + casts: Record; + getCastFromObject(data: Record, casts?: TCasts): ReturnType; + getCast(data: unknown, type: TCastableType): T; + isValidType(type: TCastableType): boolean; +} + +export type TCasts = Record; \ No newline at end of file diff --git a/src/core/interfaces/concerns/IHasRegisterableConcern.ts b/src/core/interfaces/concerns/IHasRegisterableConcern.ts new file mode 100644 index 000000000..3b92f1ea5 --- /dev/null +++ b/src/core/interfaces/concerns/IHasRegisterableConcern.ts @@ -0,0 +1,23 @@ +/* eslint-disable no-unused-vars */ +export type TRegisterMap = Map; + +export interface IRegsiterList { + [key: string]: TRegisterMap +}; + +export interface IHasRegisterableConcern +{ + register(key: string, value: unknown): void; + + registerByList(listName: string, key: string, value: unknown): void; + + setRegisteredByList(listName: string, registered: TRegisterMap): void; + + getRegisteredByList(listName: string): T; + + getRegisteredList(): T; + + getRegisteredObject(): IRegsiterList; + + isRegisteredInList(listName: string, key: string): boolean; +} \ No newline at end of file diff --git a/src/core/interfaces/concerns/INameable.ts b/src/core/interfaces/concerns/INameable.ts new file mode 100644 index 000000000..82fe7f8e5 --- /dev/null +++ b/src/core/interfaces/concerns/INameable.ts @@ -0,0 +1,3 @@ +export interface INameable { + getName(): string; +} \ No newline at end of file diff --git a/src/core/services/App.ts b/src/core/services/App.ts index 65ddd755c..756f3ee4a 100644 --- a/src/core/services/App.ts +++ b/src/core/services/App.ts @@ -51,7 +51,7 @@ export class App extends Singleton { if (kernel.booted()) { throw new Error('Kernel is already booted'); } - if (!name) { + if (!name || name === '') { throw new Error('Container name cannot be empty'); } if (kernel.containers.has(name)) { @@ -76,6 +76,41 @@ export class App extends Singleton { return kernel.containers.get(name); } + /** + * Safely retrieves a container by its name. + * Attempts to get the specified container from the kernel. + * If the container is not initialized, it returns undefined. + * Throws an error for other exceptions. + * + * @template K - The type of the container key. + * @param {K} name - The name of the container to retrieve. + * @returns {IContainers[K] | undefined} The container if found, otherwise undefined. + * @throws {Error} If an unexpected error occurs. + */ + public static safeContainer(name: K): IContainers[K] | undefined { + try { + return this.container(name); + } + catch (err) { + if(err instanceof UninitializedContainerError) { + return undefined; + } + + throw err + } + } + + /** + * Checks if a container is ready. + * A container is considered ready if it has been set using the setContainer method. + * @template K - The type of the container key. + * @param {K} name - The name of the container to check. + * @returns {boolean} Whether the container is ready or not. + */ + public static containerReady(name: K): boolean { + return this.safeContainer(name) !== undefined + } + /** * Gets the environment * @returns The environment if set, or undefined if not diff --git a/src/core/util/castObject.ts b/src/core/util/castObject.ts new file mode 100644 index 000000000..0d9933959 --- /dev/null +++ b/src/core/util/castObject.ts @@ -0,0 +1,8 @@ +import BaseCastable from "@src/core/base/BaseCastable"; +import { TCasts } from "@src/core/interfaces/concerns/IHasCastableConcern"; + +const castObject = (data: unknown, casts: TCasts): ReturnType => { + return new BaseCastable().getCastFromObject(data as Record, casts) +} + +export default castObject \ No newline at end of file diff --git a/src/tests/casts/cast.test.ts b/src/tests/casts/cast.test.ts new file mode 100644 index 000000000..51efb2127 --- /dev/null +++ b/src/tests/casts/cast.test.ts @@ -0,0 +1,214 @@ +/* eslint-disable no-undef */ +import { describe } from '@jest/globals'; +import BaseCastable from '@src/core/base/BaseCastable'; +import CastException from '@src/core/exceptions/CastException'; +import { IHasCastableConcern, TCastableType } from '@src/core/interfaces/concerns/IHasCastableConcern'; +import Kernel from '@src/core/Kernel'; +import testAppConfig from '@src/tests/config/testConfig'; + +describe('HasCastableConcern Tests', () => { + let castable: IHasCastableConcern; + + beforeAll(async () => { + await Kernel.boot({ + ...testAppConfig, + providers: [ + ...testAppConfig.providers, + ] + }, {}); + + + castable = new BaseCastable(); + }); + + describe('getCastFromObject', () => { + class TestClass extends BaseCastable { + + casts: Record = { + test: 'string', + } + + data = { + age: "18", + name: "John", + books: [ + "Book 1", + ], + createdAt: new Date() + } + + }; + + const testClass = new TestClass(); + testClass.data = testClass.getCastFromObject(testClass.data); + }) + + describe('String Casting Tests', () => { + it('should cast various types to string', () => { + expect(castable.getCast(123, 'string')).toBe('123'); + expect(castable.getCast(true, 'string')).toBe('true'); + expect(castable.getCast([1, 2, 3], 'string')).toBe('[1,2,3]'); + expect(castable.getCast({ a: 1 }, 'string')).toBe('{"a":1}'); + + const date = new Date('2024-01-01'); + expect(castable.getCast(date, 'string')).toBe(date.toISOString()); + }); + }); + + describe('Number Casting Tests', () => { + it('should cast valid values to number', () => { + expect(castable.getCast('123', 'number')).toBe(123); + expect(castable.getCast('123.45', 'number')).toBe(123.45); + expect(castable.getCast(true, 'number')).toBe(1); + expect(castable.getCast(false, 'number')).toBe(0); + }); + + it('should throw CastException for invalid number strings', () => { + expect(() => castable.getCast('abc', 'number')).toThrow(CastException); + }); + }); + + describe('Boolean Casting Tests', () => { + it('should cast strings to boolean', () => { + expect(castable.getCast('true', 'boolean')).toBe(true); + expect(castable.getCast('false', 'boolean')).toBe(false); + expect(castable.getCast('1', 'boolean')).toBe(true); + expect(castable.getCast('0', 'boolean')).toBe(false); + expect(castable.getCast('yes', 'boolean')).toBe(true); + expect(castable.getCast('no', 'boolean')).toBe(false); + }); + + it('should cast numbers to boolean', () => { + expect(castable.getCast(1, 'boolean')).toBe(true); + expect(castable.getCast(0, 'boolean')).toBe(false); + }); + + it('should throw CastException for invalid boolean strings', () => { + expect(() => castable.getCast('invalid', 'boolean')).toThrow(CastException); + }); + }); + + describe('Array Casting Tests', () => { + it('should cast to array', () => { + expect(castable.getCast('["a","b"]', 'array')).toEqual(['a', 'b']); + expect(castable.getCast(new Set([1, 2]), 'array')).toEqual([1, 2]); + expect(castable.getCast(123, 'array')).toEqual([123]); + expect(castable.getCast('invalid json', 'array')).toEqual(['invalid json']); + }); + }); + + describe('Object Casting Tests', () => { + it('should cast to object', () => { + expect(castable.getCast('{"a":1}', 'object')).toEqual({ a: 1 }); + expect(castable.getCast([1, 2], 'object')).toEqual({ '0': 1, '1': 2 }); + }); + + it('should throw CastException for invalid JSON strings', () => { + expect(() => castable.getCast('invalid json', 'object')).toThrow(CastException); + }); + }); + + describe('Date Casting Tests', () => { + it('should cast to date', () => { + const date = new Date('2024-01-01'); + expect(castable.getCast('2024-01-01', 'date')).toEqual(date); + expect(castable.getCast(date.getTime(), 'date')).toEqual(date); + expect(castable.getCast(date, 'date')).toEqual(date); + }); + + it('should throw CastException for invalid dates', () => { + expect(() => castable.getCast('invalid date', 'date')).toThrow(CastException); + }); + }); + + describe('Integer Casting Tests', () => { + it('should cast to integer', () => { + expect(castable.getCast('123', 'integer')).toBe(123); + expect(castable.getCast('123.45', 'integer')).toBe(123); + expect(castable.getCast(123.45, 'integer')).toBe(123); + }); + + it('should throw CastException for invalid integers', () => { + expect(() => castable.getCast('abc', 'integer')).toThrow(CastException); + }); + }); + + describe('Float Casting Tests', () => { + it('should cast to float', () => { + expect(castable.getCast('123.45', 'float')).toBe(123.45); + expect(castable.getCast(123, 'float')).toBe(123.0); + }); + + it('should throw CastException for invalid floats', () => { + expect(() => castable.getCast('abc', 'float')).toThrow(CastException); + }); + }); + + describe('BigInt Casting Tests', () => { + it('should cast to BigInt', () => { + expect(castable.getCast('123', 'bigint')).toBe(BigInt(123)); + expect(castable.getCast(123, 'bigint')).toBe(BigInt(123)); + }); + + it('should throw CastException for invalid BigInt values', () => { + expect(() => castable.getCast('abc', 'bigint')).toThrow(CastException); + expect(() => castable.getCast({}, 'bigint')).toThrow(CastException); + }); + }); + + describe('Map Casting Tests', () => { + it('should handle Map casting', () => { + const map = new Map([['a', 1]]); + expect(castable.getCast(map, 'map')).toBe(map); + }); + + it('should throw CastException for invalid Map conversions', () => { + expect(() => castable.getCast({}, 'map')).toThrow(CastException); + }); + }); + + describe('Set Casting Tests', () => { + it('should cast to Set', () => { + expect(castable.getCast([1, 2, 3], 'set')).toEqual(new Set([1, 2, 3])); + expect(castable.getCast(1, 'set')).toEqual(new Set([1])); + }); + }); + + describe('Symbol Casting Tests', () => { + it('should cast to Symbol', () => { + const sym = castable.getCast('test', 'symbol') + expect(typeof sym).toBe('symbol'); + expect(sym.toString()).toBe('Symbol(test)'); + }); + }); + + describe('Null and Undefined Handling Tests', () => { + it('should handle null values correctly', () => { + expect(castable.getCast(null, 'null')).toBeNull(); + expect(() => castable.getCast(null, 'string')).toThrow(CastException); + }); + + it('should handle undefined values correctly', () => { + expect(castable.getCast(undefined, 'undefined')).toBeUndefined(); + expect(() => castable.getCast(undefined, 'string')).toThrow(CastException); + }); + }); + + describe('Invalid Type Tests', () => { + it('should throw CastException for invalid types', () => { + expect(() => castable.getCast('test', 'invalid' as any)).toThrow(CastException); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty values', () => { + expect(castable.getCast('', 'string')).toBe(''); + expect(castable.getCast([], 'array')).toEqual([]); + expect(castable.getCast({}, 'object')).toEqual({}); + }); + + it('should handle special characters', () => { + expect(castable.getCast('§±!@#$%^&*()', 'string')).toBe('§±!@#$%^&*()'); + }); + }); +}); \ No newline at end of file diff --git a/src/tests/casts/castObject.test.ts b/src/tests/casts/castObject.test.ts new file mode 100644 index 000000000..2d30c57a3 --- /dev/null +++ b/src/tests/casts/castObject.test.ts @@ -0,0 +1,276 @@ +/* eslint-disable no-undef */ +import { describe } from '@jest/globals'; +import BaseCastable from '@src/core/base/BaseCastable'; +import CastException from '@src/core/exceptions/CastException'; +import { TCastableType } from '@src/core/interfaces/concerns/IHasCastableConcern'; +import Kernel from '@src/core/Kernel'; +import testAppConfig from '@src/tests/config/testConfig'; + +describe('HasCastableConcern Tests', () => { + beforeAll(async () => { + await Kernel.boot({ + ...testAppConfig, + providers: [ + ...testAppConfig.providers, + ] + }, {}); + }); + + describe('getCastFromObject', () => { + describe('basic type casting', () => { + interface TestData { + age: number; + isActive: boolean; + joinDate: Date; + score: number; + rank: number; + items: string[]; + settings: { theme: string }; + userId: bigint; + userType: symbol; + name: string; + books: string[]; + createdAt: Date; + } + + class TestClass extends BaseCastable { + + casts: Record = { + age: 'number', + isActive: 'boolean', + joinDate: 'date', + score: 'float', + rank: 'integer', + items: 'array', + settings: 'object', + userId: 'bigint', + userType: 'symbol' + } + + data = { + age: "25", + isActive: "1", + joinDate: "2024-01-01", + score: "91.5", + rank: "1", + items: '["item1", "item2"]', + settings: '{"theme":"dark"}', + userId: "1234567890", + userType: "premium", + name: "John", + books: ["Book 1"], + createdAt: new Date() + } + + } + + const testClass = new TestClass(); + const result = testClass.getCastFromObject(testClass.data); + + it('should cast values according to casts property', () => { + expect(typeof result.age).toBe('number'); + expect(result.age).toBe(25); + + expect(typeof result.isActive).toBe('boolean'); + expect(result.isActive).toBe(true); + + expect(result.joinDate).toBeInstanceOf(Date); + expect(result.joinDate.toISOString()).toContain('2024-01-01'); + + expect(typeof result.score).toBe('number'); + expect(result.score).toBe(91.5); + + expect(typeof result.rank).toBe('number'); + expect(result.rank).toBe(1); + expect(Number.isInteger(result.rank)).toBe(true); + + expect(Array.isArray(result.items)).toBe(true); + expect(result.items).toEqual(['item1', 'item2']); + + expect(typeof result.settings).toBe('object'); + expect(result.settings).toEqual({ theme: 'dark' }); + + expect(typeof result.userId).toBe('bigint'); + expect(result.userId.toString()).toBe('1234567890'); + + expect(typeof result.userType).toBe('symbol'); + expect(result.userType.toString()).toBe('Symbol(premium)'); + }); + + it('should not cast properties not defined in casts', () => { + expect(typeof result.name).toBe('string'); + expect(result.name).toBe('John'); + + expect(Array.isArray(result.books)).toBe(true); + expect(result.books).toEqual(['Book 1']); + + expect(result.createdAt).toBeInstanceOf(Date); + }); + }); + + describe('error handling', () => { + interface InvalidData { + age: number; + joinDate: Date; + score: number; + } + + class InvalidCastClass extends BaseCastable { + + casts: Record = { + age: 'number', + joinDate: 'date', + score: 'float' + } + + data = { + age: "not a number", + joinDate: "invalid date", + score: "not a float" + } + + } + + it('should throw CastException for invalid cast values', () => { + const invalidClass = new InvalidCastClass(); + expect(() => { + invalidClass.getCastFromObject(invalidClass.data); + }).toThrow(CastException); + }); + }); + + describe('null and undefined handling', () => { + interface NullableData { + nullValue: null; + undefinedValue: undefined; + optionalNumber: number; + } + + class NullableClass extends BaseCastable { + + casts: Record = { + nullValue: 'null', + undefinedValue: 'undefined', + optionalNumber: 'number' + } + + data = { + nullValue: null, + undefinedValue: undefined, + optionalNumber: null + } + + } + + it('should handle null and undefined values correctly', () => { + const nullableClass = new NullableClass(); + + // Test null and undefined separately first + const validData = { + nullValue: null, + undefinedValue: undefined + }; + + const result: NullableData = nullableClass.getCastFromObject(validData); + expect(result.nullValue).toBeNull(); + expect(result.undefinedValue).toBeUndefined(); + }); + + it('should throw CastException when trying to cast null to number', () => { + const nullableClass = new NullableClass(); + + expect(() => { + nullableClass.getCastFromObject(nullableClass.data); + }).toThrow(CastException); + }); + }); + + describe('empty and invalid casts', () => { + interface EmptyData { + name: string; + age: string; + } + + class EmptyCastClass extends BaseCastable { + + casts: Record = {} + + data = { + name: "John", + age: "25" + } + + } + + it('should return original data when no casts are defined', () => { + const emptyClass = new EmptyCastClass(); + const result = emptyClass.getCastFromObject(emptyClass.data); + + expect(result).toEqual(emptyClass.data); + }); + + interface InvalidTypeData { + age: unknown; + } + + class InvalidTypeCastClass extends BaseCastable { + + casts: Record = { + age: 'invalid' as TCastableType + } + + data = { + age: "25" + } + + } + + it('should throw CastException for invalid cast types', () => { + const invalidClass = new InvalidTypeCastClass(); + expect(() => { + invalidClass.getCastFromObject(invalidClass.data); + }).toThrow(CastException); + }); + }); + + describe('performance with many properties', () => { + interface LargeData { + prop1: number; + prop2: string; + prop3: boolean; + nonCast1: string; + nonCast2: string; + nonCast3: string; + } + + class LargeDataClass extends BaseCastable { + + casts: Record = { + prop1: 'number', + prop2: 'string', + prop3: 'boolean', + } + + data = { + prop1: "1", + prop2: 2, + prop3: "true", + nonCast1: "value1", + nonCast2: "value2", + nonCast3: "value3" + } + + } + + it('should handle large objects efficiently', () => { + const largeClass = new LargeDataClass(); + const result = largeClass.getCastFromObject(largeClass.data); + + expect(typeof result.prop1).toBe('number'); + expect(typeof result.prop2).toBe('string'); + expect(typeof result.prop3).toBe('boolean'); + expect(result.nonCast1).toBe('value1'); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/tests/events/eventAuthUserRegistered.test.ts b/src/tests/events/eventAuthUserRegistered.test.ts new file mode 100644 index 000000000..063707629 --- /dev/null +++ b/src/tests/events/eventAuthUserRegistered.test.ts @@ -0,0 +1,78 @@ +/* eslint-disable no-undef */ +import { describe } from '@jest/globals'; +import { IUserData } from '@src/app/models/auth/User'; +import Kernel from '@src/core/Kernel'; +import { App } from '@src/core/services/App'; +import testAppConfig from '@src/tests/config/testConfig'; +import { TestUserCreatedListener } from '@src/tests/events/events/auth/TestUserCreatedListener'; +import TestUserCreatedSubscriber from '@src/tests/events/events/auth/TestUserCreatedSubscriber'; +import TestConsoleProvider from '@src/tests/providers/TestConsoleProvider'; +import TestDatabaseProvider from '@src/tests/providers/TestDatabaseProvider'; +import TestEventProvider from '@src/tests/providers/TestEventProvider'; + +import TestUserFactory from '../factory/factories/TestUserFactory'; +import { dropWorkerTables } from './helpers/createWorketTables'; + + +describe('mock queable event', () => { + + /** + * Register the test event provider + */ + beforeAll(async () => { + await Kernel.boot({ + ...testAppConfig, + providers: [ + ...testAppConfig.providers, + new TestConsoleProvider(), + new TestDatabaseProvider(), + new TestEventProvider(), + ] + }, {}) + }) + + afterAll(async () => { + await dropWorkerTables(); + }) + + + /** + * - Dispatch TestEventQueueEvent, this will add a queued item to the database + * - Run the worker, which will dispatch TestEventQueueCalledFromWorkerEvent + * - Check the events have been dispatched + * - Check the worker empty has been cleared + */ + test('test queued worker ', async () => { + + const eventService = App.container('events'); + + eventService.mockEvent(TestUserCreatedListener) + eventService.mockEvent(TestUserCreatedSubscriber) + + const testUser = new TestUserFactory().createWithData({ + email: 'test@example.com', + hashedPassword: 'password', + roles: [], + groups: [], + firstName: 'Tony', + lastName: 'Stark' + }) + + await testUser.save(); + expect(testUser.getId()).toBeTruthy(); + + const expectedPayloadCallback = (payload: IUserData) => { + return payload.id === testUser.getId() && payload.email === 'test@example.com' + } + + expect( + eventService.assertDispatched(TestUserCreatedListener, expectedPayloadCallback) + ).toBeTruthy() + + expect( + eventService.assertDispatched(TestUserCreatedSubscriber, expectedPayloadCallback) + ).toBeTruthy() + }) + + +}); \ No newline at end of file diff --git a/src/tests/events/eventQueableFailed.test.ts b/src/tests/events/eventQueableFailed.test.ts new file mode 100644 index 000000000..b5d90ed49 --- /dev/null +++ b/src/tests/events/eventQueableFailed.test.ts @@ -0,0 +1,76 @@ +/* eslint-disable no-undef */ +import { describe } from '@jest/globals'; +import QueueableDriver, { TQueueDriverOptions } from '@src/core/domains/events/drivers/QueableDriver'; +import EventService from '@src/core/domains/events/services/EventService'; +import { IModel } from '@src/core/interfaces/IModel'; +import Kernel from '@src/core/Kernel'; +import { App } from '@src/core/services/App'; +import testAppConfig from '@src/tests/config/testConfig'; +import TestEventQueueAddAlwaysFailsEventToQueue from '@src/tests/events/events/TestEventQueueAddAlwaysFailsEventToQueue'; +import TestEventQueueAlwaysFailsEvent from '@src/tests/events/events/TestEventQueueAlwaysFailsEvent'; +import createWorkerTables, { dropWorkerTables } from '@src/tests/events/helpers/createWorketTables'; +import TestFailedWorkerModel from '@src/tests/models/models/TestFailedWorkerModel'; +import TestWorkerModel from '@src/tests/models/models/TestWorkerModel'; +import TestConsoleProvider from '@src/tests/providers/TestConsoleProvider'; +import TestDatabaseProvider from '@src/tests/providers/TestDatabaseProvider'; +import TestEventProvider from '@src/tests/providers/TestEventProvider'; + + +describe('mock queable event failed', () => { + + /** + * Register the test event provider + */ + beforeAll(async () => { + await Kernel.boot({ + ...testAppConfig, + providers: [ + ...testAppConfig.providers, + new TestConsoleProvider(), + new TestDatabaseProvider(), + new TestEventProvider() + ] + }, {}) + }) + + afterAll(async () => { + await dropWorkerTables(); + }) + + + /** + * - Dispatch TestEventQueueEvent, this will add a queued item to the database + * - Run the worker, which will dispatch TestEventQueueCalledFromWorkerEvent + * - Check the events have been dispatched + * - Check the worker empty has been cleared + */ + test('test dispatch event queable ', async () => { + + await dropWorkerTables(); + await createWorkerTables(); + + const eventService = App.container('events'); + const driverOptions = eventService.getDriverOptionsByName(EventService.getDriverName(QueueableDriver))?.['options'] as TQueueDriverOptions; + const attempts = driverOptions?.retries ?? 3 + + eventService.mockEvent(TestEventQueueAddAlwaysFailsEventToQueue) + + await eventService.dispatch(new TestEventQueueAddAlwaysFailsEventToQueue()); + + expect(eventService.assertDispatched(TestEventQueueAddAlwaysFailsEventToQueue)).toBeTruthy() + + for(let i = 0; i < attempts; i++) { + App.container('events').mockEvent(TestEventQueueAlwaysFailsEvent); + + await App.container('console').reader(['worker', '--queue=testQueue']).handle(); + + expect(App.container('events').assertDispatched(TestEventQueueAlwaysFailsEvent)).toBeTruthy() + } + + const results = await App.container('db').documentManager().table(new TestWorkerModel().table).findMany({}) + expect(results.length).toBe(0) + + const failedResults = await App.container('db').documentManager().table(new TestFailedWorkerModel().table).findMany({}) + expect(failedResults.length).toBe(1) + }) +}); \ No newline at end of file diff --git a/src/tests/events/eventQueableSuccess.test.ts b/src/tests/events/eventQueableSuccess.test.ts new file mode 100644 index 000000000..8c23c1594 --- /dev/null +++ b/src/tests/events/eventQueableSuccess.test.ts @@ -0,0 +1,76 @@ +/* eslint-disable no-undef */ +import { describe } from '@jest/globals'; +import { IModel } from '@src/core/interfaces/IModel'; +import Kernel from '@src/core/Kernel'; +import { App } from '@src/core/services/App'; +import testAppConfig from '@src/tests/config/testConfig'; +import TestEventQueueCalledFromWorkerEvent from '@src/tests/events/events/TestEventQueueCalledFromWorkerEvent'; +import TestEventQueueEvent from '@src/tests/events/events/TestEventQueueEvent'; +import createWorkerTables, { dropWorkerTables } from '@src/tests/events/helpers/createWorketTables'; +import TestWorkerModel from '@src/tests/models/models/TestWorkerModel'; +import TestConsoleProvider from '@src/tests/providers/TestConsoleProvider'; +import TestDatabaseProvider from '@src/tests/providers/TestDatabaseProvider'; +import TestEventProvider from '@src/tests/providers/TestEventProvider'; + + +describe('mock queable event', () => { + + /** + * Register the test event provider + */ + beforeAll(async () => { + await Kernel.boot({ + ...testAppConfig, + providers: [ + ...testAppConfig.providers, + new TestConsoleProvider(), + new TestDatabaseProvider(), + new TestEventProvider() + ] + }, {}) + }) + + afterAll(async () => { + await dropWorkerTables(); + }) + + + /** + * - Dispatch TestEventQueueEvent, this will add a queued item to the database + * - Run the worker, which will dispatch TestEventQueueCalledFromWorkerEvent + * - Check the events have been dispatched + * - Check the worker empty has been cleared + */ + test('test queued worker ', async () => { + + await dropWorkerTables(); + await createWorkerTables(); + + const eventService = App.container('events'); + + eventService.mockEvent(TestEventQueueEvent) + eventService.mockEvent(TestEventQueueCalledFromWorkerEvent); + + await eventService.dispatch(new TestEventQueueEvent({ hello: 'world', createdAt: new Date() })); + + expect( + eventService.assertDispatched<{ hello: string }>(TestEventQueueEvent, (payload) => { + return payload.hello === 'world' + }) + ).toBeTruthy() + + // run the worker + await App.container('console').reader(['worker', '--queue=testQueue']).handle(); + + expect( + eventService.assertDispatched<{ hello: string, createdAt: Date }>(TestEventQueueCalledFromWorkerEvent, (payload) => { + return payload.hello === 'world' && payload.createdAt instanceof Date + }) + ).toBeTruthy() + + + const results = await App.container('db').documentManager().table(new TestWorkerModel().table).findMany({}) + expect(results.length).toBe(0) + }) + +}); \ No newline at end of file diff --git a/src/tests/events/eventQueue.test.ts b/src/tests/events/eventQueue.test.ts deleted file mode 100644 index 65b792a34..000000000 --- a/src/tests/events/eventQueue.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* eslint-disable no-undef */ -import { beforeAll, describe, test } from '@jest/globals'; -import Repository from '@src/core/base/Repository'; -import Kernel from '@src/core/Kernel'; -import { App } from '@src/core/services/App'; -import testAppConfig from '@src/tests/config/testConfig'; -import TestQueueSubscriber from '@src/tests/events/subscribers/TestQueueSubscriber'; -import { TestMovieModel } from '@src/tests/models/models/TestMovie'; -import TestConsoleProvider from '@src/tests/providers/TestConsoleProvider'; -import TestDatabaseProvider from '@src/tests/providers/TestDatabaseProvider'; -import TestEventProvider from '@src/tests/providers/TestEventProvider'; -import 'dotenv/config'; -import { DataTypes } from 'sequelize'; - -const createTable = async () => { - await App.container('db').schema().createTable('testsWorker', { - queueName: DataTypes.STRING, - eventName: DataTypes.STRING, - payload: DataTypes.JSON, - attempt: DataTypes.INTEGER, - retries: DataTypes.INTEGER, - createdAt: DataTypes.DATE - }); - - await App.container('db').schema().createTable('tests', { - name: DataTypes.STRING, - createdAt: DataTypes.DATE, - updatedAt: DataTypes.DATE - }); -} - -const dropTable = async () => { - await App.container('db').schema().dropTable('tests') - await App.container('db').schema().dropTable('testsWorker') -} - -const movieName = 'testMovie'; - -describe('mock event service', () => { - - /** - * Setup MongoDB - * Setup Kernel with test Console and Event provider - */ - beforeAll(async () => { - await Kernel.boot({ - ...testAppConfig, - providers: [ - ...testAppConfig.providers, - new TestDatabaseProvider(), - new TestConsoleProvider(), - new TestEventProvider() - ] - }, {}); - }); - - - /** - * Dispatches the TestQueueSubscriber event to the worker - */ - test('dispatch a test event', async () => { - await dropTable() - await createTable() - - // Dispatch an event - const events = App.container('events'); - await events.dispatch(new TestQueueSubscriber({ name: movieName })); - - // Wait for the event to be processed - await App.container('console').reader(['worker']).handle(); - - // Check if the movie was created - const repository = new Repository(TestMovieModel); - const movie = await repository.findOne({ name: movieName }); - expect(typeof movie?.getId() === 'string').toBe(true) - expect(movie?.getAttribute('name')).toBe(movieName); - - await movie?.delete(); - expect(movie?.attributes).toBeNull(); - }); - - -}); \ No newline at end of file diff --git a/src/tests/events/eventSync.test.ts b/src/tests/events/eventSync.test.ts index 83836c2d6..477cdde98 100644 --- a/src/tests/events/eventSync.test.ts +++ b/src/tests/events/eventSync.test.ts @@ -3,7 +3,8 @@ import { describe } from '@jest/globals'; import Kernel from '@src/core/Kernel'; import { App } from '@src/core/services/App'; import testAppConfig from '@src/tests/config/testConfig'; -import TestSubscriber from '@src/tests/events/subscribers/TestSyncSubscriber'; +import TestEventSyncBadPayloadEvent from '@src/tests/events/events/TestEventSyncBadPayloadEvent'; +import TestEventSyncEvent from '@src/tests/events/events/TestEventSyncEvent'; import TestConsoleProvider from '@src/tests/providers/TestConsoleProvider'; import TestEventProvider from '@src/tests/providers/TestEventProvider'; @@ -26,8 +27,38 @@ describe('mock event service', () => { /** * Dispatch a synchronus event */ - test('test dispatch event sync', () => { - App.container('events').dispatch(new TestSubscriber({ hello: 'world' })); - expect(true).toBeTruthy() + test('test dispatch event sync with valid payload', async () => { + + const eventService = App.container('events'); + + eventService.mockEvent(TestEventSyncEvent) + + await eventService.dispatch(new TestEventSyncEvent({ hello: 'world' })); + + expect( + eventService.assertDispatched<{ hello: string }>(TestEventSyncEvent, (payload) => { + return payload.hello === 'world' + }) + ).toBeTruthy() + }) + + /** + * Dispatch a synchronus event + */ + test('test dispatch event sync with invalid payload', async () => { + + const eventService = App.container('events'); + + eventService.mockEvent(TestEventSyncBadPayloadEvent) + + await eventService.dispatch(new TestEventSyncBadPayloadEvent({ unexpectedProperty: 123 })); + + expect(eventService.assertDispatched(TestEventSyncBadPayloadEvent)).toBeTruthy() + + expect( + eventService.assertDispatched<{ hello: string }>(TestEventSyncBadPayloadEvent, (payload) => { + return payload.hello === 'world' + }) + ).toBeFalsy() }) }); \ No newline at end of file diff --git a/src/tests/events/events/TestEventQueueAddAlwaysFailsEventToQueue.ts b/src/tests/events/events/TestEventQueueAddAlwaysFailsEventToQueue.ts new file mode 100644 index 000000000..25a1f180a --- /dev/null +++ b/src/tests/events/events/TestEventQueueAddAlwaysFailsEventToQueue.ts @@ -0,0 +1,34 @@ + +import BaseEvent from "@src/core/domains/events/base/BaseEvent"; +import QueueableDriver from "@src/core/domains/events/drivers/QueableDriver"; +import { App } from "@src/core/services/App"; +import TestEventQueueAlwaysFailsEvent from "@src/tests/events/events/TestEventQueueAlwaysFailsEvent"; + + +class TestEventQueueAddAlwaysFailsEventToQueue extends BaseEvent { + + protected namespace: string = 'testing'; + + static readonly eventName = 'TestEventQueueAddAlwaysFailsEventToQueue'; + + constructor() { + super(null, QueueableDriver) + } + + getQueueName(): string { + return 'testQueue'; + } + + getName(): string { + return TestEventQueueAddAlwaysFailsEventToQueue.eventName; + } + + async execute(): Promise { + console.log('Executed TestEventQueueAddAlwaysFailsEventToQueue', this.getPayload(), this.getName()) + + await App.container('events').dispatch(new TestEventQueueAlwaysFailsEvent()) + } + +} + +export default TestEventQueueAddAlwaysFailsEventToQueue \ No newline at end of file diff --git a/src/tests/events/events/TestEventQueueAlwaysFailsEvent.ts b/src/tests/events/events/TestEventQueueAlwaysFailsEvent.ts new file mode 100644 index 000000000..704e68a9b --- /dev/null +++ b/src/tests/events/events/TestEventQueueAlwaysFailsEvent.ts @@ -0,0 +1,26 @@ + +import BaseEvent from "@src/core/domains/events/base/BaseEvent"; + + +class TestEventQueueAlwaysFailsEvent extends BaseEvent { + + protected namespace: string = 'testing'; + + static readonly eventName = 'TestEventQueueAlwaysFailsEvent'; + + getQueueName(): string { + return 'testQueue'; + } + + getName(): string { + return TestEventQueueAlwaysFailsEvent.eventName; + } + + async execute(): Promise { + console.log('Executed TestEventQueueAlwaysFailsEvent', this.getPayload(), this.getName()) + throw new Error('Always fails'); + } + +} + +export default TestEventQueueAlwaysFailsEvent \ No newline at end of file diff --git a/src/tests/events/events/TestEventQueueCalledFromWorkerEvent.ts b/src/tests/events/events/TestEventQueueCalledFromWorkerEvent.ts new file mode 100644 index 000000000..4b2e50ad0 --- /dev/null +++ b/src/tests/events/events/TestEventQueueCalledFromWorkerEvent.ts @@ -0,0 +1,26 @@ + +import BaseEvent from "@src/core/domains/events/base/BaseEvent"; +import SyncDriver from "@src/core/domains/events/drivers/SyncDriver"; +import { TCasts } from "@src/core/interfaces/concerns/IHasCastableConcern"; + +class TestEventQueueCalledFromWorkerEvent extends BaseEvent { + + protected namespace: string = 'testing'; + + static readonly eventName = 'TestEventQueueCalledFromWorkerEvent'; + + casts: TCasts = { + createdAt: "date" + } + + constructor(payload) { + super(payload, SyncDriver) + } + + getName(): string { + return TestEventQueueCalledFromWorkerEvent.eventName; + } + +} + +export default TestEventQueueCalledFromWorkerEvent \ No newline at end of file diff --git a/src/tests/events/events/TestEventQueueEvent.ts b/src/tests/events/events/TestEventQueueEvent.ts new file mode 100644 index 000000000..6837107d0 --- /dev/null +++ b/src/tests/events/events/TestEventQueueEvent.ts @@ -0,0 +1,33 @@ + +import BaseEvent from "@src/core/domains/events/base/BaseEvent"; +import QueueableDriver from "@src/core/domains/events/drivers/QueableDriver"; +import { App } from "@src/core/services/App"; +import TestEventQueueCalledFromWorkerEvent from "@src/tests/events/events/TestEventQueueCalledFromWorkerEvent"; + +class TestEventQueueEvent extends BaseEvent { + + protected namespace: string = 'testing'; + + static readonly eventName = 'TestEventQueueEvent'; + + constructor(payload) { + super(payload, QueueableDriver) + } + + getQueueName(): string { + return 'testQueue'; + } + + getName(): string { + return TestEventQueueEvent.eventName; + } + + async execute(): Promise { + console.log('Executed TestEventQueueEvent', this.getPayload(), this.getName()) + + App.container('events').dispatch(new TestEventQueueCalledFromWorkerEvent(this.getPayload())) + } + +} + +export default TestEventQueueEvent \ No newline at end of file diff --git a/src/tests/events/events/TestEventSyncBadPayloadEvent.ts b/src/tests/events/events/TestEventSyncBadPayloadEvent.ts new file mode 100644 index 000000000..5f82e6049 --- /dev/null +++ b/src/tests/events/events/TestEventSyncBadPayloadEvent.ts @@ -0,0 +1,23 @@ + +import BaseEvent from "@src/core/domains/events/base/BaseEvent"; + + +class TestEventSyncBadPayloadEvent extends BaseEvent { + + protected namespace: string = 'testing'; + + constructor(payload) { + super(payload); + } + + async execute(): Promise { + console.log('Executed TestEventSyncBadPayloadEvent', this.getPayload(), this.getName()) + } + + getName(): string { + return 'TestEventSyncBadPayloadEvent' + } + +} + +export default TestEventSyncBadPayloadEvent \ No newline at end of file diff --git a/src/tests/events/events/TestEventSyncEvent.ts b/src/tests/events/events/TestEventSyncEvent.ts new file mode 100644 index 000000000..d4866c0c4 --- /dev/null +++ b/src/tests/events/events/TestEventSyncEvent.ts @@ -0,0 +1,17 @@ + +import BaseEvent from "@src/core/domains/events/base/BaseEvent"; + + +class TestEventSyncEvent extends BaseEvent { + + static readonly eventName = 'TestEventSyncEvent'; + + protected namespace: string = 'testing'; + + async execute(): Promise { + console.log('Executed TestEventSyncEvent', this.getPayload(), this.getName()) + } + +} + +export default TestEventSyncEvent \ No newline at end of file diff --git a/src/tests/events/events/auth/TestUserCreatedListener.ts b/src/tests/events/events/auth/TestUserCreatedListener.ts new file mode 100644 index 000000000..319d2ad9f --- /dev/null +++ b/src/tests/events/events/auth/TestUserCreatedListener.ts @@ -0,0 +1,17 @@ +import BaseEventListener from "@src/core/domains/events/base/BaseEventListener"; + +export class TestUserCreatedListener extends BaseEventListener { + + static readonly eventName = 'TestUserCreatedListener'; + + protected namespace: string = 'testing'; + + async execute(): Promise { + console.log('Executed TestUserCreatedListener', this.getPayload(), this.getName()) + } + + getName(): string { + return TestUserCreatedListener.eventName + } + +} \ No newline at end of file diff --git a/src/tests/events/events/auth/TestUserCreatedSubscriber.ts b/src/tests/events/events/auth/TestUserCreatedSubscriber.ts new file mode 100644 index 000000000..98ec7384e --- /dev/null +++ b/src/tests/events/events/auth/TestUserCreatedSubscriber.ts @@ -0,0 +1,29 @@ +import { IUserData } from "@src/app/models/auth/User"; +import BaseEvent from "@src/core/domains/events/base/BaseEvent"; +import SyncDriver from "@src/core/domains/events/drivers/SyncDriver"; + +export default class TestUserCreatedSubscriber extends BaseEvent { + + static readonly eventName = 'TestUserCreatedSubscriber'; + + protected namespace: string = 'testing'; + + constructor(payload) { + super(payload, SyncDriver); + } + + getName(): string { + return TestUserCreatedSubscriber.eventName; + } + + getQueueName(): string { + return 'default'; + } + + async execute(): Promise { + const payload = this.getPayload(); + + console.log('User was created', payload); + } + +} \ No newline at end of file diff --git a/src/tests/events/helpers/createWorketTables.ts b/src/tests/events/helpers/createWorketTables.ts new file mode 100644 index 000000000..9d751143c --- /dev/null +++ b/src/tests/events/helpers/createWorketTables.ts @@ -0,0 +1,32 @@ +import { App } from "@src/core/services/App"; +import TestFailedWorkerModel from "@src/tests/models/models/TestFailedWorkerModel"; +import TestWorkerModel from "@src/tests/models/models/TestWorkerModel"; +import { DataTypes } from "sequelize"; + +export const dropWorkerTables = async () => { + await App.container('db').schema().dropTable((new TestWorkerModel).table); + + await App.container('db').schema().dropTable((new TestFailedWorkerModel).table); +} + +export const createWorkerTables = async () => { + + await App.container('db').schema().createTable((new TestWorkerModel).table, { + queueName: DataTypes.STRING, + eventName: DataTypes.STRING, + payload: DataTypes.JSON, + attempt: DataTypes.INTEGER, + retries: DataTypes.INTEGER, + createdAt: DataTypes.DATE + }); + + await App.container('db').schema().createTable((new TestFailedWorkerModel).table, { + queueName: DataTypes.STRING, + eventName: DataTypes.STRING, + payload: DataTypes.JSON, + error: DataTypes.STRING, + failedAt: DataTypes.DATE + }) +} + +export default createWorkerTables \ No newline at end of file diff --git a/src/tests/events/listeners/TestListener.ts b/src/tests/events/listeners/TestListener.ts index 44694bda9..5bea708d0 100644 --- a/src/tests/events/listeners/TestListener.ts +++ b/src/tests/events/listeners/TestListener.ts @@ -1,10 +1,17 @@ -import EventListener from "@src/core/domains/events/services/EventListener"; -import { App } from "@src/core/services/App"; - -export class TestListener extends EventListener { +import BaseEventListener from "@src/core/domains/events/base/BaseEventListener"; +import SyncDriver from "@src/core/domains/events/drivers/SyncDriver"; + +class TestListener extends BaseEventListener { + + constructor(payload: { hello: string }) { + super(payload, SyncDriver); + } - handle = async (payload: any) => { - App.container('logger').info('[TestListener]', payload) + + async execute(): Promise { + console.log('Executed TestListener', this.getPayload(), this.getName()); } -} \ No newline at end of file +} + +export default TestListener \ No newline at end of file diff --git a/src/tests/events/listeners/TestQueueListener.ts b/src/tests/events/listeners/TestQueueListener.ts deleted file mode 100644 index 719d278c1..000000000 --- a/src/tests/events/listeners/TestQueueListener.ts +++ /dev/null @@ -1,16 +0,0 @@ -import EventListener from "@src/core/domains/events/services/EventListener"; -import { App } from "@src/core/services/App"; -import { TestMovieModel } from "@src/tests/models/models/TestMovie"; - -export class TestQueueListener extends EventListener<{name: string}> { - - handle = async (payload: {name: string}) => { - App.container('logger').info('[TestQueueListener]', { name: payload }) - - const movie = new TestMovieModel({ - name: payload.name - }); - await movie.save(); - } - -} \ No newline at end of file diff --git a/src/tests/events/subscribers/TestQueueSubscriber.ts b/src/tests/events/subscribers/TestQueueSubscriber.ts deleted file mode 100644 index 2945e92dd..000000000 --- a/src/tests/events/subscribers/TestQueueSubscriber.ts +++ /dev/null @@ -1,12 +0,0 @@ -import EventSubscriber from "@src/core/domains/events/services/EventSubscriber"; - -export default class TestQueueSubscriber extends EventSubscriber { - - constructor(payload: any) { - const eventName = 'TestQueueEvent' - const driver = 'testing'; - - super(eventName, driver, payload) - } - -} \ No newline at end of file diff --git a/src/tests/events/subscribers/TestSubscriber.ts b/src/tests/events/subscribers/TestSubscriber.ts new file mode 100644 index 000000000..9c993cb30 --- /dev/null +++ b/src/tests/events/subscribers/TestSubscriber.ts @@ -0,0 +1,15 @@ +import BaseEvent from "@src/core/domains/events/base/BaseEvent"; +import TestEventSyncEvent from "@src/tests/events/events/TestEventSyncEvent"; + + +class TestSubscriber extends BaseEvent { + + async execute(): Promise { + console.log('Executed TestSubscriber', this.getPayload(), this.getName()) + + this.getEventService().dispatch(new TestEventSyncEvent(this.getPayload())); + } + +} + +export default TestSubscriber \ No newline at end of file diff --git a/src/tests/events/subscribers/TestSyncSubscriber.ts b/src/tests/events/subscribers/TestSyncSubscriber.ts deleted file mode 100644 index 5696e5fd8..000000000 --- a/src/tests/events/subscribers/TestSyncSubscriber.ts +++ /dev/null @@ -1,12 +0,0 @@ -import EventSubscriber from "@src/core/domains/events/services/EventSubscriber"; - -export default class TestSubscriber extends EventSubscriber { - - constructor(payload: any) { - const eventName = 'TestEvent' - const driver = 'sync'; - - super(eventName, driver, payload) - } - -} \ No newline at end of file diff --git a/src/tests/factory/factories/TestUserFactory.ts b/src/tests/factory/factories/TestUserFactory.ts new file mode 100644 index 000000000..5f47df103 --- /dev/null +++ b/src/tests/factory/factories/TestUserFactory.ts @@ -0,0 +1,21 @@ +import Factory from '@src/core/base/Factory'; +import TestUser from '@src/tests/models/models/TestUser'; + +/** + * Factory for creating User models. + * + * @class UserFactory + * @extends {Factory} + */ +export default class TestUserFactory extends Factory { + + /** + * Constructor + * + * @constructor + */ + constructor() { + super(TestUser) + } + +} diff --git a/src/tests/models/models/TestFailedWorkerModel.ts b/src/tests/models/models/TestFailedWorkerModel.ts new file mode 100644 index 000000000..dda8a7459 --- /dev/null +++ b/src/tests/models/models/TestFailedWorkerModel.ts @@ -0,0 +1,11 @@ +import { TFailedWorkerModelData } from "@src/core/domains/events/interfaces/IEventWorkerConcern"; +import FailedWorkerModel from "@src/core/domains/events/models/FailedWorkerModel"; + +export default class TestFailedWorkerModel extends FailedWorkerModel { + + constructor(data: TFailedWorkerModelData | null = null) { + super(data ?? {} as TFailedWorkerModelData) + this.table = 'testsWorkerFailed' + } + +} \ No newline at end of file diff --git a/src/tests/models/models/TestUser.ts b/src/tests/models/models/TestUser.ts new file mode 100644 index 000000000..d2f6b5036 --- /dev/null +++ b/src/tests/models/models/TestUser.ts @@ -0,0 +1,129 @@ +import ApiToken from "@src/app/models/auth/ApiToken"; +import Model from "@src/core/base/Model"; +import IUserModel from "@src/core/domains/auth/interfaces/IUserModel"; +import IModelAttributes from "@src/core/interfaces/IModelData"; +import TestUserObserver from "@src/tests/observers/TestUserObserver"; + +/** + * User structure + */ +export interface IUserData extends IModelAttributes { + email: string; + password?: string; + hashedPassword: string; + roles: string[]; + groups: string[]; + firstName?: string; + lastName?: string; + createdAt?: Date; + updatedAt?: Date; +} + +/** + * User model + * + * Represents a user in the database. + */ +export default class TestUser extends Model implements IUserModel { + + /** + * Table name + */ + public table: string = 'users'; + + /** + * @param data User data + */ + constructor(data: IUserData | null = null) { + super(data); + this.observeWith(TestUserObserver); + } + + /** + * Guarded fields + * + * These fields cannot be set directly. + */ + guarded: string[] = [ + 'hashedPassword', + 'password', + 'roles', + 'groups', + ]; + + /** + * The fields that are allowed to be set directly + * + * These fields can be set directly on the model. + */ + fields: string[] = [ + 'email', + 'password', + 'hashedPassword', + 'roles', + 'firstName', + 'lastName', + 'createdAt', + 'updatedAt', + ] + + /** + * Fields that should be returned as JSON + * + * These fields will be returned as JSON when the model is serialized. + */ + json = [ + 'groups', + 'roles' + ] + + /** + * Checks if the user has the given role + * + * @param role The role to check + * @returns True if the user has the role, false otherwise + */ + hasRole(roles: string | string[]): boolean { + roles = typeof roles === 'string' ? [roles] : roles; + const userRoles = this.getAttribute('roles') ?? []; + + for(const role of roles) { + if(!userRoles.includes(role)) return false; + } + + return true; + } + + /** + * Checks if the user has the given role + * + * @param role The role to check + * @returns True if the user has the role, false otherwise + */ + hasGroup(groups: string | string[]): boolean { + groups = typeof groups === 'string' ? [groups] : groups; + const userGroups = this.getAttribute('groups') ?? []; + + for(const group of groups) { + if(!userGroups.includes(group)) return false; + } + + return true; + } + + /** + * @returns The tokens associated with this user + * + * Retrieves the ApiToken models associated with this user. + */ + async tokens(active: boolean = true): Promise { + const filters = active ? { revokedAt: null } : {}; + + return this.hasMany(ApiToken, { + localKey: 'id', + foreignKey: 'userId', + filters + }) + } + +} diff --git a/src/tests/models/models/TestWorkerModel.ts b/src/tests/models/models/TestWorkerModel.ts index 96415b8a9..5e98dbee7 100644 --- a/src/tests/models/models/TestWorkerModel.ts +++ b/src/tests/models/models/TestWorkerModel.ts @@ -1,9 +1,10 @@ -import WorkerModel, { WorkerModelData } from "@src/core/domains/events/models/WorkerModel"; +import { TWorkerModelData } from "@src/core/domains/events/interfaces/IEventWorkerConcern"; +import WorkerModel from "@src/core/domains/events/models/WorkerModel"; export default class TestWorkerModel extends WorkerModel { - constructor(data: WorkerModelData | null = null) { - super(data ?? {} as WorkerModelData) + constructor(data: TWorkerModelData | null = null) { + super(data ?? {} as TWorkerModelData) this.table = 'testsWorker' } diff --git a/src/tests/observers/TestUserObserver.ts b/src/tests/observers/TestUserObserver.ts new file mode 100644 index 000000000..704f0273c --- /dev/null +++ b/src/tests/observers/TestUserObserver.ts @@ -0,0 +1,76 @@ +import { IUserData } from "@src/app/models/auth/User"; +import hashPassword from "@src/core/domains/auth/utils/hashPassword"; +import Observer from "@src/core/domains/observer/services/Observer"; +import { App } from "@src/core/services/App"; + +import { TestUserCreatedListener } from "../events/events/auth/TestUserCreatedListener"; + +/** + * Observer for the User model. + * + * Automatically hashes the password on create/update if it is provided. + */ +export default class TestUserObserver extends Observer { + + /** + * Called when the User model is being created. + * Automatically hashes the password if it is provided. + * @param data The User data being created. + * @returns The processed User data. + */ + async creating(data: IUserData): Promise { + data = this.onPasswordChange(data) + data = await this.updateRoles(data) + return data + } + + /** + * Called after the User model has been created. + * @param data The User data that has been created. + * @returns The processed User data. + */ + async created(data: IUserData): Promise { + await App.container('events').dispatch(new TestUserCreatedListener(data)) + return data + } + + /** + * Updates the roles of the user based on the groups they belong to. + * Retrieves the roles associated with each group the user belongs to from the permissions configuration. + * @param data The User data being created/updated. + * @returns The processed User data with the updated roles. + */ + async updateRoles(data: IUserData): Promise { + let updatedRoles: string[] = []; + + for(const group of data.groups) { + const relatedRoles = App.container('auth').config.permissions.groups.find(g => g.name === group)?.roles ?? []; + + updatedRoles = [ + ...updatedRoles, + ...relatedRoles + ] + } + + data.roles = updatedRoles + + return data + } + + /** + * Automatically hashes the password if it is provided. + * @param data The User data being created/updated. + * @returns The processed User data. + */ + onPasswordChange(data: IUserData): IUserData { + if(!data.password) { + return data + } + + data.hashedPassword = hashPassword(data.password); + delete data.password; + + return data + } + +} diff --git a/src/tests/providers/TestAuthProvider.ts b/src/tests/providers/TestAuthProvider.ts new file mode 100644 index 000000000..9ddb586ab --- /dev/null +++ b/src/tests/providers/TestAuthProvider.ts @@ -0,0 +1,9 @@ +import AuthProvider from "@src/core/domains/auth/providers/AuthProvider"; + +export default class TestAuthProvider extends AuthProvider { + + /** + * todo use test models + */ + +} diff --git a/src/tests/providers/TestEventLegacyProvider.ts b/src/tests/providers/TestEventLegacyProvider.ts new file mode 100644 index 000000000..eac2b1142 --- /dev/null +++ b/src/tests/providers/TestEventLegacyProvider.ts @@ -0,0 +1,42 @@ +import QueueDriver, { QueueDriverOptions } from '@src/core/domains/events-legacy/drivers/QueueDriver'; +import SynchronousDriver from "@src/core/domains/events-legacy/drivers/SynchronousDriver"; +import { EventLegacyServiceConfig } from "@src/core/domains/events-legacy/interfaces/IEventService"; +import EventLegacyProvider from "@src/core/domains/events-legacy/providers/EventLegacyProvider"; +import { default as DriverOptions } from '@src/core/domains/events-legacy/services/QueueDriverOptions'; +import { TestListener } from "@src/tests/events/listeners/TestListenerLegacy"; +import { TestQueueListenerLegacy } from "@src/tests/events/listeners/TestQueueListenerLegacy"; +import TestWorkerModel from "@src/tests/models/models/TestWorkerModel"; + +class TestEventProvider extends EventLegacyProvider { + + protected config: EventLegacyServiceConfig = { + defaultDriver: 'sync', + drivers: { + testing: { + driverCtor: QueueDriver, + options: new DriverOptions({ + queueName: 'testQueue', + retries: 3, + failedCollection: 'testFailedWorkers', + runAfterSeconds: 0, + workerModelCtor: TestWorkerModel, + runOnce: true + }) + }, + sync: { + driverCtor: SynchronousDriver + } + }, + subscribers: { + 'TestQueueEvent': [ + TestQueueListenerLegacy + ], + 'TestEvent': [ + TestListener + ] + } + } + +} + +export default TestEventProvider \ No newline at end of file diff --git a/src/tests/providers/TestEventProvider.ts b/src/tests/providers/TestEventProvider.ts index 43707b957..0b32a67ba 100644 --- a/src/tests/providers/TestEventProvider.ts +++ b/src/tests/providers/TestEventProvider.ts @@ -1,40 +1,68 @@ -import QueueDriver, { QueueDriverOptions } from '@src/core/domains/events/drivers/QueueDriver'; -import SynchronousDriver from "@src/core/domains/events/drivers/SynchronousDriver"; -import { EventServiceConfig } from "@src/core/domains/events/interfaces/IEventService"; -import EventProvider from "@src/core/domains/events/providers/EventProvider"; -import { default as DriverOptions } from '@src/core/domains/events/services/QueueDriverOptions'; -import { TestListener } from "@src/tests/events/listeners/TestListener"; -import { TestQueueListener } from "@src/tests/events/listeners/TestQueueListener"; +import { EVENT_DRIVERS } from '@src/config/events'; +import QueueableDriver, { TQueueDriverOptions } from '@src/core/domains/events/drivers/QueableDriver'; +import SyncDriver from '@src/core/domains/events/drivers/SyncDriver'; +import { IEventConfig } from '@src/core/domains/events/interfaces/config/IEventConfig'; +import EventProvider from '@src/core/domains/events/providers/EventProvider'; +import EventService from '@src/core/domains/events/services/EventService'; +import TestEventQueueAddAlwaysFailsEventToQueue from '@src/tests/events/events/TestEventQueueAddAlwaysFailsEventToQueue'; +import TestEventQueueAlwaysFailsEvent from '@src/tests/events/events/TestEventQueueAlwaysFailsEvent'; +import TestEventQueueCalledFromWorkerEvent from '@src/tests/events/events/TestEventQueueCalledFromWorkerEvent'; +import TestEventQueueEvent from '@src/tests/events/events/TestEventQueueEvent'; +import TestEventSyncBadPayloadEvent from '@src/tests/events/events/TestEventSyncBadPayloadEvent'; +import TestEventSyncEvent from '@src/tests/events/events/TestEventSyncEvent'; +import TestListener from '@src/tests/events/listeners/TestListener'; +import TestSubscriber from '@src/tests/events/subscribers/TestSubscriber'; +import TestFailedWorkerModel from '@src/tests/models/models/TestFailedWorkerModel'; import TestWorkerModel from "@src/tests/models/models/TestWorkerModel"; +import { TestUserCreatedListener } from '../events/events/auth/TestUserCreatedListener'; +import TestUserCreatedSubscriber from '../events/events/auth/TestUserCreatedSubscriber'; + class TestEventProvider extends EventProvider { - protected config: EventServiceConfig = { - defaultDriver: 'sync', + protected config: IEventConfig = { + + defaultDriver: SyncDriver, + drivers: { - testing: { - driver: QueueDriver, - options: new DriverOptions({ - queueName: 'testQueue', - retries: 3, - failedCollection: 'testFailedWorkers', - runAfterSeconds: 0, - workerModelCtor: TestWorkerModel, - runOnce: true - }) + [EVENT_DRIVERS.SYNC]: EventService.createConfigDriver(SyncDriver, {}), + [EVENT_DRIVERS.QUEABLE]: EventService.createConfigDriver(QueueableDriver, { + queueName: 'testQueue', + retries: 3, + runAfterSeconds: 0, + failedWorkerModelCtor: TestFailedWorkerModel, + workerModelCtor: TestWorkerModel, + runOnce: true + }) + }, + + events: [ + // Sync events + TestEventSyncEvent, + TestEventSyncBadPayloadEvent, + + // Queable (worker events) + TestEventQueueEvent, + TestEventQueueCalledFromWorkerEvent, + TestEventQueueAddAlwaysFailsEventToQueue, + TestEventQueueAlwaysFailsEvent, + + ], + + listeners: EventService.createConfigListeners([ + { + listener: TestListener, + subscribers: [ + TestSubscriber + ] }, - sync: { - driver: SynchronousDriver + { + listener: TestUserCreatedListener, + subscribers: [ + TestUserCreatedSubscriber + ] } - }, - subscribers: { - 'TestQueueEvent': [ - TestQueueListener - ], - 'TestEvent': [ - TestListener - ] - } + ]) } } diff --git a/src/tests/runApp.test.ts b/src/tests/runApp.test.ts index 445e50554..e9705437b 100644 --- a/src/tests/runApp.test.ts +++ b/src/tests/runApp.test.ts @@ -3,7 +3,7 @@ import appConfig from '@src/config/app'; import AuthService from '@src/core/domains/auth/services/AuthService'; import ConsoleService from '@src/core/domains/console/service/ConsoleService'; import DatabaseService from '@src/core/domains/database/services/DatabaseService'; -import EventService from '@src/core/domains/events/services/EventService'; +import EventService from '@src/core/domains/events-legacy/services/EventService'; import ExpressService from '@src/core/domains/express/services/ExpressService'; import Kernel from '@src/core/Kernel'; import { App } from '@src/core/services/App';