test(09-03): add vitest unit test for muted user route behavior

- 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
This commit is contained in:
lorentz 2026-05-10 07:29:20 -04:00
parent 86acc06b16
commit fd19a5d997

View file

@ -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();
});
});