From 65db3e2e422ab39fb5cf88a6a251ccf02c11c512 Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Sun, 4 Oct 2026 05:57:22 -0500 Subject: [PATCH] feat: add newsletter signup port --- .../IntroAndEmailSignupBlock.svelte | 81 +++++++ src/lib/components/NewsletterForm.svelte | 75 ++++++ src/lib/server/newsletter.ts | 113 +++++++++ src/routes/api/newsletter/+server.ts | 18 ++ tests/route-smoke.mjs | 66 +++++ tests/unit/newsletter.test.ts | 225 ++++++++++++++++++ 6 files changed, 578 insertions(+) create mode 100644 src/lib/components/IntroAndEmailSignupBlock.svelte create mode 100644 src/lib/components/NewsletterForm.svelte create mode 100644 src/lib/server/newsletter.ts create mode 100644 src/routes/api/newsletter/+server.ts create mode 100644 tests/unit/newsletter.test.ts diff --git a/src/lib/components/IntroAndEmailSignupBlock.svelte b/src/lib/components/IntroAndEmailSignupBlock.svelte new file mode 100644 index 0000000..a461cc3 --- /dev/null +++ b/src/lib/components/IntroAndEmailSignupBlock.svelte @@ -0,0 +1,81 @@ + + +
+
+
+
+

+ Invest In Yourself and Hone Your Craft +

+

+ Method Conference Springfield, MO is an immersive one-day + conference with three tracks, two one-hour session tracks, a + workshop track, and a keynote. All with an intense focus on + digital development, design, UX, content, code, and more. +

+
+ + Register Now + +

+ Download Our Mobile App! +

+ +
+
+
+
+

+ Stay in-the-know about event updates and new speaker + announcements. +

+ +
+
+
diff --git a/src/lib/components/NewsletterForm.svelte b/src/lib/components/NewsletterForm.svelte new file mode 100644 index 0000000..df15249 --- /dev/null +++ b/src/lib/components/NewsletterForm.svelte @@ -0,0 +1,75 @@ + + +
+ + + + + + diff --git a/src/lib/server/newsletter.ts b/src/lib/server/newsletter.ts new file mode 100644 index 0000000..110fedb --- /dev/null +++ b/src/lib/server/newsletter.ts @@ -0,0 +1,113 @@ +export interface NewsletterConfig { + endpoint?: URL; + listId?: string; +} + +export interface NewsletterResult { + body: { success: boolean }; + status: number; +} + +export type NewsletterFetch = ( + input: string, + init: RequestInit, +) => Promise; + +const DEFAULT_TIMEOUT_MS = 5_000; + +function failure(status: number): NewsletterResult { + return { body: { success: false }, status }; +} + +function success(): NewsletterResult { + return { body: { success: true }, status: 200 }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function trimString(value: unknown): string | undefined { + return typeof value === 'string' ? value.trim() : undefined; +} + +function isValidEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+$/.test(value); +} + +export async function handleNewsletterRequest( + request: Request, + config: NewsletterConfig, + fetcher: NewsletterFetch = fetch, + timeoutMs = DEFAULT_TIMEOUT_MS, +): Promise { + const { endpoint, listId } = config; + + if (!endpoint || !listId) { + return failure(500); + } + + let payload: unknown; + + try { + payload = await request.json(); + } catch { + return failure(400); + } + + return submitNewsletter(payload, { endpoint, listId }, fetcher, timeoutMs); +} + +export async function submitNewsletter( + payload: unknown, + config: Required, + fetcher: NewsletterFetch = fetch, + timeoutMs = DEFAULT_TIMEOUT_MS, +): Promise { + if (!isRecord(payload)) { + return failure(400); + } + + if (payload.nullCheck) { + return success(); + } + + const email = trimString(payload.email); + + if (!email || !isValidEmail(email)) { + return failure(400); + } + + const name = trimString(payload.name) ?? ''; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetcher(config.endpoint.toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + email, + list_uuids: [config.listId], + }), + signal: controller.signal, + }); + + if (!response.ok) { + return failure(502); + } + + const upstream = await response.json().catch(() => undefined); + + if (!isRecord(upstream) || upstream.data !== true) { + return failure(502); + } + } catch { + return failure(controller.signal.aborted ? 504 : 502); + } finally { + clearTimeout(timeout); + } + + return success(); +} diff --git a/src/routes/api/newsletter/+server.ts b/src/routes/api/newsletter/+server.ts new file mode 100644 index 0000000..1100194 --- /dev/null +++ b/src/routes/api/newsletter/+server.ts @@ -0,0 +1,18 @@ +import { getNewsletterConfig } from '#lib/server/config.ts'; +import { handleNewsletterRequest } from '#lib/server/newsletter.ts'; +import type { RequestHandler } from './$types'; + +export const trailingSlash = 'ignore'; + +export const GET: RequestHandler = () => + Response.json({ success: false }, { status: 405 }); + +export const POST: RequestHandler = async ({ request, fetch }) => { + const result = await handleNewsletterRequest( + request, + getNewsletterConfig(), + fetch, + ); + + return Response.json(result.body, { status: result.status }); +}; diff --git a/tests/route-smoke.mjs b/tests/route-smoke.mjs index 828a40b..ca4eb8c 100644 --- a/tests/route-smoke.mjs +++ b/tests/route-smoke.mjs @@ -10,6 +10,7 @@ const GOOGLE_PLAY_STORE_LINK = 'https://play.google.com/store/apps/details?id=com.sgfdevs.methodConfApp'; const APPLE_APP_STORE_LINK = 'https://apps.apple.com/us/app/method-conf/id1498359521'; +const NEWSLETTER_LIST_ID = 'fake-newsletter-list'; const conference = (slug, date, callForSpeakersUrl) => ({ contentType: 'conference', @@ -44,6 +45,7 @@ const state = { ), ], mediaRequests: [], + newsletterRequests: [], cmsRequests: 0, }; @@ -110,6 +112,23 @@ async function startUpstream() { return; } + if (url.pathname === '/api/public/subscription') { + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk) => { + body += chunk; + }); + request.on('end', () => { + state.newsletterRequests.push({ + method: request.method, + contentType: request.headers['content-type'], + body, + }); + writeJson(response, 200, { data: true }); + }); + return; + } + if (url.pathname === '/media/image.jpg') { state.mediaRequests.push({ method: request.method, @@ -244,6 +263,8 @@ async function startApp({ upstreamUrl, searchIndexingEnabled }) { CMS_PUBLIC_URL: 'https://cms.example.test/', SITE_URL: 'https://www.example.test/', SEARCH_INDEXING_ENABLED: searchIndexingEnabled ? 'true' : 'false', + NEWSLETTER_ENDPOINT: `${upstreamUrl}api/public/subscription`, + NEWSLETTER_LIST_ID, }, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -472,6 +493,50 @@ async function runRouteChecks(origin) { ]; } +async function runNewsletterChecks(origin) { + state.newsletterRequests = []; + + const get = await request(origin, '/api/newsletter/'); + assert.equal(get.status, 405); + assert.deepEqual(await get.json(), { success: false }); + + const success = await request(origin, '/api/newsletter/', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: ' Ada Lovelace ', + email: ' ada@example.com ', + nullCheck: '', + }), + }); + assert.equal(success.status, 200); + assert.deepEqual(await success.json(), { success: true }); + assert.equal(state.newsletterRequests.length, 1); + assert.deepEqual(JSON.parse(state.newsletterRequests.at(-1).body), { + name: 'Ada Lovelace', + email: 'ada@example.com', + list_uuids: [NEWSLETTER_LIST_ID], + }); + + const slashless = await request(origin, '/api/newsletter', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'a@b', nullCheck: '' }), + }); + assert.equal(slashless.status, 200); + assert.deepEqual(await slashless.json(), { success: true }); + assert.equal(state.newsletterRequests.length, 2); + + const honeypot = await request(origin, '/api/newsletter/', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: '', nullCheck: 'bot' }), + }); + assert.equal(honeypot.status, 200); + assert.deepEqual(await honeypot.json(), { success: true }); + assert.equal(state.newsletterRequests.length, 2); +} + async function runMobileChecks(origin) { await expectRedirect( origin, @@ -818,6 +883,7 @@ try { try { await runRouteChecks(app.origin); await runMobileChecks(app.origin); + await runNewsletterChecks(app.origin); await runHeadChecks(app.origin, { searchIndexingEnabled: true }); await runMediaChecks(app.origin); await runImageOptimizerChecks(app.origin); diff --git a/tests/unit/newsletter.test.ts b/tests/unit/newsletter.test.ts new file mode 100644 index 0000000..3663d04 --- /dev/null +++ b/tests/unit/newsletter.test.ts @@ -0,0 +1,225 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { render } from 'svelte/server'; +import { describe, expect, it } from 'vitest'; +import IntroAndEmailSignupBlock from '../../src/lib/components/IntroAndEmailSignupBlock.svelte'; +import NewsletterForm from '../../src/lib/components/NewsletterForm.svelte'; +import { + handleNewsletterRequest, + type NewsletterConfig, + type NewsletterFetch, +} from '../../src/lib/server/newsletter.ts'; + +const endpoint = new URL('http://127.0.0.1:9/api/public/subscription'); +const listId = 'fake-newsletter-list'; +const config: Required = { endpoint, listId }; + +function requestWithBody(body: string): Request { + return new Request('http://localhost/api/newsletter/', { + method: 'POST', + body, + }); +} + +function jsonRequest(body: unknown): Request { + return requestWithBody(JSON.stringify(body)); +} + +async function readPayload(init: RequestInit): Promise { + expect(init.method).toBe('POST'); + expect(init.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(typeof init.body).toBe('string'); + + return JSON.parse(init.body as string); +} + +describe('newsletter server handler', () => { + it('posts the exact listmonk payload with trimmed optional fields', async () => { + const calls: Array<{ input: string; init: RequestInit }> = []; + const fetcher: NewsletterFetch = async (input, init) => { + calls.push({ input, init }); + return Response.json({ data: true }); + }; + + const result = await handleNewsletterRequest( + jsonRequest({ + name: ' Ada Lovelace ', + email: ' ada@example.com ', + nullCheck: '', + }), + config, + fetcher, + ); + + expect(result).toEqual({ body: { success: true }, status: 200 }); + expect(calls).toHaveLength(1); + expect(calls[0].input).toBe(endpoint.toString()); + expect(await readPayload(calls[0].init)).toEqual({ + name: 'Ada Lovelace', + email: 'ada@example.com', + list_uuids: [listId], + }); + }); + + it('allows an omitted name and keeps browser-valid a@b addresses', async () => { + let forwarded: unknown; + const fetcher: NewsletterFetch = async (_input, init) => { + forwarded = await readPayload(init); + return Response.json({ data: true }); + }; + + const result = await handleNewsletterRequest( + jsonRequest({ email: 'a@b' }), + config, + fetcher, + ); + + expect(result.status).toBe(200); + expect(forwarded).toEqual({ + name: '', + email: 'a@b', + list_uuids: [listId], + }); + }); + + it('short-circuits honeypot and missing config before upstream calls', async () => { + let calls = 0; + const fetcher: NewsletterFetch = async () => { + calls += 1; + return Response.json({ data: true }); + }; + + expect( + await handleNewsletterRequest( + jsonRequest({ nullCheck: 'bot', email: '' }), + config, + fetcher, + ), + ).toEqual({ body: { success: true }, status: 200 }); + expect(calls).toBe(0); + + expect( + await handleNewsletterRequest( + requestWithBody('not json'), + {}, + fetcher, + ), + ).toEqual({ body: { success: false }, status: 500 }); + expect(calls).toBe(0); + }); + + it.each([ + ['malformed JSON', requestWithBody('{')], + ['array JSON', jsonRequest([])], + ['missing email', jsonRequest({ name: 'Ada' })], + ['empty email', jsonRequest({ email: ' ' })], + ['invalid email', jsonRequest({ email: 'not-an-email' })], + ])('fails %s without contacting upstream', async (_label, request) => { + let calls = 0; + const fetcher: NewsletterFetch = async () => { + calls += 1; + return Response.json({ data: true }); + }; + + expect(await handleNewsletterRequest(request, config, fetcher)).toEqual( + { body: { success: false }, status: 400 }, + ); + expect(calls).toBe(0); + }); + + it.each([ + ['non-2xx', Response.json({ data: true }, { status: 500 })], + ['bad JSON', new Response('not json')], + ['missing data true', Response.json({ data: false })], + ])('fails upstream %s responses', async (_label, response) => { + const fetcher: NewsletterFetch = async () => response; + + expect( + await handleNewsletterRequest( + jsonRequest({ email: 'test@example.com' }), + config, + fetcher, + ), + ).toEqual({ body: { success: false }, status: 502 }); + }); + + it('returns a timeout failure when the upstream request is aborted', async () => { + const fetcher: NewsletterFetch = (_input, init) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => { + reject(new Error('aborted')); + }); + }); + + expect( + await handleNewsletterRequest( + jsonRequest({ email: 'test@example.com' }), + config, + fetcher, + 1, + ), + ).toEqual({ body: { success: false }, status: 504 }); + }); +}); + +describe('newsletter components', () => { + it('keeps the public form fields, native validation, and duplicate pending guard', () => { + const html = render(NewsletterForm).body; + + expect(html).toContain('
'); + expect(html).toContain('Email Signup'); + + const source = readFileSync( + join(process.cwd(), 'src/lib/components/NewsletterForm.svelte'), + 'utf8', + ); + expect(source).toContain('if (pending)'); + expect(source).toContain("fetch('/api/newsletter'"); + expect(source).toContain("text: 'Something went wrong!'"); + expect(source).toContain("title: 'Check your email'"); + expect(source).not.toContain('confirmButtonText'); + }); + + it('renders the hard-coded intro/signup block and app badge links', () => { + const html = render(IntroAndEmailSignupBlock, { + props: { params: { conference: '2024' } }, + }).body; + + expect(html).toContain('class="flex flex-col lg:flex-row"'); + expect(html).toContain('Invest In Yourself and Hone Your Craft'); + expect(html).toContain('Method Conference Springfield, MO'); + expect(html).toContain('href="/2024/register/"'); + expect(html).toContain('Download Our Mobile App!'); + expect(html).toContain( + 'href="https://apps.apple.com/us/app/method-conf/id1498359521"', + ); + expect(html).toContain('title="Download iOS app"'); + expect(html).toContain('alt="Download on the App Store"'); + expect(html).toContain('width="165"'); + expect(html).toContain('height="55"'); + expect(html).toContain( + 'href="https://play.google.com/store/apps/details?id=com.sgfdevs.methodConfApp"', + ); + expect(html).toContain('title="Download android app"'); + expect(html).toContain('alt="Get it on Google Play"'); + expect(html).toContain('width="186"'); + expect(html).toMatch( + /Stay in-the-know about event updates and new speaker\s+announcements\./, + ); + expect(html).toContain('Email Signup'); + }); +});