openvibers/openvibe.coupons Commits

POST /merchants waits for its code sync to commit: store().tx(syncMerchant) was started and abandoned (no await, no catch), so the API answered 201 while the sync was in flight and a failure was swallowed, leaving the merchant active with its waiting codes unpublished. It is awaited, as the staff path already was; a failed sync answers 500 through run(). test/lifecycle.test.js: a rejecting syncMerchant answers 500 with no unhandled rejection (fails on the old code with 201). Found by the estate-wide floating-promise sweep; reviewed independently (VERDICT: SHIP). npm test 13/13 test files passed.

OP OpenVibers committed 29 Sept 2026 · fb617696c1 · 2 files, +18 −1


Changes

ddiff --git a/server/http/api.js b/server/http/api.js
iindex a5a699f..29a5354 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -194,7 +194,7 @@ function createApi(ctx) {
const body = req.body || {};
const status = body.status === 'pending' ? 'pending' : 'active';
const m = await merchants.create(body, { status, actor });
-- store().tx(async () => await coupons.syncMerchant(m));
++ await store().tx(async () => await coupons.syncMerchant(m));
return { merchant: { ...await merchantView(m), status: m.status } };
}, 201));
ddiff --git a/test/lifecycle.test.js b/test/lifecycle.test.js
iindex ca217f9..f77e1fd 100644
---- a/test/lifecycle.test.js
++++ b/test/lifecycle.test.js
@@@ -205,6 +205,23 @@ const DAY = 24 * HOUR;
assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=shop.new-gadgets.net')).status, 200);
});
++ await check('POST /merchants waits for syncMerchant: a failed sync reaches the caller as a 500, not a swallowed rejection', async () => {
++ const real = t.ctx.coupons.syncMerchant;
++ const swallowed = [];
++ const onUnhandled = (err) => { swallowed.push(err); };
++ process.on('unhandledRejection', onUnhandled);
++ t.ctx.coupons.syncMerchant = async () => { throw new Error('sync failed'); };
++ try {
++ const res = await t.get('/api/v1/merchants', { as: t.staffToken(), json: { name: 'Failed Sync Shop', domains: [{ host: 'failed-sync.com', include_subdomains: true, path_prefix: '' }] } });
++ assert.strictEqual(res.status, 500, `the sync failure must reach the caller: ${res.status} ${res.text}`);
++ assert.deepStrictEqual(swallowed.map((e) => e.message), [], 'the sync rejection must not be left unhandled');
++ } finally {
++ t.ctx.coupons.syncMerchant = real;
++ await new Promise((r) => setTimeout(r, 20));
++ process.removeListener('unhandledRejection', onUnhandled);
++ }
++ });
++
await check('duplicates add evidence to the same code; a taken-down code cannot be resubmitted', async () => {
const a = await t.submit(bob, { host: 'acme-outdoor.com', code: 'trail15', title: 'Same code, other case', evidence_url: 'https://forum.example-deals.org/t/1' });
assert.strictEqual(a.status, 200);