- src/tracking-links.ts — create, get, record click, validate URL, bot detection - src/tracking-links.test.ts — 20 tests, all passing - src/db.ts — tracking_links table, post_drafts table, 6 pilot columns on customers - src/index.ts — public GET /t/:token redirect route (no auth) Analytics (click count) is fire-and-forget after redirect. MySQL is source of truth; redirect never depends on Redis. Related: SquareMCP/2026-06-09-tracking-links-deployment.md
201 lines
7.1 KiB
TypeScript
201 lines
7.1 KiB
TypeScript
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();
|
|
});
|
|
});
|