From d7c55cb82b7881fec05662455325fdfd792cc5e8 Mon Sep 17 00:00:00 2001 From: Garfield Date: Thu, 14 May 2026 19:24:53 -0400 Subject: [PATCH] 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 --- src/notifications/notifications.test.ts | 269 ++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 src/notifications/notifications.test.ts diff --git a/src/notifications/notifications.test.ts b/src/notifications/notifications.test.ts new file mode 100644 index 0000000..abd56d4 --- /dev/null +++ b/src/notifications/notifications.test.ts @@ -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]> = [ + ['@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(); + }); +});