← back to Costa Rica

test/host-listing-race.test.js

91 lines

'use strict';
// Race/atomicity fix for /host/listings (Cody gate, cycle 9, TK-10346).
//
// The guard SELECT and the place_booking upsert in routes/app.js POST /host/listings
// are separate statements with no lock between them. The OLD unconditional
// `ON CONFLICT (place_id) DO UPDATE SET host_id=EXCLUDED.host_id` let a second host
// SILENTLY overwrite the first's ownership (last commit wins; loser gets a false
// 200). The fix adds `WHERE place_booking.host_id = EXCLUDED.host_id OR
// place_booking.host_id IS NULL` so the single statement is atomic: only the owner
// (or an unowned/NULL row) can win; a racing non-owner matches no row -> 409.
//
// This proves the WHERE semantics at the REAL DB layer (the fix's core), against
// the actual `place_booking` table. Sentinel fixtures + FK-safe self-cleanup, no
// live creds, no money. Mirrors the upsert in routes/app.js — keep in sync.
require('dotenv').config();
const { test, after } = require('node:test');
const assert = require('node:assert');
const { pool } = require('../lib/db');

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

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

// The exact upsert routes/app.js POST /host/listings runs (kept in sync with it).
const UPSERT = `
  INSERT INTO place_booking (place_id, host_id, booking_type, currency, base_price, cleaning_fee, max_guests, min_nights, instant_book, is_active)
  VALUES ($1,$2,'nightly','USD',12000,0,1,1,TRUE,TRUE)
  ON CONFLICT (place_id) DO UPDATE SET host_id=EXCLUDED.host_id, base_price=EXCLUDED.base_price, updated_at=NOW()
  WHERE place_booking.host_id = EXCLUDED.host_id OR place_booking.host_id IS NULL
  RETURNING host_id`;

test('place_booking upsert: a non-owner cannot silently overwrite the owner (atomic ownership)', async () => {
  const created = { placeBookings: [], places: [], hosts: [], users: [] };
  try {
    const mkUser = async (n) => (await pool.query(`INSERT INTO app_users (full_name) VALUES ($1) RETURNING id`, [`${SENT}-${n}`])).rows[0].id;
    const mkHost = async (uid) => (await pool.query(`INSERT INTO hosts (user_id, legal_name, country, kyc_status) VALUES ($1,$2,'CR','verified') RETURNING id`, [uid, `${SENT} host`])).rows[0].id;
    const uA = await mkUser('uA'); created.users.push(uA);
    const uB = await mkUser('uB'); created.users.push(uB);
    const hostA = await mkHost(uA); created.hosts.push(hostA);
    const hostB = await mkHost(uB); created.hosts.push(hostB);
    const { rows: [pl] } = await pool.query(
      `INSERT INTO places (slug, name, category, vertical) VALUES ($1,$2,'rentals','rentals') RETURNING id`,
      [`${SENT}-slug`, `${SENT} place`]);
    created.places.push(pl.id);

    // 1. Host A lists first -> wins, becomes owner.
    const first = await pool.query(UPSERT, [pl.id, hostA]);
    created.placeBookings.push(pl.id);
    assert.equal(first.rows.length, 1, 'first lister gets a row');
    assert.equal(String(first.rows[0].host_id), String(hostA), 'host A is the owner');

    // 2. Host B tries to list the SAME place -> the WHERE rejects the update:
    //    no row returned (would have been a SILENT overwrite + false 200 before the fix).
    const second = await pool.query(UPSERT, [pl.id, hostB]);
    assert.equal(second.rows.length, 0, 'a racing non-owner gets NO row back (route turns this into a 409)');

    // 3. Ownership is UNCHANGED — B did not overwrite A.
    const { rows: [pb] } = await pool.query(`SELECT host_id FROM place_booking WHERE place_id=$1`, [pl.id]);
    assert.equal(String(pb.host_id), String(hostA), 'owner is still host A — no silent overwrite');

    // 4. Host A (the owner) can re-list / update -> allowed.
    const relist = await pool.query(UPSERT, [pl.id, hostA]);
    assert.equal(relist.rows.length, 1, 'the owner can update its own listing');
  } finally {
    if (created.placeBookings.length) await pool.query(`DELETE FROM place_booking WHERE place_id = ANY($1)`, [created.placeBookings]);
    if (created.places.length) await pool.query(`DELETE FROM places WHERE id = ANY($1)`, [created.places]);
    if (created.hosts.length) await pool.query(`DELETE FROM hosts WHERE id = ANY($1)`, [created.hosts]);
    if (created.users.length) await pool.query(`DELETE FROM app_users WHERE id = ANY($1)`, [created.users]);
  }
});

test('place_booking upsert: an unowned (NULL host) place can be claimed (host-deleted re-list path preserved)', async () => {
  const created = { placeBookings: [], places: [], hosts: [], users: [] };
  try {
    const { rows: [u] } = await pool.query(`INSERT INTO app_users (full_name) VALUES ($1) RETURNING id`, [`${SENT}-uN`]); created.users.push(u.id);
    const { rows: [h] } = await pool.query(`INSERT INTO hosts (user_id, legal_name, country) VALUES ($1,$2,'CR') RETURNING id`, [u.id, `${SENT} host`]); created.hosts.push(h.id);
    const { rows: [pl] } = await pool.query(`INSERT INTO places (slug, name, category, vertical) VALUES ($1,$2,'rentals','rentals') RETURNING id`, [`${SENT}-slugN`, `${SENT} placeN`]); created.places.push(pl.id);
    // Seed a place_booking whose host was deleted (host_id NULL).
    await pool.query(`INSERT INTO place_booking (place_id, host_id, base_price) VALUES ($1, NULL, 5000)`, [pl.id]); created.placeBookings.push(pl.id);

    const claim = await pool.query(UPSERT, [pl.id, h.id]);
    assert.equal(claim.rows.length, 1, 'an unowned (NULL) place can be claimed');
    assert.equal(String(claim.rows[0].host_id), String(h.id), 'the claimer becomes the owner');
  } finally {
    if (created.placeBookings.length) await pool.query(`DELETE FROM place_booking WHERE place_id = ANY($1)`, [created.placeBookings]);
    if (created.places.length) await pool.query(`DELETE FROM places WHERE id = ANY($1)`, [created.places]);
    if (created.hosts.length) await pool.query(`DELETE FROM hosts WHERE id = ANY($1)`, [created.hosts]);
    if (created.users.length) await pool.query(`DELETE FROM app_users WHERE id = ANY($1)`, [created.users]);
  }
});