From 5e8656dc124b7f5d3209d702bd5cc7da37c1cfc8 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Fri, 22 May 2026 09:27:27 +0200 Subject: [PATCH 1/7] [STEP-2.13-001] Add notification types, interfaces and config token Define foundational types for the notifications module: FireflyEvent, NotificationAdapter, NotificationOptions, NotificationConfig and NOTIFICATION_CONFIG injection token. --- .../lib/notifications/notification.types.ts | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 packages/core/src/lib/notifications/notification.types.ts diff --git a/packages/core/src/lib/notifications/notification.types.ts b/packages/core/src/lib/notifications/notification.types.ts new file mode 100644 index 0000000..e30faa0 --- /dev/null +++ b/packages/core/src/lib/notifications/notification.types.ts @@ -0,0 +1,180 @@ +import { InjectionToken } from '@angular/core'; + +// --------------------------------------------------------------------------- +// Event payload +// --------------------------------------------------------------------------- + +/** + * Severity levels for system events. + */ +export type EventSeverity = 'info' | 'warning' | 'error' | 'critical'; + +/** + * A system event emitted to notification adapters. + * + * Represents an internal event (deployment, error, threshold breach, etc.) + * that should be dispatched to one or more external channels. + * + * @example + * ```typescript + * const event: FireflyEvent = { + * type: 'error.unhandled', + * product: 'distributor-portal', + * severity: 'critical', + * message: 'Database connection pool exhausted', + * timestamp: new Date().toISOString(), + * data: { pool: 'primary', active: 50, max: 50 }, + * }; + * ``` + */ +export interface FireflyEvent { + /** Event type identifier (dot-separated namespace, e.g. `'error.unhandled'`). */ + readonly type: string; + + /** Product or service that originated the event. */ + readonly product: string; + + /** Severity level of the event. */ + readonly severity: EventSeverity; + + /** Human-readable message describing the event. */ + readonly message: string; + + /** ISO-8601 timestamp of when the event occurred. */ + readonly timestamp: string; + + /** Arbitrary payload attached to the event. */ + readonly data?: Record; +} + +// --------------------------------------------------------------------------- +// Adapter contract +// --------------------------------------------------------------------------- + +/** + * Contract that all notification adapters must implement. + * + * An adapter receives a `FireflyEvent` and dispatches it to an external + * channel (console, Slack, email, webhook, etc.). + * + * `supports()` enables event routing: the `NotificationService` only + * calls `send()` on adapters that accept the given event type. + * + * @example + * ```typescript + * class MyAdapter implements NotificationAdapter { + * readonly name = 'my-adapter'; + * supports(eventType: string): boolean { return true; } + * async send(event: FireflyEvent): Promise { ... } + * } + * ``` + */ +export interface NotificationAdapter { + /** Unique name of this adapter (e.g. `'slack'`, `'console'`). */ + readonly name: string; + + /** Whether this adapter handles the given event type. */ + supports(eventType: string): boolean; + + /** Dispatch the event to the external channel. */ + send(event: FireflyEvent): Promise; +} + +// --------------------------------------------------------------------------- +// Adapter-specific options +// --------------------------------------------------------------------------- + +/** Configuration for the Slack adapter. */ +export interface SlackAdapterOptions { + /** Slack incoming webhook URL. */ + readonly webhookUrl: string; +} + +/** Configuration for the Email adapter (HTTP relay). */ +export interface EmailAdapterOptions { + /** URL of the HTTP relay endpoint that sends the email. */ + readonly smtpEndpoint: string; + + /** Default sender address. */ + readonly from: string; +} + +/** Configuration for the generic Webhook adapter. */ +export interface WebhookAdapterOptions { + /** Target URL to POST events to. */ + readonly url: string; + + /** Additional HTTP headers to include in the request. */ + readonly headers?: Record; +} + +// --------------------------------------------------------------------------- +// Module configuration (user-facing) +// --------------------------------------------------------------------------- + +/** + * Configuration input for `provideNotifications()`. + * + * Selects which adapters to enable and provides their settings. + * All fields are optional — when omitted, only the `ConsoleAdapter` + * (always-on fallback) is registered. + * + * @example + * ```typescript + * provideNotifications({ + * adapters: ['slack', 'email'], + * slack: { webhookUrl: 'https://hooks.slack.com/...' }, + * email: { smtpEndpoint: 'https://api.internal/send-email', from: 'noreply@acme.com' }, + * }) + * ``` + */ +export interface NotificationOptions { + /** Adapters to enable (in addition to the always-on ConsoleAdapter). */ + readonly adapters?: ReadonlyArray<'slack' | 'email' | 'webhook'>; + + /** Slack adapter configuration. Required when `'slack'` is in `adapters`. */ + readonly slack?: SlackAdapterOptions; + + /** Email adapter configuration. Required when `'email'` is in `adapters`. */ + readonly email?: EmailAdapterOptions; + + /** Webhook adapter configuration. Required when `'webhook'` is in `adapters`. */ + readonly webhook?: WebhookAdapterOptions; +} + +// --------------------------------------------------------------------------- +// Resolved config (internal) +// --------------------------------------------------------------------------- + +/** + * Resolved configuration stored in the DI container. + * + * Built from `NotificationOptions` by `provideNotifications()`. + * Services inject this via `NOTIFICATION_CONFIG`. + */ +export interface NotificationConfig { + /** Names of enabled adapters (always includes `'console'`). */ + readonly enabledAdapters: ReadonlyArray; + + /** Slack settings (undefined if not enabled). */ + readonly slack?: SlackAdapterOptions; + + /** Email settings (undefined if not enabled). */ + readonly email?: EmailAdapterOptions; + + /** Webhook settings (undefined if not enabled). */ + readonly webhook?: WebhookAdapterOptions; +} + +// --------------------------------------------------------------------------- +// Injection token +// --------------------------------------------------------------------------- + +/** + * Injection token for the notifications module configuration. + * + * Provided by `provideNotifications()`. Services inject this to read config. + */ +export const NOTIFICATION_CONFIG = new InjectionToken( + 'NOTIFICATION_CONFIG', +); From eeaf12110222d9a5745ce1e704f3dd6a6591d48e Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Fri, 22 May 2026 09:38:16 +0200 Subject: [PATCH 2/7] [STEP-2.13-002] Add NotificationService with adapter registry and event emission Implement central service with registerAdapter(), emit() and getAdapters(). Uses Promise.allSettled so a failing adapter never blocks others. 12 unit tests covering filtering, dedup, parallel execution and error isolation. --- .../notification.service.spec.ts | 189 ++++++++++++++++++ .../lib/notifications/notification.service.ts | 70 +++++++ 2 files changed, 259 insertions(+) create mode 100644 packages/core/src/lib/notifications/notification.service.spec.ts create mode 100644 packages/core/src/lib/notifications/notification.service.ts diff --git a/packages/core/src/lib/notifications/notification.service.spec.ts b/packages/core/src/lib/notifications/notification.service.spec.ts new file mode 100644 index 0000000..61610d4 --- /dev/null +++ b/packages/core/src/lib/notifications/notification.service.spec.ts @@ -0,0 +1,189 @@ +import { TestBed } from '@angular/core/testing'; +import { NotificationService } from './notification.service'; +import { FireflyEvent, NotificationAdapter, NOTIFICATION_CONFIG } from './notification.types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createEvent(overrides?: Partial): FireflyEvent { + return { + type: 'test.event', + product: 'test-app', + severity: 'info', + message: 'Test event', + timestamp: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +function createMockAdapter( + name: string, + supportsFn: (eventType: string) => boolean = () => true, +): NotificationAdapter & { send: ReturnType } { + return { + name, + supports: supportsFn, + send: vi.fn().mockResolvedValue(undefined), + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('NotificationService', () => { + function setup() { + TestBed.configureTestingModule({ + providers: [NotificationService], + }); + return TestBed.inject(NotificationService); + } + + // ----------------------------------------------------------------------- + // registerAdapter + // ----------------------------------------------------------------------- + describe('registerAdapter', () => { + it('should register an adapter', () => { + const svc = setup(); + const adapter = createMockAdapter('test'); + svc.registerAdapter(adapter); + expect(svc.getAdapters()).toEqual([adapter]); + }); + + it('should ignore duplicate adapters with the same name', () => { + const svc = setup(); + const a1 = createMockAdapter('dup'); + const a2 = createMockAdapter('dup'); + svc.registerAdapter(a1); + svc.registerAdapter(a2); + expect(svc.getAdapters().length).toBe(1); + expect(svc.getAdapters()[0]).toBe(a1); + }); + + it('should allow multiple adapters with different names', () => { + const svc = setup(); + svc.registerAdapter(createMockAdapter('alpha')); + svc.registerAdapter(createMockAdapter('beta')); + svc.registerAdapter(createMockAdapter('gamma')); + expect(svc.getAdapters().length).toBe(3); + }); + }); + + // ----------------------------------------------------------------------- + // emit + // ----------------------------------------------------------------------- + describe('emit', () => { + it('should call send() on all adapters that support the event type', async () => { + const svc = setup(); + const a1 = createMockAdapter('a1'); + const a2 = createMockAdapter('a2'); + svc.registerAdapter(a1); + svc.registerAdapter(a2); + + const event = createEvent(); + await svc.emit(event); + + expect(a1.send).toHaveBeenCalledWith(event); + expect(a2.send).toHaveBeenCalledWith(event); + }); + + it('should skip adapters that do not support the event type', async () => { + const svc = setup(); + const supported = createMockAdapter('yes', () => true); + const unsupported = createMockAdapter('no', () => false); + svc.registerAdapter(supported); + svc.registerAdapter(unsupported); + + await svc.emit(createEvent()); + + expect(supported.send).toHaveBeenCalledTimes(1); + expect(unsupported.send).not.toHaveBeenCalled(); + }); + + it('should filter based on event type passed to supports()', async () => { + const svc = setup(); + const onlyCritical = createMockAdapter( + 'critical-only', + (eventType) => eventType.startsWith('error.'), + ); + svc.registerAdapter(onlyCritical); + + await svc.emit(createEvent({ type: 'info.deploy' })); + expect(onlyCritical.send).not.toHaveBeenCalled(); + + await svc.emit(createEvent({ type: 'error.unhandled' })); + expect(onlyCritical.send).toHaveBeenCalledTimes(1); + }); + + it('should not throw when a single adapter fails (Promise.allSettled)', async () => { + const svc = setup(); + const failing = createMockAdapter('failing'); + failing.send.mockRejectedValue(new Error('network error')); + const healthy = createMockAdapter('healthy'); + svc.registerAdapter(failing); + svc.registerAdapter(healthy); + + // Should not throw + await expect(svc.emit(createEvent())).resolves.toBeUndefined(); + expect(healthy.send).toHaveBeenCalledTimes(1); + }); + + it('should resolve when no adapters are registered', async () => { + const svc = setup(); + await expect(svc.emit(createEvent())).resolves.toBeUndefined(); + }); + + it('should resolve when no adapters support the event type', async () => { + const svc = setup(); + svc.registerAdapter(createMockAdapter('none', () => false)); + await expect(svc.emit(createEvent())).resolves.toBeUndefined(); + }); + + it('should call all adapters in parallel (not sequentially)', async () => { + const svc = setup(); + const order: string[] = []; + + const slow = createMockAdapter('slow'); + slow.send.mockImplementation(async () => { + order.push('slow-start'); + await new Promise(r => setTimeout(r, 50)); + order.push('slow-end'); + }); + + const fast = createMockAdapter('fast'); + fast.send.mockImplementation(async () => { + order.push('fast-start'); + order.push('fast-end'); + }); + + svc.registerAdapter(slow); + svc.registerAdapter(fast); + await svc.emit(createEvent()); + + // Both should start before slow ends (parallel execution) + expect(order.indexOf('fast-start')).toBeLessThan(order.indexOf('slow-end')); + }); + }); + + // ----------------------------------------------------------------------- + // getAdapters + // ----------------------------------------------------------------------- + describe('getAdapters', () => { + it('should return empty array initially', () => { + const svc = setup(); + expect(svc.getAdapters()).toEqual([]); + }); + + it('should return adapters in registration order', () => { + const svc = setup(); + const a = createMockAdapter('a'); + const b = createMockAdapter('b'); + const c = createMockAdapter('c'); + svc.registerAdapter(a); + svc.registerAdapter(b); + svc.registerAdapter(c); + expect(svc.getAdapters()).toEqual([a, b, c]); + }); + }); +}); diff --git a/packages/core/src/lib/notifications/notification.service.ts b/packages/core/src/lib/notifications/notification.service.ts new file mode 100644 index 0000000..3069419 --- /dev/null +++ b/packages/core/src/lib/notifications/notification.service.ts @@ -0,0 +1,70 @@ +import { Injectable, inject } from '@angular/core'; +import { + FireflyEvent, + NotificationAdapter, + NOTIFICATION_CONFIG, +} from './notification.types'; + +/** + * Central notification service that dispatches events to registered adapters. + * + * This is the core orchestrator of the notifications module. It maintains + * a registry of `NotificationAdapter` instances and routes events to + * those that support the event type. + * + * Uses `Promise.allSettled` so a failing adapter never blocks other adapters. + * + * @example + * ```typescript + * const service = inject(NotificationService); + * + * service.registerAdapter(myAdapter); + * + * await service.emit({ + * type: 'error.unhandled', + * product: 'my-app', + * severity: 'critical', + * message: 'Something went wrong', + * timestamp: new Date().toISOString(), + * }); + * ``` + */ +@Injectable() +export class NotificationService { + private readonly config = inject(NOTIFICATION_CONFIG, { optional: true }); + private readonly adapters: NotificationAdapter[] = []; + + /** + * Register an adapter to receive events. + * + * Duplicate adapters (same `name`) are silently ignored. + */ + registerAdapter(adapter: NotificationAdapter): void { + if (this.adapters.some(a => a.name === adapter.name)) { + return; + } + this.adapters.push(adapter); + } + + /** + * Emit an event to all adapters that support its type. + * + * Adapters are called in parallel via `Promise.allSettled` — + * a failure in one adapter does not prevent others from receiving the event. + * + * @returns Resolves when all adapters have completed (or failed). + */ + async emit(event: FireflyEvent): Promise { + const targets = this.adapters.filter(a => a.supports(event.type)); + await Promise.allSettled(targets.map(a => a.send(event))); + } + + /** + * Returns a read-only snapshot of all registered adapters. + * + * Useful for debugging and the showcase inspector panel. + */ + getAdapters(): ReadonlyArray { + return this.adapters; + } +} From 9c728ff456805a4b121055b6796e5e686c0faec9 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Fri, 22 May 2026 09:45:08 +0200 Subject: [PATCH 3/7] [STEP-2.13-003] Add ConsoleAdapter and NoopAdapter with tests ConsoleAdapter logs formatted events to console (log/warn/error by severity). NoopAdapter resolves immediately without side effects, for testing. Both accept all event types. 13 unit tests. --- .../adapters/console-adapter.spec.ts | 92 +++++++++++++++++++ .../notifications/adapters/console-adapter.ts | 42 +++++++++ .../adapters/noop-adapter.spec.ts | 44 +++++++++ .../notifications/adapters/noop-adapter.ts | 25 +++++ 4 files changed, 203 insertions(+) create mode 100644 packages/core/src/lib/notifications/adapters/console-adapter.spec.ts create mode 100644 packages/core/src/lib/notifications/adapters/console-adapter.ts create mode 100644 packages/core/src/lib/notifications/adapters/noop-adapter.spec.ts create mode 100644 packages/core/src/lib/notifications/adapters/noop-adapter.ts diff --git a/packages/core/src/lib/notifications/adapters/console-adapter.spec.ts b/packages/core/src/lib/notifications/adapters/console-adapter.spec.ts new file mode 100644 index 0000000..8f0add8 --- /dev/null +++ b/packages/core/src/lib/notifications/adapters/console-adapter.spec.ts @@ -0,0 +1,92 @@ +import { ConsoleAdapter } from './console-adapter'; +import { FireflyEvent } from '../notification.types'; + +function createEvent(overrides?: Partial): FireflyEvent { + return { + type: 'test.event', + product: 'test-app', + severity: 'info', + message: 'Test message', + timestamp: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('ConsoleAdapter', () => { + let adapter: ConsoleAdapter; + + beforeEach(() => { + adapter = new ConsoleAdapter(); + }); + + it('should have name "console"', () => { + expect(adapter.name).toBe('console'); + }); + + it('should support all event types', () => { + expect(adapter.supports('any.event')).toBe(true); + expect(adapter.supports('error.critical')).toBe(true); + expect(adapter.supports('')).toBe(true); + }); + + it('should log info events with console.log', async () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await adapter.send(createEvent({ severity: 'info' })); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toContain('[INFO]'); + expect(spy.mock.calls[0][0]).toContain('test-app'); + expect(spy.mock.calls[0][0]).toContain('test.event'); + spy.mockRestore(); + }); + + it('should log warning events with console.warn', async () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await adapter.send(createEvent({ severity: 'warning' })); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toContain('[WARN]'); + spy.mockRestore(); + }); + + it('should log error events with console.error', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + await adapter.send(createEvent({ severity: 'error' })); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toContain('[ERROR]'); + spy.mockRestore(); + }); + + it('should log critical events with console.error', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + await adapter.send(createEvent({ severity: 'critical' })); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toContain('[CRITICAL]'); + spy.mockRestore(); + }); + + it('should include event data when present', async () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const data = { key: 'value' }; + await adapter.send(createEvent({ data })); + expect(spy.mock.calls[0][1]).toEqual(data); + spy.mockRestore(); + }); + + it('should pass empty string when no data', async () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await adapter.send(createEvent()); + expect(spy.mock.calls[0][1]).toBe(''); + spy.mockRestore(); + }); + + it('should format the log line correctly', async () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await adapter.send(createEvent({ + severity: 'info', + product: 'my-app', + type: 'deploy.complete', + message: 'Deployment finished', + })); + expect(spy.mock.calls[0][0]).toBe('[INFO] [my-app] deploy.complete: Deployment finished'); + spy.mockRestore(); + }); +}); diff --git a/packages/core/src/lib/notifications/adapters/console-adapter.ts b/packages/core/src/lib/notifications/adapters/console-adapter.ts new file mode 100644 index 0000000..334b038 --- /dev/null +++ b/packages/core/src/lib/notifications/adapters/console-adapter.ts @@ -0,0 +1,42 @@ +import { FireflyEvent, NotificationAdapter } from '../notification.types'; + +const SEVERITY_PREFIX: Record = { + info: '[INFO]', + warning: '[WARN]', + error: '[ERROR]', + critical: '[CRITICAL]', +}; + +/** + * Adapter that logs events to the browser console. + * + * Always registered as a fallback — accepts all event types. + * Formats output as: `[SEVERITY] [product] type: message` + * + * @example + * ```typescript + * const adapter = new ConsoleAdapter(); + * adapter.supports('any.event'); // true + * await adapter.send(event); // logs to console + * ``` + */ +export class ConsoleAdapter implements NotificationAdapter { + readonly name = 'console'; + + supports(): boolean { + return true; + } + + async send(event: FireflyEvent): Promise { + const prefix = SEVERITY_PREFIX[event.severity] ?? '[INFO]'; + const line = `${prefix} [${event.product}] ${event.type}: ${event.message}`; + + if (event.severity === 'error' || event.severity === 'critical') { + console.error(line, event.data ?? ''); + } else if (event.severity === 'warning') { + console.warn(line, event.data ?? ''); + } else { + console.log(line, event.data ?? ''); + } + } +} diff --git a/packages/core/src/lib/notifications/adapters/noop-adapter.spec.ts b/packages/core/src/lib/notifications/adapters/noop-adapter.spec.ts new file mode 100644 index 0000000..e9e96d0 --- /dev/null +++ b/packages/core/src/lib/notifications/adapters/noop-adapter.spec.ts @@ -0,0 +1,44 @@ +import { NoopAdapter } from './noop-adapter'; +import { FireflyEvent } from '../notification.types'; + +function createEvent(overrides?: Partial): FireflyEvent { + return { + type: 'test.event', + product: 'test-app', + severity: 'info', + message: 'Test message', + timestamp: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('NoopAdapter', () => { + let adapter: NoopAdapter; + + beforeEach(() => { + adapter = new NoopAdapter(); + }); + + it('should have name "noop"', () => { + expect(adapter.name).toBe('noop'); + }); + + it('should support all event types', () => { + expect(adapter.supports('any.event')).toBe(true); + expect(adapter.supports('')).toBe(true); + }); + + it('should resolve without doing anything', async () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await expect(adapter.send(createEvent())).resolves.toBeUndefined(); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('should handle multiple sends without side effects', async () => { + await adapter.send(createEvent()); + await adapter.send(createEvent({ severity: 'critical' })); + await adapter.send(createEvent({ type: 'error.fatal' })); + // If we got here without error, test passes + }); +}); diff --git a/packages/core/src/lib/notifications/adapters/noop-adapter.ts b/packages/core/src/lib/notifications/adapters/noop-adapter.ts new file mode 100644 index 0000000..edfea98 --- /dev/null +++ b/packages/core/src/lib/notifications/adapters/noop-adapter.ts @@ -0,0 +1,25 @@ +import { FireflyEvent, NotificationAdapter } from '../notification.types'; + +/** + * Adapter that does nothing — for use in test environments. + * + * Accepts all event types and resolves immediately without side effects. + * + * @example + * ```typescript + * const adapter = new NoopAdapter(); + * adapter.supports('any.event'); // true + * await adapter.send(event); // no-op + * ``` + */ +export class NoopAdapter implements NotificationAdapter { + readonly name = 'noop'; + + supports(): boolean { + return true; + } + + async send(_event: FireflyEvent): Promise { + // intentionally empty + } +} From 45b5f0bc3d6f322f440bab32330b1bdb5aaaa4dd Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Fri, 22 May 2026 09:50:27 +0200 Subject: [PATCH 4/7] [STEP-2.13-004] Add SlackAdapter, WebhookAdapter and EmailAdapter with tests Three HTTP-based adapters using native fetch: - SlackAdapter: formats with severity emoji, POSTs to webhook URL - WebhookAdapter: sends full FireflyEvent as JSON with optional headers - EmailAdapter: POSTs email payload to HTTP relay endpoint 20 unit tests with vi.stubGlobal fetch mock. --- .../adapters/email-adapter.spec.ts | 76 +++++++++++++++++++ .../notifications/adapters/email-adapter.ts | 42 ++++++++++ .../adapters/slack-adapter.spec.ts | 75 ++++++++++++++++++ .../notifications/adapters/slack-adapter.ts | 40 ++++++++++ .../adapters/webhook-adapter.spec.ts | 69 +++++++++++++++++ .../notifications/adapters/webhook-adapter.ts | 36 +++++++++ 6 files changed, 338 insertions(+) create mode 100644 packages/core/src/lib/notifications/adapters/email-adapter.spec.ts create mode 100644 packages/core/src/lib/notifications/adapters/email-adapter.ts create mode 100644 packages/core/src/lib/notifications/adapters/slack-adapter.spec.ts create mode 100644 packages/core/src/lib/notifications/adapters/slack-adapter.ts create mode 100644 packages/core/src/lib/notifications/adapters/webhook-adapter.spec.ts create mode 100644 packages/core/src/lib/notifications/adapters/webhook-adapter.ts diff --git a/packages/core/src/lib/notifications/adapters/email-adapter.spec.ts b/packages/core/src/lib/notifications/adapters/email-adapter.spec.ts new file mode 100644 index 0000000..ab603b9 --- /dev/null +++ b/packages/core/src/lib/notifications/adapters/email-adapter.spec.ts @@ -0,0 +1,76 @@ +import { EmailAdapter } from './email-adapter'; +import { FireflyEvent } from '../notification.types'; + +function createEvent(overrides?: Partial): FireflyEvent { + return { + type: 'test.event', + product: 'test-app', + severity: 'info', + message: 'Test message', + timestamp: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('EmailAdapter', () => { + let adapter: EmailAdapter; + let fetchSpy: ReturnType; + + beforeEach(() => { + adapter = new EmailAdapter({ + smtpEndpoint: 'https://api.internal/send-email', + from: 'noreply@acme.com', + }); + fetchSpy = vi.fn().mockResolvedValue(new Response('ok')); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should have name "email"', () => { + expect(adapter.name).toBe('email'); + }); + + it('should support all event types', () => { + expect(adapter.supports('any')).toBe(true); + }); + + it('should POST to the smtp endpoint', async () => { + await adapter.send(createEvent()); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.internal/send-email'); + expect(fetchSpy.mock.calls[0][1].method).toBe('POST'); + }); + + it('should include from address in payload', async () => { + await adapter.send(createEvent()); + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.from).toBe('noreply@acme.com'); + }); + + it('should format subject with severity, product and type', async () => { + await adapter.send(createEvent({ + severity: 'critical', + product: 'lending', + type: 'error.db', + })); + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.subject).toBe('[CRITICAL] [lending] error.db'); + }); + + it('should include message as body', async () => { + await adapter.send(createEvent({ message: 'DB connection lost' })); + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.body).toBe('DB connection lost'); + }); + + it('should include data and timestamp', async () => { + const data = { pool: 'primary' }; + await adapter.send(createEvent({ data, timestamp: '2026-06-01T12:00:00Z' })); + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.data).toEqual({ pool: 'primary' }); + expect(body.timestamp).toBe('2026-06-01T12:00:00Z'); + }); +}); diff --git a/packages/core/src/lib/notifications/adapters/email-adapter.ts b/packages/core/src/lib/notifications/adapters/email-adapter.ts new file mode 100644 index 0000000..cbadb08 --- /dev/null +++ b/packages/core/src/lib/notifications/adapters/email-adapter.ts @@ -0,0 +1,42 @@ +import { FireflyEvent, NotificationAdapter, EmailAdapterOptions } from '../notification.types'; + +/** + * Adapter that sends events as email via an HTTP relay endpoint. + * + * The browser cannot do SMTP directly, so this adapter POSTs an email + * payload to an HTTP relay service that handles the actual sending. + * + * @example + * ```typescript + * const adapter = new EmailAdapter({ + * smtpEndpoint: 'https://api.internal/send-email', + * from: 'noreply@acme.com', + * }); + * await adapter.send(event); + * ``` + */ +export class EmailAdapter implements NotificationAdapter { + readonly name = 'email'; + + constructor(private readonly options: EmailAdapterOptions) {} + + supports(): boolean { + return true; + } + + async send(event: FireflyEvent): Promise { + const subject = `[${event.severity.toUpperCase()}] [${event.product}] ${event.type}`; + + await fetch(this.options.smtpEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + from: this.options.from, + subject, + body: event.message, + data: event.data, + timestamp: event.timestamp, + }), + }); + } +} diff --git a/packages/core/src/lib/notifications/adapters/slack-adapter.spec.ts b/packages/core/src/lib/notifications/adapters/slack-adapter.spec.ts new file mode 100644 index 0000000..0fce477 --- /dev/null +++ b/packages/core/src/lib/notifications/adapters/slack-adapter.spec.ts @@ -0,0 +1,75 @@ +import { SlackAdapter } from './slack-adapter'; +import { FireflyEvent } from '../notification.types'; + +function createEvent(overrides?: Partial): FireflyEvent { + return { + type: 'test.event', + product: 'test-app', + severity: 'info', + message: 'Test message', + timestamp: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('SlackAdapter', () => { + let adapter: SlackAdapter; + let fetchSpy: ReturnType; + + beforeEach(() => { + adapter = new SlackAdapter({ webhookUrl: 'https://hooks.slack.com/test' }); + fetchSpy = vi.fn().mockResolvedValue(new Response('ok')); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should have name "slack"', () => { + expect(adapter.name).toBe('slack'); + }); + + it('should support all event types', () => { + expect(adapter.supports('any')).toBe(true); + }); + + it('should POST to the webhook URL', async () => { + await adapter.send(createEvent()); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toBe('https://hooks.slack.com/test'); + expect(fetchSpy.mock.calls[0][1].method).toBe('POST'); + }); + + it('should send JSON content type', async () => { + await adapter.send(createEvent()); + expect(fetchSpy.mock.calls[0][1].headers['Content-Type']).toBe('application/json'); + }); + + it('should format message with severity emoji', async () => { + await adapter.send(createEvent({ severity: 'critical', product: 'my-app', type: 'error.fatal', message: 'DB down' })); + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.text).toContain('\uD83D\uDEA8'); + expect(body.text).toContain('[my-app]'); + expect(body.text).toContain('error.fatal'); + expect(body.text).toContain('DB down'); + }); + + it('should use info emoji for info severity', async () => { + await adapter.send(createEvent({ severity: 'info' })); + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.text).toContain('\u2139\uFE0F'); + }); + + it('should use warning emoji for warning severity', async () => { + await adapter.send(createEvent({ severity: 'warning' })); + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.text).toContain('\u26A0\uFE0F'); + }); + + it('should use error emoji for error severity', async () => { + await adapter.send(createEvent({ severity: 'error' })); + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.text).toContain('\u274C'); + }); +}); diff --git a/packages/core/src/lib/notifications/adapters/slack-adapter.ts b/packages/core/src/lib/notifications/adapters/slack-adapter.ts new file mode 100644 index 0000000..5f4b0c3 --- /dev/null +++ b/packages/core/src/lib/notifications/adapters/slack-adapter.ts @@ -0,0 +1,40 @@ +import { FireflyEvent, NotificationAdapter, SlackAdapterOptions } from '../notification.types'; + +const SEVERITY_EMOJI: Record = { + info: '\u2139\uFE0F', + warning: '\u26A0\uFE0F', + error: '\u274C', + critical: '\uD83D\uDEA8', +}; + +/** + * Adapter that sends events to a Slack incoming webhook. + * + * Formats the message with a severity emoji prefix. + * + * @example + * ```typescript + * const adapter = new SlackAdapter({ webhookUrl: 'https://hooks.slack.com/...' }); + * await adapter.send(event); // POSTs formatted message to Slack + * ``` + */ +export class SlackAdapter implements NotificationAdapter { + readonly name = 'slack'; + + constructor(private readonly options: SlackAdapterOptions) {} + + supports(): boolean { + return true; + } + + async send(event: FireflyEvent): Promise { + const emoji = SEVERITY_EMOJI[event.severity] ?? '\u2139\uFE0F'; + const text = `${emoji} [${event.product}] ${event.type}: ${event.message}`; + + await fetch(this.options.webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + } +} diff --git a/packages/core/src/lib/notifications/adapters/webhook-adapter.spec.ts b/packages/core/src/lib/notifications/adapters/webhook-adapter.spec.ts new file mode 100644 index 0000000..0aed6d9 --- /dev/null +++ b/packages/core/src/lib/notifications/adapters/webhook-adapter.spec.ts @@ -0,0 +1,69 @@ +import { WebhookAdapter } from './webhook-adapter'; +import { FireflyEvent } from '../notification.types'; + +function createEvent(overrides?: Partial): FireflyEvent { + return { + type: 'test.event', + product: 'test-app', + severity: 'info', + message: 'Test message', + timestamp: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('WebhookAdapter', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn().mockResolvedValue(new Response('ok')); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should have name "webhook"', () => { + const adapter = new WebhookAdapter({ url: 'https://api.test/events' }); + expect(adapter.name).toBe('webhook'); + }); + + it('should support all event types', () => { + const adapter = new WebhookAdapter({ url: 'https://api.test/events' }); + expect(adapter.supports('any')).toBe(true); + }); + + it('should POST the full event as JSON', async () => { + const adapter = new WebhookAdapter({ url: 'https://api.test/events' }); + const event = createEvent({ data: { key: 'value' } }); + await adapter.send(event); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.test/events'); + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.type).toBe('test.event'); + expect(body.product).toBe('test-app'); + expect(body.data).toEqual({ key: 'value' }); + }); + + it('should include custom headers', async () => { + const adapter = new WebhookAdapter({ + url: 'https://api.test/events', + headers: { 'X-Api-Key': 'secret123' }, + }); + await adapter.send(createEvent()); + + const headers = fetchSpy.mock.calls[0][1].headers; + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['X-Api-Key']).toBe('secret123'); + }); + + it('should work without custom headers', async () => { + const adapter = new WebhookAdapter({ url: 'https://api.test/events' }); + await adapter.send(createEvent()); + + const headers = fetchSpy.mock.calls[0][1].headers; + expect(headers['Content-Type']).toBe('application/json'); + }); +}); diff --git a/packages/core/src/lib/notifications/adapters/webhook-adapter.ts b/packages/core/src/lib/notifications/adapters/webhook-adapter.ts new file mode 100644 index 0000000..a1f42b3 --- /dev/null +++ b/packages/core/src/lib/notifications/adapters/webhook-adapter.ts @@ -0,0 +1,36 @@ +import { FireflyEvent, NotificationAdapter, WebhookAdapterOptions } from '../notification.types'; + +/** + * Adapter that POSTs events as JSON to a generic HTTP endpoint. + * + * Sends the full `FireflyEvent` payload. Supports optional custom headers. + * + * @example + * ```typescript + * const adapter = new WebhookAdapter({ + * url: 'https://api.internal/events', + * headers: { 'X-Api-Key': 'secret' }, + * }); + * await adapter.send(event); + * ``` + */ +export class WebhookAdapter implements NotificationAdapter { + readonly name = 'webhook'; + + constructor(private readonly options: WebhookAdapterOptions) {} + + supports(): boolean { + return true; + } + + async send(event: FireflyEvent): Promise { + await fetch(this.options.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.options.headers, + }, + body: JSON.stringify(event), + }); + } +} From f6ceac3e148c18c1bb57bc682cf475967b9f2036 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Fri, 22 May 2026 10:10:07 +0200 Subject: [PATCH 5/7] [STEP-2.13-005] Add provideNotifications() factory, barrel exports and wire into main entry point provideNotifications() registers NotificationService, NOTIFICATION_CONFIG token and adapters via provideAppInitializer. ConsoleAdapter always on, HTTP adapters conditional on options. Barrel exports all public symbols. 8 unit tests for provider factory. --- packages/core/src/index.ts | 1 + packages/core/src/lib/notifications/index.ts | 25 ++++++ .../provide-notifications.spec.ts | 84 +++++++++++++++++++ .../notifications/provide-notifications.ts | 72 ++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 packages/core/src/lib/notifications/index.ts create mode 100644 packages/core/src/lib/notifications/provide-notifications.spec.ts create mode 100644 packages/core/src/lib/notifications/provide-notifications.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 54d97a6..51564c3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,3 +14,4 @@ export * from './lib/storage'; export * from './lib/environment'; export * from './lib/security'; export * from './lib/files'; +export * from './lib/notifications'; diff --git a/packages/core/src/lib/notifications/index.ts b/packages/core/src/lib/notifications/index.ts new file mode 100644 index 0000000..a9c2901 --- /dev/null +++ b/packages/core/src/lib/notifications/index.ts @@ -0,0 +1,25 @@ +// Types +export type { + EventSeverity, + FireflyEvent, + NotificationAdapter, + SlackAdapterOptions, + EmailAdapterOptions, + WebhookAdapterOptions, + NotificationOptions, + NotificationConfig, +} from './notification.types'; +export { NOTIFICATION_CONFIG } from './notification.types'; + +// Provider factory +export { provideNotifications } from './provide-notifications'; + +// Service +export { NotificationService } from './notification.service'; + +// Adapters +export { ConsoleAdapter } from './adapters/console-adapter'; +export { NoopAdapter } from './adapters/noop-adapter'; +export { SlackAdapter } from './adapters/slack-adapter'; +export { WebhookAdapter } from './adapters/webhook-adapter'; +export { EmailAdapter } from './adapters/email-adapter'; diff --git a/packages/core/src/lib/notifications/provide-notifications.spec.ts b/packages/core/src/lib/notifications/provide-notifications.spec.ts new file mode 100644 index 0000000..51c15a3 --- /dev/null +++ b/packages/core/src/lib/notifications/provide-notifications.spec.ts @@ -0,0 +1,84 @@ +import { TestBed } from '@angular/core/testing'; +import { provideNotifications } from './provide-notifications'; +import { NotificationService } from './notification.service'; +import { NOTIFICATION_CONFIG, NotificationConfig } from './notification.types'; + +describe('provideNotifications', () => { + function setup(options?: Parameters[0]) { + TestBed.configureTestingModule({ + providers: [provideNotifications(options)], + }); + } + + it('should provide NotificationService', () => { + setup(); + const service = TestBed.inject(NotificationService); + expect(service).toBeInstanceOf(NotificationService); + }); + + it('should provide NOTIFICATION_CONFIG with console as default enabled adapter', () => { + setup(); + const config = TestBed.inject(NOTIFICATION_CONFIG); + expect(config.enabledAdapters).toContain('console'); + }); + + it('should register ConsoleAdapter by default', async () => { + setup(); + // Trigger initializers + await TestBed.inject(NotificationService); + const service = TestBed.inject(NotificationService); + const adapters = service.getAdapters(); + expect(adapters.some(a => a.name === 'console')).toBe(true); + }); + + it('should register additional adapters from options', async () => { + setup({ + adapters: ['slack', 'webhook'], + slack: { webhookUrl: 'https://hooks.slack.com/test' }, + webhook: { url: 'https://api.test/events' }, + }); + const service = TestBed.inject(NotificationService); + const adapters = service.getAdapters(); + expect(adapters.some(a => a.name === 'console')).toBe(true); + expect(adapters.some(a => a.name === 'slack')).toBe(true); + expect(adapters.some(a => a.name === 'webhook')).toBe(true); + }); + + it('should include adapter names in config.enabledAdapters', () => { + setup({ + adapters: ['slack', 'email'], + slack: { webhookUrl: 'https://hooks.slack.com/test' }, + email: { smtpEndpoint: 'https://api.test/email', from: 'no@test.com' }, + }); + const config = TestBed.inject(NOTIFICATION_CONFIG); + expect(config.enabledAdapters).toEqual(['console', 'slack', 'email']); + }); + + it('should store adapter-specific config', () => { + setup({ + adapters: ['slack'], + slack: { webhookUrl: 'https://hooks.slack.com/test' }, + }); + const config = TestBed.inject(NOTIFICATION_CONFIG); + expect(config.slack?.webhookUrl).toBe('https://hooks.slack.com/test'); + }); + + it('should not register adapter if options for it are missing', async () => { + setup({ + adapters: ['slack'], + // slack options intentionally omitted + }); + const service = TestBed.inject(NotificationService); + const adapters = service.getAdapters(); + expect(adapters.some(a => a.name === 'slack')).toBe(false); + expect(adapters.some(a => a.name === 'console')).toBe(true); + }); + + it('should work with no options at all', async () => { + setup(); + const service = TestBed.inject(NotificationService); + const config = TestBed.inject(NOTIFICATION_CONFIG); + expect(service.getAdapters().length).toBeGreaterThanOrEqual(1); + expect(config.enabledAdapters).toEqual(['console']); + }); +}); diff --git a/packages/core/src/lib/notifications/provide-notifications.ts b/packages/core/src/lib/notifications/provide-notifications.ts new file mode 100644 index 0000000..2d41c44 --- /dev/null +++ b/packages/core/src/lib/notifications/provide-notifications.ts @@ -0,0 +1,72 @@ +import { EnvironmentProviders, makeEnvironmentProviders, inject, provideAppInitializer } from '@angular/core'; +import { + NOTIFICATION_CONFIG, + NotificationConfig, + NotificationOptions, +} from './notification.types'; +import { NotificationService } from './notification.service'; +import { ConsoleAdapter } from './adapters/console-adapter'; +import { SlackAdapter } from './adapters/slack-adapter'; +import { WebhookAdapter } from './adapters/webhook-adapter'; +import { EmailAdapter } from './adapters/email-adapter'; + +/** + * Configure the Notifications module. + * + * Registers `NotificationService` and adapters based on options. + * `ConsoleAdapter` is always registered as a fallback. + * + * This is an **EXTENDED** module — services are NOT available globally. + * Call `provideNotifications()` in your `appConfig` to enable them. + * + * @example + * ```ts + * import { provideNotifications } from '@fireflyframework/core'; + * + * export const appConfig: ApplicationConfig = { + * providers: [ + * provideNotifications({ + * adapters: ['slack', 'webhook'], + * slack: { webhookUrl: 'https://hooks.slack.com/...' }, + * webhook: { url: 'https://api.internal/events' }, + * }), + * ], + * }; + * ``` + * + * @param options - Optional notification configuration. Only ConsoleAdapter is registered if omitted. + * @returns EnvironmentProviders to register in the application config + */ +export function provideNotifications( + options?: NotificationOptions, +): EnvironmentProviders { + const enabledAdapters = ['console', ...(options?.adapters ?? [])]; + + const config: NotificationConfig = { + enabledAdapters, + slack: options?.slack, + email: options?.email, + webhook: options?.webhook, + }; + + return makeEnvironmentProviders([ + NotificationService, + { provide: NOTIFICATION_CONFIG, useValue: config }, + provideAppInitializer(() => { + const service = inject(NotificationService); + + // ConsoleAdapter always registered + service.registerAdapter(new ConsoleAdapter()); + + if (options?.adapters?.includes('slack') && options.slack) { + service.registerAdapter(new SlackAdapter(options.slack)); + } + if (options?.adapters?.includes('webhook') && options.webhook) { + service.registerAdapter(new WebhookAdapter(options.webhook)); + } + if (options?.adapters?.includes('email') && options.email) { + service.registerAdapter(new EmailAdapter(options.email)); + } + }), + ]); +} From bc9631c5dc7ec5c53ba710b6b8010299d78e9855 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Fri, 22 May 2026 10:38:04 +0200 Subject: [PATCH 6/7] [STEP-2.13-006] Bump core version to 0.14.0 and update CHANGELOG for notifications module Add [0.14.0] entry documenting all notifications module additions: NotificationService, 5 adapters, provideNotifications() factory, types. Fix missing link references for 0.12.0, 0.13.0, 0.14.0. --- packages/core/CHANGELOG.md | 19 ++++++++++++++++++- packages/core/package.json | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 1709ea3..b94f604 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.0] - 2026-05-22 + +### Added +- Notifications module (EXTENDED): `provideNotifications(options?)` factory with `NOTIFICATION_CONFIG` injection token +- `NotificationService` — central event dispatcher with adapter registry, `emit()` routing via `supports()`, and `Promise.allSettled` fault tolerance +- `NotificationAdapter` interface — contract for pluggable notification channels (`name`, `supports()`, `send()`) +- `FireflyEvent` type — system event payload with `type`, `product`, `severity`, `message`, `timestamp`, and optional `data` +- `ConsoleAdapter` — always-on fallback adapter, logs events to console with severity-aware formatting +- `NoopAdapter` — silent no-op adapter for testing environments +- `SlackAdapter` — dispatches events to Slack via incoming webhook URL with severity emoji formatting +- `WebhookAdapter` — generic HTTP POST adapter with configurable URL and custom headers +- `EmailAdapter` — dispatches events to an HTTP relay endpoint with email-specific payload (from, subject, body) +- Types: `EventSeverity`, `SlackAdapterOptions`, `EmailAdapterOptions`, `WebhookAdapterOptions`, `NotificationOptions`, `NotificationConfig` + ## [0.13.0] - 2026-05-22 ### Added @@ -200,7 +214,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - User context module: `UserContextService` - API runtime module: `ApiClient`, `TransportRegistry`, `HttpTransportAdapter` -[Unreleased]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.11.0...HEAD +[Unreleased]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.14.0...HEAD +[0.14.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.13.0...core@0.14.0 +[0.13.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.12.0...core@0.13.0 +[0.12.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.11.0...core@0.12.0 [0.11.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.10.1...core@0.11.0 [0.10.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.9.0...core@0.10.0 [0.9.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.8.0...core@0.9.0 diff --git a/packages/core/package.json b/packages/core/package.json index 4132f7b..b54db61 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@fireflyframework/core", - "version": "0.13.0", + "version": "0.14.0", "publishConfig": { "registry": "https://npm.pkg.github.com" }, From d4ce8ea50629f71ff5efaef250f2854bb2e8687e Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Fri, 22 May 2026 10:43:41 +0200 Subject: [PATCH 7/7] [PASO 2.13] Fix lint errors in notification adapters Replace empty arrow functions with vi.fn() in test mocks to satisfy no-empty-function rule. Add eslint-disable for required unused param in NoopAdapter.send(). --- .../notifications/adapters/console-adapter.spec.ts | 14 +++++++------- .../notifications/adapters/noop-adapter.spec.ts | 2 +- .../src/lib/notifications/adapters/noop-adapter.ts | 1 + 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/core/src/lib/notifications/adapters/console-adapter.spec.ts b/packages/core/src/lib/notifications/adapters/console-adapter.spec.ts index 8f0add8..e726eb7 100644 --- a/packages/core/src/lib/notifications/adapters/console-adapter.spec.ts +++ b/packages/core/src/lib/notifications/adapters/console-adapter.spec.ts @@ -30,7 +30,7 @@ describe('ConsoleAdapter', () => { }); it('should log info events with console.log', async () => { - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const spy = vi.spyOn(console, 'log').mockImplementation(vi.fn()); await adapter.send(createEvent({ severity: 'info' })); expect(spy).toHaveBeenCalledTimes(1); expect(spy.mock.calls[0][0]).toContain('[INFO]'); @@ -40,7 +40,7 @@ describe('ConsoleAdapter', () => { }); it('should log warning events with console.warn', async () => { - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const spy = vi.spyOn(console, 'warn').mockImplementation(vi.fn()); await adapter.send(createEvent({ severity: 'warning' })); expect(spy).toHaveBeenCalledTimes(1); expect(spy.mock.calls[0][0]).toContain('[WARN]'); @@ -48,7 +48,7 @@ describe('ConsoleAdapter', () => { }); it('should log error events with console.error', async () => { - const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const spy = vi.spyOn(console, 'error').mockImplementation(vi.fn()); await adapter.send(createEvent({ severity: 'error' })); expect(spy).toHaveBeenCalledTimes(1); expect(spy.mock.calls[0][0]).toContain('[ERROR]'); @@ -56,7 +56,7 @@ describe('ConsoleAdapter', () => { }); it('should log critical events with console.error', async () => { - const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const spy = vi.spyOn(console, 'error').mockImplementation(vi.fn()); await adapter.send(createEvent({ severity: 'critical' })); expect(spy).toHaveBeenCalledTimes(1); expect(spy.mock.calls[0][0]).toContain('[CRITICAL]'); @@ -64,7 +64,7 @@ describe('ConsoleAdapter', () => { }); it('should include event data when present', async () => { - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const spy = vi.spyOn(console, 'log').mockImplementation(vi.fn()); const data = { key: 'value' }; await adapter.send(createEvent({ data })); expect(spy.mock.calls[0][1]).toEqual(data); @@ -72,14 +72,14 @@ describe('ConsoleAdapter', () => { }); it('should pass empty string when no data', async () => { - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const spy = vi.spyOn(console, 'log').mockImplementation(vi.fn()); await adapter.send(createEvent()); expect(spy.mock.calls[0][1]).toBe(''); spy.mockRestore(); }); it('should format the log line correctly', async () => { - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const spy = vi.spyOn(console, 'log').mockImplementation(vi.fn()); await adapter.send(createEvent({ severity: 'info', product: 'my-app', diff --git a/packages/core/src/lib/notifications/adapters/noop-adapter.spec.ts b/packages/core/src/lib/notifications/adapters/noop-adapter.spec.ts index e9e96d0..2eb6b25 100644 --- a/packages/core/src/lib/notifications/adapters/noop-adapter.spec.ts +++ b/packages/core/src/lib/notifications/adapters/noop-adapter.spec.ts @@ -29,7 +29,7 @@ describe('NoopAdapter', () => { }); it('should resolve without doing anything', async () => { - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const spy = vi.spyOn(console, 'log').mockImplementation(vi.fn()); await expect(adapter.send(createEvent())).resolves.toBeUndefined(); expect(spy).not.toHaveBeenCalled(); spy.mockRestore(); diff --git a/packages/core/src/lib/notifications/adapters/noop-adapter.ts b/packages/core/src/lib/notifications/adapters/noop-adapter.ts index edfea98..f6f7da8 100644 --- a/packages/core/src/lib/notifications/adapters/noop-adapter.ts +++ b/packages/core/src/lib/notifications/adapters/noop-adapter.ts @@ -19,6 +19,7 @@ export class NoopAdapter implements NotificationAdapter { return true; } + // eslint-disable-next-line @typescript-eslint/no-unused-vars async send(_event: FireflyEvent): Promise { // intentionally empty }