← back to Costa Rica

test/double-book.test.js

81 lines

'use strict';
// P1 — DB-level double-book EXCLUDE constraint (migration 007) against the REAL
// local dev DB. This is the race-proof backstop BEHIND the app-level overlap guard:
// even if two requests slip past the app SELECT, the btree_gist EXCLUDE constraint
// makes the second INSERT fail atomically.
//
// Proves:
//   1. an overlapping CONFIRMED stay is REJECTED (exclusion_violation, code 23P01),
//   2. an ADJACENT stay on the '[)' boundary SUCCEEDS (check_out==next check_in),
//   3. an overlapping CANCELLED stay is ALLOWED (constraint WHERE status IN
//      ('confirmed','pending') — cancelled rows are outside the partial index).
//
// Isolation: sentinel-coded rows, inserted committed, DELETEd in a guaranteed
// finally (same rail as test/payouts.test.js). Not SIGINT-proof (harmless YOLOTEST-*
// leftovers, dedupable). Run: node --test
require('dotenv').config(); // DATABASE_URL before lib/db builds the pool
const { test, after } = require('node:test');
const assert = require('node:assert');
const { pool } = require('../lib/db');

after(() => pool.end());

const SENT = `YOLOTEST-DBK-${Date.now()}`;

// A raw bookings INSERT that satisfies every migration-008 CHECK (money reconciles:
// total = platform_fee + host_payout; has-a-date; stay-order). Uses a dedicated
// throwaway place so seed/demo bookings on place 1 can't collide.
async function insertStay(placeId, travelerId, checkIn, checkOut, status) {
  const { rows: [b] } = await pool.query(
    `INSERT INTO bookings (code, place_id, traveler_id, check_in, check_out, currency,
        subtotal, total, platform_fee, host_payout, guests, status)
     VALUES ($1, $2, $3, $4, $5, 'USD', 30000, 30000, 3000, 27000, 1, $6) RETURNING id`,
    [`${SENT}-${Math.random().toString(36).slice(2, 8)}`, placeId, travelerId, checkIn, checkOut, status]);
  return b.id;
}

test('P1 — DB EXCLUDE constraint enforces no-double-book (real DB, self-cleaning)', async (t) => {
  const created = { users: [], bookings: [], places: [] };
  const track = (bucket, id) => { created[bucket].push(id); return id; };
  try {
    const { rows: [u] } = await pool.query(
      `INSERT INTO app_users (full_name) VALUES ($1) RETURNING id`, [`${SENT}-traveler`]);
    const traveler = track('users', u.id);

    // Dedicated throwaway place — isolates the test from seeded/demo bookings.
    const slug = `${SENT.toLowerCase()}-place-${Math.random().toString(36).slice(2, 8)}`;
    const { rows: [pl] } = await pool.query(
      `INSERT INTO places (slug, name, category, vertical) VALUES ($1, $2, 'rentals', 'rentals_short') RETURNING id`,
      [slug, `${SENT} Place`]);
    const place = track('places', pl.id);

    // Baseline confirmed booking [2026-09-10, 2026-09-13).
    track('bookings', await insertStay(place, traveler, '2026-09-10', '2026-09-13', 'confirmed'));

    await t.test('an overlapping confirmed stay is REJECTED by the exclusion constraint', async () => {
      await assert.rejects(
        () => insertStay(place, traveler, '2026-09-12', '2026-09-15', 'confirmed'),
        (e) => /exclusion|no_overlap|conflicting key|23P01/i.test(String(e && (e.message + ' ' + e.code))),
        'an overlapping confirmed booking must violate bookings_no_overlap_stay');
    });

    await t.test("an ADJACENT stay on the '[)' boundary SUCCEEDS (check_out==next check_in)", async () => {
      const id = await insertStay(place, traveler, '2026-09-13', '2026-09-15', 'confirmed');
      track('bookings', id);
      assert.ok(id, 'adjacent [2026-09-13,2026-09-15) does not overlap [2026-09-10,2026-09-13)');
    });

    await t.test('an overlapping CANCELLED stay is ALLOWED (outside the partial WHERE)', async () => {
      const id = await insertStay(place, traveler, '2026-09-11', '2026-09-14', 'cancelled');
      track('bookings', id);
      assert.ok(id, 'a cancelled overlapping booking is permitted — the constraint only guards confirmed/pending');
    });
  } finally {
    const del = async (sql, ids) => { if (ids.length) await pool.query(sql, [ids]); };
    await del(`DELETE FROM bookings WHERE place_id = ANY($1)`, created.places); // any untracked rows too
    await del(`DELETE FROM bookings WHERE id = ANY($1)`, created.bookings);
    await del(`DELETE FROM places WHERE id = ANY($1)`, created.places);
    await del(`DELETE FROM app_users WHERE id = ANY($1)`, created.users);
  }
});