Steps 0–10 of the v2 plan, 194 tests passing. Core infrastructure - Shared Redis client (src/redis.ts); all four Redis consumers migrated - Vitest test harness with vitest.config.ts and npm test/test:watch scripts Billing & invoicing (Steps 1–2) - Monthly invoice generation with idempotency (MySQL uq_customer_period unique key) - Cron job with Redis distributed lock (Lua compare-delete, 1-hr TTL) - Invoice emailer via nodemailer (FETCHERPAY SMTP) - Billing middleware: checkLimit gate in handleToolCall; platform attribution fix Email multi-tenancy (Step 3) - EmailCtx = Account | EmailCredentials; imap.ts + smtp.ts accept both - resolveEmailCtx helper in tools.ts; all email tools use customer credentials Analytics + platform health (Steps 4–5) - Chart.js bar charts for platform breakdown and daily activity - Token expiry check in getCredential with dynamic import refresh - platform-health.ts: per-platform health probe with 10-min Redis cache - GET /api/health/platforms; "Token expired" amber badge in dashboard Tool schema filtering (Step 6) - stripAccountParam deep-clones tool schemas; multi-tenant sessions never see the internal account enum OAuth hardening (Step 7) - Atomic auth code consumption: UPDATE SET used=TRUE, check affectedRows - customer_id threaded through oauth_auth_codes → oauth_tokens - getTokenCustomer(); requireAuth resolves req.customer from Bearer token - Consent page requires authenticated session; redirect_uri validated against registered URIs; http://localhost:* loopback wildcard DCR browser flow (Step 8) - ensureOAuthAppRegistered() upserts pre-registered SquareMCP OAuth app on startup with redirect URIs for mcp-callback, localhost:*, claude-desktop, opencode - GET /oauth/connect-mcp → server-side redirect (client_id off frontend) - GET /oauth/mcp-callback → exchanges code, renders config snippet page with copy buttons for Claude Desktop and Codex CLI Webhooks (Step 9) - webhook_url + webhook_secret columns on customers - deliverWebhook(): HMAC-SHA256 signing, 3× exponential retry (1s/4s/16s), Redis DLQ with 7-day TTL on total failure - isValidWebhookUrl(): SSRF protection (blocks RFC-1918, localhost, .local) - POST /api/webhooks/config (secret returned once), GET, DELETE - GET /api/admin/webhooks/dlq/:customerId - WhatsApp POST route uses express.raw() for raw body preservation - Dashboard Webhooks tab with secret-once display and copy button Developer docs (Step 10) - docs/ static HTML site (GitHub Pages, no build pipeline) - index.html: landing page with client + platform overview - getting-started.html: tabbed MCP config for Claude Desktop, Codex CLI, opencode - platforms.html: LinkedIn, TikTok, WhatsApp, Instagram, Twitter, Telegram guides - agent-tutorial.html: complete Node.js agent (Anthropic SDK + MCP SDK), LinkedIn posting loop, extensions for multi-platform + inbound webhook reaction Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
152 lines
6.1 KiB
TypeScript
152 lines
6.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import nodemailer from 'nodemailer';
|
|
|
|
const mockQuery = vi.fn();
|
|
vi.mock('../db.js', () => ({
|
|
getPool: () => ({ query: mockQuery }),
|
|
}));
|
|
vi.mock('nodemailer', () => ({
|
|
default: { createTransport: vi.fn() },
|
|
}));
|
|
|
|
import {
|
|
createInvoice,
|
|
generateMonthlyInvoice,
|
|
getInvoiceByNumber,
|
|
emailInvoice,
|
|
type Invoice,
|
|
} from './invoices.js';
|
|
|
|
const baseInvoice: Invoice = {
|
|
id: 1,
|
|
customer_id: 'cust-1',
|
|
invoice_number: 'SMCP-20260501-aabb1122',
|
|
amount: 25.0,
|
|
currency: 'USD',
|
|
status: 'draft',
|
|
period_start: '2026-04-01',
|
|
period_end: '2026-04-30',
|
|
line_items: [{ description: 'email actions', quantity: 500, unit_price: 0.05, amount: 25.0 }],
|
|
sent_at: null,
|
|
paid_at: null,
|
|
created_at: '2026-05-01T00:00:00Z',
|
|
constructor: { name: 'RowDataPacket' } as any,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// ── generateInvoiceNumber format ─────────────────────────────────────────────
|
|
|
|
describe('generateMonthlyInvoice — billing period', () => {
|
|
it('queries previous month, not current', async () => {
|
|
mockQuery.mockResolvedValueOnce([[]]); // usage query returns empty
|
|
await generateMonthlyInvoice('cust-1');
|
|
const sql: string = mockQuery.mock.calls[0][0];
|
|
// Must reference DATE_SUB for the start of the previous month
|
|
expect(sql).toContain('DATE_SUB');
|
|
// Must NOT use a plain NOW() as the lower bound (that would be current month)
|
|
expect(sql).not.toMatch(/>=\s*DATE_FORMAT\(NOW\(\)/);
|
|
});
|
|
|
|
it('returns null when customer has zero usage', async () => {
|
|
mockQuery.mockResolvedValueOnce([[]]); // no usage rows
|
|
const result = await generateMonthlyInvoice('cust-1');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('creates invoice for previous month period', async () => {
|
|
const now = new Date();
|
|
const expectedStart = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
|
.toISOString().slice(0, 10);
|
|
|
|
mockQuery
|
|
.mockResolvedValueOnce([[{ platform: 'email', count: 100 }]]) // usage
|
|
.mockResolvedValueOnce([[]]) // insert
|
|
.mockResolvedValueOnce([[{ ...baseInvoice, period_start: expectedStart }]]); // select after insert
|
|
|
|
const invoice = await generateMonthlyInvoice('cust-1');
|
|
expect(invoice?.period_start).toBe(expectedStart);
|
|
});
|
|
});
|
|
|
|
// ── Invoice number format ─────────────────────────────────────────────────────
|
|
|
|
describe('createInvoice — invoice number', () => {
|
|
it('generates hex suffix, not decimal padded', async () => {
|
|
mockQuery
|
|
.mockResolvedValueOnce([[]]) // insert
|
|
.mockResolvedValueOnce([[baseInvoice]]); // select
|
|
|
|
await createInvoice('cust-1', 25, baseInvoice.line_items, '2026-04-01', '2026-04-30');
|
|
|
|
const insertSql: string = mockQuery.mock.calls[0][0];
|
|
const invoiceNumber: string = mockQuery.mock.calls[0][1][1];
|
|
// Should be SMCP-YYYYMMDD-<8 hex chars>
|
|
expect(invoiceNumber).toMatch(/^SMCP-\d{8}-[0-9a-f]{8}$/);
|
|
});
|
|
});
|
|
|
|
// ── Idempotency ───────────────────────────────────────────────────────────────
|
|
|
|
describe('createInvoice — idempotency', () => {
|
|
it('returns existing invoice on uq_customer_period duplicate', async () => {
|
|
const dupError = Object.assign(new Error("Duplicate entry 'cust-1-2026-04-01' for key 'invoices.uq_customer_period'"), { errno: 1062 });
|
|
mockQuery
|
|
.mockRejectedValueOnce(dupError) // INSERT throws 1062
|
|
.mockResolvedValueOnce([[baseInvoice]]); // SELECT existing
|
|
|
|
const invoice = await createInvoice('cust-1', 25, baseInvoice.line_items, '2026-04-01', '2026-04-30');
|
|
expect(invoice.invoice_number).toBe(baseInvoice.invoice_number);
|
|
expect(invoice.customer_id).toBe('cust-1');
|
|
});
|
|
|
|
it('re-throws 1062 for other constraints (e.g. duplicate invoice_number)', async () => {
|
|
const dupError = Object.assign(new Error("Duplicate entry 'SMCP-...' for key 'invoices.invoice_number'"), { errno: 1062 });
|
|
mockQuery.mockRejectedValueOnce(dupError);
|
|
|
|
await expect(createInvoice('cust-1', 25, baseInvoice.line_items, '2026-04-01', '2026-04-30'))
|
|
.rejects.toThrow('invoice_number');
|
|
});
|
|
});
|
|
|
|
// ── line_items JSON normalization ─────────────────────────────────────────────
|
|
|
|
describe('getInvoiceByNumber — line_items normalization', () => {
|
|
it('parses line_items when returned as JSON string', async () => {
|
|
const rawInvoice = {
|
|
...baseInvoice,
|
|
line_items: JSON.stringify(baseInvoice.line_items) as any,
|
|
};
|
|
mockQuery.mockResolvedValueOnce([[rawInvoice]]);
|
|
|
|
const invoice = await getInvoiceByNumber('SMCP-20260501-aabb1122');
|
|
expect(Array.isArray(invoice?.line_items)).toBe(true);
|
|
expect(invoice?.line_items[0].description).toBe('email actions');
|
|
});
|
|
|
|
it('leaves line_items unchanged when already an array', async () => {
|
|
mockQuery.mockResolvedValueOnce([[baseInvoice]]);
|
|
const invoice = await getInvoiceByNumber('SMCP-20260501-aabb1122');
|
|
expect(Array.isArray(invoice?.line_items)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── emailInvoice ─────────────────────────────────────────────────────────────
|
|
|
|
describe('emailInvoice', () => {
|
|
it('sends mail with invoice_number in subject and HTML body', async () => {
|
|
const sendMail = vi.fn().mockResolvedValue({ messageId: '<abc@mail>' });
|
|
vi.mocked(nodemailer.createTransport).mockReturnValue({ sendMail } as any);
|
|
|
|
await emailInvoice(baseInvoice, 'customer@example.com');
|
|
|
|
expect(sendMail).toHaveBeenCalledOnce();
|
|
const mailArgs = sendMail.mock.calls[0][0];
|
|
expect(mailArgs.to).toBe('customer@example.com');
|
|
expect(mailArgs.subject).toContain(baseInvoice.invoice_number);
|
|
expect(mailArgs.html).toContain(baseInvoice.invoice_number);
|
|
});
|
|
});
|