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:
parent
86acc06b16
commit
fd19a5d997
1 changed files with 103 additions and 0 deletions
103
lib/services/pipeline-steps/notify.test.ts
Normal file
103
lib/services/pipeline-steps/notify.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue