alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
958 lines (760 loc) • 26.5 kB
text/typescript
import { Alepha } from "alepha";
import { describe, expect, it, vi } from "vitest";
import type { LoggerInterface } from "../interfaces/LoggerInterface.ts";
import { EventManager } from "../providers/EventManager.ts";
describe("EventManager", () => {
describe("initialization", () => {
it("should create EventManager instance", () => {
const eventManager = new EventManager();
expect(eventManager).toBeInstanceOf(EventManager);
});
it("should create EventManager with logger function", () => {
const mockLogger: LoggerInterface = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const logFn = () => mockLogger;
const eventManager = new EventManager(logFn);
expect(eventManager).toBeInstanceOf(EventManager);
});
it("should be accessible via Alepha.events", () => {
const alepha = Alepha.create();
expect(alepha.events).toBeInstanceOf(EventManager);
});
});
describe("on() - event registration", () => {
it("should register event handler with callback function", () => {
const eventManager = new EventManager();
const callback = vi.fn();
const unsubscribe = eventManager.on("echo", callback);
expect(typeof unsubscribe).toBe("function");
});
it("should register event handler with hook object", () => {
const eventManager = new EventManager();
const callback = vi.fn();
const unsubscribe = eventManager.on("echo", {
callback,
caller: undefined,
});
expect(typeof unsubscribe).toBe("function");
});
it("should register multiple handlers for the same event", () => {
const eventManager = new EventManager();
const callback1 = vi.fn();
const callback2 = vi.fn();
eventManager.on("echo", callback1);
eventManager.on("echo", callback2);
// Both handlers should be registered (verified by emit test)
});
it("should register handlers for different events", () => {
const eventManager = new EventManager();
const callback1 = vi.fn();
const callback2 = vi.fn();
eventManager.on("echo", callback1);
eventManager.on("configure", callback2);
// Handlers should be registered separately (verified by emit test)
});
});
describe("emit() - event execution", () => {
it("should execute registered callback", async () => {
const eventManager = new EventManager();
const callback = vi.fn();
eventManager.on("echo", callback);
await eventManager.emit("echo", { test: "data" });
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith({ test: "data" });
});
it("should execute multiple callbacks in order", async () => {
const eventManager = new EventManager();
const order: number[] = [];
eventManager.on("echo", async () => {
order.push(1);
});
eventManager.on("echo", async () => {
order.push(2);
});
eventManager.on("echo", async () => {
order.push(3);
});
await eventManager.emit("echo", {});
expect(order).toEqual([1, 2, 3]);
});
it("should execute callbacks sequentially (not in parallel)", async () => {
const eventManager = new EventManager();
const order: string[] = [];
eventManager.on("echo", async () => {
order.push("1-start");
await new Promise((resolve) => setTimeout(resolve, 10));
order.push("1-end");
});
eventManager.on("echo", async () => {
order.push("2-start");
await new Promise((resolve) => setTimeout(resolve, 5));
order.push("2-end");
});
await eventManager.emit("echo", {});
expect(order).toEqual(["1-start", "1-end", "2-start", "2-end"]);
});
it("should pass payload to all callbacks", async () => {
const eventManager = new EventManager();
const callback1 = vi.fn();
const callback2 = vi.fn();
const payload = { value: 42, name: "test" };
eventManager.on("echo", callback1);
eventManager.on("echo", callback2);
await eventManager.emit("echo", payload);
expect(callback1).toHaveBeenCalledWith(payload);
expect(callback2).toHaveBeenCalledWith(payload);
});
it("should not execute callbacks for different events", async () => {
const eventManager = new EventManager();
const callback1 = vi.fn();
const callback2 = vi.fn();
eventManager.on("echo", callback1);
eventManager.on("configure", callback2);
await eventManager.emit("echo", {});
expect(callback1).toHaveBeenCalledTimes(1);
expect(callback2).not.toHaveBeenCalled();
});
it("should handle events with no registered callbacks", async () => {
const eventManager = new EventManager();
// Should not throw
await expect(eventManager.emit("echo", {})).resolves.toBeUndefined();
});
it("should work with Alepha lifecycle events", async () => {
const alepha = Alepha.create();
const configureCallback = vi.fn();
const startCallback = vi.fn();
const readyCallback = vi.fn();
alepha.events.on("configure", configureCallback);
alepha.events.on("start", startCallback);
alepha.events.on("ready", readyCallback);
await alepha.start();
expect(configureCallback).toHaveBeenCalledTimes(1);
expect(startCallback).toHaveBeenCalledTimes(1);
expect(readyCallback).toHaveBeenCalledTimes(1);
});
});
describe("hook priorities", () => {
it("should execute 'first' priority hooks before others", async () => {
const eventManager = new EventManager();
const order: string[] = [];
eventManager.on("echo", async () => {
order.push("normal");
});
eventManager.on("echo", {
callback: async () => {
order.push("first");
},
priority: "first",
});
await eventManager.emit("echo", {});
expect(order).toEqual(["first", "normal"]);
});
it("should execute 'last' priority hooks after others", async () => {
const eventManager = new EventManager();
const order: string[] = [];
eventManager.on("echo", async () => {
order.push("normal");
});
eventManager.on("echo", {
callback: async () => {
order.push("last");
},
priority: "last",
});
await eventManager.emit("echo", {});
expect(order).toEqual(["normal", "last"]);
});
it("should handle multiple priority levels correctly", async () => {
const eventManager = new EventManager();
const order: string[] = [];
eventManager.on("echo", async () => {
order.push("normal1");
});
eventManager.on("echo", {
callback: async () => {
order.push("first1");
},
priority: "first",
});
eventManager.on("echo", {
callback: async () => {
order.push("last1");
},
priority: "last",
});
eventManager.on("echo", async () => {
order.push("normal2");
});
eventManager.on("echo", {
callback: async () => {
order.push("first2");
},
priority: "first",
});
eventManager.on("echo", {
callback: async () => {
order.push("last2");
},
priority: "last",
});
await eventManager.emit("echo", {});
// First hooks preserve registration order, then normal, then last
expect(order[0]).toBe("first1");
expect(order[1]).toBe("first2");
expect(order[order.length - 2]).toBe("last1");
expect(order[order.length - 1]).toBe("last2");
});
it("should insert normal hooks before existing last hooks", async () => {
const eventManager = new EventManager();
const order: string[] = [];
eventManager.on("echo", {
callback: async () => {
order.push("last");
},
priority: "last",
});
eventManager.on("echo", async () => {
order.push("normal");
});
await eventManager.emit("echo", {});
expect(order).toEqual(["normal", "last"]);
});
});
describe("error handling", () => {
it("should throw error when callback throws", async () => {
const eventManager = new EventManager();
eventManager.on("echo", async () => {
throw new Error("Test error");
});
await expect(eventManager.emit("echo", {})).rejects.toThrow("Test error");
});
it("should stop execution on first error", async () => {
const eventManager = new EventManager();
const callback2 = vi.fn();
eventManager.on("echo", async () => {
throw new Error("Test error");
});
eventManager.on("echo", callback2);
await expect(eventManager.emit("echo", {})).rejects.toThrow();
expect(callback2).not.toHaveBeenCalled();
});
it("should catch errors when catch option is true", async () => {
const eventManager = new EventManager();
const callback2 = vi.fn();
eventManager.on("echo", async () => {
throw new Error("Test error");
});
eventManager.on("echo", callback2);
await expect(
eventManager.emit("echo", {}, { catch: true }),
).resolves.toBeUndefined();
// Second callback should still execute
expect(callback2).toHaveBeenCalledTimes(1);
});
it("should continue executing after caught error", async () => {
const eventManager = new EventManager();
const order: number[] = [];
eventManager.on("echo", async () => {
order.push(1);
throw new Error("Error in 1");
});
eventManager.on("echo", async () => {
order.push(2);
});
eventManager.on("echo", async () => {
order.push(3);
throw new Error("Error in 3");
});
eventManager.on("echo", async () => {
order.push(4);
});
await eventManager.emit("echo", {}, { catch: true });
expect(order).toEqual([1, 2, 3, 4]);
});
it("should provide better error message with log option", async () => {
const mockLogger: LoggerInterface = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const eventManager = new EventManager(() => mockLogger);
class TestService {}
eventManager.on("echo", {
callback: async () => {
throw new Error("Test error");
},
caller: TestService,
});
await expect(
eventManager.emit("echo", {}, { log: true }),
).rejects.toThrow("Failed during 'echo()' hook for service: TestService");
});
it("should log errors when catch and log options are both true", async () => {
const mockLogger: LoggerInterface = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const eventManager = new EventManager(() => mockLogger);
class TestService {}
eventManager.on("echo", {
callback: async () => {
throw new Error("Test error");
},
caller: TestService,
});
await eventManager.emit("echo", {}, { catch: true, log: true });
expect(mockLogger.error).toHaveBeenCalled();
});
});
describe("logging", () => {
it("should log trace and debug messages when log option is true", async () => {
const mockLogger: LoggerInterface = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const eventManager = new EventManager(() => mockLogger);
eventManager.on("echo", async () => {
// Simple callback
});
await eventManager.emit("echo", {}, { log: true });
expect(mockLogger.trace).toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalled();
});
it("should log execution time for each hook", async () => {
const mockLogger: LoggerInterface = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const eventManager = new EventManager(() => mockLogger);
class TestService {}
eventManager.on("echo", {
callback: async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
},
caller: TestService,
});
await eventManager.emit("echo", {}, { log: true });
// Should log the hook execution with timing
const debugCalls = (mockLogger.debug as any).mock.calls;
const hasTimingLog = debugCalls.some(
(call: any[]) => call[0].includes("OK") && call[0].includes("ms"),
);
expect(hasTimingLog).toBe(true);
});
it("should log service name from caller", async () => {
const mockLogger: LoggerInterface = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const eventManager = new EventManager(() => mockLogger);
class MyCustomService {}
eventManager.on("echo", {
callback: async () => {},
caller: MyCustomService,
});
await eventManager.emit("echo", {}, { log: true });
const traceCalls = (mockLogger.trace as any).mock.calls;
const hasServiceName = traceCalls.some((call: any[]) =>
call[0].includes("MyCustomService"),
);
expect(hasServiceName).toBe(true);
});
it("should log 'unknown' when caller is not provided", async () => {
const mockLogger: LoggerInterface = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const eventManager = new EventManager(() => mockLogger);
eventManager.on("echo", async () => {});
await eventManager.emit("echo", {}, { log: true });
const traceCalls = (mockLogger.trace as any).mock.calls;
const hasUnknown = traceCalls.some((call: any[]) =>
call[0].includes("unknown"),
);
expect(hasUnknown).toBe(true);
});
it("should not log when log option is false", async () => {
const mockLogger: LoggerInterface = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const eventManager = new EventManager(() => mockLogger);
eventManager.on("echo", async () => {});
await eventManager.emit("echo", {});
expect(mockLogger.trace).not.toHaveBeenCalled();
expect(mockLogger.debug).not.toHaveBeenCalled();
});
});
describe("unsubscribe", () => {
it("should return unsubscribe function from on()", () => {
const eventManager = new EventManager();
const unsubscribe = eventManager.on("echo", async () => {});
expect(typeof unsubscribe).toBe("function");
});
it("should remove handler when unsubscribe is called", async () => {
const eventManager = new EventManager();
const callback = vi.fn();
const unsubscribe = eventManager.on("echo", callback);
unsubscribe();
await eventManager.emit("echo", {});
expect(callback).not.toHaveBeenCalled();
});
it("should only remove specific handler", async () => {
const eventManager = new EventManager();
const callback1 = vi.fn();
const callback2 = vi.fn();
const callback3 = vi.fn();
const unsubscribe1 = eventManager.on("echo", callback1);
eventManager.on("echo", callback2);
eventManager.on("echo", callback3);
unsubscribe1();
await eventManager.emit("echo", {});
expect(callback1).not.toHaveBeenCalled();
expect(callback2).toHaveBeenCalledTimes(1);
expect(callback3).toHaveBeenCalledTimes(1);
});
it("should allow multiple unsubscribe calls safely", async () => {
const eventManager = new EventManager();
const callback = vi.fn();
const unsubscribe = eventManager.on("echo", callback);
unsubscribe();
unsubscribe(); // Should not throw
await eventManager.emit("echo", {});
expect(callback).not.toHaveBeenCalled();
});
it("should work with different events", async () => {
const eventManager = new EventManager();
const callback1 = vi.fn();
const callback2 = vi.fn();
const unsubscribe1 = eventManager.on("echo", callback1);
eventManager.on("configure", callback2);
unsubscribe1();
await eventManager.emit("echo", {});
await eventManager.emit("configure", {} as any);
expect(callback1).not.toHaveBeenCalled();
expect(callback2).toHaveBeenCalledTimes(1);
});
});
describe("integration with Alepha", () => {
it("should share EventManager instance across Alepha", () => {
const alepha = Alepha.create();
const events1 = alepha.events;
const events2 = alepha.events;
expect(events1).toBe(events2);
});
it("should trigger configure, start, and ready hooks in order", async () => {
const alepha = Alepha.create();
const order: string[] = [];
alepha.events.on("configure", async () => {
order.push("configure");
});
alepha.events.on("start", async () => {
order.push("start");
});
alepha.events.on("ready", async () => {
order.push("ready");
});
await alepha.start();
expect(order).toEqual(["configure", "start", "ready"]);
});
it("should allow manual event emission", async () => {
const alepha = Alepha.create();
const callback = vi.fn();
alepha.events.on("echo", callback);
await alepha.events.emit("echo", { custom: "data" });
expect(callback).toHaveBeenCalledWith({ custom: "data" });
});
it("should work with custom hook types via module augmentation", async () => {
const alepha = Alepha.create();
const callback = vi.fn();
// Using the 'echo' hook which is defined in Hooks interface
alepha.events.on("echo", callback);
await alepha.events.emit("echo", { test: true });
expect(callback).toHaveBeenCalledWith({ test: true });
});
});
describe("async behavior", () => {
it("should wait for async callbacks to complete", async () => {
const eventManager = new EventManager();
let completed = false;
eventManager.on("echo", async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
completed = true;
});
await eventManager.emit("echo", {});
expect(completed).toBe(true);
});
it("should execute async callbacks sequentially", async () => {
const eventManager = new EventManager();
const order: string[] = [];
eventManager.on("echo", async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
order.push("first");
});
eventManager.on("echo", async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
order.push("second");
});
await eventManager.emit("echo", {});
// Second callback should wait for first to complete
expect(order).toEqual(["first", "second"]);
});
it("should handle Promise rejections as errors", async () => {
const eventManager = new EventManager();
eventManager.on("echo", async () => {
return Promise.reject(new Error("Async error"));
});
await expect(eventManager.emit("echo", {})).rejects.toThrow(
"Async error",
);
});
});
describe("before/after ordering", () => {
it("should run hook with after constraint after the referenced service", async () => {
const eventManager = new EventManager();
const order: string[] = [];
class ServiceA {}
class ServiceB {}
eventManager.on("echo", {
caller: ServiceA,
callback: async () => {
order.push("A");
},
});
eventManager.on("echo", {
caller: ServiceB,
after: [ServiceA],
callback: async () => {
order.push("B");
},
});
await eventManager.emit("echo", {});
expect(order).toEqual(["A", "B"]);
});
it("should run hook with after constraint after the referenced service (reverse registration)", async () => {
const eventManager = new EventManager();
const order: string[] = [];
class ServiceA {}
class ServiceB {}
// Register B first, but it has after: [ServiceA]
eventManager.on("echo", {
caller: ServiceB,
after: [ServiceA],
callback: async () => {
order.push("B");
},
});
eventManager.on("echo", {
caller: ServiceA,
callback: async () => {
order.push("A");
},
});
await eventManager.emit("echo", {});
expect(order).toEqual(["A", "B"]);
});
it("should run hook with before constraint before the referenced service", async () => {
const eventManager = new EventManager();
const order: string[] = [];
class ServiceA {}
class ServiceB {}
// Register B first, then A with before: [ServiceB]
eventManager.on("echo", {
caller: ServiceB,
callback: async () => {
order.push("B");
},
});
eventManager.on("echo", {
caller: ServiceA,
before: [ServiceB],
callback: async () => {
order.push("A");
},
});
await eventManager.emit("echo", {});
expect(order).toEqual(["A", "B"]);
});
it("should handle combined before and after on same hook", async () => {
const eventManager = new EventManager();
const order: string[] = [];
class ServiceA {}
class ServiceB {}
class ServiceC {}
eventManager.on("echo", {
caller: ServiceC,
callback: async () => {
order.push("C");
},
});
eventManager.on("echo", {
caller: ServiceA,
callback: async () => {
order.push("A");
},
});
// B runs after A and before C
eventManager.on("echo", {
caller: ServiceB,
after: [ServiceA],
before: [ServiceC],
callback: async () => {
order.push("B");
},
});
await eventManager.emit("echo", {});
expect(order).toEqual(["A", "B", "C"]);
});
it("should detect circular dependencies", async () => {
const eventManager = new EventManager();
class ServiceA {}
class ServiceB {}
eventManager.on("echo", {
caller: ServiceA,
after: [ServiceB],
callback: async () => {},
});
eventManager.on("echo", {
caller: ServiceB,
after: [ServiceA],
callback: async () => {},
});
await expect(eventManager.emit("echo", {})).rejects.toThrow(
"Circular dependency detected in hook before/after constraints",
);
});
it("should preserve registration order for unconstrained hooks", async () => {
const eventManager = new EventManager();
const order: string[] = [];
class ServiceA {}
class ServiceB {}
class ServiceC {}
eventManager.on("echo", {
caller: ServiceA,
callback: async () => {
order.push("A");
},
});
eventManager.on("echo", {
caller: ServiceB,
callback: async () => {
order.push("B");
},
});
eventManager.on("echo", {
caller: ServiceC,
callback: async () => {
order.push("C");
},
});
await eventManager.emit("echo", {});
expect(order).toEqual(["A", "B", "C"]);
});
it("should handle multiple hooks from the same service with constraints", async () => {
const eventManager = new EventManager();
const order: string[] = [];
class ServiceA {}
class ServiceB {}
eventManager.on("echo", {
caller: ServiceA,
callback: async () => {
order.push("A1");
},
});
eventManager.on("echo", {
caller: ServiceA,
callback: async () => {
order.push("A2");
},
});
eventManager.on("echo", {
caller: ServiceB,
after: [ServiceA],
callback: async () => {
order.push("B");
},
});
await eventManager.emit("echo", {});
expect(order).toEqual(["A1", "A2", "B"]);
});
it("should sort within priority tiers independently", async () => {
const eventManager = new EventManager();
const order: string[] = [];
class ServiceA {}
class ServiceB {}
eventManager.on("echo", {
caller: ServiceB,
priority: "first",
callback: async () => {
order.push("first-B");
},
});
eventManager.on("echo", {
caller: ServiceA,
priority: "first",
before: [ServiceB],
callback: async () => {
order.push("first-A");
},
});
eventManager.on("echo", {
caller: ServiceA,
callback: async () => {
order.push("normal");
},
});
await eventManager.emit("echo", {});
expect(order).toEqual(["first-A", "first-B", "normal"]);
});
it("should ignore after constraints referencing services not in the same tier", async () => {
const eventManager = new EventManager();
const order: string[] = [];
class ServiceA {}
class ServiceB {}
// A is in normal tier, B is in last tier with after: [ServiceA]
// The constraint targets a service not in the same tier, so it's ignored
eventManager.on("echo", {
caller: ServiceA,
callback: async () => {
order.push("A");
},
});
eventManager.on("echo", {
caller: ServiceB,
priority: "last",
after: [ServiceA],
callback: async () => {
order.push("B");
},
});
await eventManager.emit("echo", {});
// A runs first (normal tier), B runs after (last tier)
expect(order).toEqual(["A", "B"]);
});
});
});