🌳
pt0/donateapp/testsF/donateSuiteF.mts
1import { formatEther, Wallet } from 'ethers'
13const getTestWallet = () => getPeatScratchWallet()
15// if (isDirectlyRun(import.meta.url)) {
16// betLog({testWallet})
17// }
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()
28 assertDefined(order, {orderId})
29 const mockTxId = `mock_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
30 return await finalizeDonaPayment({db, order, eth_txid: mockTxId, senderAddr: mockSenderAddr})
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})
41 return order
44export const createDonaOrderGql = `
45 mutation($input: DonaOrderInput!) {
46 gmCreateDonaOrder(input: $input) {
47 id
48 donaRef
49 donaRefKind
50 supportOppose
51 amountUsd
52 ethGwei
53 ethPaymentAddr
54 expiresAt
55 paid
56 charityName
57 charityVerifyUrl
58 isChipIn
59 targetEntryId
60 }
61 }
64const donaInfoGql = `
65 query {
66 gqDonaInfo {
67 totalEntries
68 filledEntries
69 minToEnter
70 minToBeFirst
71 }
72 }
75const entriesGql = `
76 query($limit: Int) {
77 gqDonaEntries(limit: $limit) {
78 id
79 rank
80 donaRef
81 donaRefKind
82 amountUsd
83 paidAt
84 }
85 }
88const checkPaymentGql = `
89 mutation($orderId: ID!) {
90 gmCheckPayment(orderId: $orderId) {
91 paid
92 entryId
93 }
94 }
97const charityLeaderboardGql = `
98 query {
99 gqCharityLeaderboard {
100 rank
101 name
102 totalUsd
103 donationCount
104 verifyUrl
105 }
106 }
111export const runDonateSuite = async (ctx: SuiteRunnerCtx): Promise<SuiteResult> => {
112 const skip = skipUnlessSmokeDeploy(ctx)
113 if (skip) return skip
114 assertDefined(ctx.baseUrl, {ctx})
115 const { baseUrl, singleTestName, mockEth } = ctx
116 const {tests, test} = createTestCollector()
118 test('gmCreateDonaOrder: creates order with valid input', async () => {
119 const resp = await gqlFetch({
120 baseUrl,
121 query: createDonaOrderGql,
122 variables: {input: {donaRef: 'Test User', amountUsd: 5, charityId: 'mockedfund'}},
123 })
124 if (resp.errors) {
125 return {passed: false, msg: `GraphQL error: ${resp.errors[0]?.message}`}
126 }
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}`}
132 })
134 test('gmCreateDonaOrder: rejects empty display string', async () => {
135 const resp = await gqlFetch({
136 baseUrl,
137 query: createDonaOrderGql,
138 variables: {input: {donaRef: '', amountUsd: 5, charityId: 'mockedfund'}},
139 })
140 if (!hasGqlErrMatch(resp, '1-50 characters')) {
141 return {passed: false, msg: `Expected validation error, got: ${JSON.stringify(resp.errors || resp.data)}`}
142 }
143 return {passed: true, msg: 'rejected empty string'}
144 })
146 test('gmCreateDonaOrder: rejects below minimum payment', async () => {
147 const resp = await gqlFetch({
148 baseUrl,
149 query: createDonaOrderGql,
150 variables: {input: {donaRef: 'Test', amountUsd: 0.5, charityId: 'mockedfund'}},
151 })
152 if (!hasGqlErrMatch(resp, 'minimum payment')) {
153 return {passed: false, msg: `Expected minimum payment error, got: ${JSON.stringify(resp.errors || resp.data)}`}
154 }
155 return {passed: true, msg: 'rejected $0.50'}
156 })
158 test('gqDonaInfo: returns entry statistics', async () => {
159 const resp = await gqlFetch({baseUrl, query: donaInfoGql})
160 if (resp.errors) {
161 return {passed: false, msg: `GraphQL error: ${resp.errors[0]?.message}`}
162 }
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`}
168 })
170 test('gqDonaOrder: returns null for non-existent order', async () => {
171 const fakeOrderId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
172 const resp = await gqlFetch({
173 baseUrl,
174 query: `query($id: ID!) { gqDonaOrder(id: $id) { id paid } }`,
175 variables: {id: fakeOrderId},
176 })
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)}`}
180 }
181 return {passed: true, msg: 'returns null for non-existent order'}
182 })
184 test('gqEntryBySourceOrderId: returns null for non-existent order', async () => {
185 const fakeOrderId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
186 const resp = await gqlFetch({
187 baseUrl,
188 query: `query($sourceOrderId: ID!) { gqEntryBySourceOrderId(sourceOrderId: $sourceOrderId) { id donaRef } }`,
189 variables: {sourceOrderId: fakeOrderId},
190 })
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)}`}
194 }
195 return {passed: true, msg: 'returns null for non-existent source order'}
196 })
198 test('gqDonaEntries: returns leaderboard in correct order', async () => {
199 const resp = await gqlFetch({baseUrl, query: entriesGql, variables: {limit: 10}})
200 if (resp.errors) {
201 return {passed: false, msg: `GraphQL error: ${resp.errors[0]?.message}`}
202 }
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}`}
209 }
210 }
211 return {passed: true, msg: `${entries.length} entries returned`}
212 })
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}`}
223 }
224 }
225 return {passed: true, msg: `${stats.length} charities`}
226 })
228 test('gmCreateDonaOrder: allows multiple orders with different input', async () => {
229 // Create first order
230 const resp1 = await gqlFetch({
231 baseUrl,
232 query: createDonaOrderGql,
233 variables: {input: {donaRef: 'First Order', amountUsd: 5, charityId: 'mockedfund'}},
234 })
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
240 const resp2 = await gqlFetch({
241 baseUrl,
242 query: createDonaOrderGql,
243 variables: {input: {donaRef: 'Second Order', amountUsd: 10, charityId: 'mockedfund'}},
244 })
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'}
251 }
252 if (order2.donaRef !== 'Second Order') {
253 return {passed: false, msg: `Expected 'Second Order', got '${order2.donaRef}'`}
254 }
255 if (order2.amountUsd !== 10) {
256 return {passed: false, msg: `Expected amountUsd 10, got ${order2.amountUsd}`}
257 }
258 if (!order2.charityName) {
259 return {passed: false, msg: `Expected charityName to be set`}
260 }
262 return {passed: true, msg: `created 2 orders: ${order1.id.slice(0,8)}, ${order2.id.slice(0,8)}`}
263 })
265 test('chip-in: isChipIn flag reflects existing paid entry (not pending orders)', async () => {
266 const uniqueStr = `ChipTest_${ctx.ptenv}_${getGitShaShort()}`
268 // Create first order (no paid entry exists yet)
269 const resp1 = await gqlFetch({
270 baseUrl,
271 query: createDonaOrderGql,
272 variables: {input: {donaRef: uniqueStr, amountUsd: 2, charityId: 'mockedfund'}},
273 })
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
279 const resp2 = await gqlFetch({
280 baseUrl,
281 query: createDonaOrderGql,
282 variables: {input: {donaRef: uniqueStr, amountUsd: 3, charityId: 'mockedfund'}},
283 })
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)`}
292 })
294 test('payment detection: captures sender_addr', async () => {
295 const db = getReqKnex()
296 const testDonaRef = `PayTest_${ctx.ptenv}_${getGitShaShort()}`
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
302 const orderResp = await gqlFetch({
303 baseUrl,
304 query: createDonaOrderGql,
305 variables: {input: {donaRef: testDonaRef, amountUsd: 1, charityId: 'testfund'}},
306 })
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!`}
315 let gasEth = '0'
316 if (mockEth) {
317 // Mock mode: directly finalize with fake sender
318 await mockPaymentForOrder({db, orderId: order.id, mockSenderAddr: getTestWallet().address})
319 } else {
320 // Real mode: send actual Arbitrum tx
321 const provider = getAlchemyProv('arbitrum')
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))
326 }
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}`}
331 }
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}`,
342 ]
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()})
346 }
348 // safeSendEth validates dest is in allowlist - prevents cross-identity dox
349 const tx = await rpcWithTimeout(safeSendEth({
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)
359 let detected = false
360 for (let attempt = 0; attempt < 6; attempt++) {
361 await sleep(5000)
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) {
365 detected = true
366 break
367 }
368 }
369 if (!detected) return {passed: false, msg: `Payment not detected after 30s polling`}
370 }
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}`}
383 }
385 // Verify gqEntryBySourceOrderId query works (looks up via dona_payments for chip-ins)
386 const entryByOrderResp = await gqlFetch({
387 baseUrl,
388 query: `query($sourceOrderId: ID!) { gqEntryBySourceOrderId(sourceOrderId: $sourceOrderId) { id donaRef } }`,
389 variables: {sourceOrderId: order.id},
390 })
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'}
394 }
395 if (entryByOrderResp.data.gqEntryBySourceOrderId.id !== entry.id) {
396 return {passed: false, msg: `gqEntryBySourceOrderId returned wrong entry`}
397 }
399 const mode = mockEth ? 'mock' : `real, gas=${gasEth}`
400 return {passed: true, msg: `sender_addr=${payment.sender_addr.slice(0, 10)}... (${mode})`, ethGasSpent: gasEth}
401 })
403 test('gmCreateDonaOrder: accepts supportOppose=oppose', async () => {
404 const resp = await gqlFetch({
405 baseUrl,
406 query: createDonaOrderGql,
407 variables: { input: { donaRef: `oppose_${ctx.ptenv}_${getGitShaShort()}`, amountUsd: 1, charityId: 'mockedfund', supportOppose: 'oppose' } },
408 })
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` }
414 })
416 test('gmCreateDonaOrder: defaults supportOppose to support', async () => {
417 const resp = await gqlFetch({
418 baseUrl,
419 query: createDonaOrderGql,
420 variables: { input: { donaRef: `defaultStance_${ctx.ptenv}_${getGitShaShort()}`, amountUsd: 1, charityId: 'mockedfund' } },
421 })
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` }
426 })
428 test('gqMyDonations: returns entries for sender address', async () => {
429 const db = getReqKnex()
430 const testAddr = getPeatScratchWallet().address
431 const testRef = 'f'.repeat(39) + '9'
432 const payCtx = {baseUrl, db, mockSenderAddr: testAddr}
434 await db('dona_entries').where('dona_ref', testRef).del()
436 try {
437 await createAndPayOrder(payCtx, {donaRef: testRef, donaRefKind: 'label', supportOppose: 'support'})
439 const resp = await gqlFetch({baseUrl,
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)}`}
449 } finally {
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()
452 }
453 })
455 test('chip-in: oppose donation creates separate entry, not chip-in to support entry', async () => {
456 const db = getReqKnex()
457 const testSha = 'f'.repeat(39) + '1'
458 const mockAddr = getPeatScratchWallet().address
459 const payCtx = {baseUrl, db, mockSenderAddr: mockAddr}
461 await db('dona_entries').where('dona_ref', testSha).del()
463 try {
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'}
482 } finally {
483 await db('dona_entries').where('dona_ref', testSha).del()
484 }
485 })
487 test('chip-in: explicit targetEntryId with mismatched stance creates separate entry', async () => {
488 const db = getReqKnex()
489 const testSha = 'f'.repeat(39) + '4'
490 const mockAddr = getPeatScratchWallet().address
492 await db('dona_entries').where('dona_ref', testSha).del()
494 try {
495 // Create support order and pay it
496 const resp1 = await gqlFetch({
497 baseUrl,
498 query: createDonaOrderGql,
499 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 5, charityId: 'testfund', supportOppose: 'support' } },
500 })
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
510 const resp2 = await gqlFetch({
511 baseUrl,
512 query: createDonaOrderGql,
513 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 3, charityId: 'testfund', supportOppose: 'oppose', targetEntryId: supportEntry.id } },
514 })
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' }
535 } finally {
536 await db('dona_entries').where('dona_ref', testSha).del()
537 }
538 })
540 test('chip-in: same stance chips into existing entry', async () => {
541 const db = getReqKnex()
542 const testSha = 'f'.repeat(39) + '2'
543 const mockAddr = getPeatScratchWallet().address
545 await db('dona_entries').where('dona_ref', testSha).del()
547 try {
548 // Create first support order and pay it
549 const resp1 = await gqlFetch({
550 baseUrl,
551 query: createDonaOrderGql,
552 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 5, charityId: 'testfund', supportOppose: 'support' } },
553 })
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
559 const resp2 = await gqlFetch({
560 baseUrl,
561 query: createDonaOrderGql,
562 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 3, charityId: 'testfund', supportOppose: 'support' } },
563 })
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' }
577 } finally {
578 await db('dona_entries').where('dona_ref', testSha).del()
579 }
580 })
582 test('chip-in: order page can find entry via gqEntryBySourceOrderId after chip-in payment', async () => {
583 const db = getReqKnex()
584 const testSha = 'f'.repeat(39) + '3'
585 const mockAddr = getPeatScratchWallet().address
587 await db('dona_entries').where('dona_ref', testSha).del()
589 try {
590 // Create first order and pay to create entry
591 const resp1 = await gqlFetch({
592 baseUrl,
593 query: createDonaOrderGql,
594 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 5, charityId: 'testfund' } },
595 })
596 const order1 = resp1.data?.gmCreateDonaOrder
597 await mockPaymentForOrder({ db, orderId: order1.id, mockSenderAddr: mockAddr })
599 // Create chip-in order and pay
600 const resp2 = await gqlFetch({
601 baseUrl,
602 query: createDonaOrderGql,
603 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 3, charityId: 'testfund' } },
604 })
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
611 const orderQuery = await gqlFetch({
612 baseUrl,
613 query: `query($id: ID!) { gqDonaOrder(id: $id) { id } }`,
614 variables: { id: chipInOrder.id },
615 })
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)
619 const entryQuery = await gqlFetch({
620 baseUrl,
621 query: `query($sourceOrderId: ID!) { gqEntryBySourceOrderId(sourceOrderId: $sourceOrderId) { id donaRef amountUsd } }`,
622 variables: { sourceOrderId: chipInOrder.id },
623 })
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' }
631 } finally {
632 await db('dona_entries').where('dona_ref', testSha).del()
633 }
634 })
636 test('gqMyDonations: includes chip-in payments from dona_payments', async () => {
637 const db = getReqKnex()
638 const testSha = 'f'.repeat(39) + '4'
639 const userA = getPeatScratchWallet().address
640 const userB = Wallet.createRandom().address
642 await db('dona_entries').where('dona_ref', testSha).del()
644 try {
645 // Create entry with userA as sender
646 const resp1 = await gqlFetch({
647 baseUrl,
648 query: createDonaOrderGql,
649 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 5, charityId: 'testfund' } },
650 })
651 const order1 = resp1.data?.gmCreateDonaOrder
652 await mockPaymentForOrder({ db, orderId: order1.id, mockSenderAddr: userA })
654 // Create chip-in with userB as sender
655 const resp2 = await gqlFetch({
656 baseUrl,
657 query: createDonaOrderGql,
658 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 3, charityId: 'testfund' } },
659 })
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
665 const respB = await gqlFetch({
666 baseUrl,
667 query: `query($senderAddr: String!) { gqMyDonations(senderAddr: $senderAddr) { entryId donaRef contributedUsd } }`,
668 variables: { senderAddr: userB },
669 })
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)
676 const respA = await gqlFetch({
677 baseUrl,
678 query: `query($senderAddr: String!) { gqMyDonations(senderAddr: $senderAddr) { entryId donaRef contributedUsd } }`,
679 variables: { senderAddr: userA },
680 })
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` }
686 } finally {
687 await db('dona_entries').where('dona_ref', testSha).del()
688 }
689 })
691 test('chip-in payment: captures sender_addr in dona_payments', async () => {
692 const db = getReqKnex()
693 const testSha = 'f'.repeat(39) + '5'
694 const userA = getPeatScratchWallet().address
695 const userB = Wallet.createRandom().address
697 await db('dona_entries').where('dona_ref', testSha).del()
699 try {
700 // Create entry with userA
701 const resp1 = await gqlFetch({
702 baseUrl,
703 query: createDonaOrderGql,
704 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 5, charityId: 'testfund' } },
705 })
706 await mockPaymentForOrder({ db, orderId: resp1.data.gmCreateDonaOrder.id, mockSenderAddr: userA })
708 // Create chip-in with userB
709 const resp2 = await gqlFetch({
710 baseUrl,
711 query: createDonaOrderGql,
712 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 3, charityId: 'testfund' } },
713 })
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 })
720 .first()
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}` }
725 }
727 return { passed: true, msg: `dona_payments.sender_addr=${payment.sender_addr.slice(0, 10)}...` }
728 } finally {
729 await db('dona_entries').where('dona_ref', testSha).del()
730 }
731 })
733 test('gqMyDonations: returns user contribution amount, not entry total', async () => {
734 const db = getReqKnex()
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()
741 try {
742 // Owner creates entry with $5
743 const resp1 = await gqlFetch({
744 baseUrl,
745 query: createDonaOrderGql,
746 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 5, charityId: 'testfund', supportOppose: 'support' } },
747 })
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
752 const resp2 = await gqlFetch({
753 baseUrl,
754 query: createDonaOrderGql,
755 variables: { input: { donaRef: testSha, donaRefKind: 'label', amountUsd: 3, charityId: 'testfund', supportOppose: 'support' } },
756 })
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)
765 const resp3 = await gqlFetch({
766 baseUrl,
767 query: `query($senderAddr: String!) { gqMyDonations(senderAddr: $senderAddr) { donaRef contributedUsd } }`,
768 variables: { senderAddr: chipperWallet.address },
769 })
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
777 const resp4 = await gqlFetch({
778 baseUrl,
779 query: `query($senderAddr: String!) { gqMyDonations(senderAddr: $senderAddr) { donaRef contributedUsd } }`,
780 variables: { senderAddr: ownerWallet.address },
781 })
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' }
787 } finally {
788 await db('dona_entries').where('dona_ref', testSha).del()
789 }
790 })
792 // --- IPFS upload tests ---
794 test('gmCreateDonaOrder: ipfs_cid generates pending-upload ref', async () => {
795 const resp = await gqlFetch({
796 baseUrl,
797 query: createDonaOrderGql,
798 variables: {input: {donaRefKind: 'ipfs_cid', amountUsd: 5, charityId: 'mockedfund', expectedUploadSizeMb: 5}},
799 })
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}`}
804 }
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)}...`}
807 })
809 test('gmCreateDonaOrder: ipfs_cid enforces $1/MB minimum', async () => {
810 const resp = await gqlFetch({
811 baseUrl,
812 query: createDonaOrderGql,
813 variables: {input: {donaRefKind: 'ipfs_cid', amountUsd: 3, charityId: 'mockedfund', expectedUploadSizeMb: 10}},
814 })
815 if (!hasGqlErrMatch(resp, 'minimum payment')) {
816 return {passed: false, msg: `Expected minimum payment error, got: ${resp.errors?.[0]?.message ?? 'success'}`}
817 }
818 return {passed: true, msg: 'rejected $3 for 10MB (requires $10)'}
819 })
821 test('ipfs_cid: pendingUpload flag set after payment', async () => {
822 const db = getReqKnex()
824 const orderResp = await gqlFetch({
825 baseUrl,
826 query: createDonaOrderGql,
827 variables: {input: {donaRefKind: 'ipfs_cid', amountUsd: 2, charityId: 'mockedfund', expectedUploadSizeMb: 1}},
828 })
829 if (orderResp.errors) return {passed: false, msg: `Order failed: ${orderResp.errors[0]?.message}`}
830 const order = orderResp.data?.gmCreateDonaOrder
832 await mockPaymentForOrder({db, orderId: order.id, mockSenderAddr: getPeatScratchWallet().address})
834 const entryResp = await gqlFetch({
835 baseUrl,
836 query: `query($id: ID!) { gqDonaEntryById(id: $id) { id donaRef donaRefKind pendingUpload ipfsCid } }`,
837 variables: {id: order.id},
838 })
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`}
846 })
848 // --- Merit tests ---
850 const meritByAddrQuery = `
851 query($addr: String!) {
852 gqMeritByAddr(addr: $addr) {
853 addr
854 totalMeritUsd
855 totalDonatedUsd
856 byKind { kind meritUsd entryCount }
857 authored { entryId donaRef netReceivedUsd contributorCount }
858 contributions { entryId donaRef contributedUsd }
859 }
860 }
861 `
863 const topDonorsQueryGql = `
864 query($limit: Int) {
865 gqTopDonors(limit: $limit) {
866 rank
867 addr
868 totalDonatedUsd
869 donationCount
870 }
871 }
872 `
874 const topMeritEarnersQuery = `
875 query($limit: Int) {
876 gqTopMeritEarners(limit: $limit) {
877 rank
878 addr
879 meritUsd
880 topEntryRef
881 }
882 }
883 `
885 test('gqMeritByAddr: returns merit for author of paid entry', async () => {
886 const db = getReqKnex()
887 const testAddr = '0x' + 'ab'.repeat(20)
888 const testRef = `meritAuthor_${ctx.ptenv}_${getGitShaShort()}`
890 await db('dona_entries').where('dona_ref', testRef).del()
892 const orderResp = await gqlFetch({
893 baseUrl,
894 query: createDonaOrderGql,
895 variables: {input: {donaRef: testRef, amountUsd: 10, charityId: 'mockedfund'}},
896 })
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)}`}
913 })
915 test('gqMeritByAddr: chip-in increases author merit', async () => {
916 const db = getReqKnex()
917 const authorAddr = '0x' + 'cc'.repeat(20)
918 const chipInAddr = '0x' + 'dd'.repeat(20)
919 const testRef = `meritChipIn_${ctx.ptenv}_${getGitShaShort()}`
921 await db('dona_entries').where('dona_ref', testRef).del()
923 const orderResp = await gqlFetch({
924 baseUrl,
925 query: createDonaOrderGql,
926 variables: {input: {donaRef: testRef, amountUsd: 5, charityId: 'mockedfund'}},
927 })
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
934 const chipInResp = await gqlFetch({
935 baseUrl,
936 query: createDonaOrderGql,
937 variables: {input: {donaRef: testRef, amountUsd: 7, charityId: 'mockedfund'}},
938 })
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}`}
948 }
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}`}
953 }
955 return {passed: true, msg: `merit increased $${meritBeforeUsd} -> $${meritAfterUsd}`}
956 })
958 test('gqMeritByAddr: oppose donation reduces author merit', async () => {
959 const db = getReqKnex()
960 const authorAddr = '0x' + '11'.repeat(20)
961 const opposerAddr = '0x' + '22'.repeat(20)
962 const testRef = `meritOppose_${ctx.ptenv}_${getGitShaShort()}`
964 await db('dona_entries').where('dona_ref', testRef).del()
966 const supportResp = await gqlFetch({
967 baseUrl,
968 query: createDonaOrderGql,
969 variables: {input: {donaRef: testRef, supportOppose: 'support', amountUsd: 10, charityId: 'mockedfund'}},
970 })
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})
974 const opposeResp = await gqlFetch({
975 baseUrl,
976 query: createDonaOrderGql,
977 variables: {input: {donaRef: testRef, supportOppose: 'oppose', amountUsd: 4, charityId: 'mockedfund'}},
978 })
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)`}
993 })
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`}
1012 })
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`}
1035 })
1037 const result = await runTestsWithProgress({tests, suiteName: 'donate', singleTestName})
1038 return result
1041export const donateSuiteConfig = { name: 'donate', runner: runDonateSuite, deps: ['db'] } as const