← 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();
});