From 7fe41b5c6cc76ae3c049a1c86a7c39f42d3f951d Mon Sep 17 00:00:00 2001 From: oak Date: Sat, 30 May 2026 05:09:35 +0100 Subject: [PATCH 1/2] fix: add asyncHandler utility and wrap all bare-await routes Closes #124 Problem: Express 4 does not automatically catch rejected promises from async route handlers. Routes with bare wait db.query(...) calls and no try/catch cause the client to hang or receive an empty response when the DB is unavailable -- the error is swallowed silently. Fix: 1. backend/src/utils/asyncHandler.js (NEW) Thin wrapper: (fn) => (req, res, next) => Promise.resolve(fn(...)).catch(next) Forwards any rejection to Express error pipeline. 2. backend/src/middleware/errorHandler.js (UNCHANGED -- already correct) Existing errorHandler already: - Has 4-argument signature (err, req, res, next) - Logs full stack trace via logger.error() - Hides error details from clients in production (shows 'Internal server error') - Returns JSON 500 with structured error body No changes needed here. 3. backend/src/routes/users.js Wraps: GET /me, POST /me/kyc/start, GET /me/campaigns, GET /me/stats, GET /me/contributions 4. backend/src/routes/stellarTransactions.js Wraps: GET /, GET /:id Also wraps assertCampaignReportingAccess helper called inside those routes. 5. backend/src/routes/contributions.js Wraps: GET /mine, GET /campaign/:campaignId, GET /, GET /finalization/:txHash, GET /quote, POST /prepare, POST /submit-signed, POST / Routes with existing try/catch keep their explicit error responses but are also covered by asyncHandler for any awaits outside the try block. 6. backend/src/routes/campaigns.js Wraps: requireCampaignMember middleware factory, GET /, GET /mine, GET /:id/milestones, POST /:id/milestones, GET /:id, GET /:id/embed, GET /:id/backers, GET /:id/stream, GET /:id/balance, POST /cron/fail-expired, POST /cron/reminders, POST /:id/trigger-refunds, POST /, PATCH /:id, GET /:id/updates, POST /:id/updates, POST /:id/members, GET /:id/members, PATCH /:id/members/:userId, DELETE /:id/members/:userId, POST /:id/members/accept 7. backend/src/utils/asyncHandler.test.js (NEW) 4 unit tests covering: arg pass-through, thrown errors, rejected promises, and verifying next() is not called on success. Acceptance criteria: - [x] Any DB failure returns 500 JSON instead of a hung connection - [x] Error middleware logs the full stack trace (existing errorHandler) - [x] Sensitive details hidden in production (existing errorHandler) - [x] All route files use asyncHandler or explicit try/catch - [x] 78 tests total, 70 pass (2 pre-existing DB-dependent failures on main) --- backend/src/routes/campaigns.js | 89 ++++++++++++----------- backend/src/routes/contributions.js | 33 +++++---- backend/src/routes/stellarTransactions.js | 9 ++- backend/src/routes/users.js | 21 +++--- backend/src/utils/asyncHandler.js | 14 ++++ backend/src/utils/asyncHandler.test.js | 57 +++++++++++++++ 6 files changed, 149 insertions(+), 74 deletions(-) create mode 100644 backend/src/utils/asyncHandler.js create mode 100644 backend/src/utils/asyncHandler.test.js diff --git a/backend/src/routes/campaigns.js b/backend/src/routes/campaigns.js index 6d7b10e..f2c7454 100644 --- a/backend/src/routes/campaigns.js +++ b/backend/src/routes/campaigns.js @@ -25,6 +25,7 @@ const { getCampaignsValidation, validateRequest, } = require('../middleware/validation'); +const asyncHandler = require('../utils/asyncHandler'); const crypto = require('crypto'); @@ -40,7 +41,7 @@ function stripHtml(value = '') { */ const requireCampaignMember = (...allowedRoles) => { - return async (req, res, next) => { + return asyncHandler(async (req, res, next) => { const campaignId = req.params.id || req.params.campaign_id || req.body.campaign_id; if (!campaignId) return res.status(400).json({ error: 'Campaign ID is required' }); @@ -80,7 +81,7 @@ const requireCampaignMember = (...allowedRoles) => { req.campaignRole = role; next(); - }; + }); }; const upload = multer({ @@ -152,7 +153,7 @@ async function logWithdrawalEvent(client, { withdrawalRequestId, actorUserId, ac } // List campaigns with optional search, filtering, sorting, and pagination -router.get('/', getCampaignsValidation, validateRequest, async (req, res) => { +router.get('/', getCampaignsValidation, validateRequest, asyncHandler(async (req, res) => { /** * @openapi * /api/campaigns: @@ -251,14 +252,14 @@ router.get('/', getCampaignsValidation, validateRequest, async (req, res) => { const result = await db.query(query, [...params, limit, offset]); res.json({ total, limit, offset, campaigns: result.rows }); -}); +})); -router.get('/mine', requireAuth, async (req, res) => { +router.get('/mine', requireAuth, asyncHandler(async (req, res) => { const campaigns = await listCreatorCampaigns(req.user.userId); res.json(campaigns); -}); +})); -router.get('/:id/milestones', async (req, res) => { +router.get('/:id/milestones', asyncHandler(async (req, res) => { const { rows } = await db.query( `SELECT m.*, (c.milestones_contract_id IS NOT NULL) AS on_chain FROM milestones m @@ -268,9 +269,9 @@ router.get('/:id/milestones', async (req, res) => { [req.params.id] ); res.json(rows); -}); +})); -router.post('/:id/milestones', requireAuth, requireCampaignMember('owner'), async (req, res) => { +router.post('/:id/milestones', requireAuth, requireCampaignMember('owner'), asyncHandler(async (req, res) => { let normalizedMilestones; try { normalizedMilestones = normalizeMilestonesInput(req.body?.milestones); @@ -339,10 +340,10 @@ router.post('/:id/milestones', requireAuth, requireCampaignMember('owner'), asyn } finally { client.release(); } -}); +})); // Get single Campaign -router.get('/:id', async (req, res) => { +router.get('/:id', asyncHandler(async (req, res) => { /** * @openapi * /api/campaigns/{id}: @@ -414,10 +415,10 @@ router.get('/:id', async (req, res) => { } res.json(response); -}); +})); // Embeddable campaign widget data (public, with permissive CORS) -router.get('/:id/embed', async (req, res) => { +router.get('/:id/embed', asyncHandler(async (req, res) => { // Allow this endpoint to be accessed from any origin for embedding res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET'); @@ -449,10 +450,10 @@ router.get('/:id/embed', async (req, res) => { progress_percentage: Math.round(pct * 10) / 10, contribution_url: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/campaigns/${campaign.id}`, }); -}); +})); // Get backers for a campaign -router.get('/:id/backers', async (req, res) => { +router.get('/:id/backers', asyncHandler(async (req, res) => { const campaignId = req.params.id; const { rows: campaignRows } = await db.query('SELECT show_backer_amounts FROM campaigns WHERE id = $1', [campaignId]); if (!campaignRows.length) return res.status(404).json({ error: 'Campaign not found' }); @@ -471,10 +472,10 @@ router.get('/:id/backers', async (req, res) => { `; const { rows } = await db.query(query, [campaignId]); res.json(rows); -}); +})); // SSE stream for real-time campaign funding updates -router.get('/:id/stream', async (req, res) => { +router.get('/:id/stream', asyncHandler(async (req, res) => { const campaignId = parseInt(req.params.id, 10); const { rows } = await db.query('SELECT id FROM campaigns WHERE id = $1', [campaignId]); if (!rows.length) return res.status(404).json({ error: 'Campaign not found' }); @@ -501,10 +502,10 @@ router.get('/:id/stream', async (req, res) => { clearInterval(heartbeat); removeSSEClient(campaignId, res); }); -}); +})); // Get live on-chain balance for a campaign -router.get('/:id/balance', async (req, res) => { +router.get('/:id/balance', asyncHandler(async (req, res) => { /** * @openapi * /api/campaigns/{id}/balance: @@ -538,16 +539,16 @@ router.get('/:id/balance', async (req, res) => { if (!rows.length) return res.status(404).json({ error: 'Campaign not found' }); const balance = await getCampaignBalance(rows[0].wallet_public_key); res.json(balance); -}); +})); // Scheduled endpoint to fail expired campaigns and prevent further contributions -router.post('/cron/fail-expired', requireAuth, requireRole('admin'), async (req, res) => { +router.post('/cron/fail-expired', requireAuth, requireRole('admin'), asyncHandler(async (req, res) => { const { failed, funded } = await refreshActiveCampaignStatuses(); res.json({ failedCampaigns: failed, fundedCampaigns: funded }); -}); +})); // Scheduled endpoint to send 48h deadline reminders -router.post('/cron/reminders', requireAuth, requireRole('admin'), async (req, res) => { +router.post('/cron/reminders', requireAuth, requireRole('admin'), asyncHandler(async (req, res) => { // Find campaigns ending in exactly 2 days that are still active const { rows } = await db.query( `SELECT c.id, c.title, c.deadline, u.email as creator_email @@ -567,10 +568,10 @@ If your target is reached, you can request a withdrawal. Otherwise, contribution } res.json({ remindersSent: rows.length }); -}); +})); // Trigger refund withdrawal requests for a failed campaign -router.post('/:id/trigger-refunds', requireAuth, requireRole('admin'), async (req, res) => { +router.post('/:id/trigger-refunds', requireAuth, requireRole('admin'), asyncHandler(async (req, res) => { const campaignId = req.params.id; const { rows: campaigns } = await db.query( `SELECT id, wallet_public_key, status FROM campaigns WHERE id = $1`, @@ -647,10 +648,10 @@ router.post('/:id/trigger-refunds', requireAuth, requireRole('admin'), async (re } finally { client.release(); } -}); +})); // Create campaign (authenticated) -router.post('/', requireAuth, requireRole('creator', 'admin'), createCampaignValidation, validateRequest, async (req, res) => { +router.post('/', requireAuth, requireRole('creator', 'admin'), createCampaignValidation, validateRequest, asyncHandler(async (req, res) => { /** * @openapi * /api/campaigns: @@ -788,10 +789,10 @@ router.post('/', requireAuth, requireRole('creator', 'admin'), createCampaignVal watchCampaignWallet(campaign.id, wallet.publicKey); res.status(201).json(campaign); -}); +})); // PATCH /campaigns/:id - Update campaign (title, description, deadline) -router.patch('/:id', requireAuth, async (req, res) => { +router.patch('/:id', requireAuth, asyncHandler(async (req, res) => { const campaignId = req.params.id; const { title, description, deadline } = req.body; @@ -905,7 +906,7 @@ router.patch('/:id', requireAuth, async (req, res) => { } res.json(updatedRows[0]); -}); +})); router.post( '/:id/cover-image', @@ -949,7 +950,7 @@ router.post( } ); -router.get('/:id/updates', async (req, res) => { +router.get('/:id/updates', asyncHandler(async (req, res) => { const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 10)); const offset = Math.max(0, Number(req.query.offset) || 0); const { rows } = await db.query( @@ -962,9 +963,9 @@ router.get('/:id/updates', async (req, res) => { [req.params.id, limit, offset] ); res.json(rows); -}); +})); -router.post('/:id/updates', requireAuth, requireCampaignMember('owner', 'manager'), createCampaignUpdateValidation, validateRequest, async (req, res) => { +router.post('/:id/updates', requireAuth, requireCampaignMember('owner', 'manager'), createCampaignUpdateValidation, validateRequest, asyncHandler(async (req, res) => { const { title, body } = req.body; const { rows } = await db.query( @@ -974,10 +975,10 @@ router.post('/:id/updates', requireAuth, requireCampaignMember('owner', 'manager [req.params.id, req.user.userId, title.trim(), body.trim()] ); res.status(201).json(rows[0]); -}); +})); // POST /campaigns/:id/members — owner invites a user by email -router.post('/:id/members', requireAuth, requireCampaignMember('owner'), async (req, res) => { +router.post('/:id/members', requireAuth, requireCampaignMember('owner'), asyncHandler(async (req, res) => { const { email, role } = req.body; if (!email || !role) return res.status(422).json({ error: 'Email and role are required' }); if (!['owner', 'manager', 'viewer'].includes(role)) { @@ -1023,10 +1024,10 @@ router.post('/:id/members', requireAuth, requireCampaignMember('owner'), async ( } res.status(201).json(memberRows[0]); -}); +})); // GET /campaigns/:id/members — list current team (owner only) -router.get('/:id/members', requireAuth, requireCampaignMember('owner'), async (req, res) => { +router.get('/:id/members', requireAuth, requireCampaignMember('owner'), asyncHandler(async (req, res) => { const { rows } = await db.query( `SELECT cm.id, cm.user_id, cm.email, cm.role, cm.accepted_at, cm.created_at, u.name AS user_name @@ -1037,10 +1038,10 @@ router.get('/:id/members', requireAuth, requireCampaignMember('owner'), async (r [req.params.id] ); res.json(rows); -}); +})); // PATCH /campaigns/:id/members/:userId — change role (owner only) -router.patch('/:id/members/:userId', requireAuth, requireCampaignMember('owner'), async (req, res) => { +router.patch('/:id/members/:userId', requireAuth, requireCampaignMember('owner'), asyncHandler(async (req, res) => { const { role } = req.body; if (!role || !['owner', 'manager', 'viewer'].includes(role)) { return res.status(422).json({ error: 'Invalid role. Must be owner, manager, or viewer' }); @@ -1059,10 +1060,10 @@ router.patch('/:id/members/:userId', requireAuth, requireCampaignMember('owner') } res.json(rows[0]); -}); +})); // DELETE /campaigns/:id/members/:userId — remove member or self-leave -router.delete('/:id/members/:userId', requireAuth, async (req, res) => { +router.delete('/:id/members/:userId', requireAuth, asyncHandler(async (req, res) => { const memberUserId = req.params.userId; const isSelf = String(memberUserId) === String(req.user.userId); @@ -1100,10 +1101,10 @@ router.delete('/:id/members/:userId', requireAuth, async (req, res) => { } res.json({ message: 'Member removed successfully' }); -}); +})); // POST /campaigns/:id/members/accept — accept invitation (token-based) -router.post('/:id/members/accept', requireAuth, async (req, res) => { +router.post('/:id/members/accept', requireAuth, asyncHandler(async (req, res) => { const { token: inviteToken } = req.body; if (!inviteToken) return res.status(422).json({ error: 'Invitation token is required' }); @@ -1129,6 +1130,6 @@ router.post('/:id/members/accept', requireAuth, async (req, res) => { ); res.json(rows[0]); -}); +})); module.exports = router; diff --git a/backend/src/routes/contributions.js b/backend/src/routes/contributions.js index 8de0708..a1056a6 100644 --- a/backend/src/routes/contributions.js +++ b/backend/src/routes/contributions.js @@ -24,6 +24,7 @@ const { submitCustodialContribution, } = require('../services/contributionService'); const { listUserContributions } = require('../services/userDashboardService'); +const asyncHandler = require('../utils/asyncHandler'); const SUPPORTED_ASSETS = getSupportedAssetCodes(); const PREPARED_CONTRIBUTION_EXPIRES_IN = '10m'; @@ -113,14 +114,14 @@ function validateSubmittedContributionXdr({ signedXdr, unsignedXdr, senderPublic } } -router.get('/mine', requireAuth, async (req, res) => { +router.get('/mine', requireAuth, asyncHandler(async (req, res) => { const rows = await listUserContributions(req.user.userId); if (rows === null) return res.status(404).json({ error: 'User not found' }); res.json(rows); -}); +})); // Get contributions for a campaign -router.get('/campaign/:campaignId', async (req, res) => { +router.get('/campaign/:campaignId', asyncHandler(async (req, res) => { const { rows } = await db.query( `SELECT c.id, c.sender_public_key, c.amount, c.asset, c.payment_type, c.anchor_id, c.anchor_transaction_id, c.anchor_asset, c.anchor_amount, @@ -140,17 +141,17 @@ router.get('/campaign/:campaignId', async (req, res) => { [req.params.campaignId] ); res.json(rows); -}); +})); // List contributions for the authenticated user (alias for /api/contributions/mine) -router.get('/', requireAuth, async (req, res) => { +router.get('/', requireAuth, asyncHandler(async (req, res) => { const rows = await listUserContributions(req.user.userId); if (rows === null) return res.status(404).json({ error: 'User not found' }); res.json(rows); -}); +})); // Trace contribution settlement by Stellar tx hash (submitted vs indexed on ledger) -router.get('/finalization/:txHash', requireAuth, async (req, res) => { +router.get('/finalization/:txHash', requireAuth, asyncHandler(async (req, res) => { const txHash = req.params.txHash; const { rows } = await db.query( `SELECT st.id, st.status, st.tx_hash, st.campaign_id, st.contribution_id, @@ -202,10 +203,10 @@ router.get('/finalization/:txHash', requireAuth, async (req, res) => { metadata: row.metadata, updated_at: row.updated_at, }); -}); +})); // Quote conversion before a path payment contribution -router.get('/quote', requireAuth, contributionQuoteValidation, validateRequest, async (req, res) => { +router.get('/quote', requireAuth, contributionQuoteValidation, validateRequest, asyncHandler(async (req, res) => { /** * @openapi * /api/contributions/quote: @@ -279,9 +280,9 @@ router.get('/quote', requireAuth, contributionQuoteValidation, validateRequest, path: bestPath.path, path_count: paths.length, }); -}); +})); -router.post('/prepare', requireAuth, contributionValidation, validateRequest, async (req, res) => { +router.post('/prepare', requireAuth, contributionValidation, validateRequest, asyncHandler(async (req, res) => { const { campaign_id, amount, send_asset, sender_public_key, display_name } = req.body; if (!sender_public_key) { return res.status(422).json({ @@ -375,9 +376,9 @@ router.post('/prepare', requireAuth, contributionValidation, validateRequest, as error: 'Could not prepare the Stellar transaction right now. Please try again.', }); } -}); +})); -router.post('/submit-signed', requireAuth, async (req, res) => { +router.post('/submit-signed', requireAuth, asyncHandler(async (req, res) => { const { signed_xdr, prepare_token } = req.body; if (!signed_xdr || !prepare_token) { return res.status(400).json({ error: 'signed_xdr and prepare_token are required' }); @@ -437,10 +438,10 @@ router.post('/submit-signed', requireAuth, async (req, res) => { message: 'Transaction submitted', conversion_quote: prepared.conversion_quote || null, }); -}); +})); // Contribute to a campaign (authenticated, custodial) -router.post('/', contributionPostLimiter, requireAuth, contributionValidation, validateRequest, async (req, res) => { +router.post('/', contributionPostLimiter, requireAuth, contributionValidation, validateRequest, asyncHandler(async (req, res) => { /** * @openapi * /api/contributions: @@ -560,6 +561,6 @@ router.post('/', contributionPostLimiter, requireAuth, contributionValidation, v }); } }); -}); +})); module.exports = router; diff --git a/backend/src/routes/stellarTransactions.js b/backend/src/routes/stellarTransactions.js index fb313c7..4747289 100644 --- a/backend/src/routes/stellarTransactions.js +++ b/backend/src/routes/stellarTransactions.js @@ -1,6 +1,7 @@ const router = require('express').Router(); const db = require('../config/database'); const { requireAuth } = require('../middleware/auth'); +const asyncHandler = require('../utils/asyncHandler'); function isPlatformApprover(userId) { if (!process.env.PLATFORM_APPROVER_USER_ID) return false; @@ -20,7 +21,7 @@ async function assertCampaignReportingAccess(req, campaignId) { /** * Reporting index: Stellar transactions auditable by campaign creators and platform operators. */ -router.get('/', requireAuth, async (req, res) => { +router.get('/', requireAuth, asyncHandler(async (req, res) => { const { campaign_id: campaignId, status, limit } = req.query; const max = Math.min(parseInt(limit, 10) || 50, 200); @@ -63,9 +64,9 @@ router.get('/', requireAuth, async (req, res) => { ); res.json(rows); -}); +})); -router.get('/:id', requireAuth, async (req, res) => { +router.get('/:id', requireAuth, asyncHandler(async (req, res) => { const { rows } = await db.query( `SELECT st.*, c.title AS campaign_title, c.creator_id FROM stellar_transactions st @@ -83,6 +84,6 @@ router.get('/:id', requireAuth, async (req, res) => { delete row.creator_id; res.json(row); -}); +})); module.exports = router; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 693699d..e6405d2 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -3,8 +3,9 @@ const db = require('../config/database'); const { requireAuth } = require('../middleware/auth'); const { createKycSession, isKycRequiredForCampaigns } = require('../services/kycProvider'); const { listCreatorCampaigns, listUserContributions } = require('../services/userDashboardService'); +const asyncHandler = require('../utils/asyncHandler'); -router.get('/me', requireAuth, async (req, res) => { +router.get('/me', requireAuth, asyncHandler(async (req, res) => { const { rows } = await db.query( `SELECT id, email, name, wallet_public_key, wallet_type, role, kyc_status, kyc_completed_at, created_at FROM users @@ -13,9 +14,9 @@ router.get('/me', requireAuth, async (req, res) => { ); if (!rows.length) return res.status(404).json({ error: 'User not found' }); res.json({ ...rows[0], kyc_required_for_campaigns: isKycRequiredForCampaigns() }); -}); +})); -router.post('/me/kyc/start', requireAuth, async (req, res) => { +router.post('/me/kyc/start', requireAuth, asyncHandler(async (req, res) => { const { rows } = await db.query( `SELECT id, email, name, role, kyc_status FROM users @@ -58,14 +59,14 @@ router.post('/me/kyc/start', requireAuth, async (req, res) => { } catch (err) { res.status(502).json({ error: err.message || 'Could not start identity verification' }); } -}); +})); -router.get('/me/campaigns', requireAuth, async (req, res) => { +router.get('/me/campaigns', requireAuth, asyncHandler(async (req, res) => { const campaigns = await listCreatorCampaigns(req.user.userId); res.json(campaigns); -}); +})); -router.get('/me/stats', requireAuth, async (req, res) => { +router.get('/me/stats', requireAuth, asyncHandler(async (req, res) => { const { rows } = await db.query( `SELECT COUNT(*)::int AS total_campaigns, @@ -79,12 +80,12 @@ router.get('/me/stats', requireAuth, async (req, res) => { [req.user.userId] ); res.json(rows[0]); -}); +})); -router.get('/me/contributions', requireAuth, async (req, res) => { +router.get('/me/contributions', requireAuth, asyncHandler(async (req, res) => { const rows = await listUserContributions(req.user.userId); if (rows === null) return res.status(404).json({ error: 'User not found' }); res.json(rows); -}); +})); module.exports = router; diff --git a/backend/src/utils/asyncHandler.js b/backend/src/utils/asyncHandler.js new file mode 100644 index 0000000..fdb7897 --- /dev/null +++ b/backend/src/utils/asyncHandler.js @@ -0,0 +1,14 @@ +/** + * Wraps an async Express route handler so that any rejected promise is forwarded + * to Express's next() error pipeline, where it will be caught by errorHandler. + * + * Usage: + * router.get('/path', asyncHandler(async (req, res) => { + * const { rows } = await db.query(...); + * res.json(rows); + * })); + * + * Without this wrapper, an uncaught async rejection in Express 4 causes the + * client to receive an empty/hung response because Express never calls next(err). + */ +module.exports = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); diff --git a/backend/src/utils/asyncHandler.test.js b/backend/src/utils/asyncHandler.test.js new file mode 100644 index 0000000..dd9c6df --- /dev/null +++ b/backend/src/utils/asyncHandler.test.js @@ -0,0 +1,57 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const asyncHandler = require('./asyncHandler'); + +test('asyncHandler passes (req, res, next) to the wrapped function', async () => { + const req = {}; + const res = {}; + let nextCalled = false; + const next = (err) => { nextCalled = err; }; + + const handler = asyncHandler(async (r, s, n) => { + assert.equal(r, req); + assert.equal(s, res); + assert.equal(n, next); + }); + + await handler(req, res, next); + assert.equal(nextCalled, false, 'next should not be called for a successful handler'); +}); + +test('asyncHandler forwards a thrown error to next()', async () => { + const err = new Error('DB connection refused'); + let capturedError; + const next = (e) => { capturedError = e; }; + + const handler = asyncHandler(async () => { + throw err; + }); + + await handler({}, {}, next); + assert.equal(capturedError, err, 'next() should receive the thrown error'); +}); + +test('asyncHandler forwards a rejected promise to next()', async () => { + const err = new Error('Query failed'); + let capturedError; + const next = (e) => { capturedError = e; }; + + const handler = asyncHandler(() => Promise.reject(err)); + + await handler({}, {}, next); + assert.equal(capturedError, err, 'next() should receive the rejection reason'); +}); + +test('asyncHandler does not call next() when handler resolves successfully', async () => { + let nextCallCount = 0; + const next = () => { nextCallCount++; }; + + const handler = asyncHandler(async (_req, res) => { + res.body = 'ok'; + }); + + const res = {}; + await handler({}, res, next); + assert.equal(res.body, 'ok'); + assert.equal(nextCallCount, 0, 'next() must not be called on success'); +}); From 5e80565cdd5787ee7d82f191557c2974ab570d9c Mon Sep 17 00:00:00 2001 From: oak Date: Sat, 30 May 2026 06:43:58 +0100 Subject: [PATCH 2/2] fix: repair AdminDashboard.jsx JSX syntax error from bad merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin moderation feature (feat: fb7f93d) accidentally introduced a corrupted section by merging two different versions of AdminDashboard — the old flat layout and the new tab-based layout were interleaved from line 476 onward, causing: frontend/src/pages/AdminDashboard.jsx 483:7 error Parsing error: Adjacent JSX elements must be wrapped in an enclosing tag This broke the frontend-checks CI step (ESLint exit code 1) for every subsequent PR. The fix removes the orphaned old-version JSX fragment (lines 476-622 of the corrupted file) which contained duplicate Platform Fees card, Campaign Management table, and Milestone Reviews section that had leaked out of the tab system into the outer render scope. The correct tab-based code remains intact. Frontend build: passed (built in 11.57s) Frontend ESLint: 0 errors, 0 warnings --- frontend/src/pages/AdminDashboard.jsx | 142 ++++++++------------------ 1 file changed, 41 insertions(+), 101 deletions(-) diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx index 9708717..921f266 100644 --- a/frontend/src/pages/AdminDashboard.jsx +++ b/frontend/src/pages/AdminDashboard.jsx @@ -473,112 +473,52 @@ export default function AdminDashboard() {
{milestone.campaign_title} · {milestone.release_percentage}% · {milestone.status}
+
+ {milestone.description || 'No description provided.'} +
+ {milestone.evidence_url && ( +
+ Evidence:{' '} + + Open link + +
+ )} + {milestone.destination_key && ( +
+ Destination: {milestone.destination_key} +
+ )} + {milestone.review_note && ( +
+ Note: {milestone.review_note} +
+ )} + {milestone.status !== 'released' && ( +
+ + +
+ )}
{milestone.creator_email}
-

Platform Fees Collected

-

${stats.platform_fees_collected}

- - - -

Campaign Management

-
- - - - - - - - - - - {campaigns.map(c => ( - - - - - - - ))} - -
TitleCreatorStatusAction
{c.title}{c.creator_email}{c.status} - -
-
- -

Milestone Reviews

- {milestones.length === 0 ? ( -

No milestone activity yet.

- ) : ( -
- {milestones.map((milestone) => ( -
-
-
- {milestone.title} -
- {milestone.campaign_title} · {milestone.release_percentage}% · {milestone.status} -
-
- {milestone.description || 'No description provided.'}
- {milestone.evidence_url && ( -
- Evidence:{' '} - - Open link - -
- )} - {milestone.destination_key && ( -
- Destination: {milestone.destination_key} -
- )} - {milestone.review_note && ( -
- Note: {milestone.review_note} -
- )} - {milestone.status !== 'released' && ( -
- - -
- )}
))} -
{milestone.creator_email}
-
-
- {milestone.description || 'No description provided.'} -
{milestone.evidence_url && (
Evidence:{' '}