feat(tracking): MySQL-backed tracking links + pilot schema

- 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
This commit is contained in:
Garfield
2026-06-09 14:22:58 -04:00
parent 95b4138f87
commit 5effb41af4
4 changed files with 419 additions and 0 deletions

200
src/tracking-links.test.ts Normal file
View File

@@ -0,0 +1,200 @@
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();
});
});