diff --git a/lib/services/pipeline-steps/notify.test.ts b/lib/services/pipeline-steps/notify.test.ts new file mode 100644 index 0000000..115b850 --- /dev/null +++ b/lib/services/pipeline-steps/notify.test.ts @@ -0,0 +1,103 @@ +/** + * notify.ts — route_to_user unit tests + * + * Critical behavioral assertion (D-12 / ROUTE-05): + * When a user has the (event_key, channel_type) toggle DISABLED in + * user_event_subscriptions, the notify step must return success:true with + * notified:false and MUST NOT contact the global channel or any personal + * channel. Grep cannot prove this — only a behavioral test can. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock postgresClient BEFORE importing the module under test. +const queryMock = vi.fn(); +vi.mock('../postgres-client', () => ({ + postgresClient: { + query: (...args: unknown[]) => queryMock(...args), + }, +})); + +// Mock the resolver registry to short-circuit user resolution. +vi.mock('./notify-resolvers', () => ({ + resolveRecipient: vi.fn(async () => ({ + recipient: { user_id: 'test-user-id' }, + resolverFound: true, + })), + RESOLVERS: new Map(), + registerResolver: vi.fn(), +})); + +// Mock pipeline-engine to prevent the registerStepExecutor side effect. +vi.mock('../pipeline-engine', () => ({ + registerStepExecutor: vi.fn(), +})); + +// Mock global fetch so any stray send call is observable and doesn't hit network. +const fetchMock = vi.fn(async () => new Response('ok', { status: 200 })); +vi.stubGlobal('fetch', fetchMock); + +// Import AFTER mocks are declared so vi.mock hoisting takes effect. +import { _INTERNALS } from './notify'; + +describe('notify.ts route_to_user', () => { + beforeEach(() => { + queryMock.mockReset(); + fetchMock.mockReset(); + fetchMock.mockResolvedValue(new Response('ok', { status: 200 })); + }); + + it('muted user must not fall back to global channel', async () => { + // Arrange: user has the (event_key, channel_type) toggle DISABLED. + // Any SQL beyond the user_event_subscriptions check is a failure — + // the mute branch must short-circuit before querying notification_channels + // or calling fallbackToGlobal. + queryMock.mockImplementation((sql: string) => { + if (sql.includes('user_event_subscriptions')) { + return Promise.resolve({ rows: [{ enabled: false }], rowCount: 1 }); + } + // Any other SQL (notification_channels, "user" lookup, etc.) is unexpected. + throw new Error(`Unexpected SQL after mute: ${String(sql).slice(0, 80)}`); + }); + + const step = { + id: 1, + step_type: 'notify' as const, + name: 'test', + config: { + channel_id: 99, + message: 'hi', + route_to_user: { + source: 'ticket', + field: 'assignedResourceID', + resolve: 'pulse_user_id', + event_key: 'ticket_assigned_to_me', + channel_type: 'ntfy', + }, + }, + }; + const context = { ticket: { assignedResourceID: 'test-user-id' } }; + + // Act — call dispatchUserRoute directly via the _INTERNALS test seam. + const result = await _INTERNALS.dispatchUserRoute({ + step: step as any, + context, + message: 'hi', + channelId: 99, + route: step.config.route_to_user as any, + }); + + // Assert: success + notified=false + skipped_reason=user_muted, NO fallback key. + expect(result.success).toBe(true); + expect(result.output?.notified).toBe(false); + expect(result.output?.skipped_reason).toBe('user_muted'); + expect(result.output?.user_route_fallback).toBeUndefined(); + + // The notification_channels SELECT must NEVER have been issued. + const calls = queryMock.mock.calls.map((c) => String(c[0])); + expect(calls.some((sql) => sql.includes('notification_channels'))).toBe(false); + + // No outbound HTTP request must have occurred (no send to personal or global channel). + expect(fetchMock).not.toHaveBeenCalled(); + }); +});