← back to Costa Rica
test/booking.test.js
225 lines
'use strict';
// Money-path tests (node:test) for the booking state machine + double-booking guard.
// computeSplit conservation is covered in marketplace.test.js; THIS covers the flow:
// - confirmBooking() is idempotent — a DUPLICATE payment webhook must NOT
// double-confirm or double-send the WhatsApp confirmation (guarded by
// `WHERE status='pending' RETURNING *` → no row on the 2nd call).
// - POST /bookings rejects overlapping dates (the c1 double-booking fix) with 409
// and never reaches the INSERT.
// - the happy path persists a split whose parts reconstruct the total.
// No real DB / network: pool.query is a queue mock, wa.sendText is stubbed. Run: node --test
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const http = require('node:http');
const express = require('express');
const { signToken } = require('../lib/auth');
const db = require('../lib/db');
const wa = require('../lib/whatsapp');
const { router, confirmBooking } = require('../routes/app');
let responses = []; // queued pool.query results (FIFO)
let calls = []; // every { sql, args }
let sentTexts = []; // every wa.sendText invocation
const origQuery = db.pool.query, origSend = wa.sendText;
let server, base;
before(async () => {
db.pool.query = async (sql, args) => { calls.push({ sql, args }); return responses.length ? responses.shift() : { rows: [], rowCount: 0 }; };
wa.sendText = async (...a) => { sentTexts.push(a); return { sandbox: true }; };
const app = express();
app.use(express.json());
app.use('/api/app', router);
await new Promise(r => { server = app.listen(0, r); });
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => { db.pool.query = origQuery; wa.sendText = origSend; server && server.close(); });
function reset(resp) { responses = resp.slice(); calls = []; sentTexts = []; }
const hasSql = (re) => calls.some(c => re.test(c.sql));
function post(path, body, token) {
const data = JSON.stringify(body);
return new Promise((resolve, reject) => {
const r = http.request(base + path, { method: 'POST', headers: {
'content-type': 'application/json', 'content-length': Buffer.byteLength(data),
...(token ? { authorization: 'Bearer ' + token } : {}) } },
res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(b || '{}') })); });
r.on('error', reject); r.end(data);
});
}
test('confirmBooking: first confirmation flips pending→confirmed and sends ONE WhatsApp', async () => {
reset([
{ rows: [{ id: 7, code: 'CR-ABC', currency: 'USD', total: 40000, traveler_id: 3, place_id: 5 }] }, // UPDATE ... RETURNING *
{ rows: [{ phone_e164: '+50688887777', wa_opt_in: true }] }, // SELECT user
{ rows: [{ name: 'Casa Vista' }] }, // SELECT place
]);
await confirmBooking(7);
assert.equal(sentTexts.length, 1, 'exactly one confirmation message');
assert.match(sentTexts[0][1], /CR-ABC/); // the booking code is in the message body
});
test('CONSENT: confirmBooking does NOT WhatsApp a traveler who has not opted in (wa_opt_in=false)', async () => {
reset([
{ rows: [{ id: 7, code: 'CR-ABC', currency: 'USD', total: 40000, traveler_id: 3, place_id: 5 }] }, // UPDATE
{ rows: [{ phone_e164: '+50688887777', wa_opt_in: false }] }, // user opted OUT
{ rows: [{ name: 'Casa Vista' }] },
]);
await confirmBooking(7);
assert.equal(sentTexts.length, 0, 'an opted-out traveler must not be messaged even though a phone number exists');
});
test('SECURITY/MONEY: confirmBooking is idempotent — a duplicate payment webhook does NOT re-confirm or re-notify', async () => {
reset([{ rows: [] }]); // UPDATE matched no row (already confirmed) → early return
await confirmBooking(7);
assert.equal(sentTexts.length, 0, 'no second confirmation notification — the guarded UPDATE matched no row so it early-returns');
});
test('MONEY: POST /bookings rejects overlapping dates with 409 and never INSERTs a booking', async () => {
const token = signToken({ sub: 3, role: 'guest' });
reset([
{ rows: [{ max_guests: 4, booking_type: 'nightly', min_nights: 1, base_price: 12000, cleaning_fee: 4000, platform_fee_bps: 1000, place_id: 5, host_id: 9, currency: 'USD', name: 'Casa' }] }, // place_booking
{ rows: [{ '?column?': 1 }] }, // overlap conflict found
]);
const r = await post('/api/app/bookings', { place_slug: 'casa', check_in: '2026-09-10', check_out: '2026-09-13', guests: 2 }, token);
assert.equal(r.status, 409);
assert.equal(hasSql(/INSERT INTO bookings/), false, 'a conflicting booking must never be inserted');
});
test('MONEY: POST /bookings happy path persists a split whose parts reconstruct the total', async () => {
const token = signToken({ sub: 3, role: 'guest' });
reset([
{ rows: [{ max_guests: 4, booking_type: 'nightly', min_nights: 1, base_price: 12000, cleaning_fee: 4000, platform_fee_bps: 1000, place_id: 5, host_id: 9, currency: 'USD', name: 'Casa' }] }, // place_booking
{ rows: [] }, // no overlap
{ rows: [{ id: 7, code: 'CR-XYZ', status: 'pending' }] }, // INSERT ... RETURNING *
]);
const r = await post('/api/app/bookings', { place_slug: 'casa', check_in: '2026-09-10', check_out: '2026-09-13', guests: 2 }, token);
assert.equal(r.status, 200);
assert.ok(hasSql(/INSERT INTO bookings/), 'a valid booking should be inserted');
const s = r.json.split;
assert.equal(s.total, 40000); // 12000*3 nights + 4000 cleaning
assert.equal(s.platformFee, 4000); // 10% of 40000
assert.equal(s.hostPayout + s.platformFee + s.processorFee, s.total); // conservation: no money vanishes (processorFee always present)
});
// The place_booking row every stay-mode validation test needs (queued as response #1).
const PB = () => ({ rows: [{ max_guests: 4, booking_type: 'nightly', min_nights: 1, base_price: 12000, cleaning_fee: 4000, platform_fee_bps: 1000, place_id: 5, host_id: 9, currency: 'USD', name: 'Casa' }] });
const SLOT_PB = () => ({ rows: [{ max_guests: 4, booking_type: 'tour', min_nights: 1, base_price: 5000, cleaning_fee: 0, platform_fee_bps: 1000, place_id: 5, host_id: 9, currency: 'USD', name: 'Tour' }] });
// P2 — C2 date validation: same-day and reversed ranges are a clean 4xx BEFORE the
// INSERT (route gate), never a bookings CHECK-violation 500.
test('P2: POST /bookings with check_in==check_out → 400 and NO INSERT', async () => {
const token = signToken({ sub: 3, role: 'guest' });
reset([PB()]); // only the place_booking SELECT should be consumed
const r = await post('/api/app/bookings', { place_slug: 'casa', check_in: '2026-09-10', check_out: '2026-09-10', guests: 2 }, token);
assert.ok(r.status === 400 || r.status === 409, `expected 4xx, got ${r.status}`);
assert.equal(hasSql(/INSERT INTO bookings/), false, 'a zero-night booking must never be inserted');
});
test('P2: POST /bookings with check_out < check_in → 400 and NO INSERT', async () => {
const token = signToken({ sub: 3, role: 'guest' });
reset([PB()]);
const r = await post('/api/app/bookings', { place_slug: 'casa', check_in: '2026-09-13', check_out: '2026-09-10', guests: 2 }, token);
assert.ok(r.status === 400 || r.status === 409, `expected 4xx, got ${r.status}`);
assert.equal(hasSql(/INSERT INTO bookings/), false, 'a reversed-date booking must never be inserted');
});
test('P2: POST /bookings with a non-ISO check_in → 400 and NO INSERT (no NaN money)', async () => {
const token = signToken({ sub: 3, role: 'guest' });
reset([PB()]);
const r = await post('/api/app/bookings', { place_slug: 'casa', check_in: 'not-a-date', check_out: '2026-09-13', guests: 2 }, token);
assert.equal(r.status, 400);
assert.equal(hasSql(/INSERT INTO bookings/), false, 'an unparseable date must never reach the INSERT');
});
// Money-math hardening (cycle 14): an unbounded date range overflows the INTEGER
// money columns (a 500) and lets a client squat a listing's availability for years.
test('MONEY: a stay longer than MAX_BOOKING_NIGHTS → 400 and NO INSERT (no overflow, no availability-squat)', async () => {
const token = signToken({ sub: 3, role: 'guest' });
reset([PB()]); // only the place_booking SELECT — the cap fires before the overlap query + INSERT
const r = await post('/api/app/bookings', { place_slug: 'casa', check_in: '2026-01-01', check_out: '2028-01-01', guests: 2 }, token); // ~731 nights
assert.equal(r.status, 400);
assert.match(r.json.error, /maximum stay/i);
assert.equal(hasSql(/INSERT INTO bookings/), false, 'an over-long stay must never be inserted');
assert.equal(hasSql(/SELECT 1 FROM bookings WHERE place_id/), false, 'the cap fires before the overlap query');
});
test('MONEY: a booking SUBTOTAL that would overflow int4 → 400 and NO INSERT (clean reject, not a DB 500)', async () => {
const token = signToken({ sub: 3, role: 'guest' });
// base_price $60k/night; ~364 nights (<= MAX 365) -> subtotal ~2.18e9 > int4 max.
const bigPB = () => ({ rows: [{ max_guests: 4, booking_type: 'nightly', min_nights: 1, base_price: 6000000, cleaning_fee: 0, platform_fee_bps: 1000, place_id: 5, host_id: 9, currency: 'USD', name: 'Casa' }] });
reset([bigPB(), { rows: [] } /* no overlap */]);
const r = await post('/api/app/bookings', { place_slug: 'casa', check_in: '2026-01-01', check_out: '2026-12-31', guests: 2 }, token); // 364 nights
assert.equal(r.status, 400);
assert.match(r.json.error, /exceeds the maximum/i);
assert.equal(hasSql(/INSERT INTO bookings/), false, 'an overflowing subtotal must never reach the INSERT (would be a Postgres int4 500)');
});
// NEGATIVE TEST (Cody gate, cycle 14 — TK-11431 doctrine): the overflow guard must
// go RED on the column that actually overflows FIRST. `fees = cleaningFee +
// platformFee` reaches ~2x total, so a huge host-set cleaning_fee overflows the
// `fees` int4 column at the DEFAULT 10% fee while `total` is still under int4 max —
// a total-only guard would let this INSERT 500. This proves the guard catches `fees`.
test('MONEY: a huge cleaning_fee overflows the FEES column (total still under int4) → 400 and NO INSERT', async () => {
const token = signToken({ sub: 3, role: 'guest' });
// cleaning_fee ~2e9, tiny subtotal -> total ~2e9 (under int4 max), but
// fees = cleaning_fee + platformFee(10%) ~2.2e9 > int4 max.
const feePB = () => ({ rows: [{ max_guests: 4, booking_type: 'nightly', min_nights: 1, base_price: 100, cleaning_fee: 1999999000, platform_fee_bps: 1000, place_id: 5, host_id: 9, currency: 'USD', name: 'Casa' }] });
reset([feePB(), { rows: [] } /* no overlap */]);
const r = await post('/api/app/bookings', { place_slug: 'casa', check_in: '2026-09-10', check_out: '2026-09-13', guests: 2 }, token); // 3 nights
assert.equal(r.status, 400, 'fees overflow is rejected even though total is under int4 max');
assert.match(r.json.error, /exceeds the maximum/i);
assert.equal(hasSql(/INSERT INTO bookings/), false, 'a fees-overflowing booking must never reach the INSERT');
});
// Config-trap guard (Cody gate, cycle 14): a min_nights above the MAX_BOOKING_NIGHTS
// cap makes a listing permanently unbookable (n>=min_nights AND n<=MAX is impossible).
// Reject it at listing-write time.
test('HOST: /host/listings rejects min_nights above the booking cap (400, no self-lockout listing created)', async () => {
const token = signToken({ sub: 3, role: 'guest' });
reset([
{ rows: [{ id: 9, user_id: 3, legal_name: 'H', country: 'CR' }] }, // requireHost
{ rows: [{ id: 5 }] }, // SELECT place by slug
]);
const r = await post('/api/app/host/listings', { place_slug: 'casa', base_price: 12000, min_nights: 400 }, token);
assert.equal(r.status, 400);
assert.match(r.json.error, /min_nights/i);
assert.equal(hasSql(/INSERT INTO place_booking/), false, 'an unbookable (min_nights>cap) listing must never be created');
});
// P5 — slot/tour pricing (base_price*guests) + the guests>max_guests gate (C1).
test('P5: slot/tour booking prices base_price*guests and inserts (happy path)', async () => {
const token = signToken({ sub: 3, role: 'guest' });
reset([
SLOT_PB(), // place_booking (tour)
{ rows: [{ id: 8, code: 'CR-TOUR', status: 'pending' }] }, // INSERT ... RETURNING *
]);
const r = await post('/api/app/bookings',
{ place_slug: 'tour', slot_start: '2026-09-10T09:00:00Z', slot_end: '2026-09-10T12:00:00Z', guests: 3 }, token);
assert.equal(r.status, 200);
assert.ok(hasSql(/INSERT INTO bookings/), 'a valid tour booking should be inserted');
assert.equal(r.json.split.subtotal, 15000, 'subtotal = base_price(5000) * guests(3)');
assert.equal(r.json.split.total, 15000);
});
test('P5: slot/tour booking with guests > max_guests → 400 and NO INSERT', async () => {
const token = signToken({ sub: 3, role: 'guest' });
reset([SLOT_PB()]); // only place_booking SELECT consumed — gate rejects before INSERT
const r = await post('/api/app/bookings',
{ place_slug: 'tour', slot_start: '2026-09-10T09:00:00Z', slot_end: '2026-09-10T12:00:00Z', guests: 99 }, token);
assert.equal(r.status, 400);
assert.match(r.json.error, /max 4 guests/);
assert.equal(hasSql(/INSERT INTO bookings/), false, 'an over-capacity booking must never be inserted');
});
test('P5/C1: guests=0 (or non-integer) → 400 and NO INSERT', async () => {
const token = signToken({ sub: 3, role: 'guest' });
reset([SLOT_PB()]);
const r = await post('/api/app/bookings',
{ place_slug: 'tour', slot_start: '2026-09-10T09:00:00Z', slot_end: '2026-09-10T12:00:00Z', guests: 0 }, token);
assert.equal(r.status, 400);
assert.match(r.json.error, /positive integer/);
assert.equal(hasSql(/INSERT INTO bookings/), false, 'guests<1 must never be inserted (no base_price*0 subtotal)');
});