← back to Costa Rica
test/payment-poll-rescue.test.js
89 lines
'use strict';
// Cody gate, cycle 12 (TK-10346): the webhook idempotency-release fix introduced a
// new valid state — payments.status='succeeded' while its booking is still 'pending'
// (a webhook marked the payment succeeded, then confirmBooking failed, released its
// marker, and 500'd for a provider retry). GET /payments/:id used to only reconcile
// when status==='processing', so it would return 'succeeded' to the client while the
// booking sat pending, invisible to the poll. The fix calls confirmBooking whenever
// the payment is 'succeeded' (idempotent). These tests prove the poll now rescues
// that state, and does nothing harmful when the booking is already confirmed.
// No real DB: pool.query is a queue mock.
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 { router } = require('../routes/app');
let responses = [];
let calls = [];
const origQuery = db.pool.query;
let server, base;
before(async () => {
db.pool.query = async (sql, args) => { calls.push({ sql, args }); return responses.length ? responses.shift() : { rows: [], rowCount: 0 }; };
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; server && server.close(); });
const hasSql = (re) => calls.some(c => re.test(c.sql));
function reset(resp) { responses = resp.slice(); calls = []; }
function get(path, token) {
return new Promise((resolve, reject) => {
http.get(base + path, { headers: token ? { authorization: 'Bearer ' + token } : {} },
res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(b || '{}') })); }).on('error', reject);
});
}
test('GET /payments/:id: a SUCCEEDED payment with a still-PENDING booking is rescued — confirmBooking fires from the poll', async () => {
const token = signToken({ sub: 3, role: 'guest' });
reset([
// SELECT the payment (already succeeded — e.g. a webhook set it, then confirmBooking failed)
{ rows: [{ id: 55, status: 'succeeded', booking_id: 7, provider: 'tilopay', provider_ref: 'ref-1', amount: 12000, currency: 'USD' }] },
// confirmBooking: UPDATE bookings ... WHERE status='pending' RETURNING * -> a pending row is found + confirmed
{ rows: [{ id: 7, code: 'CR-P', currency: 'USD', total: 12000, traveler_id: 3, place_id: 5 }] },
{ rows: [{ phone_e164: '+50600000000', wa_opt_in: false }] }, // confirmBooking loads user (opted out -> no WA)
{ rows: [{ name: 'Casa' }] }, // confirmBooking loads place
]);
const r = await get('/api/app/payments/55', token);
assert.equal(r.status, 200);
assert.equal(r.json.payment.status, 'succeeded');
assert.ok(hasSql(/UPDATE bookings SET status='confirmed'/), 'the poll rescues a succeeded-but-pending booking via confirmBooking');
});
test('GET /payments/:id: a succeeded payment whose booking is ALREADY confirmed does nothing harmful (idempotent)', async () => {
const token = signToken({ sub: 3, role: 'guest' });
reset([
{ rows: [{ id: 56, status: 'succeeded', booking_id: 8, provider: 'tilopay', provider_ref: 'ref-2', amount: 12000, currency: 'USD' }] },
{ rows: [] }, // confirmBooking UPDATE matched no pending row (already confirmed) -> early return, no WA
]);
const r = await get('/api/app/payments/56', token);
assert.equal(r.status, 200);
assert.equal(r.json.payment.status, 'succeeded');
assert.ok(hasSql(/UPDATE bookings SET status='confirmed'/), 'confirmBooking is attempted (idempotent guard makes it a no-op)');
assert.equal(hasSql(/SELECT phone_e164/), false, 'no WhatsApp lookup when the guarded UPDATE matched nothing (no double-notify)');
});
test('GET /payments/:id: a still-PROCESSING payment polls the provider (unchanged happy path)', async () => {
const token = signToken({ sub: 3, role: 'guest' });
reset([
{ rows: [{ id: 57, status: 'processing', booking_id: 9, provider: 'tilopay', provider_ref: 'ref_sbx_x', amount: 12000, currency: 'USD' }] },
// getCharge is the sandbox provider (real), returns succeeded for an _sbx_ ref -> UPDATE payments
{ rowCount: 1 }, // UPDATE payments SET status
{ rows: [{ id: 9, code: 'CR-Q', currency: 'USD', total: 12000, traveler_id: 3, place_id: 5 }] }, // confirmBooking UPDATE
{ rows: [{ phone_e164: '+50600000000', wa_opt_in: false }] },
{ rows: [{ name: 'Casa' }] },
]);
const r = await get('/api/app/payments/57', token);
assert.equal(r.status, 200);
assert.equal(r.json.payment.status, 'succeeded', 'the poll moved processing -> succeeded');
});