import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('./db.js', () => ({ getPool: vi.fn(), })); import { getPool } from './db.js'; import { isValidTokenFormat, isBot, validateDestinationUrl, createTrackingLink, getTrackingLink, recordClick, } from './tracking-links.js'; // ── isValidTokenFormat ──────────────────────────────────────────────────────── describe('isValidTokenFormat', () => { it('accepts a 32-char base64url string', () => { expect(isValidTokenFormat('abcdefghijklmnopqrstuvwxyz123456')).toBe(true); expect(isValidTokenFormat('ABCDEFGHIJKLMNOPQRSTUVWXYZ-_1234')).toBe(true); }); it('rejects wrong length', () => { expect(isValidTokenFormat('short')).toBe(false); expect(isValidTokenFormat('a'.repeat(33))).toBe(false); }); it('rejects non-base64url characters', () => { expect(isValidTokenFormat('a'.repeat(31) + '!')).toBe(false); expect(isValidTokenFormat('a'.repeat(31) + '+')).toBe(false); }); }); // ── isBot ───────────────────────────────────────────────────────────────────── describe('isBot', () => { it('flags known bot user agents', () => { expect(isBot('Googlebot/2.1')).toBe(true); expect(isBot('facebookexternalhit/1.1')).toBe(true); expect(isBot('AhrefsBot/7.0')).toBe(true); }); it('passes real browser user agents', () => { expect(isBot('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)')).toBe(false); expect(isBot('Mozilla/5.0 (Windows NT 10.0) Chrome/120')).toBe(false); }); }); // ── validateDestinationUrl ──────────────────────────────────────────────────── describe('validateDestinationUrl', () => { it('accepts http and https URLs', () => { expect(() => validateDestinationUrl('https://calendly.com/alex/30min')).not.toThrow(); expect(() => validateDestinationUrl('http://example.com')).not.toThrow(); }); it('rejects non-http protocols', () => { expect(() => validateDestinationUrl('javascript:alert(1)')).toThrow(); expect(() => validateDestinationUrl('ftp://example.com')).toThrow(); }); it('rejects URLs with embedded credentials', () => { expect(() => validateDestinationUrl('https://user:pass@example.com')).toThrow('credentials'); }); it('rejects URLs over 2048 chars', () => { expect(() => validateDestinationUrl('https://example.com/' + 'a'.repeat(2048))).toThrow('too long'); }); it('rejects malformed URLs', () => { expect(() => validateDestinationUrl('not a url')).toThrow(); }); }); // ── createTrackingLink ──────────────────────────────────────────────────────── describe('createTrackingLink', () => { const mockQuery = vi.fn(); beforeEach(() => { mockQuery.mockClear(); vi.mocked(getPool).mockReturnValue({ query: mockQuery } as any); mockQuery.mockResolvedValue([{ insertId: 1, affectedRows: 1 }]); }); it('returns a 32-char token on success', async () => { const token = await createTrackingLink({ customerId: 'cust-1', draftId: 42, destinationUrl: 'https://calendly.com/alex', expiresAt: null, }); expect(token).toHaveLength(32); expect(/^[A-Za-z0-9_-]{32}$/.test(token)).toBe(true); }); it('retries on duplicate key error and succeeds', async () => { mockQuery .mockRejectedValueOnce({ code: 'ER_DUP_ENTRY' }) .mockResolvedValueOnce([{ insertId: 1 }]); const token = await createTrackingLink({ customerId: 'cust-1', draftId: null, destinationUrl: 'https://example.com', expiresAt: null, }); expect(token).toHaveLength(32); expect(mockQuery).toHaveBeenCalledTimes(2); }); it('throws when destination URL is invalid', async () => { await expect( createTrackingLink({ customerId: 'cust-1', draftId: null, destinationUrl: 'javascript:alert(1)', expiresAt: null, }) ).rejects.toThrow(); expect(mockQuery).not.toHaveBeenCalled(); }); }); // ── getTrackingLink ─────────────────────────────────────────────────────────── describe('getTrackingLink', () => { const mockQuery = vi.fn(); beforeEach(() => { vi.mocked(getPool).mockReturnValue({ query: mockQuery } as any); }); const baseRow = { token: 'a'.repeat(32), customer_id: 'cust-1', draft_id: 7, destination_url: 'https://calendly.com/alex', click_count: 5, expires_at: null, created_at: new Date('2026-06-09'), }; it('returns a tracking link for a known token', async () => { mockQuery.mockResolvedValue([[baseRow]]); const link = await getTrackingLink('a'.repeat(32)); expect(link).not.toBeNull(); expect(link!.destinationUrl).toBe('https://calendly.com/alex'); expect(link!.clickCount).toBe(5); }); it('returns null for an unknown token', async () => { mockQuery.mockResolvedValue([[]]); expect(await getTrackingLink('b'.repeat(32))).toBeNull(); }); it('returns null for an expired token', async () => { mockQuery.mockResolvedValue([[{ ...baseRow, expires_at: new Date('2020-01-01') }]]); expect(await getTrackingLink('a'.repeat(32))).toBeNull(); }); it('returns a link when expires_at is null (no expiry)', async () => { mockQuery.mockResolvedValue([[{ ...baseRow, expires_at: null }]]); const link = await getTrackingLink('a'.repeat(32)); expect(link).not.toBeNull(); }); it('returns a link when expires_at is in the future', async () => { const future = new Date(Date.now() + 86400_000); mockQuery.mockResolvedValue([[{ ...baseRow, expires_at: future }]]); const link = await getTrackingLink('a'.repeat(32)); expect(link).not.toBeNull(); }); }); // ── recordClick ─────────────────────────────────────────────────────────────── describe('recordClick', () => { const mockQuery = vi.fn(); beforeEach(() => { vi.mocked(getPool).mockReturnValue({ query: mockQuery } as any); }); it('fires an UPDATE without throwing', () => { mockQuery.mockResolvedValue([{ affectedRows: 1 }]); expect(() => recordClick('a'.repeat(32))).not.toThrow(); }); it('swallows DB errors — does not propagate', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); mockQuery.mockRejectedValue(new Error('DB down')); recordClick('a'.repeat(32)); // Give the microtask queue a tick to process the rejected promise await new Promise(r => setTimeout(r, 0)); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('[tracking]'), expect.any(Error) ); consoleSpy.mockRestore(); }); });