← back to Costa Rica
test/plaid-routes.test.js
117 lines
'use strict';
// The Plaid routes in routes/app.js now catch a live Plaid throw and return a clean
// gateway error (502, or 504 on PROVIDER_TIMEOUT) — instead of an UNCAUGHT async
// rejection. This router has no error middleware (Express 4), so an uncaught throw
// would hang the request and crash the process on Node's unhandledRejection. These
// tests prove the route converts the throw to a response.
// (The systemic 23-route uncaught-async gap is tracked separately — see YOLO_NOTES.)
//
// No real DB/network: pool.query is a queue mock, plaid methods are stubbed on the
// shared module (no require-cache reset, so the app router's plaid ref stays valid).
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 plaid = require('../lib/plaid');
const { router } = require('../routes/app');
let responses = [];
let calls = [];
const origQuery = db.pool.query;
const origLink = plaid.createLinkToken;
const origExchange = plaid.exchangePublicToken;
const origGetAuth = plaid.getAuth;
let server, base;
before(async () => {
db.pool.query = async (sql, args) => { calls.push({ sql, args }); if (responses.length) { const r = responses.shift(); if (r instanceof Error) throw r; return r; } return { 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; plaid.createLinkToken = origLink; plaid.exchangePublicToken = origExchange; plaid.getAuth = origGetAuth; server && server.close(); });
const hasSql = (re) => calls.some(c => re.test(c.sql));
const findCall = (re) => calls.find(c => re.test(c.sql));
function post(path, body, token) {
const data = JSON.stringify(body);
return new Promise((resolve, reject) => {
const r = http.request(base + path, { method: 'POST', headers: {
'content-type': 'application/json', 'content-length': Buffer.byteLength(data),
...(token ? { authorization: 'Bearer ' + token } : {}) } },
res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(b || '{}') })); });
r.on('error', reject); r.end(data);
});
}
const HOST_ROW = { rows: [{ id: 9, user_id: 3, legal_name: 'H', country: 'CR' }] }; // requireHost
test('POST /host/plaid/link-token: a Plaid ERROR becomes a 502 (not an uncaught crash/hang)', async () => {
const token = signToken({ sub: 3, role: 'guest' });
responses = [HOST_ROW]; calls = [];
plaid.createLinkToken = async () => { throw new Error('plaid /link/token/create HTTP 400: INVALID_API_KEYS'); };
const r = await post('/api/app/host/plaid/link-token', {}, token);
assert.equal(r.status, 502, 'a Plaid error returns a clean 502');
assert.equal(r.json.ok, false);
assert.equal(/INVALID_API_KEYS|plaid\.com|link\/token/.test(JSON.stringify(r.json)), false, 'no internal Plaid detail leaked to the client');
});
test('POST /host/plaid/link-token: a Plaid TIMEOUT becomes a 504', async () => {
const token = signToken({ sub: 3, role: 'guest' });
responses = [HOST_ROW]; calls = [];
plaid.createLinkToken = async () => { const e = new Error('provider fetch timeout after 15000ms'); e.code = 'PROVIDER_TIMEOUT'; throw e; };
const r = await post('/api/app/host/plaid/link-token', {}, token);
assert.equal(r.status, 504, 'a Plaid timeout returns 504 Gateway Timeout');
});
test('POST /host/plaid/exchange: a Plaid ERROR on exchange becomes a 502 (before any DB write)', async () => {
const token = signToken({ sub: 3, role: 'guest' });
responses = [HOST_ROW]; calls = [];
plaid.exchangePublicToken = async () => { throw new Error('plaid /item/public_token/exchange HTTP 400: INVALID_PUBLIC_TOKEN'); };
const r = await post('/api/app/host/plaid/exchange', { public_token: 'public-bad' }, token);
assert.equal(r.status, 502, 'a failed exchange returns 502, not an uncaught throw');
assert.equal(hasSql(/INSERT INTO payout_methods/), false, 'no payout method persisted when exchange fails');
});
test('POST /host/plaid/exchange: getAuth failure -> payout method saved verified=FALSE, NOT set as default (no false-verified bank)', async () => {
const token = signToken({ sub: 3, role: 'guest' });
responses = [HOST_ROW, { rows: [{ id: 77 }] }]; calls = []; // requireHost, then the INSERT
plaid.exchangePublicToken = async () => ({ access_token: 'access-x', item_id: 'item-x' });
plaid.getAuth = async () => { throw new Error('plaid /auth/get HTTP 500'); }; // account details unavailable
const r = await post('/api/app/host/plaid/exchange', { public_token: 'public-ok' }, token);
assert.equal(r.status, 200, 'the linked item is still recorded');
assert.equal(r.json.verified, false, 'a method with no account details is NOT verified');
const ins = findCall(/INSERT INTO payout_methods/);
assert.ok(ins, 'the payout method was inserted');
assert.equal(ins.args[5], false, 'verified param is false (VALUES ...$6,$6 -> verified + is_default both false)');
assert.equal(hasSql(/UPDATE hosts SET default_payout_method_id/), false, 'an unverified method is NEVER made the host default');
});
test('POST /host/plaid/exchange: getAuth success -> verified=TRUE and set as host default', async () => {
const token = signToken({ sub: 3, role: 'guest' });
responses = [HOST_ROW, { rows: [{ id: 88 }] }, { rowCount: 1 }]; calls = []; // requireHost, INSERT, UPDATE hosts
plaid.exchangePublicToken = async () => ({ access_token: 'access-y', item_id: 'item-y' });
plaid.getAuth = async () => ({ accounts: [{ account_id: 'acc_real', mask: '6789' }] });
const r = await post('/api/app/host/plaid/exchange', { public_token: 'public-ok' }, token);
assert.equal(r.status, 200);
assert.equal(r.json.verified, true);
const ins = findCall(/INSERT INTO payout_methods/);
assert.equal(ins.args[5], true, 'verified param is true');
assert.ok(hasSql(/UPDATE hosts SET default_payout_method_id/), 'a verified method becomes the host default');
});
test('POST /host/plaid/exchange: a DB throw on the INSERT becomes a 502 (not an uncaught process crash)', async () => {
const token = signToken({ sub: 3, role: 'guest' });
responses = [HOST_ROW, new Error('db connection reset')]; calls = []; // requireHost ok, INSERT throws
plaid.exchangePublicToken = async () => ({ access_token: 'access-z', item_id: 'item-z' });
plaid.getAuth = async () => ({ accounts: [{ account_id: 'acc_z', mask: '0000' }] });
const r = await post('/api/app/host/plaid/exchange', { public_token: 'public-ok' }, token);
assert.equal(r.status, 502, 'a DB failure while persisting is caught -> 502, never an unhandled rejection');
});