getMessage() . "\n"); } } function assertTrue($v, string $msg = 'expected true'): void { if (!$v) throw new RuntimeException($msg); } function assertSame($exp, $act, string $msg = ''): void { if ($exp !== $act) throw new RuntimeException(($msg ? "$msg: " : '') . 'expected ' . var_export($exp, true) . ' got ' . var_export($act, true)); } function assertContains(string $needle, string $hay, string $msg = ''): void { if (!str_contains($hay, $needle)) throw new RuntimeException(($msg ? "$msg: " : '') . "'$needle' not found in '$hay'"); } function http(string $method, string $url, ?array $json = null, array $headers = []): array { $ch = curl_init($url); $h = ['Accept: application/json']; foreach ($headers as $k => $v) $h[] = "$k: $v"; if ($json !== null) { $h[] = 'Content-Type: application/json'; curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($json)); } curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $h, CURLOPT_TIMEOUT => 20]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); return ['status' => $status, 'json' => json_decode((string)$body, true), 'body' => (string)$body]; } function uuid(): string { $d = random_bytes(16); $d[6] = chr(ord($d[6]) & 0x0f | 0x40); $d[8] = chr(ord($d[8]) & 0x3f | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($d), 4)); } function validPayload(array $over = []): array { return array_merge([ 'submissionId' => uuid(), 'firstName' => 'Jordan', 'lastName' => 'Lee', 'email' => 'jordan+' . bin2hex(random_bytes(3)) . '@example.com', 'phone' => '(508) 555-0142', 'campusId' => 'norwood', 'visitDate' => date('Y-m-d', time() + 7 * 86400), 'serviceTime' => 'Sunday 9:30 AM', 'adults' => 2, 'children' => 1, 'childAgeRanges' => ['3-5'], 'questions' => 'Is there parking?', 'emailConsent' => true, 'smsConsent' => true, 'consentVersion' => '2026-09-22.v1', 'website' => '', 'startedAt' => (int)(microtime(true) * 1000) - 15000, 'attribution' => ['utm_source' => 'facebook', 'utm_campaign' => 'fall-visit', 'fbclid' => 'FB123', 'variant' => 'b', 'landing_url' => 'https://visit.waterschurch.org/?utm_source=facebook', 'referrer' => 'https://l.facebook.com/', 'evil_key' => 'x'], ], $over); } // ---------------------------------------------------------------- setup exec("rm -rf " . escapeshellarg($TMP)); mkdir($PRIV, 0777, true); $env = << ['file', "$TMP/mock.log", 'a'], 2 => ['file', "$TMP/mock.log", 'a']], $p1); $servers[] = proc_open([$php, '-S', '127.0.0.1:8098', '-t', "$ROOT/public"], [1 => ['file', "$TMP/api.log", 'a'], 2 => ['file', "$TMP/api.log", 'a']], $p2, null, [ 'VISIT_PRIVATE_DIR' => $PRIV, 'MAILCHIMP_BASE_URL_OVERRIDE' => "$MOCK/3.0", 'GEOIP_BASE_URL_OVERRIDE' => $MOCK, 'GEOCODE_BASE_URL_OVERRIDE' => $MOCK, 'PATH' => getenv('PATH'), ]); register_shutdown_function(static function () use (&$servers) { foreach ($servers as $s) { if (is_resource($s)) { proc_terminate($s); } } }); usleep(600000); // ---------------------------------------------------------------- unit tests $_SERVER['DOCUMENT_ROOT'] = "$ROOT/public"; require "$ROOT/public/api/lib/bootstrap.php"; require "$ROOT/public/api/lib/Validator.php"; require "$ROOT/public/api/lib/LeadProcessor.php"; fwrite(STDOUT, "\nPhone\n"); test('normalizes NANP numbers to E.164', function () { assertSame('+15086951300', Phone::normalizeNanp('(508) 695-1300')); assertSame('+15086951300', Phone::normalizeNanp('1 508 695 1300')); assertSame(null, Phone::normalizeNanp('508-695-130')); assertSame(null, Phone::normalizeNanp('011 555 1234')); assertSame(null, Phone::normalizeNanp('411 555 1234')); }); fwrite(STDOUT, "\nValidator\n"); test('accepts a valid payload and normalizes fields', function () { ['data' => $d, 'errors' => $e] = Validator::planVisit(validPayload(['email' => 'Jordan@Example.COM '])); assertSame([], $e); assertSame('jordan@example.com', $d['email']); assertSame('+15085550142', $d['phone_e164']); assertSame('3-5', $d['child_age_ranges']); assertSame(true, $d['sms_consent']); }); test('enforces SMS consent ⇒ valid phone', function () { ['errors' => $e] = Validator::planVisit(validPayload(['phone' => '', 'smsConsent' => true])); assertContains('mobile number', $e['phone'] ?? ''); ['errors' => $e2] = Validator::planVisit(validPayload(['phone' => '', 'smsConsent' => false])); assertSame([], $e2); }); test('rejects launching/unknown campuses, bad emails, past dates, bad ranges', function () { ['errors' => $e] = Validator::planVisit(validPayload(['campusId' => 'orlando', 'email' => 'nope', 'visitDate' => '2020-01-01', 'childAgeRanges' => ['99-100']])); assertTrue(isset($e['campusId']) && isset($e['email']) && isset($e['visitDate'])); ['data' => $d] = Validator::planVisit(validPayload(['childAgeRanges' => ['99-100', '0-2']])); assertSame('0-2', $d['child_age_ranges']); }); test('whitelists attribution keys and strips control characters', function () { ['data' => $d] = Validator::planVisit(validPayload(['attribution' => ['utm_source' => "face\x00book", 'evil' => 'x', 'gclid' => str_repeat('a', 400)]])); assertSame('facebook', $d['attribution']['utm_source']); assertTrue(!isset($d['attribution']['evil'])); assertSame(200, strlen($d['attribution']['gclid'])); }); fwrite(STDOUT, "\nMailchimp mapping\n"); $campus = Campuses::get('norwood'); $leadRow = ['submission_id' => 'sub-1', 'first_name' => 'Jordan', 'last_name' => 'Lee', 'email' => 'j@example.com', 'phone_e164' => '+15085550142', 'visit_date' => '2026-10-04', 'service_time' => 'Sunday 9:30 AM', 'adults' => 2, 'children' => 1, 'child_age_ranges' => '3-5', 'questions' => 'Parking?', 'email_consent' => 1, 'sms_consent' => 1, 'attribution' => json_encode(['utm_source' => 'facebook', 'utm_campaign' => 'fall-visit', 'variant' => 'b'])]; test('maps merge fields', function () use ($leadRow, $campus) { $p = Mailchimp::buildMemberPayload($leadRow, $campus); assertSame('j@example.com', $p['email_address']); assertSame('subscribed', $p['status_if_new']); assertSame('Norwood', $p['merge_fields']['CAMPUS']); assertSame('2026-10-04', $p['merge_fields']['VISITDATE']); assertSame('+15085550142', $p['merge_fields']['PHONE']); assertSame('facebook', $p['merge_fields']['UTMSOURCE']); }); test('applies visitor, campus, source, platform, campaign, visit, kids and SMS tags', function () use ($leadRow, $campus) { $t = Mailchimp::buildTags($leadRow, $campus); assertSame('Visit:Norwood', $t[0], 'campus tag first, matching the Mailchimp journey trigger convention'); foreach (['Visit:First-Time Visitor', 'Visit:Norwood', 'Visit:Source visit.waterschurch.org', 'Visit:Platform Facebook', 'Visit:Campaign fall-visit', 'Visit:Visit 2026-10-04', 'Visit:Has Kids', 'Visit:SMS Opt-In'] as $x) { assertTrue(in_array($x, $t, true), "missing tag $x"); } }); test('skips Mailchimp without email consent', function () use ($leadRow, $campus) { $r = Mailchimp::upsertVisitor(array_merge($leadRow, ['email_consent' => 0]), $campus); assertSame('skipped', $r['status']); }); fwrite(STDOUT, "\nSinch message construction\n"); test('builds a concise confirmation with campus, details and STOP language', function () use ($leadRow, $campus) { $msg = Sinch::buildConfirmation($leadRow, $campus); assertContains('Hi Jordan', $msg); assertContains('Waters Church Norwood', $msg); assertContains('Sun, Oct 4 · Sunday 9:30 AM', $msg); assertContains('Reply STOP', $msg); assertTrue(mb_strlen($msg) <= 320); }); test('falls back to campus service times when no visit details were given', function () use ($leadRow, $campus) { $msg = Sinch::buildConfirmation(array_merge($leadRow, ['visit_date' => null, 'service_time' => null]), $campus); assertContains('Sunday 9:30 AM, 11:00 AM', $msg); }); test('uses the campus number and sub-account', function () use ($campus) { assertSame('+17817623357', Sinch::sourceNumberFor($campus)); assertSame('WatersChurch_OLA_0002', Sinch::accountFor($campus)); }); test('skips SMS without consent and never claims success without provider confirmation', function () use ($leadRow, $campus) { assertSame('skipped', Sinch::sendVisitConfirmation(array_merge($leadRow, ['sms_consent' => 0]), $campus)['status']); assertSame('failed', Sinch::sendVisitConfirmation(array_merge($leadRow, ['phone_e164' => null]), $campus)['status']); }); fwrite(STDOUT, "\nLeadProcessor (persistence, idempotency, retries)\n"); test('persists first, replays duplicates idempotently', function () { ['data' => $d] = Validator::planVisit(validPayload()); $a = LeadProcessor::persist($d, '203.0.113.5', 'UA'); $b = LeadProcessor::persist($d, '203.0.113.5', 'UA'); assertSame(false, $a['duplicate']); assertSame(true, $b['duplicate']); assertSame($a['lead']['id'], $b['lead']['id']); assertSame('pending', $a['lead']['mailchimp_status']); assertSame('pending', $a['lead']['sms_status']); assertTrue(strlen($a['lead']['consent_ip_hash']) === 64 && $a['lead']['consent_ip_hash'] !== '203.0.113.5', 'ip must be hashed'); }); test('runs both integrations exactly once (live mode against mock providers)', function () { ['data' => $d] = Validator::planVisit(validPayload()); $lead = LeadProcessor::persist($d, '203.0.113.5', 'UA')['lead']; $s1 = LeadProcessor::runIntegrations($lead); assertSame('sent', $s1['email']); assertSame('sent', $s1['sms']); $row = LeadProcessor::find($lead['submission_id']); assertTrue(str_starts_with((string)$row['sms_message_id'], 'msg-'), 'provider message id stored'); $s2 = LeadProcessor::runIntegrations($row); $row2 = LeadProcessor::find($lead['submission_id']); assertSame(1, (int)$row2['sms_attempts'], 'second run must not resend'); assertSame(1, (int)$row2['mailchimp_attempts']); assertSame('sent', $s2['sms']); }); test('partial failure keeps the lead, marks retry with backoff, and cron recovers', function () use ($TMP) { touch("$TMP/fail-sinch"); ['data' => $d] = Validator::planVisit(validPayload()); $lead = LeadProcessor::persist($d, '203.0.113.5', 'UA')['lead']; $s = LeadProcessor::runIntegrations($lead); assertSame('sent', $s['email']); assertSame('retry', $s['sms']); $row = LeadProcessor::find($lead['submission_id']); assertTrue($row['next_retry_at'] !== null, 'retry scheduled'); assertSame(1, (int)$row['sms_attempts']); // not yet due → cron does nothing $r0 = LeadProcessor::retryDue(); assertSame(0, $r0['processed']); // make it due and fix the provider Db::pdo()->prepare("UPDATE leads SET next_retry_at = '2000-01-01T00:00:00Z' WHERE id = ?")->execute([$row['id']]); unlink("$TMP/fail-sinch"); $r1 = LeadProcessor::retryDue(); assertSame(1, $r1['processed']); $fixed = LeadProcessor::find($lead['submission_id']); assertSame('sent', $fixed['sms_status']); assertSame(2, (int)$fixed['sms_attempts']); assertSame(1, (int)$fixed['mailchimp_attempts'], 'mailchimp must not be re-sent'); }); test('gives up after MAX_ATTEMPTS', function () use ($TMP) { touch("$TMP/fail-mailchimp"); ['data' => $d] = Validator::planVisit(validPayload()); $lead = LeadProcessor::persist($d, '203.0.113.5', 'UA')['lead']; for ($i = 0; $i < LeadProcessor::MAX_ATTEMPTS + 2; $i++) { $row = LeadProcessor::find($lead['submission_id']); Db::pdo()->prepare("UPDATE leads SET next_retry_at = '2000-01-01T00:00:00Z' WHERE id = ?")->execute([$row['id']]); LeadProcessor::runIntegrations($row); } $row = LeadProcessor::find($lead['submission_id']); unlink("$TMP/fail-mailchimp"); assertSame('failed', $row['mailchimp_status']); assertSame(LeadProcessor::MAX_ATTEMPTS, (int)$row['mailchimp_attempts']); }); test('backoff grows and is capped', function () { assertSame(60, LeadProcessor::backoffSeconds(1)); assertSame(120, LeadProcessor::backoffSeconds(2)); assertSame(6 * 3600, LeadProcessor::backoffSeconds(20)); }); fwrite(STDOUT, "\nLog redaction\n"); test('redacts emails, phones and secrets', function () { $r = Log::redact(['email' => 'jordan@example.com', 'note' => 'call +1 508 555 0142 or jo@x.io', 'authorization' => 'Basic abcdefghijklmnop']); assertTrue(str_starts_with($r['email'], 'jo') && str_ends_with($r['email'], 'om') && !str_contains($r['email'], '@'), 'email masked'); assertContains('[phone]', $r['note']); assertContains('[email]', $r['note']); assertTrue(!str_contains($r['authorization'], 'abcdefghijklmnop')); }); // ---------------------------------------------------------------- HTTP end-to-end fwrite(STDOUT, "\nHTTP endpoints (php -S)\n"); $origin = ['Origin' => 'http://127.0.0.1:8098', 'X-Requested-With' => 'visit-page']; test('health check reports ok', function () use ($API) { $r = http('GET', "$API/health.php"); assertSame(200, $r['status'], $r['body']); assertSame(true, $r['json']['ok']); assertSame('live', $r['json']['checks']['integration_mode']); }); test('rejects cross-origin and unknown-origin submissions', function () use ($API) { $r = http('POST', "$API/plan-visit.php", validPayload(), ['Origin' => 'https://evil.example']); assertSame(403, $r['status']); $r2 = http('POST', "$API/plan-visit.php", validPayload()); // no origin, no custom header assertSame(403, $r2['status']); }); test('rejects wrong method', function () use ($API) { assertSame(405, http('GET', "$API/plan-visit.php")['status']); }); test('returns field errors for invalid input (422)', function () use ($API, $origin) { $r = http('POST', "$API/plan-visit.php", validPayload(['email' => 'bad', 'firstName' => '']), $origin); assertSame(422, $r['status']); assertTrue(isset($r['json']['fieldErrors']['email']) && isset($r['json']['fieldErrors']['firstName'])); }); test('honeypot and too-fast submissions get a decoy success and are not stored', function () use ($API, $origin) { $p = validPayload(['website' => 'http://spam']); $r = http('POST', "$API/plan-visit.php", $p, $origin); assertSame(200, $r['status']); assertSame(true, $r['json']['ok']); assertSame(null, LeadProcessor::find($p['submissionId'])); $p2 = validPayload(['startedAt' => (int)(microtime(true) * 1000) - 500]); http('POST', "$API/plan-visit.php", $p2, $origin); assertSame(null, LeadProcessor::find($p2['submissionId'])); }); test('successful submission persists, sends both, and replays idempotently', function () use ($API, $origin, $TMP) { file_put_contents("$TMP/requests.log", ''); $p = validPayload(); $r = http('POST', "$API/plan-visit.php", $p, $origin); assertSame(200, $r['status'], $r['body']); assertSame(true, $r['json']['ok']); assertSame(false, $r['json']['duplicate']); assertSame('sent', $r['json']['email']['status']); assertSame('sent', $r['json']['sms']['status']); $row = LeadProcessor::find($p['submissionId']); assertSame('norwood', $row['campus_id']); $attr = json_decode($row['attribution'], true); assertSame('fall-visit', $attr['utm_campaign']); assertTrue(!isset($attr['evil_key'])); // provider requests: Sinch used the campus sub-account + number $reqs = array_map(static fn($l) => json_decode($l, true), array_filter(explode("\n", file_get_contents("$TMP/requests.log")))); $sinch = array_values(array_filter($reqs, static fn($q) => $q['path'] === '/v1/messages')); assertSame(1, count($sinch), 'exactly one SMS'); assertSame('WatersChurch_OLA_0002', $sinch[0]['headers']['account']); assertSame('+17817623357', $sinch[0]['body']['messages'][0]['source_number']); assertSame('+15085550142', $sinch[0]['body']['messages'][0]['destination_number']); assertContains('Reply STOP', $sinch[0]['body']['messages'][0]['content']); $mc = array_values(array_filter($reqs, static fn($q) => str_starts_with($q['path'], '/3.0/lists/abc123/members/') && $q['method'] === 'PUT')); assertSame(1, count($mc), 'exactly one Mailchimp upsert'); // duplicate replay $r2 = http('POST', "$API/plan-visit.php", $p, $origin); assertSame(true, $r2['json']['duplicate']); $reqs2 = array_filter(explode("\n", file_get_contents("$TMP/requests.log"))); assertSame(count($reqs), count($reqs2), 'replay must not call providers again'); }); test('email-only consent never sends an SMS', function () use ($API, $origin, $TMP) { file_put_contents("$TMP/requests.log", ''); $r = http('POST', "$API/plan-visit.php", validPayload(['smsConsent' => false]), $origin); assertSame('skipped', $r['json']['sms']['status']); assertSame('sent', $r['json']['email']['status']); assertTrue(!str_contains(file_get_contents("$TMP/requests.log"), '/v1/messages')); }); test('no consent at all still stores the lead', function () use ($API, $origin) { $p = validPayload(['smsConsent' => false, 'emailConsent' => false, 'phone' => '']); $r = http('POST', "$API/plan-visit.php", $p, $origin); assertSame(200, $r['status']); assertSame('skipped', $r['json']['email']['status']); assertTrue(LeadProcessor::find($p['submissionId']) !== null); }); test('provider outage → lead kept, sms queued for retry, user still gets a confirmation', function () use ($API, $origin, $TMP) { touch("$TMP/fail-sinch"); $p = validPayload(); $r = http('POST', "$API/plan-visit.php", $p, $origin); unlink("$TMP/fail-sinch"); assertSame(200, $r['status']); assertSame(true, $r['json']['ok']); assertSame('queued', $r['json']['sms']['status']); assertSame('retry', LeadProcessor::find($p['submissionId'])['sms_status']); }); test('rate limiter allows N hits per window then denies with a retry hint', function () { require_once dirname(__DIR__, 2) . '/public/api/lib/RateLimit.php'; $bucket = 'test:' . bin2hex(random_bytes(4)); for ($i = 0; $i < 3; $i++) assertSame(true, RateLimit::hit($bucket, 3, 600)['allowed']); $denied = RateLimit::hit($bucket, 3, 600); assertSame(false, $denied['allowed']); assertTrue($denied['retryAfter'] > 0 && $denied['retryAfter'] <= 600); assertTrue(RateLimit::ipBucket('submit', '1.2.3.4') !== RateLimit::ipBucket('submit', '1.2.3.5')); assertTrue(!str_contains(RateLimit::ipBucket('submit', '1.2.3.4'), '1.2.3.4'), 'ip must not appear in bucket key'); }); test('geocode resolves a ZIP and rejects junk', function () use ($API) { $r = http('GET', "$API/geocode.php?zip=02062"); assertSame(true, $r['json']['ok'], $r['body']); assertSame(42.1945, $r['json']['location']['lat']); assertSame(400, http('GET', "$API/geocode.php?zip=12")['status']); $r3 = http('GET', "$API/geocode.php?zip=00000"); assertSame(false, $r3['json']['ok']); }); test('locate returns an approximate, rounded position (or a graceful unavailable)', function () use ($API) { $r = http('GET', "$API/locate.php", null, ['X-Forwarded-For' => '203.0.113.9']); assertSame(200, $r['status']); // php -S sees 127.0.0.1 which is private → unavailable unless proxy headers trusted; both are valid outcomes assertTrue(in_array($r['json']['ok'], [true, false], true)); if ($r['json']['ok']) { assertSame(41.82, $r['json']['approximate']['lat']); } }); test('private folders are not web-accessible via php -S (router) and blocked by .htaccess on Apache', function () use ($ROOT) { assertContains('Require all denied', file_get_contents("$ROOT/public/api/lib/.htaccess")); assertContains('Require all denied', file_get_contents("$ROOT/public/api/cron/.htaccess")); assertContains('RewriteRule ^(lib|cron|config|data)', file_get_contents("$ROOT/public/api/.htaccess")); }); fwrite(STDOUT, "\n$passed passed, $failed failed\n"); exit($failed === 0 ? 0 : 1);