← back to Costa Rica

test/payments-race-index.test.js

64 lines

'use strict';
// Double-charge backstop (Cody cold audit, cycle 19, TK-10346). The partial unique
// index payments_one_inflight_per_booking (migrate_011) enforces at most ONE
// in-flight ('processing'/'requires_action') payment per booking, so two concurrent
// POST /bookings/:code/pay requests can't both insert a pre-charge row and both
// charge the card. This proves the DB-level guard directly (real dev DB), and that
// a legitimate retry after a 'failed' attempt is still allowed (that's the whole
// point of the PARTIAL predicate). Sentinel fixtures + FK-safe self-cleanup.
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-payrace-${Date.now()}`;
const INS = `INSERT INTO payments (booking_id, provider, method, currency, amount, status, live_mode)
             VALUES ($1,'tilopay','card','USD',12000,$2,false) RETURNING id`;

test('payments_one_inflight_per_booking: a 2nd concurrent in-flight payment is rejected (23505); a retry after failed is allowed', async () => {
  const created = { users: [], bookings: [], payments: [] };
  try {
    const { rows: [u] } = await pool.query(`INSERT INTO app_users (full_name) VALUES ($1) RETURNING id`, [`${SENT}-u`]);
    created.users.push(u.id);
    // Far-future, unique date window so this pending booking on place_id=1 can't
    // collide with other real-DB test files' place_id=1 bookings under the
    // bookings_no_overlap_stay EXCLUDE constraint (node --test runs files in parallel).
    const off = 6000 + (Date.now() % 1000);
    const { rows: [b] } = await pool.query(
      `INSERT INTO bookings (traveler_id, place_id, code, status, currency, subtotal, total, platform_fee, host_payout, check_in, check_out)
       VALUES ($1,1,$2,'pending','USD',10000,12000,1200,10800,CURRENT_DATE + $3::int, CURRENT_DATE + ($3::int + 1)) RETURNING id`,
      [u.id, `${SENT}-bk`, off]);
    created.bookings.push(b.id);

    // First in-flight payment -> OK.
    const { rows: [p1] } = await pool.query(INS, [b.id, 'processing']);
    created.payments.push(p1.id);

    // A second in-flight payment for the SAME booking (the race-loser) -> rejected.
    await assert.rejects(
      () => pool.query(INS, [b.id, 'processing']),
      (e) => { assert.equal(e.code, '23505', 'unique_violation'); return true; },
      'a 2nd in-flight payment for one booking must violate payments_one_inflight_per_booking',
    );

    // Terminal states don't count as in-flight, so a fresh attempt after a FAILED one is allowed.
    await pool.query(`UPDATE payments SET status='failed' WHERE id=$1`, [p1.id]);
    const { rows: [p2] } = await pool.query(INS, [b.id, 'processing']);
    created.payments.push(p2.id);
    assert.ok(p2.id, 'a retry after a failed attempt creates a fresh in-flight payment (partial index)');

    // And a 'succeeded' payment also doesn't block... actually it shouldn't happen
    // (booking would be confirmed), but prove the predicate: mark p2 succeeded, insert allowed.
    await pool.query(`UPDATE payments SET status='succeeded' WHERE id=$1`, [p2.id]);
    const { rows: [p3] } = await pool.query(INS, [b.id, 'processing']);
    created.payments.push(p3.id);
    assert.ok(p3.id, 'a succeeded payment is terminal, not in-flight');
  } finally {
    if (created.payments.length) await pool.query(`DELETE FROM payments WHERE id = ANY($1)`, [created.payments]);
    if (created.bookings.length) await pool.query(`DELETE FROM bookings WHERE id = ANY($1)`, [created.bookings]);
    if (created.users.length) await pool.query(`DELETE FROM app_users WHERE id = ANY($1)`, [created.users]);
  }
});