Files
Krishna KumarandClaude Fable 5 e73b4f7e34 Add optional message field to /submit for iOS review intercept
- /submit now requires email OR message (email-only calls unchanged)
- message trimmed + capped at 10k chars; stored via guarded ALTER TABLE migration
- /export CSV gains message column with proper quoting/escaping
- test suite (node --test): 9 tests covering compat, validation, truncation, CSV escaping

Ticket: T2 of review-flow-10x epic (chatai-ios#136)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014WPnLnXS4YRz9vVt1BEdeR
2026-07-22 12:22:30 -05:00

128 lines
4.5 KiB
JavaScript

const { test, before, after } = require('node:test');
const assert = require('node:assert');
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const PORT = 3999;
const BASE = `http://127.0.0.1:${PORT}`;
const API_KEY = 'test-key';
let server;
let dataDir;
before(async () => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'form-capture-test-'));
server = spawn('node', [path.join(__dirname, '..', 'index.js')], {
env: { ...process.env, PORT: String(PORT), DATA_DIR: dataDir, API_KEY },
stdio: 'ignore',
});
// Wait for the server to come up
for (let i = 0; i < 50; i++) {
try {
const res = await fetch(`${BASE}/health`);
if (res.ok) return;
} catch {}
await new Promise(r => setTimeout(r, 100));
}
throw new Error('server did not start');
});
after(() => {
server.kill();
fs.rmSync(dataDir, { recursive: true, force: true });
});
test('email-only submission still works (backward compat)', async () => {
const res = await fetch(`${BASE}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'a@b.com', form_name: 'newsletter' }),
});
assert.strictEqual(res.status, 200);
assert.strictEqual((await res.json()).success, true);
});
test('message-only submission works (review intercept)', async () => {
const res = await fetch(`${BASE}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'The app crashed, twice', form_name: 'review_intercept', source: 'ios' }),
});
assert.strictEqual(res.status, 200);
});
test('message + reply email submission works', async () => {
const res = await fetch(`${BASE}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'reply@me.com', message: 'line1\nline2, "quoted"', form_name: 'review_intercept' }),
});
assert.strictEqual(res.status, 200);
});
test('empty body is rejected', async () => {
const res = await fetch(`${BASE}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ form_name: 'review_intercept' }),
});
assert.strictEqual(res.status, 400);
assert.match((await res.json()).error, /Email or message/);
});
test('whitespace-only message is rejected', async () => {
const res = await fetch(`${BASE}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: ' ' }),
});
assert.strictEqual(res.status, 400);
});
test('invalid email is still rejected even with message', async () => {
const res = await fetch(`${BASE}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'not-an-email', message: 'hello' }),
});
assert.strictEqual(res.status, 400);
});
test('message longer than 10k chars is truncated, not rejected', async () => {
const res = await fetch(`${BASE}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'x'.repeat(20000), form_name: 'review_intercept' }),
});
assert.strictEqual(res.status, 200);
const list = await (await fetch(`${BASE}/emails?form_name=review_intercept&limit=100`, {
headers: { Authorization: `Bearer ${API_KEY}` },
})).json();
const long = list.submissions.find(s => s.message && s.message.startsWith('xxx'));
assert.ok(long, 'truncated submission should be listed');
assert.strictEqual(long.message.length, 10000);
});
test('/emails returns message field', async () => {
const res = await fetch(`${BASE}/emails?form_name=review_intercept`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
assert.strictEqual(res.status, 200);
const body = await res.json();
const withMsg = body.submissions.find(s => s.message === 'The app crashed, twice');
assert.ok(withMsg, 'message should round-trip through /emails');
assert.strictEqual(withMsg.email, '');
});
test('/export CSV includes and escapes message', async () => {
const res = await fetch(`${BASE}/export?form_name=review_intercept`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
assert.strictEqual(res.status, 200);
const csv = await res.text();
assert.match(csv.split('\n')[0], /^email,form_name,source,message,created_at$/);
// Multi-line + quoted message must be wrapped and quote-doubled
assert.ok(csv.includes('"line1\nline2, ""quoted"""'), 'CSV should escape newlines, commas, quotes');
});