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