[object Object]

← back to Ventura Claw Leads

yolo tick 3: 46-test npm test suite covering auth, compliance, stripe-lib, verticals, routes

e42956b75770990671c127b49e5dec11538606ac · 2026-05-06 17:06:16 -0700 · Steve Abrams

A safety net for night-long autonomous changes. Each ~30-min YOLO tick from
here on can run npm test before commit + before deploy and stop on any red.

tests/auth.test.js (6 tests)
- bcrypt password round-trip + null-hash safety
- email shape validator (valid/invalid)
- email normalization (case + whitespace)
- claim token mint→consume→mark consumed→double-consume blocked
- invalid token returns ok:false / reason:invalid
- createUser ON CONFLICT idempotency

tests/compliance.test.js (8 tests)
- hasMailingAddress accepts real, rejects placeholder/empty
- publicUrlOk: https public ok, localhost/127.0.0.1/empty rejected (note:
  IS_PROD is module-load-frozen so we don't try to flip env at runtime)
- addSuppression idempotent on (channel, identifier)
- isSuppressed lookup + fail-closed on bad input
- mintUnsubscribeToken→consumeUnsubscribeToken round-trip + idempotency
- complianceFooter renders address + unsubscribe URL + campaign + visible link
- listUnsubscribeHeader: RFC 2369 + RFC 8058 shape

tests/stripe-lib.test.js (10 tests)
- VCL_TIERS structure (3 tiers, all required fields)
- isLive false when STRIPE_SECRET_KEY missing
- priceIdFor / tierFromPriceId round-trip
- eventBelongsToVCL filter: project metadata, item-level price match,
  invoice line-level price match, foreign price rejected, malformed event
  doesn't throw

tests/verticals.test.js (6 tests)
- VERTICALS has exactly 8 entries, all with key/label/blurb/icon
- keys exactly match the green list (food/beauty/retail/fitness/pet_services/auto_detail/cleaning/creative)
- NO regulated-vertical key (medical/legal/financial/real_estate/contracting/etc.)
- BY_KEY index consistent
- label/icon helpers

tests/routes.test.js (16 tests)
- 13 route smoke (200 / 404 expectations)
- homepage + /find ItemList JSON-LD presence
- /api/businesses.geo content-type + array shape
- contact form rejects bad email shape (with real CSRF flow)

46/46 pass in 902ms. PGHOST=/tmp required for socket auth on Mac dev.

Files touched

Diff

commit e42956b75770990671c127b49e5dec11538606ac
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 17:06:16 2026 -0700

    yolo tick 3: 46-test npm test suite covering auth, compliance, stripe-lib, verticals, routes
    
    A safety net for night-long autonomous changes. Each ~30-min YOLO tick from
    here on can run npm test before commit + before deploy and stop on any red.
    
    tests/auth.test.js (6 tests)
    - bcrypt password round-trip + null-hash safety
    - email shape validator (valid/invalid)
    - email normalization (case + whitespace)
    - claim token mint→consume→mark consumed→double-consume blocked
    - invalid token returns ok:false / reason:invalid
    - createUser ON CONFLICT idempotency
    
    tests/compliance.test.js (8 tests)
    - hasMailingAddress accepts real, rejects placeholder/empty
    - publicUrlOk: https public ok, localhost/127.0.0.1/empty rejected (note:
      IS_PROD is module-load-frozen so we don't try to flip env at runtime)
    - addSuppression idempotent on (channel, identifier)
    - isSuppressed lookup + fail-closed on bad input
    - mintUnsubscribeToken→consumeUnsubscribeToken round-trip + idempotency
    - complianceFooter renders address + unsubscribe URL + campaign + visible link
    - listUnsubscribeHeader: RFC 2369 + RFC 8058 shape
    
    tests/stripe-lib.test.js (10 tests)
    - VCL_TIERS structure (3 tiers, all required fields)
    - isLive false when STRIPE_SECRET_KEY missing
    - priceIdFor / tierFromPriceId round-trip
    - eventBelongsToVCL filter: project metadata, item-level price match,
      invoice line-level price match, foreign price rejected, malformed event
      doesn't throw
    
    tests/verticals.test.js (6 tests)
    - VERTICALS has exactly 8 entries, all with key/label/blurb/icon
    - keys exactly match the green list (food/beauty/retail/fitness/pet_services/auto_detail/cleaning/creative)
    - NO regulated-vertical key (medical/legal/financial/real_estate/contracting/etc.)
    - BY_KEY index consistent
    - label/icon helpers
    
    tests/routes.test.js (16 tests)
    - 13 route smoke (200 / 404 expectations)
    - homepage + /find ItemList JSON-LD presence
    - /api/businesses.geo content-type + array shape
    - contact form rejects bad email shape (with real CSRF flow)
    
    46/46 pass in 902ms. PGHOST=/tmp required for socket auth on Mac dev.
---
 tests/auth.test.js       |  85 ++++++++++++++++++++++++++++++
 tests/compliance.test.js | 109 +++++++++++++++++++++++++++++++++++++++
 tests/routes.test.js     | 131 +++++++++++++++++++++++++++++++++++++++++++++++
 tests/stripe-lib.test.js | 102 ++++++++++++++++++++++++++++++++++++
 tests/verticals.test.js  |  48 +++++++++++++++++
 5 files changed, 475 insertions(+)

diff --git a/tests/auth.test.js b/tests/auth.test.js
new file mode 100644
index 0000000..73370c7
--- /dev/null
+++ b/tests/auth.test.js
@@ -0,0 +1,85 @@
+// Auth lib unit tests — bcrypt round-trip + claim token lifecycle.
+// Uses the live local PG (PGHOST=/tmp), seeds a temp business, cleans up.
+
+require('dotenv').config({ path: require('path').resolve(__dirname, '..', '.env') });
+process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'test-secret';
+process.env.NODE_ENV = 'test';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const auth = require('../lib/auth');
+const db = require('../lib/db');
+
+let testBusinessId;
+
+test.before(async () => {
+  const r = await db.one(
+    `INSERT INTO businesses (slug, business_name, vertical, status, source)
+     VALUES ('__test_auth__', 'Auth Test Co', 'creative', 'active', 'test')
+     ON CONFLICT (slug) DO UPDATE SET business_name = EXCLUDED.business_name
+     RETURNING id`
+  );
+  testBusinessId = r.id;
+});
+
+test.after(async () => {
+  await db.query(`DELETE FROM businesses WHERE slug = '__test_auth__'`);
+  await db.pool.end();
+});
+
+test('bcrypt password round-trip', async () => {
+  const hash = await auth.hashPassword('correct-horse-battery-staple');
+  assert.ok(hash.startsWith('$2'), 'bcrypt prefix present');
+  assert.equal(await auth.verifyPassword('correct-horse-battery-staple', hash), true);
+  assert.equal(await auth.verifyPassword('wrong-password', hash), false);
+  assert.equal(await auth.verifyPassword('correct-horse-battery-staple', null), false);
+});
+
+test('email shape validator', () => {
+  assert.equal(auth.isEmailShape('a@b.co'), true);
+  assert.equal(auth.isEmailShape('Mixed.Case+Tag@Sub.Example.com'), true);
+  assert.equal(auth.isEmailShape('no-at-sign'), false);
+  assert.equal(auth.isEmailShape(''), false);
+  assert.equal(auth.isEmailShape('a@b'), false);
+});
+
+test('email normalization', () => {
+  assert.equal(auth.normalizeEmail('  Foo@BAR.com '), 'foo@bar.com');
+  assert.equal(auth.normalizeEmail(null), '');
+});
+
+test('claim token: mint, consume, mark consumed, double-consume blocked', async () => {
+  const { token, expiresAt } = await auth.issueClaimToken({
+    businessId: testBusinessId,
+    email: 'tok-test@example.com',
+    ipHash: 'abc'
+  });
+  assert.equal(token.length, 32);
+  assert.ok(new Date(expiresAt) > new Date(), 'expiresAt is in the future');
+
+  const r1 = await auth.consumeClaimToken(token);
+  assert.equal(r1.ok, true);
+  assert.equal(r1.claim.business_id, testBusinessId);
+
+  await auth.markClaimConsumed(token);
+  const r2 = await auth.consumeClaimToken(token);
+  assert.equal(r2.ok, false);
+  assert.equal(r2.reason, 'already_used');
+
+  // Cleanup
+  await db.query(`DELETE FROM claim_tokens WHERE token = $1`, [token]);
+});
+
+test('claim token: invalid token returns ok:false', async () => {
+  const r = await auth.consumeClaimToken('not-a-real-token-32-chars-abcde');
+  assert.equal(r.ok, false);
+  assert.equal(r.reason, 'invalid');
+});
+
+test('createUser idempotent on email conflict', async () => {
+  const a = await auth.createUser({ email: 'create-test@example.com', businessId: testBusinessId, displayName: 'A' });
+  assert.ok(a.id);
+  const b = await auth.createUser({ email: 'create-test@example.com', businessId: testBusinessId, displayName: 'B' });
+  assert.equal(b.id, a.id, 'second createUser returns same row');
+  await db.query(`DELETE FROM business_users WHERE email = 'create-test@example.com'`);
+});
diff --git a/tests/compliance.test.js b/tests/compliance.test.js
new file mode 100644
index 0000000..3c87f33
--- /dev/null
+++ b/tests/compliance.test.js
@@ -0,0 +1,109 @@
+// Compliance lib unit tests — suppression + unsubscribe-token lifecycle.
+
+require('dotenv').config({ path: require('path').resolve(__dirname, '..', '.env') });
+process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'test-secret';
+process.env.MAILING_ADDRESS = process.env.MAILING_ADDRESS || 'Test, 1 Real St, City, ST 12345';
+process.env.PUBLIC_URL = process.env.PUBLIC_URL || 'http://localhost:9789';
+process.env.NODE_ENV = 'test';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const c = require('../lib/compliance');
+const db = require('../lib/db');
+
+const TEST_EMAIL = '__compliance_test__@example.com';
+
+test.after(async () => {
+  await db.query(`DELETE FROM comms_suppression WHERE identifier = $1`, [TEST_EMAIL]);
+  await db.query(`DELETE FROM unsubscribe_tokens WHERE identifier = $1`, [TEST_EMAIL]);
+  await db.query(`DELETE FROM comms_send_audit WHERE recipient = $1`, [TEST_EMAIL]);
+  await db.pool.end();
+});
+
+test('hasMailingAddress accepts real, rejects placeholders', () => {
+  process.env.MAILING_ADDRESS = 'Real Co, 1 Real St, City, ST 12345';
+  assert.equal(c.hasMailingAddress(), true);
+  process.env.MAILING_ADDRESS = 'TBD';
+  assert.equal(c.hasMailingAddress(), false);
+  process.env.MAILING_ADDRESS = 'TODO_REPLACE';
+  assert.equal(c.hasMailingAddress(), false);
+  process.env.MAILING_ADDRESS = '';
+  assert.equal(c.hasMailingAddress(), false);
+  // Restore
+  process.env.MAILING_ADDRESS = 'Real Co, 1 Real St, City, ST 12345';
+});
+
+test('publicUrlOk: accepts https public, rejects localhost', () => {
+  // Note: lib/compliance.js captures IS_PROD at module-load time
+  // (const IS_PROD = process.env.NODE_ENV === 'production'). Mutating
+  // NODE_ENV at test-runtime won't flip IS_PROD inside the already-loaded
+  // module, so we can only assert the load-time-stable conditions: empty
+  // and localhost are always rejected, valid https public is always ok.
+  const oldUrl = process.env.PUBLIC_URL;
+
+  process.env.PUBLIC_URL = 'https://leads.venturaclaw.com';
+  assert.equal(c.publicUrlOk(), true);
+
+  process.env.PUBLIC_URL = 'http://localhost:9789';
+  assert.equal(c.publicUrlOk(), false);
+
+  process.env.PUBLIC_URL = 'http://127.0.0.1:9789';
+  assert.equal(c.publicUrlOk(), false);
+
+  process.env.PUBLIC_URL = '';
+  assert.equal(c.publicUrlOk(), false);
+
+  process.env.PUBLIC_URL = oldUrl;
+});
+
+test('addSuppression idempotent on (channel, identifier)', async () => {
+  await c.addSuppression({ channel: 'email', identifier: TEST_EMAIL, reason: 'unsubscribe', source: 'test' });
+  await c.addSuppression({ channel: 'email', identifier: TEST_EMAIL, reason: 'unsubscribe', source: 'test' });
+  const r = await db.query(`SELECT COUNT(*)::int AS n FROM comms_suppression WHERE identifier = $1`, [TEST_EMAIL]);
+  assert.equal(r.rows[0].n, 1, 'second add did not duplicate');
+});
+
+test('isSuppressed returns true after addSuppression, false otherwise', async () => {
+  assert.equal(await c.isSuppressed({ channel: 'email', identifier: TEST_EMAIL }), true);
+  assert.equal(await c.isSuppressed({ channel: 'email', identifier: 'never-seen@example.com' }), false);
+  assert.equal(await c.isSuppressed({ channel: '', identifier: TEST_EMAIL }), true, 'fail-closed on bad input');
+});
+
+test('mintUnsubscribeToken → consumeUnsubscribeToken round-trip', async () => {
+  const token = await c.mintUnsubscribeToken({
+    channel: 'email', identifier: 'unsub-test@example.com',
+    campaign: 'lead_delivery', businessId: null
+  });
+  assert.equal(token.length, 32);
+
+  const consumed1 = await c.consumeUnsubscribeToken(token);
+  assert.ok(consumed1);
+  assert.equal(consumed1.identifier, 'unsub-test@example.com');
+  assert.equal(consumed1.campaign, 'lead_delivery');
+
+  // Idempotent — consuming twice returns the row but used_at is unchanged on
+  // the second call (COALESCE preserves the original used_at).
+  const consumed2 = await c.consumeUnsubscribeToken(token);
+  assert.deepEqual(consumed1.used_at, consumed2.used_at);
+
+  // Garbage token
+  const consumed3 = await c.consumeUnsubscribeToken('not-a-token');
+  assert.equal(consumed3, null);
+
+  await db.query(`DELETE FROM unsubscribe_tokens WHERE identifier = 'unsub-test@example.com'`);
+});
+
+test('complianceFooter includes MAILING_ADDRESS + unsubscribe link', () => {
+  const html = c.complianceFooter({ campaign: 'lead_delivery', unsubscribeUrl: 'https://x/unsub?t=abc' });
+  assert.ok(html.includes('Real Co, 1 Real St'), 'address present');
+  assert.ok(html.includes('https://x/unsub?t=abc'), 'unsubscribe URL embedded verbatim');
+  assert.ok(html.includes('lead_delivery'), 'campaign label present');
+  assert.ok(html.includes('Unsubscribe'), 'human-visible unsubscribe text');
+});
+
+test('listUnsubscribeHeader returns RFC 2369/8058 headers', () => {
+  const h = c.listUnsubscribeHeader('https://x/unsub?t=abc');
+  assert.equal(h['List-Unsubscribe'], '<https://x/unsub?t=abc>');
+  assert.equal(h['List-Unsubscribe-Post'], 'List-Unsubscribe=One-Click');
+  assert.equal(c.listUnsubscribeHeader(null), null);
+});
diff --git a/tests/routes.test.js b/tests/routes.test.js
new file mode 100644
index 0000000..23a8ace
--- /dev/null
+++ b/tests/routes.test.js
@@ -0,0 +1,131 @@
+// Smoke test the public routes — boots the Express app in-process without
+// pm2 and hits every route through supertest-equivalent (built-in fetch +
+// http.createServer.listen on port 0).
+
+require('dotenv').config({ path: require('path').resolve(__dirname, '..', '.env') });
+process.env.NODE_ENV = 'test';
+process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'test-secret';
+// Avoid colliding with the dev server on :9789.
+process.env.PORT = '0';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const path = require('node:path');
+
+// Re-implement just enough of server.js to mount the public routes for testing.
+// Mounting the actual server.js would call app.listen synchronously; we'd
+// rather avoid the side-effect and stand up our own short-lived server.
+const express = require('express');
+const session = require('express-session');
+const PgSession = require('connect-pg-simple')(session);
+const helmet = require('helmet');
+const auth = require('../lib/auth');
+const { csrfMiddleware } = require('../lib/csrf');
+const db = require('../lib/db');
+const publicRoutes = require('../routes/public');
+
+let server, baseUrl;
+
+test.before(async () => {
+  const app = express();
+  app.set('view engine', 'ejs');
+  app.set('views', path.join(__dirname, '..', 'views'));
+  app.use(helmet({ contentSecurityPolicy: false }));
+  app.use(session({
+    store: new PgSession({ pool: db.pool, tableName: 'session' }),
+    secret: 'test', resave: false, saveUninitialized: false,
+    cookie: { httpOnly: true, secure: false, sameSite: 'lax' }
+  }));
+  app.use(express.urlencoded({ extended: false }));
+  app.use(express.json());
+  app.use((req, res, next) => { res.locals.publicUrl = 'http://test.local'; res.locals.path = req.path; next(); });
+  app.use(auth.attachBusiness);
+  app.use(csrfMiddleware);
+  app.use('/', publicRoutes);
+  app.use((err, req, res, next) => {
+    console.error('[test-app]', err.stack);
+    res.status(500).send('boom');
+  });
+
+  await new Promise((resolve) => {
+    server = app.listen(0, () => {
+      const port = server.address().port;
+      baseUrl = `http://127.0.0.1:${port}`;
+      resolve();
+    });
+  });
+});
+
+test.after(async () => {
+  await new Promise((resolve) => server.close(resolve));
+  await db.pool.end();
+});
+
+const ROUTES = [
+  { path: '/', expect: 200 },
+  { path: '/find', expect: 200 },
+  { path: '/find?vertical=food', expect: 200 },
+  { path: '/find?vertical=invalid', expect: 200 },         // unknown vertical = silent skip, no 4xx
+  { path: '/map', expect: 200 },
+  { path: '/about', expect: 200 },
+  { path: '/for-businesses', expect: 200 },
+  { path: '/privacy', expect: 200 },
+  { path: '/terms', expect: 200 },
+  { path: '/healthz', expect: 200 },
+  { path: '/api/businesses.geo', expect: 200 },
+  { path: '/business/non-existent-slug', expect: 404 },
+  { path: '/business/non-existent/contact', method: 'GET', expect: 404 }
+];
+
+for (const r of ROUTES) {
+  test(`${r.method || 'GET'} ${r.path} → ${r.expect}`, async () => {
+    const res = await fetch(baseUrl + r.path, { method: r.method || 'GET' });
+    assert.equal(res.status, r.expect);
+  });
+}
+
+test('homepage HTML contains brand mark', async () => {
+  const res = await fetch(baseUrl + '/');
+  const body = await res.text();
+  assert.ok(body.includes('Ventura Claw'), 'brand name in body');
+  assert.ok(body.includes('every business worth knowing') || body.includes('Every business worth knowing'),
+    'hero copy present');
+});
+
+test('/find renders ItemList JSON-LD', async () => {
+  const res = await fetch(baseUrl + '/find');
+  const body = await res.text();
+  assert.ok(body.includes('"@type":"ItemList"'), 'ItemList JSON-LD present');
+});
+
+test('/api/businesses.geo returns JSON with businesses array', async () => {
+  const res = await fetch(baseUrl + '/api/businesses.geo');
+  assert.equal(res.headers.get('content-type'), 'application/json; charset=utf-8');
+  const body = await res.json();
+  assert.ok(Array.isArray(body.businesses));
+});
+
+test('contact form rejects bad email', async () => {
+  // Pull a real business slug + CSRF token.
+  const slug = await db.one(`SELECT slug FROM businesses WHERE status='active' ORDER BY id LIMIT 1`);
+  if (!slug) { console.warn('  (no businesses seeded — skipping)'); return; }
+
+  const j = new Map();
+  const r1 = await fetch(baseUrl + '/business/' + slug.slug);
+  for (const c of r1.headers.getSetCookie()) j.set(c.split(';')[0].split('=')[0], c.split(';')[0]);
+  const cookie = Array.from(j.values()).join('; ');
+  const html = await r1.text();
+  const csrf = (html.match(/name="_csrf" value="([^"]+)"/) || [])[1];
+  assert.ok(csrf, 'csrf extracted');
+
+  const params = new URLSearchParams({ _csrf: csrf, name: 'Smoke', email: 'not-a-real-email', message: 'hi' });
+  const r2 = await fetch(baseUrl + '/business/' + slug.slug + '/contact', {
+    method: 'POST',
+    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
+    body: params.toString()
+  });
+  assert.equal(r2.status, 400);
+  const body2 = await r2.text();
+  assert.ok(body2.includes('valid email') || body2.includes('Email needed'));
+});
diff --git a/tests/stripe-lib.test.js b/tests/stripe-lib.test.js
new file mode 100644
index 0000000..db182ba
--- /dev/null
+++ b/tests/stripe-lib.test.js
@@ -0,0 +1,102 @@
+// lib/stripe.js unit tests — config + tier resolution + cross-project filter.
+// No live Stripe calls; everything is in-memory metadata logic.
+
+require('dotenv').config({ path: require('path').resolve(__dirname, '..', '.env') });
+process.env.NODE_ENV = 'test';
+// Force missing-key path so client() returns null (don't accidentally hit live Stripe).
+delete process.env.STRIPE_SECRET_KEY;
+process.env.STRIPE_PRICE_VCL_STARTER_MONTH  = 'price_test_starter';
+process.env.STRIPE_PRICE_VCL_STANDARD_MONTH = 'price_test_standard';
+process.env.STRIPE_PRICE_VCL_PREMIER_MONTH  = 'price_test_premier';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const s = require('../lib/stripe');
+
+test('VCL_TIERS has 3 entries with required fields', () => {
+  const keys = Object.keys(s.VCL_TIERS);
+  assert.deepEqual(keys.sort(), ['premier', 'standard', 'starter']);
+  for (const k of keys) {
+    const t = s.VCL_TIERS[k];
+    assert.ok(t.name, `${k}.name`);
+    assert.ok(t.envMonth, `${k}.envMonth`);
+    assert.ok(t.cents > 0, `${k}.cents`);
+    assert.ok(t.label, `${k}.label`);
+  }
+});
+
+test('isLive returns false when STRIPE_SECRET_KEY missing', () => {
+  assert.equal(s.isLive(), false);
+});
+
+test('priceIdFor resolves env vars per tier', () => {
+  assert.equal(s.priceIdFor('starter'), 'price_test_starter');
+  assert.equal(s.priceIdFor('standard'), 'price_test_standard');
+  assert.equal(s.priceIdFor('premier'), 'price_test_premier');
+  assert.equal(s.priceIdFor('nope'), null);
+});
+
+test('tierFromPriceId reverse-resolves', () => {
+  assert.equal(s.tierFromPriceId('price_test_starter'), 'starter');
+  assert.equal(s.tierFromPriceId('price_test_standard'), 'standard');
+  assert.equal(s.tierFromPriceId('price_test_premier'), 'premier');
+  assert.equal(s.tierFromPriceId('price_unknown'), null);
+  assert.equal(s.tierFromPriceId(null), null);
+  assert.equal(s.tierFromPriceId(''), null);
+});
+
+test('eventBelongsToVCL: metadata.project=ventura-claw-leads ⇒ true', () => {
+  const event = {
+    type: 'customer.subscription.updated',
+    data: { object: { metadata: { project: 'ventura-claw-leads' } } }
+  };
+  assert.equal(s.eventBelongsToVCL(event), true);
+});
+
+test('eventBelongsToVCL: metadata.project=national-paper-hangers ⇒ false', () => {
+  const event = {
+    type: 'customer.subscription.updated',
+    data: { object: { metadata: { project: 'national-paper-hangers' } } }
+  };
+  assert.equal(s.eventBelongsToVCL(event), false);
+});
+
+test('eventBelongsToVCL: subscription items reference VCL price ⇒ true', () => {
+  const event = {
+    type: 'customer.subscription.created',
+    data: { object: {
+      metadata: {},
+      items: { data: [{ price: { id: 'price_test_premier' } }] }
+    } }
+  };
+  assert.equal(s.eventBelongsToVCL(event), true);
+});
+
+test('eventBelongsToVCL: invoice lines reference VCL price ⇒ true', () => {
+  const event = {
+    type: 'invoice.payment_succeeded',
+    data: { object: {
+      metadata: {},
+      lines: { data: [{ price: { id: 'price_test_starter' } }] }
+    } }
+  };
+  assert.equal(s.eventBelongsToVCL(event), true);
+});
+
+test('eventBelongsToVCL: no metadata + foreign price ⇒ false', () => {
+  const event = {
+    type: 'customer.subscription.created',
+    data: { object: {
+      metadata: {},
+      items: { data: [{ price: { id: 'price_npx_install_deposit' } }] }
+    } }
+  };
+  assert.equal(s.eventBelongsToVCL(event), false);
+});
+
+test('eventBelongsToVCL: malformed event ⇒ false (no throw)', () => {
+  assert.equal(s.eventBelongsToVCL(null), false);
+  assert.equal(s.eventBelongsToVCL({}), false);
+  assert.equal(s.eventBelongsToVCL({ data: null }), false);
+  assert.equal(s.eventBelongsToVCL({ data: {} }), false);
+});
diff --git a/tests/verticals.test.js b/tests/verticals.test.js
new file mode 100644
index 0000000..5112e86
--- /dev/null
+++ b/tests/verticals.test.js
@@ -0,0 +1,48 @@
+// Verticals registry — single source of truth for the 8-vertical green list.
+// Constraint enforced at the DB level (CHECK on businesses.vertical) — these
+// tests assert the registry stays aligned with that enum.
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const v = require('../lib/verticals');
+
+const ALLOWED = ['food','beauty','retail','fitness','pet_services','auto_detail','cleaning','creative'];
+const REGULATED_FORBIDDEN = ['medical','legal','financial','real_estate','realestate','contracting','contractors','doctors','lawyers','dentists','therapists','accountants','insurance'];
+
+test('VERTICALS has exactly 8 entries', () => {
+  assert.equal(v.VERTICALS.length, 8);
+});
+
+test('every VERTICALS entry has key/label/blurb/icon', () => {
+  for (const entry of v.VERTICALS) {
+    assert.ok(entry.key);
+    assert.ok(entry.label);
+    assert.ok(entry.blurb);
+    assert.ok(entry.icon);
+  }
+});
+
+test('VERTICALS keys exactly match the green list', () => {
+  const keys = v.VERTICALS.map(x => x.key).sort();
+  assert.deepEqual(keys, ALLOWED.slice().sort());
+});
+
+test('NO regulated-vertical key is present', () => {
+  const keys = v.VERTICALS.map(x => x.key);
+  for (const forbidden of REGULATED_FORBIDDEN) {
+    assert.equal(keys.includes(forbidden), false, `regulated key "${forbidden}" must not appear`);
+  }
+});
+
+test('BY_KEY index is consistent with VERTICALS', () => {
+  for (const entry of v.VERTICALS) {
+    assert.equal(v.BY_KEY[entry.key], entry);
+  }
+});
+
+test('label() and icon() helpers', () => {
+  assert.equal(v.label('food'), 'Food & drink');
+  assert.equal(v.label('unknown'), 'unknown');
+  assert.ok(v.icon('food'));
+  assert.equal(v.icon('unknown'), '•');
+});

← 9c3866d yolo tick 2: /unsubscribe handler — RFC 8058 one-click + lan  ·  back to Ventura Claw Leads  ·  yolo tick 4: /robots.txt + /sitemap.xml — Google indexing in 713a5b4 →