1import { formatEther, Wallet } from 'ethers' 15// if (isDirectlyRun(import.meta.url)) { 16// betLog({testWallet}) 19const minTestWalletBalance = 0.0005 // ~$1, enough for many test txs (only gas drain since self-payment) 21import type { Knex } from 'knex' 25// Mock payment helper - directly finalizes an order without real blockchain tx 26export const mockPaymentForOrder = async ({db, orderId, mockSenderAddr}: {db: Knex, orderId: string, mockSenderAddr: string}) => { 27 const order = await db('dona_entries').where({id: orderId}).whereNull('paid_at').first() 29 const mockTxId = `mock_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` 33type OrderInput = {donaRef: string, amountUsd?: number, charityId?: string, donaRefKind?: string, supportOppose?: string, targetEntryId?: string} 34type CreatePayCtx = {baseUrl: string, db: Knex, mockSenderAddr: string} 36const createAndPayOrder = async ({baseUrl, db, mockSenderAddr}: CreatePayCtx, input: OrderInput) => { 37 const resp = await gqlFetch({baseUrl, query: createDonaOrderGql, variables: {input: {amountUsd: 5, charityId: 'testfund', ...input}}}) 38 if (resp.errors) throw new Error(`Order error: ${resp.errors[0]?.message}`) 39 const order = resp.data.gmCreateDonaOrder 40 await mockPaymentForOrder({db, orderId: order.id, mockSenderAddr}) 44export const createDonaOrderGql = ` 45 mutation($input: DonaOrderInput!) { 46 gmCreateDonaOrder(input: $input) { 77 gqDonaEntries(limit: $limit) { 88const checkPaymentGql = ` 89 mutation($orderId: ID!) { 90 gmCheckPayment(orderId: $orderId) { 97const charityLeaderboardGql = ` 99 gqCharityLeaderboard { 113 if (skip) return skip 115 const { baseUrl, singleTestName, mockEth } = ctx 118 test('gmCreateDonaOrder: creates order with valid input', async () => { 121 query: createDonaOrderGql, 122 variables: {input: {donaRef: 'Test User', amountUsd: 5, charityId: 'mockedfund'}}, 125 return {passed: false, msg: `GraphQL error: ${resp.errors[0]?.message}`} 127 const order = resp.data?.gmCreateDonaOrder 128 if (!order?.id) return {passed: false, msg: 'No order ID returned'} 129 if (!order?.ethPaymentAddr) return {passed: false, msg: 'No ethPaymentAddr returned'} 130 if (order.amountUsd !== 5) return {passed: false, msg: `Wrong amountUsd: ${order.amountUsd}`} 131 return {passed: true, msg: `order ${order.id}`} 134 test('gmCreateDonaOrder: rejects empty display string', async () => { 137 query: createDonaOrderGql, 138 variables: {input: {donaRef: '', amountUsd: 5, charityId: 'mockedfund'}}, 141 return {passed: false, msg: `Expected validation error, got: ${JSON.stringify(resp.errors || resp.data)}`} 143 return {passed: true, msg: 'rejected empty string'} 146 test('gmCreateDonaOrder: rejects below minimum payment', async () => { 149 query: createDonaOrderGql, 150 variables: {input: {donaRef: 'Test', amountUsd: 0.5, charityId: 'mockedfund'}}, 153 return {passed: false, msg: `Expected minimum payment error, got: ${JSON.stringify(resp.errors || resp.data)}`} 155 return {passed: true, msg: 'rejected $0.50'} 158 test('gqDonaInfo: returns entry statistics', async () => { 159 const resp = await gqlFetch({baseUrl, query: donaInfoGql}) 161 return {passed: false, msg: `GraphQL error: ${resp.errors[0]?.message}`} 163 const info = resp.data?.gqDonaInfo 164 if (info?.totalEntries !== 1000) return {passed: false, msg: `Wrong totalEntries: ${info?.totalEntries}`} 165 if (typeof info?.filledEntries !== 'number') return {passed: false, msg: 'Missing filledEntries'} 166 if (typeof info?.minToEnter !== 'number') return {passed: false, msg: 'Missing minToEnter'} 167 return {passed: true, msg: `${info.filledEntries}/1000 filled`} 170 test('gqDonaOrder: returns null for non-existent order', async () => { 171 const fakeOrderId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' 174 query: `query($id: ID!) { gqDonaOrder(id: $id) { id paid } }`, 175 variables: {id: fakeOrderId}, 177 if (resp.errors) return {passed: false, msg: `Query error: ${resp.errors[0]?.message}`} 178 if (resp.data?.gqDonaOrder !== null) { 179 return {passed: false, msg: `Expected null for fake order, got: ${JSON.stringify(resp.data?.gqDonaOrder)}`} 181 return {passed: true, msg: 'returns null for non-existent order'} 184 test('gqEntryBySourceOrderId: returns null for non-existent order', async () => { 185 const fakeOrderId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' 188 query: `query($sourceOrderId: ID!) { gqEntryBySourceOrderId(sourceOrderId: $sourceOrderId) { id donaRef } }`, 189 variables: {sourceOrderId: fakeOrderId}, 191 if (resp.errors) return {passed: false, msg: `Query error: ${resp.errors[0]?.message}`} 192 if (resp.data?.gqEntryBySourceOrderId !== null) { 193 return {passed: false, msg: `Expected null for fake order, got: ${JSON.stringify(resp.data?.gqEntryBySourceOrderId)}`} 195 return {passed: true, msg: 'returns null for non-existent source order'} 198 test('gqDonaEntries: returns leaderboard in correct order', async () => { 199 const resp = await gqlFetch({baseUrl, query: entriesGql, variables: {limit: 10}}) 201 return {passed: false, msg: `GraphQL error: ${resp.errors[0]?.message}`} 203 const entries = resp.data?.gqDonaEntries 204 if (!Array.isArray(entries)) return {passed: false, msg: 'Expected array'} 205 // Verify ranking order (highest amount first) 206 for (let i = 1; i < entries.length; i++) { 207 if (entries[i].amountUsd > entries[i-1].amountUsd) { 208 return {passed: false, msg: `Wrong order: entry ${i} has higher amount than entry ${i-1}`} 211 return {passed: true, msg: `${entries.length} entries returned`} 214 test('gqCharityLeaderboard: returns charity statistics', async () => { 215 const resp = await gqlFetch({baseUrl, query: charityLeaderboardGql}) 216 if (resp.errors) return {passed: false, msg: `GraphQL error: ${resp.errors[0]?.message}`} 217 const stats = resp.data?.gqCharityLeaderboard 218 if (!Array.isArray(stats)) return {passed: false, msg: 'Expected array'} 219 // Verify ranking order (highest total first) 220 for (let i = 1; i < stats.length; i++) { 221 if (stats[i].totalUsd > stats[i-1].totalUsd) { 222 return {passed: false, msg: `Wrong order at index ${i}`} 225 return {passed: true, msg: `${stats.length} charities`} 228 test('gmCreateDonaOrder: allows multiple orders with different input', async () => { 229 // Create first order 232 query: createDonaOrderGql, 233 variables: {input: {donaRef: 'First Order', amountUsd: 5, charityId: 'mockedfund'}}, 235 if (resp1.errors) return {passed: false, msg: `First order error: ${resp1.errors[0]?.message}`} 236 const order1 = resp1.data?.gmCreateDonaOrder 237 if (!order1?.id) return {passed: false, msg: 'Failed to create first order'} 239 // Create second order with DIFFERENT input 242 query: createDonaOrderGql, 243 variables: {input: {donaRef: 'Second Order', amountUsd: 10, charityId: 'mockedfund'}}, 245 if (resp2.errors) return {passed: false, msg: `Second order error: ${resp2.errors[0]?.message}`} 246 const order2 = resp2.data?.gmCreateDonaOrder 248 // Should be a NEW order with NEW values 249 if (order2.id === order1.id) { 250 return {passed: false, msg: 'Got same order ID - should create new order'} 252 if (order2.donaRef !== 'Second Order') { 253 return {passed: false, msg: `Expected 'Second Order', got '${order2.donaRef}'`} 255 if (order2.amountUsd !== 10) { 256 return {passed: false, msg: `Expected amountUsd 10, got ${order2.amountUsd}`} 258 if (!order2.charityName) { 259 return {passed: false, msg: `Expected charityName to be set`} 262 return {passed: true, msg: `created 2 orders: ${order1.id.slice(0,8)}, ${order2.id.slice(0,8)}`} 265 test('chip-in: isChipIn flag reflects existing paid entry (not pending orders)', async () => { 268 // Create first order (no paid entry exists yet) 271 query: createDonaOrderGql, 272 variables: {input: {donaRef: uniqueStr, amountUsd: 2, charityId: 'mockedfund'}}, 274 if (resp1.errors) return {passed: false, msg: `First order error: ${resp1.errors[0]?.message}`} 275 const order1 = resp1.data?.gmCreateDonaOrder 276 if (order1.isChipIn) return {passed: false, msg: 'First order should not be chip-in (no paid entry)'} 278 // Create second order with SAME string - should NOT be chip-in since first order is unpaid 281 query: createDonaOrderGql, 282 variables: {input: {donaRef: uniqueStr, amountUsd: 3, charityId: 'mockedfund'}}, 284 if (resp2.errors) return {passed: false, msg: `Second order error: ${resp2.errors[0]?.message}`} 285 const order2 = resp2.data?.gmCreateDonaOrder 287 // Neither should be chip-in since no paid entry exists 288 if (order2.isChipIn) return {passed: false, msg: 'Second order should not be chip-in (first order not paid)'} 289 if (!order2.charityName) return {passed: false, msg: `Expected charityName to be set`} 291 return {passed: true, msg: `both not chip-in (correct - no paid entry exists)`} 294 test('payment detection: captures sender_addr', async () => { 298 // Clean up stale data from previous runs (idempotent) 299 await db('dona_entries').where({dona_ref: testDonaRef}).delete() 301 // Create order with $1 minimum - pays to testfund (self) so funds auto-rotate 304 query: createDonaOrderGql, 305 variables: {input: {donaRef: testDonaRef, amountUsd: 1, charityId: 'testfund'}}, 307 if (orderResp.errors) return {passed: false, msg: `Order create failed: ${orderResp.errors[0]?.message}`} 308 const order = orderResp.data?.gmCreateDonaOrder 309 if (!order?.ethGwei || !order?.ethPaymentAddr) return {passed: false, msg: 'Order missing ethGwei or ethPaymentAddr'} 311 // Verify order actually persisted to DB (catches DB connection issues) 312 const orderInDb = await db('dona_entries').where({id: order.id}).whereNull('paid_at').first() 313 if (!orderInDb) return {passed: false, msg: `Order ${order.id} not persisted to DB!`} 317 // Mock mode: directly finalize with fake sender 318 await mockPaymentForOrder({db, orderId: order.id, mockSenderAddr: getTestWallet().address}) 320 // Real mode: send actual Arbitrum tx 322 const rpcWithTimeout = <T,>(p: Promise<T>, label: string): Promise<T> => { 323 let timer: ReturnType<typeof setTimeout> 324 const timeout = new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`${label} timeout after 30s`)), 30_000) }) 325 return Promise.race([p, timeout]).finally(() => clearTimeout(timer)) 328 const balance = await rpcWithTimeout(provider.getBalance(getTestWallet().address), 'getBalance') 329 if (parseFloat(formatEther(balance)) < minTestWalletBalance) { 330 return {passed: false, msg: `Test wallet needs funding. Balance: ${formatEther(balance)} ETH, need: ${minTestWalletBalance}`} 333 // Seed startBlock for all networks so scanner doesn't scan from block 0. 334 // Arbitrum gets the real current block; others get a high value to skip scanning entirely. 335 const currentBlock = await rpcWithTimeout(provider.getBlockNumber(), 'getBlockNumber') 336 const startblockKeys = [ 337 `startblock-${order.ethPaymentAddr}`, 338 `startblock-arbitrum-${order.ethPaymentAddr}`, 339 `startblock-optimism-${order.ethPaymentAddr}`, 340 `startblock-base-${order.ethPaymentAddr}`, 341 `startblock-linea-${order.ethPaymentAddr}`, 343 for (const key of startblockKeys) { 344 await db('generic_kvs').insert({key, value: currentBlock, created_at: db.fn.now(), updated_at: db.fn.now()}) 345 .onConflict('key').merge({value: currentBlock, updated_at: db.fn.now()}) 348 // safeSendEth validates dest is in allowlist - prevents cross-identity dox 350 signer: getTestWallet().connect(provider), 351 destAddr: order.ethPaymentAddr, 352 value: BigInt(order.ethGwei) * 1_000_000_000n, 353 }), 'sendTransaction') 354 const receipt = await rpcWithTimeout(tx.wait(), 'tx.wait') 356 gasEth = formatEther(receipt.gasUsed * receipt.gasPrice) 358 // Poll for payment detection (6 attempts × 5s = 30s max) 360 for (let attempt = 0; attempt < 6; attempt++) { 362 const checkResp = await gqlFetch({baseUrl, query: checkPaymentGql, variables: {orderId: order.id}}) 363 if (checkResp.errors) return {passed: false, msg: `CheckPayment error: ${checkResp.errors[0]?.message}`} 364 if (checkResp.data?.gmCheckPayment?.paid) { 369 if (!detected) return {passed: false, msg: `Payment not detected after 30s polling`} 372 // Verify entry exists for this donaRef 373 const entry = await db('dona_entries').where({dona_ref: testDonaRef}).first() 374 if (!entry) return {passed: false, msg: 'Entry not created'} 376 // Verify payment recorded with correct sender_addr and source_order_id 377 // (use dona_payments since it's populated for both new entries AND chip-ins) 378 const payment = await db('dona_payments').where({source_order_id: order.id}).first() 379 if (!payment) return {passed: false, msg: `Payment not recorded for order ${order.id}`} 380 if (!payment.sender_addr) return {passed: false, msg: `sender_addr not captured in payment`} 381 if (payment.sender_addr.toLowerCase() !== getTestWallet().address.toLowerCase()) { 382 return {passed: false, msg: `sender_addr mismatch: ${payment.sender_addr} vs ${getTestWallet().address}`} 385 // Verify gqEntryBySourceOrderId query works (looks up via dona_payments for chip-ins) 388 query: `query($sourceOrderId: ID!) { gqEntryBySourceOrderId(sourceOrderId: $sourceOrderId) { id donaRef } }`, 389 variables: {sourceOrderId: order.id}, 391 if (entryByOrderResp.errors) return {passed: false, msg: `gqEntryBySourceOrderId error: ${entryByOrderResp.errors[0]?.message}`} 392 if (!entryByOrderResp.data?.gqEntryBySourceOrderId) { 393 return {passed: false, msg: 'gqEntryBySourceOrderId returned null but entry exists'} 395 if (entryByOrderResp.data.gqEntryBySourceOrderId.id !== entry.id) { 396 return {passed: false, msg: `gqEntryBySourceOrderId returned wrong entry`} 399 const mode = mockEth ? 'mock' : `real, gas=${gasEth}` 400 return {passed: true, msg: `sender_addr=${payment.sender_addr.slice(0, 10)}... (${mode})`, ethGasSpent: gasEth} 403 test('gmCreateDonaOrder: accepts supportOppose=oppose', async () => { 406 query: createDonaOrderGql, 407 variables: { input: { donaRef: `oppose_${ctx.ptenv}_${getGitShaShort()}`, amountUsd: 1, charityId: 'mockedfund', supportOppose: 'oppose' } }, 409 if (resp.errors) return { passed: false, msg: `GQL error: ${resp.errors[0]?.message}` } 410 const order = resp.data?.gmCreateDonaOrder 411 if (!order?.id) return { passed: false, msg: 'No order ID returned' } 412 if (order.supportOppose !== 'oppose') return { passed: false, msg: `Expected oppose, got: ${order.supportOppose}` } 413 return { passed: true, msg: `order ${order.id.slice(0, 8)} with oppose` } 416 test('gmCreateDonaOrder: defaults supportOppose to support', async () => { 419 query: createDonaOrderGql, 420 variables: { input: { donaRef: `defaultStance_${ctx.ptenv}_${getGitShaShort()}`, amountUsd: 1, charityId: 'mockedfund' } }, 422 if (resp.errors) return { passed: false, msg: `GQL error: ${resp.errors[0]?.message}` } 423 const order = resp.data?.gmCreateDonaOrder 424 if (order.supportOppose !== 'support') return { passed: false, msg: `Expected support, got: ${order.supportOppose}` } 425 return { passed: true, msg: `order ${order.id.slice(0, 8)} defaults to support` } 428 test('gqMyDonations: returns entries for sender address', async () => { 431 const testRef = 'f'.repeat(39) + '9' 432 const payCtx = {baseUrl, db, mockSenderAddr: testAddr} 434 await db('dona_entries').where('dona_ref', testRef).del() 437 await createAndPayOrder(payCtx, {donaRef: testRef, donaRefKind: 'label', supportOppose: 'support'}) 440 query: `query($senderAddr: String!) { gqMyDonations(senderAddr: $senderAddr) { entryId donaRef contributedUsd supportOppose } }`, 441 variables: {senderAddr: testAddr}}) 442 if (resp.errors) return {passed: false, msg: `GQL error: ${resp.errors[0]?.message}`} 444 const ours = (resp.data?.gqMyDonations ?? []).find((d: any) => d.donaRef === testRef) 445 if (!ours) return {passed: false, msg: `Entry not found in myDonations for addr ${testAddr.slice(0, 10)}`} 446 if (ours.contributedUsd !== 5) return {passed: false, msg: `Wrong amount: ${ours.contributedUsd}`} 448 return {passed: true, msg: `found donation for ${testAddr.slice(0, 10)}`} 450 // TODO: clean up ALL entries for testAddr (.where({sender_addr: testAddr})) instead of just current SHA, to prevent test data accumulation across deploys 451 await db('dona_entries').where('dona_ref', testRef).del() 455 test('chip-in: oppose donation creates separate entry, not chip-in to support entry', async () => { 457 const testSha = 'f'.repeat(39) + '1' 459 const payCtx = {baseUrl, db, mockSenderAddr: mockAddr} 461 await db('dona_entries').where('dona_ref', testSha).del() 464 await createAndPayOrder(payCtx, {donaRef: testSha, donaRefKind: 'label', supportOppose: 'support'}) 466 // Create oppose order for same commit - should NOT be chip-in 467 const resp2 = await gqlFetch({baseUrl, query: createDonaOrderGql, 468 variables: {input: {donaRef: testSha, donaRefKind: 'label', amountUsd: 3, charityId: 'testfund', supportOppose: 'oppose'}}}) 469 if (resp2.errors) return {passed: false, msg: `Oppose order error: ${resp2.errors[0]?.message}`} 470 const opposeOrder = resp2.data?.gmCreateDonaOrder 471 if (opposeOrder.isChipIn) return {passed: false, msg: 'Oppose order should NOT chip into support entry'} 472 if (opposeOrder.targetEntryId) return {passed: false, msg: `Oppose should have no targetEntryId, got: ${opposeOrder.targetEntryId}`} 474 await mockPaymentForOrder({db, orderId: opposeOrder.id, mockSenderAddr: mockAddr}) 476 const entries = await db('dona_entries').where('dona_ref', testSha).orderBy('support_oppose') 477 if (entries.length !== 2) return {passed: false, msg: `Expected 2 entries, got ${entries.length}`} 478 if (parseFloat(entries[0].amount_usd) !== 3) return {passed: false, msg: `Oppose amount wrong: ${entries[0].amount_usd}`} 479 if (parseFloat(entries[1].amount_usd) !== 5) return {passed: false, msg: `Support amount wrong: ${entries[1].amount_usd}`} 481 return {passed: true, msg: 'oppose creates separate entry, support=$5 oppose=$3'} 483 await db('dona_entries').where('dona_ref', testSha).del() 487 test('chip-in: explicit targetEntryId with mismatched stance creates separate entry', async () => { 489 const testSha = 'f'.repeat(39) + '4' 492 await db('dona_entries').where('dona_ref', testSha).del() 495 // Create support order and pay it 498 query: createDonaOrderGql, 499 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 5, charityId: 'testfund', supportOppose: 'support' } }, 501 if (resp1.errors) return { passed: false, msg: `Support order error: ${resp1.errors[0]?.message}` } 502 const supportOrder = resp1.data?.gmCreateDonaOrder 503 await mockPaymentForOrder({ db, orderId: supportOrder.id, mockSenderAddr: mockAddr }) 505 // Get the support entry id 506 const supportEntry = await db('dona_entries').where('dona_ref', testSha).first() 507 if (!supportEntry) return { passed: false, msg: 'Support entry not created' } 509 // Create oppose order with explicit targetEntryId pointing to support entry - simulates /buy?chipIn=<supportId>&oppose=1 512 query: createDonaOrderGql, 513 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 3, charityId: 'testfund', supportOppose: 'oppose', targetEntryId: supportEntry.id } }, 515 if (resp2.errors) return { passed: false, msg: `Oppose order error: ${resp2.errors[0]?.message}` } 516 const opposeOrder = resp2.data?.gmCreateDonaOrder 518 // Should NOT be a chip-in since stance doesn't match 519 if (opposeOrder.isChipIn) return { passed: false, msg: 'Oppose order should NOT be chip-in to support entry' } 520 if (opposeOrder.targetEntryId) return { passed: false, msg: `Oppose should have no targetEntryId, got: ${opposeOrder.targetEntryId}` } 522 await mockPaymentForOrder({ db, orderId: opposeOrder.id, mockSenderAddr: mockAddr }) 524 // Verify two separate entries exist 525 const entries = await db('dona_entries').where('dona_ref', testSha).orderBy('support_oppose') 526 if (entries.length !== 2) return { passed: false, msg: `Expected 2 entries, got ${entries.length}` } 527 const opposeEntry = entries.find((e: any) => e.support_oppose === 'oppose') 528 const supportEntryFinal = entries.find((e: any) => e.support_oppose === 'support') 529 if (!opposeEntry) return { passed: false, msg: 'No oppose entry found' } 530 if (!supportEntryFinal) return { passed: false, msg: 'No support entry found' } 531 if (parseFloat(opposeEntry.amount_usd) !== 3) return { passed: false, msg: `Oppose amount wrong: ${opposeEntry.amount_usd}` } 532 if (parseFloat(supportEntryFinal.amount_usd) !== 5) return { passed: false, msg: `Support amount should be $5, got $${supportEntryFinal.amount_usd}` } 534 return { passed: true, msg: 'explicit targetEntryId with mismatched stance creates separate entry' } 536 await db('dona_entries').where('dona_ref', testSha).del() 540 test('chip-in: same stance chips into existing entry', async () => { 542 const testSha = 'f'.repeat(39) + '2' 545 await db('dona_entries').where('dona_ref', testSha).del() 548 // Create first support order and pay it 551 query: createDonaOrderGql, 552 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 5, charityId: 'testfund', supportOppose: 'support' } }, 554 if (resp1.errors) return { passed: false, msg: `First order error: ${resp1.errors[0]?.message}` } 555 const order1 = resp1.data?.gmCreateDonaOrder 556 await mockPaymentForOrder({ db, orderId: order1.id, mockSenderAddr: mockAddr }) 558 // Create second support order - should be chip-in 561 query: createDonaOrderGql, 562 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 3, charityId: 'testfund', supportOppose: 'support' } }, 564 if (resp2.errors) return { passed: false, msg: `Second order error: ${resp2.errors[0]?.message}` } 565 const order2 = resp2.data?.gmCreateDonaOrder 566 if (!order2.isChipIn) return { passed: false, msg: 'Second support order should be chip-in' } 567 if (!order2.targetEntryId) return { passed: false, msg: 'Chip-in should have targetEntryId' } 569 await mockPaymentForOrder({ db, orderId: order2.id, mockSenderAddr: mockAddr }) 571 // Verify single entry with combined amount 572 const entries = await db('dona_entries').where('dona_ref', testSha) 573 if (entries.length !== 1) return { passed: false, msg: `Expected 1 entry, got ${entries.length}` } 574 if (parseFloat(entries[0].amount_usd) !== 8) return { passed: false, msg: `Expected $8, got $${entries[0].amount_usd}` } 576 return { passed: true, msg: 'chip-in incremented entry to $8' } 578 await db('dona_entries').where('dona_ref', testSha).del() 582 test('chip-in: order page can find entry via gqEntryBySourceOrderId after chip-in payment', async () => { 584 const testSha = 'f'.repeat(39) + '3' 587 await db('dona_entries').where('dona_ref', testSha).del() 590 // Create first order and pay to create entry 593 query: createDonaOrderGql, 594 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 5, charityId: 'testfund' } }, 596 const order1 = resp1.data?.gmCreateDonaOrder 597 await mockPaymentForOrder({ db, orderId: order1.id, mockSenderAddr: mockAddr }) 599 // Create chip-in order and pay 602 query: createDonaOrderGql, 603 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 3, charityId: 'testfund' } }, 605 const chipInOrder = resp2.data?.gmCreateDonaOrder 606 if (!chipInOrder.isChipIn) return { passed: false, msg: 'Expected chip-in order' } 608 await mockPaymentForOrder({ db, orderId: chipInOrder.id, mockSenderAddr: mockAddr }) 610 // Verify order is deleted 613 query: `query($id: ID!) { gqDonaOrder(id: $id) { id } }`, 614 variables: { id: chipInOrder.id }, 616 if (orderQuery.data?.gqDonaOrder !== null) return { passed: false, msg: 'Order should be deleted after payment' } 618 // Verify gqEntryBySourceOrderId finds the entry (this is the bug - currently returns null for chip-ins) 621 query: `query($sourceOrderId: ID!) { gqEntryBySourceOrderId(sourceOrderId: $sourceOrderId) { id donaRef amountUsd } }`, 622 variables: { sourceOrderId: chipInOrder.id }, 624 if (entryQuery.errors) return { passed: false, msg: `Entry query error: ${entryQuery.errors[0]?.message}` } 625 const entry = entryQuery.data?.gqEntryBySourceOrderId 626 if (!entry) return { passed: false, msg: 'gqEntryBySourceOrderId returned null for chip-in order (BUG)' } 627 if (entry.donaRef !== testSha) return { passed: false, msg: `Wrong donaRef: ${entry.donaRef}` } 628 if (entry.amountUsd !== 8) return { passed: false, msg: `Expected $8, got $${entry.amountUsd}` } 630 return { passed: true, msg: 'gqEntryBySourceOrderId found entry after chip-in' } 632 await db('dona_entries').where('dona_ref', testSha).del() 636 test('gqMyDonations: includes chip-in payments from dona_payments', async () => { 638 const testSha = 'f'.repeat(39) + '4' 640 const userB = Wallet.createRandom().address 642 await db('dona_entries').where('dona_ref', testSha).del() 645 // Create entry with userA as sender 648 query: createDonaOrderGql, 649 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 5, charityId: 'testfund' } }, 651 const order1 = resp1.data?.gmCreateDonaOrder 652 await mockPaymentForOrder({ db, orderId: order1.id, mockSenderAddr: userA }) 654 // Create chip-in with userB as sender 657 query: createDonaOrderGql, 658 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 3, charityId: 'testfund' } }, 660 const order2 = resp2.data?.gmCreateDonaOrder 661 if (!order2.isChipIn) return { passed: false, msg: 'Expected chip-in order' } 662 await mockPaymentForOrder({ db, orderId: order2.id, mockSenderAddr: userB }) 664 // Query gqMyDonations for userB - should include the chip-in 667 query: `query($senderAddr: String!) { gqMyDonations(senderAddr: $senderAddr) { entryId donaRef contributedUsd } }`, 668 variables: { senderAddr: userB }, 670 const donationsB = respB.data?.gqMyDonations ?? [] 671 const foundB = donationsB.find((d: any) => d.donaRef === testSha) 672 if (!foundB) return { passed: false, msg: `userB chip-in not in gqMyDonations` } 673 if (foundB.contributedUsd !== 3) return { passed: false, msg: `Expected $3 for chip-in, got $${foundB.contributedUsd}` } 675 // Query gqMyDonations for userA - should also include entry (as owner) 678 query: `query($senderAddr: String!) { gqMyDonations(senderAddr: $senderAddr) { entryId donaRef contributedUsd } }`, 679 variables: { senderAddr: userA }, 681 const donationsA = respA.data?.gqMyDonations ?? [] 682 const foundA = donationsA.find((d: any) => d.donaRef === testSha) 683 if (!foundA) return { passed: false, msg: `userA entry not in gqMyDonations` } 685 return { passed: true, msg: `userA sees $${foundA.contributedUsd}, userB sees $${foundB.contributedUsd} chip-in` } 687 await db('dona_entries').where('dona_ref', testSha).del() 691 test('chip-in payment: captures sender_addr in dona_payments', async () => { 693 const testSha = 'f'.repeat(39) + '5' 695 const userB = Wallet.createRandom().address 697 await db('dona_entries').where('dona_ref', testSha).del() 700 // Create entry with userA 703 query: createDonaOrderGql, 704 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 5, charityId: 'testfund' } }, 706 await mockPaymentForOrder({ db, orderId: resp1.data.gmCreateDonaOrder.id, mockSenderAddr: userA }) 708 // Create chip-in with userB 711 query: createDonaOrderGql, 712 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 3, charityId: 'testfund' } }, 714 const chipInOrder = resp2.data?.gmCreateDonaOrder 715 await mockPaymentForOrder({ db, orderId: chipInOrder.id, mockSenderAddr: userB }) 717 // Verify dona_payments has sender_addr for the chip-in 718 const payment = await db('dona_payments') 719 .where({ source_order_id: chipInOrder.id }) 721 if (!payment) return { passed: false, msg: 'Payment record not found' } 722 if (!payment.sender_addr) return { passed: false, msg: 'sender_addr not captured in dona_payments' } 723 if (payment.sender_addr.toLowerCase() !== userB.toLowerCase()) { 724 return { passed: false, msg: `sender_addr mismatch: ${payment.sender_addr} vs ${userB}` } 727 return { passed: true, msg: `dona_payments.sender_addr=${payment.sender_addr.slice(0, 10)}...` } 729 await db('dona_entries').where('dona_ref', testSha).del() 733 test('gqMyDonations: returns user contribution amount, not entry total', async () => { 735 const ownerWallet = Wallet.createRandom() 736 const chipperWallet = Wallet.createRandom() 737 const testSha = 'c'.repeat(39) + '4' 739 await db('dona_entries').where('dona_ref', testSha).del() 742 // Owner creates entry with $5 745 query: createDonaOrderGql, 746 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 5, charityId: 'testfund', supportOppose: 'support' } }, 748 if (resp1.errors) return { passed: false, msg: `Owner order error: ${resp1.errors[0]?.message}` } 749 await mockPaymentForOrder({ db, orderId: resp1.data.gmCreateDonaOrder.id, mockSenderAddr: ownerWallet.address }) 751 // Chipper chips in $3 754 query: createDonaOrderGql, 755 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 3, charityId: 'testfund', supportOppose: 'support' } }, 757 if (resp2.errors) return { passed: false, msg: `Chipper order error: ${resp2.errors[0]?.message}` } 758 await mockPaymentForOrder({ db, orderId: resp2.data.gmCreateDonaOrder.id, mockSenderAddr: chipperWallet.address }) 760 // Entry should have $8 total 761 const entry = await db('dona_entries').where('dona_ref', testSha).first() 762 if (parseFloat(entry.amount_usd) !== 8) return { passed: false, msg: `Entry total should be $8, got $${entry.amount_usd}` } 764 // Query chipper's donations with CHECKSUMMED address (tests case-insensitive matching) 767 query: `query($senderAddr: String!) { gqMyDonations(senderAddr: $senderAddr) { donaRef contributedUsd } }`, 768 variables: { senderAddr: chipperWallet.address }, 770 if (resp3.errors) return { passed: false, msg: `Query error: ${resp3.errors[0]?.message}` } 772 const donation = resp3.data?.gqMyDonations?.find((d: any) => d.donaRef === testSha) 773 if (!donation) return { passed: false, msg: 'Chipper donation not found in gqMyDonations' } 774 if (donation.contributedUsd !== 3) return { passed: false, msg: `Expected $3 contribution, got $${donation.contributedUsd}` } 776 // Query owner's donations - should show $5 contribution 779 query: `query($senderAddr: String!) { gqMyDonations(senderAddr: $senderAddr) { donaRef contributedUsd } }`, 780 variables: { senderAddr: ownerWallet.address }, 782 const ownerDonation = resp4.data?.gqMyDonations?.find((d: any) => d.donaRef === testSha) 783 if (!ownerDonation) return { passed: false, msg: 'Owner donation not found' } 784 if (ownerDonation.contributedUsd !== 5) return { passed: false, msg: `Expected owner $5, got $${ownerDonation.contributedUsd}` } 786 return { passed: true, msg: 'chipper sees $3, owner sees $5' } 788 await db('dona_entries').where('dona_ref', testSha).del() 792 // --- IPFS upload tests --- 794 test('gmCreateDonaOrder: ipfs_cid generates pending-upload ref', async () => { 797 query: createDonaOrderGql, 798 variables: {input: {donaRefKind: 'ipfs_cid', amountUsd: 5, charityId: 'mockedfund', expectedUploadSizeMb: 5}}, 800 if (resp.errors) return {passed: false, msg: `GQL error: ${resp.errors[0]?.message}`} 801 const order = resp.data?.gmCreateDonaOrder 802 if (!order?.donaRef?.startsWith('pending-upload-')) { 803 return {passed: false, msg: `Expected pending-upload ref, got: ${order?.donaRef}`} 805 if (order.donaRefKind !== 'ipfs_cid') return {passed: false, msg: `Wrong kind: ${order.donaRefKind}`} 806 return {passed: true, msg: `ref=${order.donaRef.slice(0, 20)}...`} 809 test('gmCreateDonaOrder: ipfs_cid enforces $1/MB minimum', async () => { 812 query: createDonaOrderGql, 813 variables: {input: {donaRefKind: 'ipfs_cid', amountUsd: 3, charityId: 'mockedfund', expectedUploadSizeMb: 10}}, 816 return {passed: false, msg: `Expected minimum payment error, got: ${resp.errors?.[0]?.message ?? 'success'}`} 818 return {passed: true, msg: 'rejected $3 for 10MB (requires $10)'} 821 test('ipfs_cid: pendingUpload flag set after payment', async () => { 826 query: createDonaOrderGql, 827 variables: {input: {donaRefKind: 'ipfs_cid', amountUsd: 2, charityId: 'mockedfund', expectedUploadSizeMb: 1}}, 829 if (orderResp.errors) return {passed: false, msg: `Order failed: ${orderResp.errors[0]?.message}`} 830 const order = orderResp.data?.gmCreateDonaOrder 836 query: `query($id: ID!) { gqDonaEntryById(id: $id) { id donaRef donaRefKind pendingUpload ipfsCid } }`, 837 variables: {id: order.id}, 839 const entry = entryResp.data?.gqDonaEntryById 840 if (!entry) return {passed: false, msg: 'Entry not found after payment'} 841 if (!entry.pendingUpload) return {passed: false, msg: 'Expected pendingUpload=true'} 842 if (entry.ipfsCid) return {passed: false, msg: `Should not have ipfsCid yet, got: ${entry.ipfsCid}`} 843 if (entry.donaRefKind !== 'ipfs_cid') return {passed: false, msg: `Wrong kind: ${entry.donaRefKind}`} 845 return {passed: true, msg: `entry ${entry.id.slice(0, 8)}... pendingUpload=true`} 848 // --- Merit tests --- 850 const meritByAddrQuery = ` 851 query($addr: String!) { 852 gqMeritByAddr(addr: $addr) { 856 byKind { kind meritUsd entryCount } 857 authored { entryId donaRef netReceivedUsd contributorCount } 858 contributions { entryId donaRef contributedUsd } 863 const topDonorsQueryGql = ` 865 gqTopDonors(limit: $limit) { 874 const topMeritEarnersQuery = ` 876 gqTopMeritEarners(limit: $limit) { 885 test('gqMeritByAddr: returns merit for author of paid entry', async () => { 887 const testAddr = '0x' + 'ab'.repeat(20) 890 await db('dona_entries').where('dona_ref', testRef).del() 894 query: createDonaOrderGql, 895 variables: {input: {donaRef: testRef, amountUsd: 10, charityId: 'mockedfund'}}, 897 if (orderResp.errors) return {passed: false, msg: `Order failed: ${orderResp.errors[0]?.message}`} 899 await mockPaymentForOrder({db, orderId: orderResp.data.gmCreateDonaOrder.id, mockSenderAddr: testAddr}) 901 const meritResp = await gqlFetch({baseUrl, query: meritByAddrQuery, variables: {addr: testAddr}}) 902 if (meritResp.errors) return {passed: false, msg: `Merit query failed: ${meritResp.errors[0]?.message}`} 904 const merit = meritResp.data?.gqMeritByAddr 905 if (!merit) return {passed: false, msg: 'No merit data returned'} 906 if (merit.totalMeritUsd < 10) return {passed: false, msg: `Expected merit >= 10, got ${merit.totalMeritUsd}`} 908 const authoredEntry = merit.authored.find((a: any) => a.donaRef === testRef) 909 if (!authoredEntry) return {passed: false, msg: 'Entry not found in authored list'} 910 if (authoredEntry.netReceivedUsd !== 10) return {passed: false, msg: `Wrong netReceivedUsd: ${authoredEntry.netReceivedUsd}`} 912 return {passed: true, msg: `merit=$${merit.totalMeritUsd.toFixed(2)}`} 915 test('gqMeritByAddr: chip-in increases author merit', async () => { 917 const authorAddr = '0x' + 'cc'.repeat(20) 918 const chipInAddr = '0x' + 'dd'.repeat(20) 921 await db('dona_entries').where('dona_ref', testRef).del() 925 query: createDonaOrderGql, 926 variables: {input: {donaRef: testRef, amountUsd: 5, charityId: 'mockedfund'}}, 928 if (orderResp.errors) return {passed: false, msg: `Order failed: ${orderResp.errors[0]?.message}`} 929 await mockPaymentForOrder({db, orderId: orderResp.data.gmCreateDonaOrder.id, mockSenderAddr: authorAddr}) 931 const meritBefore = await gqlFetch({baseUrl, query: meritByAddrQuery, variables: {addr: authorAddr}}) 932 const meritBeforeUsd = meritBefore.data?.gqMeritByAddr?.totalMeritUsd ?? 0 936 query: createDonaOrderGql, 937 variables: {input: {donaRef: testRef, amountUsd: 7, charityId: 'mockedfund'}}, 939 if (chipInResp.errors) return {passed: false, msg: `Chip-in order failed: ${chipInResp.errors[0]?.message}`} 940 if (!chipInResp.data.gmCreateDonaOrder.isChipIn) return {passed: false, msg: 'Expected isChipIn=true'} 941 await mockPaymentForOrder({db, orderId: chipInResp.data.gmCreateDonaOrder.id, mockSenderAddr: chipInAddr}) 943 const meritAfter = await gqlFetch({baseUrl, query: meritByAddrQuery, variables: {addr: authorAddr}}) 944 const meritAfterUsd = meritAfter.data?.gqMeritByAddr?.totalMeritUsd ?? 0 946 if (meritAfterUsd <= meritBeforeUsd) { 947 return {passed: false, msg: `Merit didn't increase: before=$${meritBeforeUsd}, after=$${meritAfterUsd}`} 950 const authoredEntry = meritAfter.data?.gqMeritByAddr?.authored?.find((a: any) => a.donaRef === testRef) 951 if (authoredEntry?.contributorCount !== 2) { 952 return {passed: false, msg: `Expected 2 contributors, got ${authoredEntry?.contributorCount}`} 955 return {passed: true, msg: `merit increased $${meritBeforeUsd} -> $${meritAfterUsd}`} 958 test('gqMeritByAddr: oppose donation reduces author merit', async () => { 960 const authorAddr = '0x' + '11'.repeat(20) 961 const opposerAddr = '0x' + '22'.repeat(20) 964 await db('dona_entries').where('dona_ref', testRef).del() 968 query: createDonaOrderGql, 969 variables: {input: {donaRef: testRef, supportOppose: 'support', amountUsd: 10, charityId: 'mockedfund'}}, 971 if (supportResp.errors) return {passed: false, msg: `Support order failed: ${supportResp.errors[0]?.message}`} 972 await mockPaymentForOrder({db, orderId: supportResp.data.gmCreateDonaOrder.id, mockSenderAddr: authorAddr}) 976 query: createDonaOrderGql, 977 variables: {input: {donaRef: testRef, supportOppose: 'oppose', amountUsd: 4, charityId: 'mockedfund'}}, 979 if (opposeResp.errors) return {passed: false, msg: `Oppose order failed: ${opposeResp.errors[0]?.message}`} 980 await mockPaymentForOrder({db, orderId: opposeResp.data.gmCreateDonaOrder.id, mockSenderAddr: opposerAddr}) 982 const meritResp = await gqlFetch({baseUrl, query: meritByAddrQuery, variables: {addr: authorAddr}}) 983 const authoredSupport = meritResp.data?.gqMeritByAddr?.authored?.find((a: any) => a.donaRef === testRef && a.netReceivedUsd > 0) 984 if (!authoredSupport) return {passed: false, msg: 'Support entry not found'} 985 if (authoredSupport.netReceivedUsd !== 10) return {passed: false, msg: `Support entry should be $10, got $${authoredSupport.netReceivedUsd}`} 987 const opposerMerit = await gqlFetch({baseUrl, query: meritByAddrQuery, variables: {addr: opposerAddr}}) 988 const opposerAuthored = opposerMerit.data?.gqMeritByAddr?.authored?.find((a: any) => a.donaRef === testRef) 989 if (!opposerAuthored) return {passed: false, msg: 'Oppose entry not found in opposer authored'} 990 if (opposerAuthored.netReceivedUsd !== -4) return {passed: false, msg: `Oppose entry should be -$4, got $${opposerAuthored.netReceivedUsd}`} 992 return {passed: true, msg: `support=$10, oppose=-$4 (net merit reflects both)`} 995 test('gqTopDonors: returns ranked donors by total donated', async () => { 996 const resp = await gqlFetch({baseUrl, query: topDonorsQueryGql, variables: {limit: 20}}) 997 if (resp.errors) return {passed: false, msg: `Query failed: ${resp.errors[0]?.message}`} 999 const donors = resp.data?.gqTopDonors 1000 if (!Array.isArray(donors)) return {passed: false, msg: 'Expected array'} 1002 for (let i = 1; i < donors.length; i++) { 1003 if (donors[i].totalDonatedUsd > donors[i-1].totalDonatedUsd) { 1004 return {passed: false, msg: `Wrong order at index ${i}: ${donors[i].totalDonatedUsd} > ${donors[i-1].totalDonatedUsd}`} 1006 if (donors[i].rank !== i + 1) { 1007 return {passed: false, msg: `Wrong rank at index ${i}: expected ${i+1}, got ${donors[i].rank}`} 1011 return {passed: true, msg: `${donors.length} donors ranked correctly`} 1014 test('gqTopMeritEarners: returns ranked earners by merit', async () => { 1015 const resp = await gqlFetch({baseUrl, query: topMeritEarnersQuery, variables: {limit: 20}}) 1016 if (resp.errors) return {passed: false, msg: `Query failed: ${resp.errors[0]?.message}`} 1018 const earners = resp.data?.gqTopMeritEarners 1019 if (!Array.isArray(earners)) return {passed: false, msg: 'Expected array'} 1021 for (let i = 1; i < earners.length; i++) { 1022 if (earners[i].meritUsd > earners[i-1].meritUsd) { 1023 return {passed: false, msg: `Wrong order at index ${i}: ${earners[i].meritUsd} > ${earners[i-1].meritUsd}`} 1025 if (earners[i].rank !== i + 1) { 1026 return {passed: false, msg: `Wrong rank at index ${i}: expected ${i+1}, got ${earners[i].rank}`} 1030 for (const earner of earners) { 1031 if (earner.meritUsd <= 0) return {passed: false, msg: `Expected positive merit, got ${earner.meritUsd}`} 1034 return {passed: true, msg: `${earners.length} earners ranked correctly`} 1041export const donateSuiteConfig = { name: 'donate', runner: runDonateSuite, deps: ['db'] } as const