Plan Your Visit landing page for visit.waterschurch.org
Next.js 16 static export + PHP 8 API for cPanel/Apache hosting. - Location-aware campus finder (IP estimate, optional precise location, ZIP search) - Three-step Plan Your Visit form with separate email/SMS consent - PHP API: validation, anti-spam, rate limiting, SQLite lead store, Mailchimp and Sinch Engage adapters, idempotent retries via cron - Attribution capture (utm_*, gclid/gbraid/wbraid, fbclid, ttclid, twclid) and consent-aware analytics hooks - Vitest and PHP test suites, cPanel deployment script and docs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { _resetAnalyticsQueue, track } from "@/lib/analytics";
|
||||
|
||||
describe("analytics layer", () => {
|
||||
beforeEach(() => {
|
||||
window.dataLayer = [];
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState({}, "", "/?utm_source=google&utm_campaign=brand&gclid=G");
|
||||
_resetAnalyticsQueue();
|
||||
});
|
||||
|
||||
it("pushes named events with attribution and device props, without PII", () => {
|
||||
track("campus_selected", { campus: "norwood", selection_source: "manual" });
|
||||
const evt = (window.dataLayer as Record<string, unknown>[]).at(-1)!;
|
||||
expect(evt.event).toBe("campus_selected");
|
||||
expect(evt.campus).toBe("norwood");
|
||||
expect(evt.source).toBe("google");
|
||||
expect(evt.campaign).toBe("brand");
|
||||
expect(evt.platform).toBe("google");
|
||||
expect(evt.variant).toBe("a");
|
||||
expect(["mobile", "tablet", "desktop"]).toContain(evt.device_category);
|
||||
expect(JSON.stringify(evt)).not.toMatch(/@|phone/);
|
||||
});
|
||||
|
||||
it("dispatches a DOM event for custom listeners", () => {
|
||||
const seen: string[] = [];
|
||||
window.addEventListener("wc:analytics", (e) => seen.push((e as CustomEvent).detail.event));
|
||||
track("text_us_clicked", { location: "header" });
|
||||
expect(seen).toEqual(["text_us_clicked"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { attributionForApi, getAttribution, inferPlatform, parseAttribution } from "@/lib/attribution";
|
||||
|
||||
describe("parseAttribution", () => {
|
||||
const now = new Date("2026-09-22T12:00:00Z");
|
||||
it("captures utm, click ids, variant, referrer and landing url", () => {
|
||||
const a = parseAttribution(
|
||||
"https://visit.waterschurch.org/?utm_source=facebook&utm_medium=paid_social&utm_campaign=fall-visit&utm_content=video1&utm_term=church+near+me&fbclid=ABC123&v=b",
|
||||
"https://l.facebook.com/",
|
||||
now,
|
||||
"a",
|
||||
);
|
||||
expect(a.utm_source).toBe("facebook");
|
||||
expect(a.utm_medium).toBe("paid_social");
|
||||
expect(a.utm_campaign).toBe("fall-visit");
|
||||
expect(a.utm_content).toBe("video1");
|
||||
expect(a.utm_term).toBe("church near me");
|
||||
expect(a.fbclid).toBe("ABC123");
|
||||
expect(a.variant).toBe("b");
|
||||
expect(a.referrer).toBe("https://l.facebook.com/");
|
||||
expect(a.landing_url).toContain("utm_campaign=fall-visit");
|
||||
expect(a.first_seen_at).toBe(now.toISOString());
|
||||
});
|
||||
it("supports Google, TikTok and X identifiers", () => {
|
||||
const a = parseAttribution("https://x.test/?gclid=G1&gbraid=GB&wbraid=WB&ttclid=T1&twclid=X1", "", now, "a");
|
||||
expect(a).toMatchObject({ gclid: "G1", gbraid: "GB", wbraid: "WB", ttclid: "T1", twclid: "X1" });
|
||||
});
|
||||
it("accepts tt_click_id as a TikTok alias", () => {
|
||||
expect(parseAttribution("https://x.test/?tt_click_id=T9", "", now, "a").ttclid).toBe("T9");
|
||||
});
|
||||
it("tolerates missing or malformed parameters", () => {
|
||||
const a = parseAttribution("not a url", "", now, "a");
|
||||
expect(a.variant).toBe("a");
|
||||
expect(a.utm_source).toBeUndefined();
|
||||
const b = parseAttribution("https://x.test/?utm_source=&gclid=%3Cscript%3Ealert(1)%3C/script%3E", "", now, "a");
|
||||
expect(b.utm_source).toBeUndefined();
|
||||
expect(b.gclid).toBe("scriptalert(1)/script");
|
||||
});
|
||||
it("caps overly long values", () => {
|
||||
const long = "x".repeat(500);
|
||||
expect(parseAttribution(`https://x.test/?utm_campaign=${long}`, "", now, "a").utm_campaign).toHaveLength(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferPlatform", () => {
|
||||
it("derives the ad platform", () => {
|
||||
expect(inferPlatform({ gclid: "1" })).toBe("google");
|
||||
expect(inferPlatform({ fbclid: "1" })).toBe("facebook");
|
||||
expect(inferPlatform({ utm_source: "instagram" })).toBe("instagram");
|
||||
expect(inferPlatform({ ttclid: "1" })).toBe("tiktok");
|
||||
expect(inferPlatform({ utm_source: "x" })).toBe("x");
|
||||
expect(inferPlatform({})).toBe("direct");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAttribution (browser)", () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
window.history.replaceState({}, "", "/?utm_source=tiktok&utm_campaign=c1&ttclid=T1");
|
||||
});
|
||||
it("captures once and persists across calls", () => {
|
||||
const first = getAttribution("a");
|
||||
expect(first.utm_source).toBe("tiktok");
|
||||
window.history.replaceState({}, "", "/");
|
||||
const second = getAttribution("a");
|
||||
expect(second.utm_campaign).toBe("c1");
|
||||
expect(second.first_seen_at).toBe(first.first_seen_at);
|
||||
});
|
||||
it("merges a new click id from a second ad click without losing first-touch data", () => {
|
||||
const first = getAttribution("a");
|
||||
window.history.replaceState({}, "", "/?utm_source=facebook&fbclid=F1");
|
||||
const merged = getAttribution("a");
|
||||
expect(merged.fbclid).toBe("F1");
|
||||
expect(merged.ttclid).toBe("T1");
|
||||
expect(merged.first_seen_at).toBe(first.first_seen_at);
|
||||
});
|
||||
it("lets an explicit ?v= override the session variant", () => {
|
||||
expect(getAttribution("a").variant).toBe("a");
|
||||
window.history.replaceState({}, "", "/?v=b");
|
||||
expect(getAttribution("a").variant).toBe("b");
|
||||
});
|
||||
it("flattens to strings for the API", () => {
|
||||
const flat = attributionForApi(getAttribution("a"));
|
||||
for (const v of Object.values(flat)) expect(typeof v).toBe("string");
|
||||
expect(flat.utm_source).toBe("tiktok");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { campuses, centralCampus, formatAddress, formatTimes, openCampuses, serviceOptions } from "@/config/campuses";
|
||||
|
||||
describe("campus configuration", () => {
|
||||
it("loads and validates every campus", () => {
|
||||
expect(campuses.length).toBeGreaterThanOrEqual(5);
|
||||
for (const c of campuses) {
|
||||
expect(c.id).toMatch(/^[a-z0-9-]+$/);
|
||||
expect(c.coordinates.lat).not.toBe(0);
|
||||
expect(c.phone).toMatch(/^\+1\d{10}$/);
|
||||
expect(c.directionsUrl).toMatch(/^https:\/\//);
|
||||
}
|
||||
});
|
||||
it("only open campuses are selectable and every open campus can be texted", () => {
|
||||
for (const c of openCampuses) {
|
||||
expect(c.status).toBe("open");
|
||||
expect(c.smsNumber).toMatch(/^\+1\d{10}$/);
|
||||
expect(c.sinchAccountId).toMatch(/^WatersChurch_OLA_\d{4}$/);
|
||||
}
|
||||
});
|
||||
it("has exactly one central campus", () => {
|
||||
expect(campuses.filter((c) => c.isCentral)).toHaveLength(1);
|
||||
expect(centralCampus.id).toBe("north-attleboro");
|
||||
});
|
||||
it("formats addresses and times", () => {
|
||||
const na = openCampuses.find((c) => c.id === "north-attleboro")!;
|
||||
expect(formatAddress(na)).toBe("57 John L Dietsch Square, North Attleboro, MA 02763");
|
||||
expect(formatTimes(["7:30 AM", "9:30 AM", "11:30 AM"])).toBe("7:30, 9:30 & 11:30 AM");
|
||||
expect(formatTimes(["10:30 AM"])).toBe("10:30 AM");
|
||||
expect(formatTimes(["9:30 AM", "7:00 PM"])).toBe("9:30 AM & 7:00 PM");
|
||||
});
|
||||
it("builds service options per campus", () => {
|
||||
const nw = openCampuses.find((c) => c.id === "norwood")!;
|
||||
const opts = serviceOptions(nw);
|
||||
expect(opts.map((o) => o.value)).toContain("Sunday 9:30 AM");
|
||||
expect(opts.some((o) => o.value.startsWith("First Wednesday"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PlanVisitForm } from "@/components/PlanVisitForm";
|
||||
import { VisitProvider } from "@/components/VisitProvider";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const ui = () =>
|
||||
render(
|
||||
<VisitProvider>
|
||||
<PlanVisitForm />
|
||||
</VisitProvider>,
|
||||
);
|
||||
|
||||
describe("PlanVisitForm (keyboard-operable, consent-enforcing, server-confirmed)", () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
window.dataLayer = [];
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
fetchMock.mockReset();
|
||||
// IntersectionObserver is used by the floating button; not needed here but keep jsdom quiet
|
||||
vi.stubGlobal("IntersectionObserver", class { observe() {} disconnect() {} });
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("walks through all three steps with the keyboard and submits a valid lead", async () => {
|
||||
const user = userEvent.setup();
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, submissionId: "x", email: { status: "mock" }, sms: { status: "mock" } }));
|
||||
ui();
|
||||
|
||||
// Step 1: campus (radio group)
|
||||
const norwood = screen.getByRole("radio", { name: /norwood/i });
|
||||
await user.click(norwood);
|
||||
await user.click(screen.getByRole("button", { name: /continue/i }));
|
||||
|
||||
// Step 2: about you
|
||||
const first = await screen.findByLabelText(/first name/i);
|
||||
await user.type(first, "Jordan");
|
||||
await user.type(screen.getByRole("textbox", { name: /^email/i }), "jordan@example.com");
|
||||
await user.type(screen.getByRole("textbox", { name: /mobile phone/i }), "508-555-0142");
|
||||
// consent boxes start unchecked and are separate
|
||||
const emailBox = screen.getByRole("checkbox", { name: /email me/i });
|
||||
const smsBox = screen.getByRole("checkbox", { name: /text me/i });
|
||||
expect(emailBox).not.toBeChecked();
|
||||
expect(smsBox).not.toBeChecked();
|
||||
await user.click(emailBox);
|
||||
await user.click(smsBox);
|
||||
await user.click(screen.getByRole("button", { name: /continue/i }));
|
||||
|
||||
// Step 3: visit details (all optional) → submit
|
||||
const submit = await screen.findByRole("button", { name: /send my visit details/i });
|
||||
submit.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toMatch(/\/api\/plan-visit\.php$/);
|
||||
const body = JSON.parse(String(init?.body));
|
||||
expect(body.campusId).toBe("norwood");
|
||||
expect(body.firstName).toBe("Jordan");
|
||||
expect(body.smsConsent).toBe(true);
|
||||
expect(body.emailConsent).toBe(true);
|
||||
expect(body.consentVersion).toMatch(/^\d{4}-\d{2}-\d{2}/);
|
||||
expect(body.submissionId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(body.website).toBe("");
|
||||
|
||||
// Confirmation preserves the page design and shows campus details + Text Us
|
||||
await screen.findByText(/you're all set/i);
|
||||
expect(screen.getByText(/668 Walpole Street/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /text us a question/i })).toHaveAttribute("href", expect.stringMatching(/^sms:\+17817623357/));
|
||||
|
||||
// Conversion fired only after server success
|
||||
const events = (window.dataLayer as { event: string }[]).map((e) => e.event);
|
||||
expect(events).toContain("plan_visit_submitted");
|
||||
expect(events.indexOf("plan_visit_success")).toBeGreaterThan(events.indexOf("plan_visit_submitted"));
|
||||
});
|
||||
|
||||
it("blocks SMS consent without a phone number and announces the error", async () => {
|
||||
const user = userEvent.setup();
|
||||
ui();
|
||||
await user.click(screen.getByRole("radio", { name: /north attleboro/i }));
|
||||
await user.click(screen.getByRole("button", { name: /continue/i }));
|
||||
await user.type(await screen.findByLabelText(/first name/i), "Sam");
|
||||
await user.type(screen.getByRole("textbox", { name: /^email/i }), "sam@example.com");
|
||||
await user.click(screen.getByRole("checkbox", { name: /text me/i }));
|
||||
await user.click(screen.getByRole("button", { name: /continue/i }));
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent(/mobile number/i);
|
||||
expect(screen.getByRole("textbox", { name: /mobile phone/i })).toHaveAttribute("aria-invalid", "true");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the visitor's answers and shows a friendly message when the server fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: false, error: "Something went wrong on our end. Please try again in a moment." }, 500));
|
||||
ui();
|
||||
await user.click(screen.getByRole("radio", { name: /woonsocket/i }));
|
||||
await user.click(screen.getByRole("button", { name: /continue/i }));
|
||||
await user.type(await screen.findByLabelText(/first name/i), "Alex");
|
||||
await user.type(screen.getByRole("textbox", { name: /^email/i }), "alex@example.com");
|
||||
await user.click(screen.getByRole("button", { name: /continue/i }));
|
||||
await user.click(await screen.findByRole("button", { name: /send my visit details/i }));
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent(/try again/i);
|
||||
// answers preserved
|
||||
await user.click(screen.getByRole("button", { name: /back/i }));
|
||||
expect(await screen.findByLabelText(/first name/i)).toHaveValue("Alex");
|
||||
});
|
||||
|
||||
it("maps server field errors back to the right step", async () => {
|
||||
const user = userEvent.setup();
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: false, error: "Please check the highlighted fields.", fieldErrors: { email: "That email doesn't look right." } }, 422));
|
||||
ui();
|
||||
await user.click(screen.getByRole("radio", { name: /southcoast/i }));
|
||||
await user.click(screen.getByRole("button", { name: /continue/i }));
|
||||
await user.type(await screen.findByLabelText(/first name/i), "Riley");
|
||||
await user.type(screen.getByRole("textbox", { name: /^email/i }), "riley@example.com");
|
||||
await user.click(screen.getByRole("button", { name: /continue/i }));
|
||||
await user.click(await screen.findByRole("button", { name: /send my visit details/i }));
|
||||
const email = await screen.findByRole("textbox", { name: /^email/i });
|
||||
expect(email).toHaveAttribute("aria-invalid", "true");
|
||||
expect(within(email.closest("div")!).getByRole("alert")).toHaveTextContent(/email/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { campuses, getCampus, openCampuses } from "@/config/campuses";
|
||||
import { FAR_AWAY_MILES, formatDistance, haversineMiles, isValidLatLng, nearestCampus, normalizeZip, rankCampuses } from "@/lib/geo";
|
||||
|
||||
describe("haversineMiles", () => {
|
||||
it("returns 0 for identical points", () => {
|
||||
expect(haversineMiles({ lat: 42, lng: -71 }, { lat: 42, lng: -71 })).toBe(0);
|
||||
});
|
||||
it("matches a known distance (Boston → Providence ≈ 41 mi)", () => {
|
||||
const d = haversineMiles({ lat: 42.3601, lng: -71.0589 }, { lat: 41.824, lng: -71.4128 });
|
||||
expect(d).toBeGreaterThan(39);
|
||||
expect(d).toBeLessThan(43);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nearestCampus", () => {
|
||||
it("recommends North Attleboro from Attleboro Falls", () => {
|
||||
const r = nearestCampus({ lat: 41.97, lng: -71.3 }, campuses)!;
|
||||
expect(r.campus.id).toBe("north-attleboro");
|
||||
expect(r.distanceMiles).toBeLessThan(1);
|
||||
});
|
||||
it("recommends Norwood from Dedham", () => {
|
||||
expect(nearestCampus({ lat: 42.2436, lng: -71.166 }, campuses)!.campus.id).toBe("norwood");
|
||||
});
|
||||
it("recommends Woonsocket from Cumberland RI", () => {
|
||||
expect(nearestCampus({ lat: 41.9668, lng: -71.4328 }, campuses)!.campus.id).toBe("woonsocket");
|
||||
});
|
||||
it("recommends Southcoast from Fall River", () => {
|
||||
expect(nearestCampus({ lat: 41.7015, lng: -71.155 }, campuses)!.campus.id).toBe("southcoast");
|
||||
});
|
||||
it("recommends Apollo Beach from Tampa", () => {
|
||||
expect(nearestCampus({ lat: 27.9506, lng: -82.4572 }, campuses)!.campus.id).toBe("apollo-beach");
|
||||
});
|
||||
it("never recommends a launching campus, even when it is closest", () => {
|
||||
// Winter Park FL is 0 mi from the Orlando launch location
|
||||
const r = nearestCampus({ lat: 28.59213, lng: -81.37501 }, campuses)!;
|
||||
expect(getCampus("orlando")?.status).toBe("launching");
|
||||
expect(r.campus.id).toBe("apollo-beach");
|
||||
expect(r.distanceMiles).toBeGreaterThan(FAR_AWAY_MILES);
|
||||
});
|
||||
it("ranks every open campus and sorts ascending", () => {
|
||||
const ranked = rankCampuses({ lat: 42.0, lng: -71.4 }, campuses);
|
||||
expect(ranked).toHaveLength(openCampuses.length);
|
||||
for (let i = 1; i < ranked.length; i++) expect(ranked[i].distanceMiles).toBeGreaterThanOrEqual(ranked[i - 1].distanceMiles);
|
||||
});
|
||||
});
|
||||
|
||||
describe("helpers", () => {
|
||||
it("formats distances for humans", () => {
|
||||
expect(formatDistance(0.4)).toBe("under a mile");
|
||||
expect(formatDistance(3.26)).toBe("3.3 mi");
|
||||
expect(formatDistance(47.6)).toBe("48 mi");
|
||||
});
|
||||
it("validates coordinates and rejects null island", () => {
|
||||
expect(isValidLatLng({ lat: 41.9, lng: -71.3 })).toBe(true);
|
||||
expect(isValidLatLng({ lat: 0, lng: 0 })).toBe(false);
|
||||
expect(isValidLatLng({ lat: 91, lng: 0 })).toBe(false);
|
||||
expect(isValidLatLng({ lat: "41", lng: -71 })).toBe(false);
|
||||
expect(isValidLatLng(null)).toBe(false);
|
||||
});
|
||||
it("normalizes ZIP codes", () => {
|
||||
expect(normalizeZip(" 02763 ")).toBe("02763");
|
||||
expect(normalizeZip("02763-1234")).toBe("02763");
|
||||
expect(normalizeZip("2763")).toBeNull();
|
||||
expect(normalizeZip("abcde")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
/**
|
||||
* Fake Mailchimp + Sinch + Zippopotam + GeoIP endpoints for the PHP test-suite.
|
||||
* Started by tests/php/run.php with: php -S 127.0.0.1:8099 tests/php/mock-providers.php
|
||||
*
|
||||
* Behaviour switches are files in tests/php/.tmp:
|
||||
* fail-sinch → Sinch returns HTTP 503
|
||||
* fail-mailchimp → Mailchimp returns HTTP 500
|
||||
* Every request is appended to tests/php/.tmp/requests.log as JSON.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
$tmp = __DIR__ . '/.tmp';
|
||||
@mkdir($tmp, 0777, true);
|
||||
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/';
|
||||
$body = file_get_contents('php://input') ?: '';
|
||||
file_put_contents("$tmp/requests.log", json_encode([
|
||||
'method' => $_SERVER['REQUEST_METHOD'],
|
||||
'path' => $path,
|
||||
'headers' => ['authorization' => $_SERVER['HTTP_AUTHORIZATION'] ?? null, 'account' => $_SERVER['HTTP_ACCOUNT'] ?? null],
|
||||
'body' => json_decode($body, true),
|
||||
]) . "\n", FILE_APPEND);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// --- Sinch Engage
|
||||
if ($path === '/v1/messages') {
|
||||
if (file_exists("$tmp/fail-sinch")) {
|
||||
http_response_code(503);
|
||||
echo json_encode(['message' => 'service unavailable']);
|
||||
exit;
|
||||
}
|
||||
$in = json_decode($body, true);
|
||||
$out = [];
|
||||
foreach ($in['messages'] ?? [] as $m) {
|
||||
$out[] = ['message_id' => 'msg-' . substr(md5($m['destination_number'] . $m['content']), 0, 12), 'status' => 'queued', 'destination_number' => $m['destination_number']];
|
||||
}
|
||||
http_response_code(202);
|
||||
echo json_encode(['messages' => $out]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Mailchimp
|
||||
if (preg_match('#^/3\.0/lists/([^/]+)/members/([a-f0-9]{32})$#', $path, $m)) {
|
||||
if (file_exists("$tmp/fail-mailchimp")) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['title' => 'Internal Error', 'detail' => 'boom']);
|
||||
exit;
|
||||
}
|
||||
$in = json_decode($body, true);
|
||||
echo json_encode(['id' => $m[2], 'email_address' => $in['email_address'] ?? '', 'status' => $in['status_if_new'] ?? 'subscribed', 'merge_fields' => $in['merge_fields'] ?? []]);
|
||||
exit;
|
||||
}
|
||||
if (preg_match('#^/3\.0/lists/[^/]+/members/[a-f0-9]{32}/tags$#', $path)) {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
if (preg_match('#^/3\.0/customer-journeys/#', $path)) {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Zippopotam
|
||||
if (preg_match('#^/us/(\d{5})$#', $path, $m)) {
|
||||
if ($m[1] === '00000') {
|
||||
http_response_code(404);
|
||||
echo '{}';
|
||||
exit;
|
||||
}
|
||||
echo json_encode(['post code' => $m[1], 'places' => [['place name' => 'Norwood', 'state abbreviation' => 'MA', 'latitude' => '42.1945', 'longitude' => '-71.1995']]]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- ipapi.co
|
||||
if (preg_match('#^/([^/]+)/json/?$#', $path)) {
|
||||
echo json_encode(['ip' => '203.0.113.9', 'city' => 'Providence', 'region_code' => 'RI', 'latitude' => 41.8240, 'longitude' => -71.4128]);
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'mock: unknown path ' . $path]);
|
||||
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
/**
|
||||
* PHP API test-suite (no PHPUnit dependency).
|
||||
*
|
||||
* npm run test:php (or: php tests/php/run.php)
|
||||
*
|
||||
* Boots two local servers:
|
||||
* 127.0.0.1:8099 fake Mailchimp / Sinch / geocode providers (tests/php/mock-providers.php)
|
||||
* 127.0.0.1:8098 the real API from ./public (php -S -t public)
|
||||
* with VISIT_PRIVATE_DIR pointing at a throwaway directory, then exercises
|
||||
* unit-level classes AND the HTTP endpoints end to end.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
$ROOT = dirname(__DIR__, 2);
|
||||
$TMP = __DIR__ . '/.tmp';
|
||||
$PRIV = "$TMP/private";
|
||||
$MOCK = 'http://127.0.0.1:8099';
|
||||
$API = 'http://127.0.0.1:8098/api';
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
$passed = 0;
|
||||
$failed = 0;
|
||||
$current = '';
|
||||
function test(string $name, callable $fn): void
|
||||
{
|
||||
global $passed, $failed, $current;
|
||||
$current = $name;
|
||||
try {
|
||||
$fn();
|
||||
$passed++;
|
||||
fwrite(STDOUT, " ✓ $name\n");
|
||||
} catch (Throwable $e) {
|
||||
$failed++;
|
||||
fwrite(STDOUT, " ✗ $name\n " . $e->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 = <<<ENV
|
||||
PUBLIC_SITE_URL=https://visit.waterschurch.org
|
||||
ALLOWED_ORIGINS=http://127.0.0.1:8098,http://localhost:3000
|
||||
INTEGRATION_MODE=live
|
||||
LOG_LEVEL=debug
|
||||
HASH_SALT=test-salt
|
||||
MAILCHIMP_API_KEY=test-key-us1
|
||||
MAILCHIMP_SERVER_PREFIX=us1
|
||||
MAILCHIMP_AUDIENCE_ID=abc123
|
||||
MAILCHIMP_JOURNEY_ID=42
|
||||
MAILCHIMP_JOURNEY_STEP_ID=7
|
||||
SINCH_API_BASE=$MOCK
|
||||
SINCH_API_KEY=k
|
||||
SINCH_API_SECRET=s
|
||||
SINCH_DEFAULT_SOURCE_NUMBER=+17746436322
|
||||
GEOIP_PROVIDER=ipapi_co
|
||||
GEOCODE_PROVIDER=zippopotam
|
||||
MIN_FORM_SECONDS=3
|
||||
RATE_LIMIT_SUBMIT_PER_10MIN=100
|
||||
RATE_LIMIT_SUBMIT_PER_DAY=100
|
||||
ENV;
|
||||
file_put_contents("$PRIV/.env", $env);
|
||||
|
||||
// Point Mailchimp at the mock: the adapter builds https://{dc}.api.mailchimp.com — we override via a test hook (see Mailchimp::baseUrl override below)
|
||||
putenv("VISIT_PRIVATE_DIR=$PRIV");
|
||||
putenv("MAILCHIMP_BASE_URL_OVERRIDE=$MOCK/3.0");
|
||||
putenv("GEOIP_BASE_URL_OVERRIDE=$MOCK");
|
||||
putenv("GEOCODE_BASE_URL_OVERRIDE=$MOCK");
|
||||
|
||||
$php = PHP_BINARY;
|
||||
$servers = [];
|
||||
$servers[] = proc_open([$php, '-S', '127.0.0.1:8099', __DIR__ . '/mock-providers.php'], [1 => ['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);
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { issuesToMap, planVisitSchema, todayIsoDate, type PlanVisitInput } from "@/lib/schema";
|
||||
import { formatNanpDisplay, normalizeNanpPhone, smsLink } from "@/lib/phone";
|
||||
|
||||
const base: PlanVisitInput = {
|
||||
submissionId: "6f1a2c3e-4b5d-4e6f-8a9b-0c1d2e3f4a5b",
|
||||
firstName: "Jordan",
|
||||
lastName: "",
|
||||
email: "jordan@example.com",
|
||||
phone: "",
|
||||
campusId: "norwood",
|
||||
visitDate: "",
|
||||
serviceTime: "",
|
||||
questions: "",
|
||||
emailConsent: false,
|
||||
smsConsent: false,
|
||||
consentVersion: "2026-09-22.v1",
|
||||
website: "",
|
||||
startedAt: Date.now() - 10_000,
|
||||
attribution: {},
|
||||
};
|
||||
|
||||
describe("planVisitSchema", () => {
|
||||
it("accepts a minimal valid submission", () => {
|
||||
expect(planVisitSchema.safeParse(base).success).toBe(true);
|
||||
});
|
||||
it("requires a first name and a real email", () => {
|
||||
const r = planVisitSchema.safeParse({ ...base, firstName: " ", email: "nope" });
|
||||
expect(r.success).toBe(false);
|
||||
const errs = issuesToMap(r.success ? [] : r.error.issues);
|
||||
expect(errs.firstName).toBeTruthy();
|
||||
expect(errs.email).toBeTruthy();
|
||||
});
|
||||
it("rejects unknown or launching campuses", () => {
|
||||
expect(planVisitSchema.safeParse({ ...base, campusId: "orlando" }).success).toBe(false);
|
||||
expect(planVisitSchema.safeParse({ ...base, campusId: "mars" }).success).toBe(false);
|
||||
});
|
||||
it("enforces consent rules: SMS consent requires a valid mobile number", () => {
|
||||
const noPhone = planVisitSchema.safeParse({ ...base, smsConsent: true, phone: "" });
|
||||
expect(noPhone.success).toBe(false);
|
||||
expect(issuesToMap(noPhone.success ? [] : noPhone.error.issues).phone).toMatch(/mobile number/i);
|
||||
|
||||
const badPhone = planVisitSchema.safeParse({ ...base, smsConsent: true, phone: "123" });
|
||||
expect(badPhone.success).toBe(false);
|
||||
|
||||
const good = planVisitSchema.safeParse({ ...base, smsConsent: true, phone: "(508) 555-0142" });
|
||||
expect(good.success).toBe(true);
|
||||
});
|
||||
it("does not bundle consents: email consent alone never needs a phone", () => {
|
||||
expect(planVisitSchema.safeParse({ ...base, emailConsent: true }).success).toBe(true);
|
||||
});
|
||||
it("consent boxes default to false in the initial state", () => {
|
||||
expect(base.emailConsent).toBe(false);
|
||||
expect(base.smsConsent).toBe(false);
|
||||
});
|
||||
it("rejects past visit dates and accepts today", () => {
|
||||
expect(planVisitSchema.safeParse({ ...base, visitDate: "2020-01-01" }).success).toBe(false);
|
||||
expect(planVisitSchema.safeParse({ ...base, visitDate: todayIsoDate() }).success).toBe(true);
|
||||
});
|
||||
it("rejects a filled honeypot", () => {
|
||||
expect(planVisitSchema.safeParse({ ...base, website: "http://spam.example" }).success).toBe(false);
|
||||
});
|
||||
it("caps children and validates age ranges", () => {
|
||||
expect(planVisitSchema.safeParse({ ...base, children: 3, childAgeRanges: ["0-2", "6-10"] }).success).toBe(true);
|
||||
expect(planVisitSchema.safeParse({ ...base, children: 99 }).success).toBe(false);
|
||||
expect(planVisitSchema.safeParse({ ...base, childAgeRanges: ["30-40"] }).success).toBe(false);
|
||||
});
|
||||
it("trims free text", () => {
|
||||
const r = planVisitSchema.safeParse({ ...base, firstName: " Sam ", questions: " Is parking free? " });
|
||||
expect(r.success && r.data.firstName).toBe("Sam");
|
||||
expect(r.success && r.data.questions).toBe("Is parking free?");
|
||||
});
|
||||
});
|
||||
|
||||
describe("phone helpers", () => {
|
||||
it("normalizes NANP numbers to E.164", () => {
|
||||
expect(normalizeNanpPhone("(508) 695-1300")).toBe("+15086951300");
|
||||
expect(normalizeNanpPhone("1-508-695-1300")).toBe("+15086951300");
|
||||
expect(normalizeNanpPhone("+1 508 695 1300")).toBe("+15086951300");
|
||||
expect(normalizeNanpPhone("508-695-130")).toBeNull();
|
||||
expect(normalizeNanpPhone("011-555-1234")).toBeNull(); // area code starting with 0
|
||||
expect(normalizeNanpPhone("911-555-1234")).toBeNull(); // N11
|
||||
});
|
||||
it("formats for display", () => {
|
||||
expect(formatNanpDisplay("+15086951300")).toBe("(508) 695-1300");
|
||||
});
|
||||
it("builds an sms: link with an editable, encoded body", () => {
|
||||
const link = smsLink("+15086951300", "Hi! I'm thinking about visiting.");
|
||||
expect(link.startsWith("sms:+15086951300?&body=")).toBe(true);
|
||||
expect(decodeURIComponent(link.split("body=")[1])).toBe("Hi! I'm thinking about visiting.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
Reference in New Issue
Block a user