- Asserts muted user (enabled=false in user_event_subscriptions) returns success:true, notified:false, skipped_reason='user_muted' - Asserts no notification_channels SQL is issued on the mute path - Asserts no outbound fetch() is called (no personal or global send) - Asserts user_route_fallback is absent from output (mute != fallback) - Uses _INTERNALS test seam to call dispatchUserRoute directly - Behavioral guarantee for D-12/ROUTE-05 enforced by CI, not just grep
103 lines
3.6 KiB
TypeScript
103 lines
3.6 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|