test(notifications): full coverage for Slack alert module

23 tests covering sendSlackAlert (graceful degradation, Block Kit payload,
HTTP errors, network failures, fallback text) and notifyNewPilotRequest
(test submission filtering, source/IP header resolution, fire-and-forget
error containment).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garfield
2026-05-14 19:24:53 -04:00
parent 1742a2f599
commit d7c55cb82b

View File

@@ -0,0 +1,269 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// ── Hoist fetch mock before imports ───────────────────────────────────────────
const { mockFetch } = vi.hoisted(() => ({
mockFetch: vi.fn(),
}));
vi.stubGlobal('fetch', mockFetch);
// ── Tests: slack.ts ───────────────────────────────────────────────────────────
describe('sendSlackAlert', () => {
const WEBHOOK = 'https://hooks.slack.com/services/T000/B000/xxxx';
const payload = {
requestId: 'req-123',
name: 'Alice',
email: 'alice@acme.com',
company: 'Acme Corp',
role: 'CTO',
use_case: 'Automate social posts',
timeline: 'ASAP',
systems: 'Salesforce',
requirements: 'SSO required',
source: 'squaremcp.com',
ipAddress: '1.2.3.4',
};
beforeEach(() => {
vi.resetModules();
mockFetch.mockReset();
delete process.env['SLACK_PILOT_WEBHOOK_URL'];
});
it('returns false and skips fetch when env var is not set', async () => {
const { sendSlackAlert } = await import('./slack.js');
const result = await sendSlackAlert(payload);
expect(result).toBe(false);
expect(mockFetch).not.toHaveBeenCalled();
});
it('returns false and skips fetch when env var is empty string', async () => {
process.env['SLACK_PILOT_WEBHOOK_URL'] = '';
const { sendSlackAlert } = await import('./slack.js');
const result = await sendSlackAlert(payload);
expect(result).toBe(false);
expect(mockFetch).not.toHaveBeenCalled();
});
it('posts JSON to the webhook URL and returns true on 200', async () => {
process.env['SLACK_PILOT_WEBHOOK_URL'] = WEBHOOK;
mockFetch.mockResolvedValueOnce({ ok: true, status: 200, text: async () => 'ok' });
const { sendSlackAlert } = await import('./slack.js');
const result = await sendSlackAlert(payload);
expect(result).toBe(true);
expect(mockFetch).toHaveBeenCalledOnce();
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe(WEBHOOK);
expect(opts.method).toBe('POST');
expect(opts.headers).toMatchObject({ 'Content-Type': 'application/json' });
});
it('sends a valid Block Kit payload containing all key fields', async () => {
process.env['SLACK_PILOT_WEBHOOK_URL'] = WEBHOOK;
mockFetch.mockResolvedValueOnce({ ok: true, status: 200, text: async () => 'ok' });
const { sendSlackAlert } = await import('./slack.js');
await sendSlackAlert(payload);
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
const raw = JSON.stringify(body);
expect(raw).toContain('Alice');
expect(raw).toContain('alice@acme.com');
expect(raw).toContain('Acme Corp');
expect(raw).toContain('req-123');
expect(raw).toContain('1.2.3.4');
expect(raw).toContain('Automate social posts');
// Must use Block Kit (blocks array)
expect(body.blocks).toBeInstanceOf(Array);
expect(body.blocks.length).toBeGreaterThan(0);
});
it('returns false and logs error when HTTP response is not ok', async () => {
process.env['SLACK_PILOT_WEBHOOK_URL'] = WEBHOOK;
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
text: async () => 'invalid_payload',
});
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { sendSlackAlert } = await import('./slack.js');
const result = await sendSlackAlert(payload);
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('[notification:slack]')
);
consoleSpy.mockRestore();
});
it('returns false and does not throw when fetch rejects (network error)', async () => {
process.env['SLACK_PILOT_WEBHOOK_URL'] = WEBHOOK;
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { sendSlackAlert } = await import('./slack.js');
const result = await sendSlackAlert(payload);
expect(result).toBe(false);
consoleSpy.mockRestore();
});
it('falls back to _None specified_ when systems/requirements are empty', async () => {
process.env['SLACK_PILOT_WEBHOOK_URL'] = WEBHOOK;
mockFetch.mockResolvedValueOnce({ ok: true, status: 200, text: async () => 'ok' });
const { sendSlackAlert } = await import('./slack.js');
await sendSlackAlert({ ...payload, systems: '', requirements: '' });
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
const raw = JSON.stringify(body);
expect(raw).toContain('_None specified_');
});
});
// ── Tests: index.ts (notifyNewPilotRequest) ────────────────────────────────────
describe('notifyNewPilotRequest', () => {
const makeReq = (overrides?: Partial<{ origin?: string; host?: string; ip?: string }>) => ({
get: (h: string) => {
if (h === 'origin') return overrides?.origin ?? 'squaremcp.com';
if (h === 'host') return overrides?.host ?? 'squaremcp.com';
return undefined;
},
ip: overrides?.ip ?? '9.9.9.9',
});
const realBody = {
name: 'Bob',
email: 'bob@realco.com',
company: 'RealCo',
role: 'VP Eng',
use_case: 'Publish content',
timeline: 'Q3',
systems: 'HubSpot',
requirements: '',
};
beforeEach(() => {
vi.resetModules();
mockFetch.mockReset();
delete process.env['SLACK_PILOT_WEBHOOK_URL'];
});
it('calls sendSlackAlert for real submissions when webhook is configured', async () => {
process.env['SLACK_PILOT_WEBHOOK_URL'] = 'https://hooks.slack.com/test';
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => 'ok' });
const { notifyNewPilotRequest } = await import('./index.js');
await notifyNewPilotRequest(realBody, 'req-abc', makeReq());
// give the fire-and-forget microtask time to run
await new Promise((r) => setTimeout(r, 10));
expect(mockFetch).toHaveBeenCalledOnce();
});
it('does not call sendSlackAlert when webhook is not configured', async () => {
const { notifyNewPilotRequest } = await import('./index.js');
await notifyNewPilotRequest(realBody, 'req-abc', makeReq());
await new Promise((r) => setTimeout(r, 10));
expect(mockFetch).not.toHaveBeenCalled();
});
describe('test submission filtering', () => {
beforeEach(() => {
process.env['SLACK_PILOT_WEBHOOK_URL'] = 'https://hooks.slack.com/test';
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => 'ok' });
});
const cases: Array<[string, Partial<typeof realBody>]> = [
['@example.com email', { email: 'test@example.com' }],
['name contains E2E', { name: 'E2E Runner' }],
['name contains Smoke Test', { name: 'Smoke Test Bot' }],
['name contains QA Test', { name: 'QA Test User' }],
['name contains Browser Test', { name: 'Browser Test' }],
['company is QA Check', { company: 'QA Check' }],
['company is QA Corp', { company: 'QA Corp' }],
['company is SquareMCP E2E', { company: 'SquareMCP E2E' }],
['company is SquareMCP Test', { company: 'SquareMCP Test' }],
['company is SquareMCP Browser', { company: 'SquareMCP Browser' }],
];
it.each(cases)('skips alert for: %s', async (_label, override) => {
const { notifyNewPilotRequest } = await import('./index.js');
await notifyNewPilotRequest({ ...realBody, ...override }, 'req-skip', makeReq());
await new Promise((r) => setTimeout(r, 10));
expect(mockFetch).not.toHaveBeenCalled();
});
});
it('uses req.socket.remoteAddress when req.ip is absent', async () => {
process.env['SLACK_PILOT_WEBHOOK_URL'] = 'https://hooks.slack.com/test';
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => 'ok' });
const reqNoIp = {
get: (_h: string) => 'squaremcp.com',
ip: undefined,
socket: { remoteAddress: '5.5.5.5' },
};
const { notifyNewPilotRequest } = await import('./index.js');
await notifyNewPilotRequest(realBody, 'req-ip', reqNoIp);
await new Promise((r) => setTimeout(r, 10));
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
expect(JSON.stringify(body)).toContain('5.5.5.5');
});
it('uses origin header as source when available', async () => {
process.env['SLACK_PILOT_WEBHOOK_URL'] = 'https://hooks.slack.com/test';
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => 'ok' });
const { notifyNewPilotRequest } = await import('./index.js');
await notifyNewPilotRequest(
realBody,
'req-origin',
makeReq({ origin: 'https://squaremcp.com' })
);
await new Promise((r) => setTimeout(r, 10));
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
expect(JSON.stringify(body)).toContain('https://squaremcp.com');
});
it('falls back to host header when origin is absent', async () => {
process.env['SLACK_PILOT_WEBHOOK_URL'] = 'https://hooks.slack.com/test';
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => 'ok' });
const reqNoOrigin = {
get: (h: string) => (h === 'host' ? 'squaremcp.com' : undefined),
ip: '1.1.1.1',
};
const { notifyNewPilotRequest } = await import('./index.js');
await notifyNewPilotRequest(realBody, 'req-host', reqNoOrigin);
await new Promise((r) => setTimeout(r, 10));
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
expect(JSON.stringify(body)).toContain('squaremcp.com');
});
it('does not propagate when sendSlackAlert rejects', async () => {
process.env['SLACK_PILOT_WEBHOOK_URL'] = 'https://hooks.slack.com/test';
mockFetch.mockRejectedValue(new Error('network failure'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { notifyNewPilotRequest } = await import('./index.js');
// Should resolve without throwing even if alert fails
await expect(notifyNewPilotRequest(realBody, 'req-fail', makeReq())).resolves.toBeUndefined();
await new Promise((r) => setTimeout(r, 10));
consoleSpy.mockRestore();
});
});