← back to NationalPaperHangers
tests/smoke.test.js
421 lines
// Smoke test suite — node:test + supertest
// Runs against the live local `national_paper_hangers` DB (read-only except
// the claim-token subtests, which clean up after themselves).
'use strict';
const { test, after } = require('node:test');
const assert = require('node:assert/strict');
const supertest = require('supertest');
const crypto = require('crypto');
const app = require('./app');
const db = require('../lib/db');
const { availableSlots } = require('../lib/slots');
const request = supertest(app);
// Known slugs from seed data
const CLAIMED_SLUG = 'atelier-bond-nyc'; // status=active, claim_status=self
const UNCLAIMED_SLUG = 'demo-unclaimed-ridgeway-paper'; // claim_status=unclaimed
const BOOK_SLUG = 'paperworks-collective-la'; // status=active, tier=signature
// ─────────────────────────────────────────────────────────────────────────────
// 1. DB connection ping
// ─────────────────────────────────────────────────────────────────────────────
test('DB: pool can query (1+1=2)', async () => {
const r = await db.query('SELECT 1+1 AS result');
assert.equal(r.rows[0].result, 2);
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. GET /find — 200 + installer cards in HTML
// ─────────────────────────────────────────────────────────────────────────────
test('GET /find returns 200 and contains installer card markup', async () => {
const res = await request.get('/find');
assert.equal(res.status, 200);
assert.match(res.headers['content-type'], /html/);
// Each card has a link to /installer/<slug>
assert.ok(
res.text.includes('/installer/'),
'Expected at least one /installer/ link in /find HTML'
);
});
test('GET /find?state=NY scopes results', async () => {
const res = await request.get('/find?state=NY');
assert.equal(res.status, 200);
// atelier-bond-nyc is in NY and should appear
assert.ok(res.text.includes('atelier-bond-nyc') || res.text.includes('Atelier Bond'));
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. GET /installer/:slug — claimed and unclaimed
// ─────────────────────────────────────────────────────────────────────────────
test('GET /installer/:slug for a claimed (self) installer returns 200', async () => {
const res = await request.get(`/installer/${CLAIMED_SLUG}`);
assert.equal(res.status, 200);
assert.match(res.headers['content-type'], /html/);
assert.ok(res.text.toLowerCase().includes('atelier bond'));
});
test('GET /installer/:slug for an unclaimed installer returns 200', async () => {
const res = await request.get(`/installer/${UNCLAIMED_SLUG}`);
assert.equal(res.status, 200);
assert.match(res.headers['content-type'], /html/);
});
test('GET /installer/nonexistent-slug returns 404', async () => {
const res = await request.get('/installer/does-not-exist-xyz');
assert.equal(res.status, 404);
});
// ─────────────────────────────────────────────────────────────────────────────
// /map — Leaflet directory map + /api/installers.geo JSON feed
// ─────────────────────────────────────────────────────────────────────────────
test('GET /map renders the Leaflet map page', async () => {
const res = await request.get('/map');
assert.equal(res.status, 200);
assert.match(res.headers['content-type'], /html/);
// Leaflet CSS lives in <head> unconditionally — load-bearing dependency.
// (leaflet.js + the map container only render inside the results block.)
assert.ok(res.text.includes('/vendor/leaflet/leaflet.css'), 'expected Leaflet CSS link');
// /map is now an address-driven near-me view (commit b3b4e14): the always-on
// entry point is the address form, not a client-side /api/installers.geo fetch.
assert.ok(res.text.includes('near-me-form'), 'expected near-me address form');
assert.ok(/action="\/map"/.test(res.text), 'expected form posts back to /map');
});
test('GET /api/installers.geo returns JSON with count + installers array', async () => {
const res = await request.get('/api/installers.geo');
assert.equal(res.status, 200);
assert.match(res.headers['content-type'], /json/);
assert.ok(typeof res.body.count === 'number');
assert.ok(Array.isArray(res.body.installers));
// If any installers are returned, every entry must have lat/lng coords
// (otherwise the marker cluster crashes)
if (res.body.installers.length > 0) {
const sample = res.body.installers[0];
assert.ok(typeof sample.lat === 'number' && Number.isFinite(sample.lat), 'lat must be a number');
assert.ok(typeof sample.lng === 'number' && Number.isFinite(sample.lng), 'lng must be a number');
assert.ok(typeof sample.slug === 'string' && sample.slug.length > 0, 'slug required');
}
});
// ─────────────────────────────────────────────────────────────────────────────
// /healthz — used by nginx upstream + uptime monitors after Kamatera deploy
// ─────────────────────────────────────────────────────────────────────────────
test('GET /healthz returns 200 with ok:true', async () => {
const res = await request.get('/healthz');
assert.equal(res.status, 200);
assert.match(res.headers['content-type'], /json/);
assert.equal(res.body.ok, true);
assert.ok(typeof res.body.ts === 'number');
assert.equal(res.headers['cache-control'], 'no-store');
});
// ─────────────────────────────────────────────────────────────────────────────
// /admin/* redirects to /login when unauthenticated
// ─────────────────────────────────────────────────────────────────────────────
test('GET /admin redirects to /login when unauthenticated', async () => {
const res = await request.get('/admin').redirects(0);
assert.equal(res.status, 302);
assert.match(res.headers.location, /^\/login/);
});
test('GET /admin/bookings/123 redirects to /login when unauthenticated', async () => {
const res = await request.get('/admin/bookings/123').redirects(0);
assert.equal(res.status, 302);
assert.match(res.headers.location, /^\/login/);
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. GET /installer/:slug/book — booking form renders
// ─────────────────────────────────────────────────────────────────────────────
test('GET /installer/:slug/book renders booking form for active installer', async () => {
const res = await request.get(`/installer/${BOOK_SLUG}/book`);
assert.equal(res.status, 200);
assert.match(res.headers['content-type'], /html/);
// Page should mention booking or the installer name
assert.ok(
res.text.toLowerCase().includes('book') || res.text.includes('Paperworks'),
'Expected book page content'
);
});
test('GET /installer/:slug/book for non-existent slug returns 404', async () => {
// The /book route now serves both active AND unclaimed studios (latter
// shows the contact-direct path). Only truly-non-existent slugs should 404.
const res = await request.get('/installer/does-not-exist-zzz/book');
assert.equal(res.status, 404);
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. lib/slots.js unit test (pure logic, no network, uses real DB for fixture)
// ─────────────────────────────────────────────────────────────────────────────
test('slots: feed availability + time-off + booking → correct openings count & timing', async () => {
// Use a test installer inserted into the DB only for this test,
// then removed in cleanup.
const email = `smoke-slots-test-${Date.now()}@test.invalid`;
const pw = '$2b$10$.2P40NWtgXQiylgmJWIprO6.cML8l.wHfMwzB29jAd01uVXyjofka'; // demo1234
// Insert a temporary installer
const ins = await db.one(
`INSERT INTO installers (slug, email, password_hash, business_name, status, tier)
VALUES ($1, $2, $3, 'Smoke Test Studio', 'active', 'pro')
RETURNING id`,
[`smoke-slots-test-${Date.now()}`, email, pw]
);
const id = ins.id;
try {
// Availability: Monday–Friday 09:00–17:00 for next 7 days
// Luxon weekday: 1=Mon..5=Fri → stored as 0=Sun..6=Sat so Mon=1, Fri=5
for (const dow of [1, 2, 3, 4, 5]) {
await db.query(
`INSERT INTO installer_availability (installer_id, day_of_week, start_time, end_time, timezone)
VALUES ($1, $2, '09:00', '17:00', 'America/Los_Angeles')`,
[id, dow]
);
}
// Pick tomorrow (should definitely be a future date)
const { DateTime } = require('luxon');
const tz = 'America/Los_Angeles';
const tomorrow = DateTime.now().setZone(tz).plus({ days: 1 });
// Find a Monday within the next 7 days so we have at least one working day
let testDay = tomorrow;
for (let i = 0; i < 7; i++) {
// Luxon weekday 1=Mon
if (testDay.weekday >= 1 && testDay.weekday <= 5) break;
testDay = testDay.plus({ days: 1 });
}
const dayStr = testDay.toISODate(); // e.g. "2026-05-06"
// Time-off: block 09:00–11:00 on that day
await db.query(
`INSERT INTO installer_time_off (installer_id, start_at, end_at, reason, all_day)
VALUES ($1,
($2::date + time '09:00') AT TIME ZONE 'America/Los_Angeles',
($2::date + time '11:00') AT TIME ZONE 'America/Los_Angeles',
'Smoke test time-off', false)`,
[id, dayStr]
);
// Existing booking: 13:00–14:00 on that day (confirmed)
await db.query(
`INSERT INTO bookings (installer_id, customer_name, customer_email, scheduled_start, scheduled_end, status)
VALUES ($1, 'Test Customer', 'customer@test.invalid',
($2::date + time '13:00') AT TIME ZONE 'America/Los_Angeles',
($2::date + time '14:00') AT TIME ZONE 'America/Los_Angeles',
'confirmed')`,
[id, dayStr]
);
// Window 09:00–17:00 (8h) minus time-off 09:00–11:00 = 11:00–17:00 (6h)
// minus booking 13:00–14:15 (with 15m buffer end) = two gaps:
// 11:00–13:00 → 2 slots of 60m (11:00, 12:00)
// 14:15–17:00 → 1h45m → 1 slot of 60m starting 14:15
// Total: 3 slots on that day.
const slots = await availableSlots(id, {
startDate: dayStr,
endDate: dayStr,
slotMinutes: 60,
bufferMinutes: 15,
timezone: tz
});
assert.ok(slots.length >= 2, `Expected at least 2 open slots, got ${slots.length}`);
// All slots must be in the future and fit within 09:00–17:00
for (const s of slots) {
const start = DateTime.fromISO(s.start).setZone(tz);
const end = DateTime.fromISO(s.end).setZone(tz);
assert.ok(start > DateTime.now().setZone(tz), `Slot start ${s.start} should be in the future`);
assert.ok(start.hour >= 9, `Slot start hour ${start.hour} should be >= 9`);
assert.ok(end.hour <= 17, `Slot end hour ${end.hour} should be <= 17`);
}
// No slot should overlap the booked 13:00–14:00 window
for (const s of slots) {
const slotStart = DateTime.fromISO(s.start).setZone(tz);
const slotEnd = DateTime.fromISO(s.end).setZone(tz);
const bookStart = testDay.set({ hour: 13, minute: 0, second: 0, millisecond: 0 });
const bookEnd = testDay.set({ hour: 14, minute: 0, second: 0, millisecond: 0 });
const overlaps = slotStart < bookEnd && slotEnd > bookStart;
assert.ok(!overlaps, `Slot ${s.start}–${s.end} overlaps existing booking`);
}
} finally {
// bookings FK is ON DELETE RESTRICT — must remove explicitly before the installer
await db.query('DELETE FROM bookings WHERE installer_id = $1', [id]);
await db.query('DELETE FROM installers WHERE id = $1', [id]);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// 5b. clockMinutes — availability time-string validation
// Guards POST /admin/availability: a malformed time 500s the ::time-cast
// INSERT, and an inverted window saves but yields zero bookable slots.
// ─────────────────────────────────────────────────────────────────────────────
test('clockMinutes parses valid times and rejects malformed/inverted input', () => {
const { clockMinutes } = require('../lib/utils');
// Valid forms → minute-of-day
assert.equal(clockMinutes('09:00'), 540);
assert.equal(clockMinutes('9:00'), 540); // single-digit hour
assert.equal(clockMinutes('17:30'), 1050);
assert.equal(clockMinutes('00:00'), 0);
assert.equal(clockMinutes(' 08:15 '), 495); // surrounding whitespace
assert.equal(clockMinutes('23:59:30'), 23 * 60 + 59 + 0.5);
// Malformed → null (these would 500 the ::time-cast INSERT)
for (const bad of ['', ' ', 'abc', '99:99', '24:00', '12:60',
'12', '12:5', '-1:00', '12:00:99', '1200', null, undefined]) {
assert.equal(clockMinutes(bad), null, `${JSON.stringify(bad)} should parse to null`);
}
// An inverted window must be detectable as start >= end
assert.ok(clockMinutes('17:00') > clockMinutes('09:00'));
assert.ok(clockMinutes('09:00') < clockMinutes('09:30'));
});
// ─────────────────────────────────────────────────────────────────────────────
// 5c. bookingConfirmationCustomer — the customer's link must carry the
// IDOR-gate ?t= token, or /bookings/:uuid 404s for the customer.
// ─────────────────────────────────────────────────────────────────────────────
test('booking confirmation email links to /bookings/:uuid with a valid ?t= token', () => {
const email = require('../lib/email');
const bookingToken = require('../lib/booking-token');
const uuid = crypto.randomUUID();
const view_token = bookingToken.sign(uuid);
const out = email.bookingConfirmationCustomer({
booking: {
uuid, view_token, customer_name: 'Jane Doe',
scheduled_start: new Date(Date.now() + 864e5), project_type: 'consultation'
},
installer: { business_name: 'Atelier Bond' },
publicUrl: 'https://www.nationalpaperhangers.com'
});
const m = /\/bookings\/([0-9a-f-]+)\?t=([A-Za-z0-9_-]+)/.exec(out.html);
assert.ok(m, 'confirmation email must link to /bookings/:uuid?t=<token>');
assert.equal(m[1], uuid, 'link uuid matches the booking');
assert.ok(bookingToken.verify(m[1], m[2]), 'embedded token must verify against the uuid');
});
// ─────────────────────────────────────────────────────────────────────────────
// 5d. Booking lifecycle guards — a terminal booking cannot be revived by a
// confirm / decline / complete POST (stale tab, back-button re-POST).
// ─────────────────────────────────────────────────────────────────────────────
test('booking transitions guard prior state: terminal bookings cannot be revived', async () => {
const ins = await db.one(
`INSERT INTO installers (slug, email, password_hash, business_name, status, tier)
VALUES ($1, $2, '$2b$10$.2P40NWtgXQiylgmJWIprO6.cML8l.wHfMwzB29jAd01uVXyjofka',
'Smoke Lifecycle Studio', 'active', 'pro')
RETURNING id`,
[`smoke-lifecycle-${Date.now()}`, `smoke-lifecycle-${Date.now()}@test.invalid`]
);
const id = ins.id;
const mkBooking = async (status) => db.one(
`INSERT INTO bookings (installer_id, customer_name, customer_email,
scheduled_start, scheduled_end, status)
VALUES ($1, 'LC Test', 'lc@test.invalid',
now() + interval '7 days', now() + interval '7 days' + interval '1 hour', $2)
RETURNING id`,
[id, status]
);
try {
// confirm guards on status='pending' — a canceled booking must not revive.
const canceled = await mkBooking('canceled');
let r = await db.query(
`UPDATE bookings SET status='confirmed', confirmed_at=now()
WHERE id=$1 AND installer_id=$2 AND status='pending'`, [canceled.id, id]);
assert.equal(r.rowCount, 0, 'confirm must not touch a canceled booking');
let row = await db.one('SELECT status FROM bookings WHERE id=$1', [canceled.id]);
assert.equal(row.status, 'canceled', 'canceled booking stays canceled');
// A pending booking confirms cleanly, then completes from confirmed.
const live = await mkBooking('pending');
r = await db.query(
`UPDATE bookings SET status='confirmed', confirmed_at=now()
WHERE id=$1 AND installer_id=$2 AND status='pending'`, [live.id, id]);
assert.equal(r.rowCount, 1, 'pending booking confirms');
r = await db.query(
`UPDATE bookings SET status='completed', completed_at=now()
WHERE id=$1 AND installer_id=$2 AND status IN ('pending','confirmed')`, [live.id, id]);
assert.equal(r.rowCount, 1, 'confirmed booking completes');
// The now-completed booking cannot be declined back out of its terminal state.
r = await db.query(
`UPDATE bookings SET status='declined', canceled_at=now()
WHERE id=$1 AND installer_id=$2 AND status IN ('pending','confirmed')`, [live.id, id]);
assert.equal(r.rowCount, 0, 'decline must not touch a completed booking');
row = await db.one('SELECT status FROM bookings WHERE id=$1', [live.id]);
assert.equal(row.status, 'completed', 'completed booking stays completed');
} finally {
await db.query('DELETE FROM bookings WHERE installer_id=$1', [id]);
await db.query('DELETE FROM installers WHERE id=$1', [id]);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// 6. Claim token: generate, verify, consume, second-use fails
// ─────────────────────────────────────────────────────────────────────────────
test('claim token: generate → verify → consume; second-use returns invalid-token state', async () => {
// Find an unclaimed installer to use as test subject (read slug only)
const target = await db.one(
`SELECT id, slug, claim_status, claim_token FROM installers
WHERE claim_status = 'unclaimed' LIMIT 1`
);
assert.ok(target, 'Expected at least one unclaimed installer in DB for claim token test');
// Snapshot original state so we can restore it
const origToken = target.claim_token;
const origStatus = target.claim_status;
const token = crypto.randomBytes(24).toString('base64url');
try {
// 1. Write token
await db.query(
`UPDATE installers SET claim_status='pending_claim', claim_token=$2, claim_token_at=now() WHERE id=$1`,
[target.id, token]
);
// 2. Verify it matches
const row1 = await db.one('SELECT claim_token, claim_status FROM installers WHERE id=$1', [target.id]);
assert.equal(row1.claim_token, token, 'Token should match what we stored');
assert.equal(row1.claim_status, 'pending_claim');
// 3. Consume it (simulate the verify endpoint's final UPDATE)
await db.query(
`UPDATE installers SET claim_status='claimed', claimed_at=now(), claim_token=NULL WHERE id=$1`,
[target.id]
);
// 4. Second-use: token is now NULL — should not match
const row2 = await db.one('SELECT claim_token, claim_status FROM installers WHERE id=$1', [target.id]);
assert.equal(row2.claim_token, null, 'Token must be nulled after consumption');
assert.notEqual(row2.claim_token, token, 'Second use of token must fail (null !== token)');
assert.equal(row2.claim_status, 'claimed');
} finally {
// Restore original state so we don't permanently alter the unclaimed listing
await db.query(
`UPDATE installers SET claim_status=$2, claim_token=$3, claim_token_at=NULL, claimed_at=NULL WHERE id=$1`,
[target.id, origStatus, origToken]
);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// Teardown: close the pg pool so node:test can exit cleanly
// ─────────────────────────────────────────────────────────────────────────────
after(async () => {
await db.pool.end();
});