PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.0
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / specs / utils / test-data.js

test-data.js in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.1.0, at specs/utils/test-data.js

1,738 lines 49.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Test data seeding and cleanup utilities.
3 *
4 * Creates and removes test campaigns and donations via REST API
5 * so E2E tests are self-contained and repeatable.
6 *
7 * @package SureDonation
8 */
9
10 const SD_API = '/suredonation/v1';
11 const CAMPAIGN_CPT = 'suredonation_cmpgn';
12
13 /**
14 * Unwrap a settings REST response.
15 *
16 * Endpoints return either `{ success, settings: {...} }` or the settings object
17 * directly; this centralizes that shape assumption so it lives in one place.
18 *
19 * @param {Object} response REST response.
20 * @return {Object} The settings object.
21 */
22 function unwrapSettings( response ) {
23 return ( response && response.settings ) || response || {};
24 }
25
26 let uniqueEmailCounter = 0;
27
28 /**
29 * Generate a collision-free test email.
30 *
31 * `Date.now()` alone can repeat within the same millisecond across fast
32 * sequential submits, so a monotonic counter is appended.
33 *
34 * @param {string} prefix Email local-part prefix.
35 * @return {string} Unique `@test.local` email address.
36 */
37 function uniqueEmail( prefix = 'e2e' ) {
38 uniqueEmailCounter += 1;
39 return `${ prefix }-${ Date.now() }-${ uniqueEmailCounter }@test.local`;
40 }
41
42 /**
43 * Get current spam-protection (honeypot) settings via REST API.
44 *
45 * @param {Object} requestUtils Playwright requestUtils fixture.
46 * @return {Promise<Object>} API response: { success, settings: { honeypot } }.
47 */
48 async function getSpamProtectionSettings( requestUtils ) {
49 return requestUtils.rest( {
50 method: 'GET',
51 path: `${ SD_API }/settings/spam-protection`,
52 } );
53 }
54
55 /**
56 * Toggle the honeypot setting via REST API.
57 *
58 * @param {Object} requestUtils Playwright requestUtils fixture.
59 * @param {boolean} enabled Whether the honeypot should be enabled.
60 * @return {Promise<Object>} API response.
61 */
62 async function setHoneypot( requestUtils, enabled ) {
63 try {
64 return await requestUtils.rest( {
65 method: 'POST',
66 path: `${ SD_API }/settings/spam-protection`,
67 data: { honeypot: enabled },
68 } );
69 } catch ( e ) {
70 // Defensive fallback (mirrors enableOfflineDonations): if the option
71 // write reports unchanged and the API surfaces an error, verify via GET.
72 const settings = unwrapSettings(
73 await getSpamProtectionSettings( requestUtils )
74 );
75 if ( settings.honeypot === enabled ) {
76 return { success: true, settings };
77 }
78 throw e;
79 }
80 }
81
82 /**
83 * Enable the honeypot spam protection via REST API.
84 *
85 * @param {Object} requestUtils Playwright requestUtils fixture.
86 * @return {Promise<Object>} API response.
87 */
88 async function enableHoneypot( requestUtils ) {
89 return setHoneypot( requestUtils, true );
90 }
91
92 /**
93 * Disable the honeypot spam protection via REST API.
94 *
95 * @param {Object} requestUtils Playwright requestUtils fixture.
96 * @return {Promise<Object>} API response.
97 */
98 async function disableHoneypot( requestUtils ) {
99 return setHoneypot( requestUtils, false );
100 }
101
102 /**
103 * Create a test campaign via the WP REST API.
104 *
105 * @param {Object} requestUtils Playwright requestUtils fixture.
106 * @return {Promise<number>} Campaign post ID.
107 */
108 async function createTestCampaign( requestUtils ) {
109 const campaign = await requestUtils.rest( {
110 method: 'POST',
111 path: `/wp/v2/${ CAMPAIGN_CPT }`,
112 data: {
113 title: `E2E Test Campaign ${ Date.now() }`,
114 status: 'publish',
115 },
116 } );
117 return campaign.id;
118 }
119
120 /**
121 * Create a test donation via the plugin REST API.
122 *
123 * Creates the donation via POST with required fields (campaign_id, amount),
124 * then PUTs all remaining fields to ensure they're set correctly.
125 *
126 * @param {Object} requestUtils Playwright requestUtils fixture.
127 * @param {Object} data Donation data.
128 * @return {Promise<Object>} Created donation object (after update).
129 */
130 async function createTestDonation( requestUtils, data ) {
131 // POST with all non-subscription fields to create the record.
132 const createData = { ...data };
133 const subscriptionFields = {};
134
135 for ( const key of [
136 'subscription_id',
137 'subscription_status',
138 'parent_subscription_id',
139 ] ) {
140 if ( key in createData ) {
141 subscriptionFields[ key ] = createData[ key ];
142 delete createData[ key ];
143 }
144 }
145
146 const response = await requestUtils.rest( {
147 method: 'POST',
148 path: `${ SD_API }/donations`,
149 data: createData,
150 } );
151
152 const donationId = response.donation?.id;
153
154 if ( ! donationId ) {
155 throw new Error(
156 `Failed to create donation: ${ JSON.stringify( response ) }`
157 );
158 }
159
160 // PUT all fields (including subscription fields) to ensure data is correct.
161 // This double-write guarantees fields are set even if POST body parsing
162 // doesn't apply all values.
163 const updateData = { ...data };
164 delete updateData.campaign_id; // Can't change campaign after creation.
165
166 if ( Object.keys( updateData ).length > 0 ) {
167 const updateResponse = await requestUtils.rest( {
168 method: 'PUT',
169 path: `${ SD_API }/donations/${ donationId }`,
170 data: updateData,
171 } );
172 return updateResponse.donation || response.donation;
173 }
174
175 return response.donation;
176 }
177
178 /**
179 * Seed a full recurring-donations test dataset.
180 *
181 * Creates 1 campaign and 4 donations:
182 * 1. One-time (completed, $25)
183 * 2. Recurring/subscription (completed, $50, active, fake sub ID)
184 * 3. Renewal #1 linked to #2
185 * 4. Renewal #2 linked to #2
186 *
187 * @param {Object} requestUtils Playwright requestUtils fixture.
188 * @return {Promise<Object>} IDs of created entities.
189 */
190 async function seedRecurringTestData( requestUtils ) {
191 const campaignId = await createTestCampaign( requestUtils );
192
193 // 1. One-time donation.
194 const oneTime = await createTestDonation( requestUtils, {
195 campaign_id: campaignId,
196 donor_name: 'E2E One-Time Donor',
197 donor_email: `e2e-onetime-${ Date.now() }@test.local`,
198 amount: 25,
199 payment_status: 'completed',
200 donation_type: 'one-time',
201 gateway: 'manual',
202 } );
203
204 // 2. Recurring subscription.
205 const recurring = await createTestDonation( requestUtils, {
206 campaign_id: campaignId,
207 donor_name: 'E2E Recurring Donor',
208 donor_email: `e2e-recurring-${ Date.now() }@test.local`,
209 amount: 50,
210 payment_status: 'completed',
211 donation_type: 'recurring',
212 gateway: 'stripe',
213 transaction_id: `pi_testE2eFake${ Date.now() }`,
214 subscription_id: `sub_testE2eFake${ Date.now() }`,
215 subscription_status: 'active',
216 } );
217
218 // 3 & 4. Renewal donations linked to the recurring subscription.
219 // Renewals must have the same subscription_id as parent (required by UI to show
220 // the Parent Subscription link — guarded by `donation.subscription_id` check).
221 const renewal1 = await createTestDonation( requestUtils, {
222 campaign_id: campaignId,
223 donor_name: 'E2E Recurring Donor',
224 donor_email: recurring.donor_email,
225 amount: 50,
226 payment_status: 'completed',
227 donation_type: 'renewal',
228 gateway: 'stripe',
229 transaction_id: `pi_testRenewal1_${ Date.now() }`,
230 subscription_id: recurring.subscription_id,
231 parent_subscription_id: recurring.id,
232 } );
233
234 const renewal2 = await createTestDonation( requestUtils, {
235 campaign_id: campaignId,
236 donor_name: 'E2E Recurring Donor',
237 donor_email: recurring.donor_email,
238 amount: 50,
239 payment_status: 'completed',
240 donation_type: 'renewal',
241 gateway: 'stripe',
242 transaction_id: `pi_testRenewal2_${ Date.now() }`,
243 subscription_id: recurring.subscription_id,
244 parent_subscription_id: recurring.id,
245 } );
246
247 return {
248 campaignId,
249 oneTimeDonationId: oneTime.id,
250 recurringDonationId: recurring.id,
251 renewalDonationIds: [ renewal1.id, renewal2.id ],
252 };
253 }
254
255 /**
256 * Clean up seeded test data.
257 *
258 * @param {Object} requestUtils Playwright requestUtils fixture.
259 * @param {Object} ids IDs from seedRecurringTestData().
260 */
261 async function cleanupTestData( requestUtils, ids ) {
262 const donationIds = [
263 ...( ids.renewalDonationIds || [] ),
264 ids.recurringDonationId,
265 ids.oneTimeDonationId,
266 ].filter( Boolean );
267
268 for ( const id of donationIds ) {
269 try {
270 await requestUtils.rest( {
271 method: 'DELETE',
272 path: `${ SD_API }/donations/${ id }`,
273 } );
274 } catch ( e ) {
275 // Ignore cleanup errors.
276 }
277 }
278
279 if ( ids.campaignId ) {
280 try {
281 await requestUtils.rest( {
282 method: 'DELETE',
283 path: `/wp/v2/${ CAMPAIGN_CPT }/${ ids.campaignId }?force=true`,
284 } );
285 } catch ( e ) {
286 // Ignore cleanup errors.
287 }
288 }
289 }
290
291 const FORM_CPT = 'suredonation_form';
292
293 /**
294 * Create a recurring donation form page.
295 *
296 * Creates a campaign (which auto-generates a default form with all fields),
297 * updates the payment block to subscription mode, then creates a published
298 * page using the [suredonation_form] shortcode.
299 *
300 * @param {Object} requestUtils Playwright requestUtils fixture.
301 * @return {Promise<Object>} { pageUrl, campaignId, formId, pageId }.
302 */
303 async function createRecurringFormPage( requestUtils ) {
304 // 1. Create a published campaign — auto-creates default form with
305 // amount, name, email, payment, and donate-button blocks.
306 const campaignId = await createTestCampaign( requestUtils );
307
308 // 2. Get the default form via the plugin's forms API.
309 const formsResponse = await requestUtils.rest( {
310 method: 'GET',
311 path: `${ SD_API }/forms?campaign_id=${ campaignId }`,
312 } );
313
314 const forms = formsResponse.forms || formsResponse;
315 const formId = forms?.[ 0 ]?.id;
316
317 if ( ! formId ) {
318 throw new Error( `Default form not created for campaign ${ campaignId }` );
319 }
320
321 // 3. Update the payment block from one-time to subscription.
322 const form = await requestUtils.rest( {
323 method: 'GET',
324 path: `/wp/v2/${ FORM_CPT }/${ formId }?context=edit`,
325 } );
326
327 const updatedContent = ( form.content?.raw || '' ).replace(
328 /"paymentType"\s*:\s*"one-time"/,
329 `"paymentType":"subscription","subscriptionPlan":{"name":"Monthly Donation","interval":"month","billingCycles":"ongoing"}`
330 );
331
332 await requestUtils.rest( {
333 method: 'PUT',
334 path: `/wp/v2/${ FORM_CPT }/${ formId }`,
335 data: { content: updatedContent },
336 } );
337
338 // 4. Create a page with the shortcode.
339 const page = await requestUtils.rest( {
340 method: 'POST',
341 path: '/wp/v2/pages',
342 data: {
343 title: `E2E Recurring Donation ${ Date.now() }`,
344 status: 'publish',
345 content: `[suredonation_form id="${ formId }"]`,
346 },
347 } );
348
349 return {
350 pageUrl: page.link,
351 campaignId,
352 formId,
353 pageId: page.id,
354 };
355 }
356
357 /**
358 * Clean up a recurring form page and its associated content.
359 *
360 * @param {Object} requestUtils Playwright requestUtils fixture.
361 * @param {Object} ids IDs from createRecurringFormPage().
362 */
363 async function cleanupRecurringFormPage( requestUtils, ids ) {
364 for ( const { path } of [
365 { path: `/wp/v2/pages/${ ids.pageId }?force=true` },
366 {
367 path: `/wp/v2/${ FORM_CPT }/${ ids.formId }?force=true`,
368 },
369 {
370 path: `/wp/v2/${ CAMPAIGN_CPT }/${ ids.campaignId }?force=true`,
371 },
372 ] ) {
373 try {
374 await requestUtils.rest( { method: 'DELETE', path } );
375 } catch ( e ) {
376 // Ignore cleanup errors.
377 }
378 }
379 }
380
381 /**
382 * Enable offline donations via REST API.
383 *
384 * @param {Object} requestUtils Playwright requestUtils fixture.
385 * @param {string} instructions Optional custom instructions HTML.
386 * @return {Promise<Object>} API response.
387 */
388 async function enableOfflineDonations( requestUtils, instructions ) {
389 const data = { enabled: true };
390 if ( instructions !== undefined ) {
391 data.instructions = instructions;
392 }
393 try {
394 return await requestUtils.rest( {
395 method: 'POST',
396 path: `${ SD_API }/payments/offline/settings`,
397 data,
398 } );
399 } catch ( e ) {
400 // WordPress update_option returns false when value is unchanged,
401 // causing the API to return 500. Verify via GET instead.
402 const current = await getOfflineSettings( requestUtils );
403 const settings = current.settings || current;
404 if ( settings.enabled === true ) {
405 return { success: true, settings };
406 }
407 throw e;
408 }
409 }
410
411 /**
412 * Disable offline donations via REST API.
413 *
414 * @param {Object} requestUtils Playwright requestUtils fixture.
415 * @return {Promise<Object>} API response.
416 */
417 async function disableOfflineDonations( requestUtils ) {
418 try {
419 return await requestUtils.rest( {
420 method: 'POST',
421 path: `${ SD_API }/payments/offline/settings`,
422 data: { enabled: false },
423 } );
424 } catch ( e ) {
425 // WordPress update_option returns false when value is unchanged.
426 const current = await getOfflineSettings( requestUtils );
427 const settings = current.settings || current;
428 if ( settings.enabled === false ) {
429 return { success: true, settings };
430 }
431 throw e;
432 }
433 }
434
435 /**
436 * Get current offline donation settings via REST API.
437 *
438 * @param {Object} requestUtils Playwright requestUtils fixture.
439 * @return {Promise<Object>} API response with settings.
440 */
441 async function getOfflineSettings( requestUtils ) {
442 return requestUtils.rest( {
443 method: 'GET',
444 path: `${ SD_API }/payments/offline/settings`,
445 } );
446 }
447
448 /**
449 * Create an offline donation form page.
450 *
451 * Creates a campaign (which auto-generates a default form),
452 * updates the payment block to offline-only mode, then creates
453 * a published page using the [suredonation_form] shortcode.
454 *
455 * @param {Object} requestUtils Playwright requestUtils fixture.
456 * @return {Promise<Object>} { pageUrl, campaignId, formId, pageId }.
457 */
458 async function createOfflineFormPage( requestUtils ) {
459 // 1. Create a published campaign — auto-creates default form.
460 const campaignId = await createTestCampaign( requestUtils );
461
462 // 2. Get the default form via the plugin's forms API.
463 const formsResponse = await requestUtils.rest( {
464 method: 'GET',
465 path: `${ SD_API }/forms?campaign_id=${ campaignId }`,
466 } );
467
468 const forms = formsResponse.forms || formsResponse;
469 const formId = forms?.[ 0 ]?.id;
470
471 if ( ! formId ) {
472 throw new Error( `Default form not created for campaign ${ campaignId }` );
473 }
474
475 // 3. Update the payment block to use offline gateway only.
476 const form = await requestUtils.rest( {
477 method: 'GET',
478 path: `/wp/v2/${ FORM_CPT }/${ formId }?context=edit`,
479 } );
480
481 let updatedContent = form.content?.raw || '';
482
483 // Update the gateway attribute to offline.
484 updatedContent = updatedContent.replace(
485 /"gateway"\s*:\s*"stripe"/,
486 `"gateway":"offline"`
487 );
488
489 // Replace paymentMethods array or add it.
490 if ( /"paymentMethods"/.test( updatedContent ) ) {
491 updatedContent = updatedContent.replace(
492 /"paymentMethods"\s*:\s*\[[^\]]*\]/,
493 `"paymentMethods":["offline"]`
494 );
495 } else {
496 // Add paymentMethods next to paymentType.
497 updatedContent = updatedContent.replace(
498 /"paymentType"\s*:\s*"one-time"/,
499 `"paymentType":"one-time","paymentMethods":["offline"]`
500 );
501 }
502
503 // Inject block_id (and formId for payment block) into ALL suredonation/* blocks.
504 // The Gutenberg editor normally sets block_id on mount via useEffect.
505 // Server-side validation skips blocks without block_id, so we must inject them.
506 updatedContent = updatedContent.replace(
507 /<!-- wp:suredonation\/(\w[\w-]*) \{(?!"block_id")/g,
508 ( match, blockName ) => {
509 const id = Math.random().toString( 36 ).substring( 2, 9 );
510 if ( blockName === 'payment' ) {
511 return `<!-- wp:suredonation/${ blockName } {"block_id":"${ id }","formId":${ formId },`;
512 }
513 return `<!-- wp:suredonation/${ blockName } {"block_id":"${ id }",`;
514 }
515 );
516
517 await requestUtils.rest( {
518 method: 'PUT',
519 path: `/wp/v2/${ FORM_CPT }/${ formId }`,
520 data: { content: updatedContent },
521 } );
522
523 // 4. Create a page with the shortcode.
524 const page = await requestUtils.rest( {
525 method: 'POST',
526 path: '/wp/v2/pages',
527 data: {
528 title: `E2E Offline Donation ${ Date.now() }`,
529 status: 'publish',
530 content: `[suredonation_form id="${ formId }"]`,
531 },
532 } );
533
534 return {
535 pageUrl: page.link,
536 campaignId,
537 formId,
538 pageId: page.id,
539 };
540 }
541
542 /**
543 * Clean up an offline form page and its associated content.
544 *
545 * @param {Object} requestUtils Playwright requestUtils fixture.
546 * @param {Object} ids IDs from createOfflineFormPage().
547 */
548 async function cleanupOfflineFormPage( requestUtils, ids ) {
549 for ( const { path } of [
550 { path: `/wp/v2/pages/${ ids.pageId }?force=true` },
551 {
552 path: `/wp/v2/${ FORM_CPT }/${ ids.formId }?force=true`,
553 },
554 {
555 path: `/wp/v2/${ CAMPAIGN_CPT }/${ ids.campaignId }?force=true`,
556 },
557 ] ) {
558 try {
559 await requestUtils.rest( { method: 'DELETE', path } );
560 } catch ( e ) {
561 // Ignore cleanup errors.
562 }
563 }
564 }
565
566 /**
567 * Seed offline donations test dataset.
568 *
569 * Creates 1 campaign and 3 donations:
570 * 1. One-time completed (stripe gateway)
571 * 2. Offline pending ($50)
572 * 3. Offline completed ($75)
573 *
574 * @param {Object} requestUtils Playwright requestUtils fixture.
575 * @return {Promise<Object>} IDs of created entities.
576 */
577 async function seedOfflineTestData( requestUtils ) {
578 const campaignId = await createTestCampaign( requestUtils );
579
580 // 1. One-time completed donation (stripe).
581 const oneTime = await createTestDonation( requestUtils, {
582 campaign_id: campaignId,
583 donor_name: 'E2E One-Time Donor',
584 donor_email: `e2e-onetime-${ Date.now() }@test.local`,
585 amount: 25,
586 payment_status: 'completed',
587 donation_type: 'one-time',
588 gateway: 'stripe',
589 transaction_id: `pi_testOneTime${ Date.now() }`,
590 } );
591
592 // 2. Offline pending donation.
593 const offlinePending = await createTestDonation( requestUtils, {
594 campaign_id: campaignId,
595 donor_name: 'E2E Offline Pending Donor',
596 donor_email: `e2e-offline-pending-${ Date.now() }@test.local`,
597 amount: 50,
598 payment_status: 'pending',
599 donation_type: 'one-time',
600 gateway: 'offline',
601 } );
602
603 // 3. Offline completed donation.
604 const offlineCompleted = await createTestDonation( requestUtils, {
605 campaign_id: campaignId,
606 donor_name: 'E2E Offline Completed Donor',
607 donor_email: `e2e-offline-completed-${ Date.now() }@test.local`,
608 amount: 75,
609 payment_status: 'completed',
610 donation_type: 'one-time',
611 gateway: 'offline',
612 } );
613
614 return {
615 campaignId,
616 oneTimeDonationId: oneTime.id,
617 offlinePendingId: offlinePending.id,
618 offlineCompletedId: offlineCompleted.id,
619 };
620 }
621
622 /**
623 * Clean up offline test data.
624 *
625 * @param {Object} requestUtils Playwright requestUtils fixture.
626 * @param {Object} ids IDs from seedOfflineTestData().
627 */
628 async function cleanupOfflineTestData( requestUtils, ids ) {
629 const donationIds = [
630 ids.offlineCompletedId,
631 ids.offlinePendingId,
632 ids.oneTimeDonationId,
633 ].filter( Boolean );
634
635 for ( const id of donationIds ) {
636 try {
637 await requestUtils.rest( {
638 method: 'DELETE',
639 path: `${ SD_API }/donations/${ id }`,
640 } );
641 } catch ( e ) {
642 // Ignore cleanup errors.
643 }
644 }
645
646 if ( ids.campaignId ) {
647 try {
648 await requestUtils.rest( {
649 method: 'DELETE',
650 path: `/wp/v2/${ CAMPAIGN_CPT }/${ ids.campaignId }?force=true`,
651 } );
652 } catch ( e ) {
653 // Ignore cleanup errors.
654 }
655 }
656 }
657
658 /**
659 * Get the global payment settings via the Stripe settings REST API.
660 *
661 * @param {Object} requestUtils Playwright requestUtils fixture.
662 * @return {Promise<Object>} Payment settings object (currency, payment_mode, …).
663 */
664 async function getPaymentSettings( requestUtils ) {
665 const response = await requestUtils.rest( {
666 method: 'GET',
667 path: `${ SD_API }/payments/stripe/settings`,
668 } );
669 return response.settings || response;
670 }
671
672 /**
673 * Set the global payment mode (test|live) via the Stripe settings REST API.
674 *
675 * @param {Object} requestUtils Playwright requestUtils fixture.
676 * @param {string} mode 'test' or 'live'.
677 * @return {Promise<Object>} API response.
678 */
679 async function setPaymentMode( requestUtils, mode ) {
680 try {
681 return await requestUtils.rest( {
682 method: 'POST',
683 path: `${ SD_API }/payments/stripe/settings`,
684 data: { payment_mode: mode },
685 } );
686 } catch ( e ) {
687 // WordPress update_option returns false when the value is unchanged,
688 // surfacing as an API error. Verify via GET instead.
689 const current = await getPaymentSettings( requestUtils );
690 if ( current.payment_mode === mode ) {
691 return { success: true, settings: current };
692 }
693 throw e;
694 }
695 }
696
697 /**
698 * Get fee recovery settings via the Stripe settings REST API.
699 *
700 * @param {Object} requestUtils Playwright requestUtils fixture.
701 * @return {Promise<Object>} Fee recovery settings object.
702 */
703 async function getFeeRecoverySettings( requestUtils ) {
704 const response = await requestUtils.rest( {
705 method: 'GET',
706 path: `${ SD_API }/payments/stripe/settings`,
707 } );
708
709 const settings = response.settings || response;
710 return settings.fee_recovery || {};
711 }
712
713 /**
714 * Update fee recovery settings via the Stripe settings REST API.
715 *
716 * @param {Object} requestUtils Playwright requestUtils fixture.
717 * @param {Object} feeRecovery Fee recovery settings to set.
718 * @return {Promise<Object>} API response.
719 */
720 async function updateFeeRecoverySettings( requestUtils, feeRecovery ) {
721 try {
722 return await requestUtils.rest( {
723 method: 'POST',
724 path: `${ SD_API }/payments/stripe/settings`,
725 data: { fee_recovery: feeRecovery },
726 } );
727 } catch ( e ) {
728 // WordPress update_option returns false when value is unchanged,
729 // causing the API to return 500. Verify via GET instead.
730 const current = await getFeeRecoverySettings( requestUtils );
731 if ( current.fee_mode === feeRecovery.fee_mode ) {
732 return { success: true, settings: { fee_recovery: current } };
733 }
734 throw e;
735 }
736 }
737
738 /**
739 * Create a donation form page with a cover-fees block.
740 *
741 * Creates a campaign (which auto-generates a default form), injects the
742 * suredonation/cover-fees block into the form content, then creates a published
743 * page using the [suredonation_form] shortcode.
744 *
745 * @param {Object} requestUtils Playwright requestUtils fixture.
746 * @param {Object} options Cover-fees block options.
747 * @return {Promise<Object>} { pageUrl, campaignId, formId, pageId }.
748 */
749 async function createFeeRecoveryFormPage( requestUtils, options = {} ) {
750 // 1. Create a published campaign — auto-creates default form.
751 const campaignId = await createTestCampaign( requestUtils );
752
753 // 2. Get the default form via the plugin's forms API.
754 const formsResponse = await requestUtils.rest( {
755 method: 'GET',
756 path: `${ SD_API }/forms?campaign_id=${ campaignId }`,
757 } );
758
759 const forms = formsResponse.forms || formsResponse;
760 const formId = forms?.[ 0 ]?.id;
761
762 if ( ! formId ) {
763 throw new Error( `Default form not created for campaign ${ campaignId }` );
764 }
765
766 // 3. Fetch form content.
767 const form = await requestUtils.rest( {
768 method: 'GET',
769 path: `/wp/v2/${ FORM_CPT }/${ formId }?context=edit`,
770 } );
771
772 let updatedContent = form.content?.raw || '';
773
774 // 4. Set payment methods.
775 const paymentMethods = options.paymentMethods || [ 'offline' ];
776 const isOfflineOnly =
777 paymentMethods.length === 1 && paymentMethods[ 0 ] === 'offline';
778
779 if ( isOfflineOnly ) {
780 updatedContent = updatedContent.replace(
781 /"gateway"\s*:\s*"stripe"/,
782 `"gateway":"offline"`
783 );
784 }
785
786 if ( /"paymentMethods"/.test( updatedContent ) ) {
787 updatedContent = updatedContent.replace(
788 /"paymentMethods"\s*:\s*\[[^\]]*\]/,
789 `"paymentMethods":${ JSON.stringify( paymentMethods ) }`
790 );
791 } else {
792 updatedContent = updatedContent.replace(
793 /"paymentType"\s*:\s*"one-time"/,
794 `"paymentType":"one-time","paymentMethods":${ JSON.stringify( paymentMethods ) }`
795 );
796 }
797
798 // 5. Inject block_id into all suredonation/* blocks (same pattern as createOfflineFormPage).
799 updatedContent = updatedContent.replace(
800 /<!-- wp:suredonation\/(\w[\w-]*) \{(?!"block_id")/g,
801 ( match, blockName ) => {
802 const id = Math.random().toString( 36 ).substring( 2, 9 );
803 if ( blockName === 'payment' ) {
804 return `<!-- wp:suredonation/${ blockName } {"block_id":"${ id }","formId":${ formId },`;
805 }
806 return `<!-- wp:suredonation/${ blockName } {"block_id":"${ id }",`;
807 }
808 );
809
810 // 6. Build the cover-fees block markup.
811 const coverFeesAttrs = {
812 block_id: Math.random().toString( 36 ).substring( 2, 9 ),
813 slug: 'cover-fees',
814 label: options.label || "I'd like to cover the processing fees",
815 useGlobalDefaults: options.useGlobalDefaults ?? true,
816 feePercentage: options.feePercentage ?? 2.9,
817 feeFixed: options.feeFixed ?? 0.3,
818 feeMode: options.feeMode ?? 'all_gateways',
819 gatewayFees: options.gatewayFees ?? {},
820 formId,
821 checked: options.checked ?? false,
822 };
823
824 const coverFeesBlock = `<!-- wp:suredonation/cover-fees ${ JSON.stringify(
825 coverFeesAttrs
826 ) } /-->`;
827
828 // 7. Insert before the donate-button block.
829 if ( updatedContent.includes( '<!-- wp:suredonation/donate-button' ) ) {
830 updatedContent = updatedContent.replace(
831 '<!-- wp:suredonation/donate-button',
832 coverFeesBlock + '\n<!-- wp:suredonation/donate-button'
833 );
834 } else {
835 // Fallback: append at the end.
836 updatedContent += '\n' + coverFeesBlock;
837 }
838
839 await requestUtils.rest( {
840 method: 'PUT',
841 path: `/wp/v2/${ FORM_CPT }/${ formId }`,
842 data: { content: updatedContent },
843 } );
844
845 // 8. Create a page with the shortcode.
846 const page = await requestUtils.rest( {
847 method: 'POST',
848 path: '/wp/v2/pages',
849 data: {
850 title: `E2E Fee Recovery ${ Date.now() }`,
851 status: 'publish',
852 content: `[suredonation_form id="${ formId }"]`,
853 },
854 } );
855
856 return {
857 pageUrl: page.link,
858 campaignId,
859 formId,
860 pageId: page.id,
861 };
862 }
863
864 /**
865 * Clean up a fee recovery form page and its associated content.
866 *
867 * @param {Object} requestUtils Playwright requestUtils fixture.
868 * @param {Object} ids IDs from createFeeRecoveryFormPage().
869 */
870 async function cleanupFeeRecoveryFormPage( requestUtils, ids ) {
871 for ( const { path } of [
872 { path: `/wp/v2/pages/${ ids.pageId }?force=true` },
873 {
874 path: `/wp/v2/${ FORM_CPT }/${ ids.formId }?force=true`,
875 },
876 {
877 path: `/wp/v2/${ CAMPAIGN_CPT }/${ ids.campaignId }?force=true`,
878 },
879 ] ) {
880 try {
881 await requestUtils.rest( { method: 'DELETE', path } );
882 } catch ( e ) {
883 // Ignore cleanup errors.
884 }
885 }
886 }
887
888 /**
889 * Update campaign meta value via the plugin's campaigns REST API.
890 *
891 * @param {Object} requestUtils Playwright requestUtils fixture.
892 * @param {number} campaignId Campaign post ID.
893 * @param {string} metaKey Meta key (e.g., 'allow_fees_coverage').
894 * @param {*} metaValue Meta value to set.
895 * @return {Promise<Object>} API response.
896 */
897 async function updateCampaignMeta(
898 requestUtils,
899 campaignId,
900 metaKey,
901 metaValue
902 ) {
903 return requestUtils.rest( {
904 method: 'PUT',
905 path: `${ SD_API }/campaigns/${ campaignId }`,
906 data: { [ metaKey ]: metaValue },
907 } );
908 }
909
910 /**
911 * Update form post meta via the WP REST API.
912 *
913 * @param {Object} requestUtils Playwright requestUtils fixture.
914 * @param {number} formId Form post ID.
915 * @param {Object} meta Meta key/value pairs to set.
916 * @return {Promise<Object>} API response.
917 */
918 async function updateFormMeta( requestUtils, formId, meta ) {
919 return requestUtils.rest( {
920 method: 'PUT',
921 path: `/wp/v2/${ FORM_CPT }/${ formId }`,
922 data: { meta },
923 } );
924 }
925
926 /**
927 * Inject block_id (and formId for payment blocks) into all suredonation/* blocks.
928 *
929 * Server-side validation skips blocks without block_id, so we must inject them
930 * when creating forms via REST API (the editor normally sets block_id on mount).
931 *
932 * @param {string} content Form block content.
933 * @param {number} formId Form post ID.
934 * @return {string} Updated content with block_id injected.
935 */
936 function injectBlockIds( content, formId ) {
937 return content.replace(
938 /<!-- wp:suredonation\/(\w[\w-]*) \{(?!"block_id")/g,
939 ( match, blockName ) => {
940 const id = Math.random().toString( 36 ).substring( 2, 9 );
941 if ( blockName === 'payment' ) {
942 return `<!-- wp:suredonation/${ blockName } {"block_id":"${ id }","formId":${ formId },`;
943 }
944 return `<!-- wp:suredonation/${ blockName } {"block_id":"${ id }",`;
945 }
946 );
947 }
948
949 /**
950 * Create a campaign with a default form, optionally setting form meta,
951 * and return the prepared form content with block_id injected.
952 *
953 * Shared setup for both block embed and shortcode form page helpers.
954 *
955 * @param {Object} requestUtils Playwright requestUtils fixture.
956 * @param {Object} options Options for form setup.
957 * @return {Promise<Object>} Object with campaignId, formId, and prepared content.
958 */
959 async function prepareFormAndCampaign( requestUtils, options = {} ) {
960 const campaignId = await createTestCampaign( requestUtils );
961
962 const formsResponse = await requestUtils.rest( {
963 method: 'GET',
964 path: `${ SD_API }/forms?campaign_id=${ campaignId }`,
965 } );
966
967 const forms = formsResponse.forms || formsResponse;
968 const formId = forms?.[ 0 ]?.id;
969
970 if ( ! formId ) {
971 throw new Error( `Default form not created for campaign ${ campaignId }` );
972 }
973
974 // Get form content and inject block_id attributes.
975 const form = await requestUtils.rest( {
976 method: 'GET',
977 path: `/wp/v2/${ FORM_CPT }/${ formId }?context=edit`,
978 } );
979
980 const updatedContent = injectBlockIds( form.content?.raw || '', formId );
981
982 await requestUtils.rest( {
983 method: 'PUT',
984 path: `/wp/v2/${ FORM_CPT }/${ formId }`,
985 data: { content: updatedContent },
986 } );
987
988 // Set form confirmation meta if provided.
989 const meta = {};
990 if ( options.confirmationType || options.successMessage || options.redirectUrl ) {
991 const confirmationData = {
992 confirmation_type: 'same page',
993 message: '',
994 submission_action: 'hide form',
995 custom_url: '',
996 page_url: '',
997 };
998
999 if ( options.confirmationType === 'redirect' ) {
1000 confirmationData.confirmation_type = 'custom url';
1001 confirmationData.custom_url = options.redirectUrl || '';
1002 } else if ( options.confirmationType === 'message' || options.successMessage ) {
1003 confirmationData.confirmation_type = 'same page';
1004 confirmationData.message = options.successMessage || '';
1005 }
1006
1007 meta._suredonation_form_confirmation = JSON.stringify( confirmationData );
1008 }
1009
1010 // For standalone forms, clear the campaign link so the form
1011 // renders without a campaign (both block and shortcode fall back
1012 // to form meta for campaign_id).
1013 if ( options.standalone ) {
1014 meta._suredonation_campaign_id = 0;
1015 }
1016
1017 if ( Object.keys( meta ).length > 0 ) {
1018 await updateFormMeta( requestUtils, formId, meta );
1019 }
1020
1021 return { campaignId, formId };
1022 }
1023
1024 /**
1025 * Create a page with the donation form embed block.
1026 *
1027 * @param {Object} requestUtils Playwright requestUtils fixture.
1028 * @param {Object} options Options.
1029 * @param {boolean} options.standalone If true, omit campaignId from block attributes.
1030 * @param {string} options.confirmationType Form confirmation type ('message' or 'redirect').
1031 * @param {string} options.successMessage Custom success message.
1032 * @param {string} options.redirectUrl Redirect URL for redirect confirmation.
1033 * @return {Promise<Object>} Object with pageUrl, campaignId, formId, pageId.
1034 */
1035 async function createEmbedBlockFormPage( requestUtils, options = {} ) {
1036 const { campaignId, formId } = await prepareFormAndCampaign(
1037 requestUtils,
1038 options
1039 );
1040
1041 // Build block attributes.
1042 const blockAttrs = { formId };
1043 if ( ! options.standalone ) {
1044 blockAttrs.campaignId = campaignId;
1045 }
1046
1047 const blockContent = `<!-- wp:suredonation/donation-form ${ JSON.stringify(
1048 blockAttrs
1049 ) } /-->`;
1050
1051 const page = await requestUtils.rest( {
1052 method: 'POST',
1053 path: '/wp/v2/pages',
1054 data: {
1055 title: `E2E Embed Block ${ Date.now() }`,
1056 status: 'publish',
1057 content: blockContent,
1058 },
1059 } );
1060
1061 return {
1062 pageUrl: page.link,
1063 campaignId,
1064 formId,
1065 pageId: page.id,
1066 };
1067 }
1068
1069 /**
1070 * Create a page with the donation form shortcode.
1071 *
1072 * @param {Object} requestUtils Playwright requestUtils fixture.
1073 * @param {Object} options Options (same shape as createEmbedBlockFormPage).
1074 * @return {Promise<Object>} Object with pageUrl, campaignId, formId, pageId.
1075 */
1076 async function createShortcodeFormPage( requestUtils, options = {} ) {
1077 const { campaignId, formId } = await prepareFormAndCampaign(
1078 requestUtils,
1079 options
1080 );
1081
1082 const page = await requestUtils.rest( {
1083 method: 'POST',
1084 path: '/wp/v2/pages',
1085 data: {
1086 title: `E2E Shortcode Form ${ Date.now() }`,
1087 status: 'publish',
1088 content: `[suredonation_form id="${ formId }"]`,
1089 },
1090 } );
1091
1092 return {
1093 pageUrl: page.link,
1094 campaignId,
1095 formId,
1096 pageId: page.id,
1097 };
1098 }
1099
1100 /**
1101 * Clean up a form page and its associated content.
1102 *
1103 * @param {Object} requestUtils Playwright requestUtils fixture.
1104 * @param {Object} ids IDs from createEmbedBlockFormPage/createShortcodeFormPage.
1105 */
1106 async function cleanupFormPage( requestUtils, ids ) {
1107 for ( const { path } of [
1108 { path: `/wp/v2/pages/${ ids.pageId }?force=true` },
1109 {
1110 path: `/wp/v2/${ FORM_CPT }/${ ids.formId }?force=true`,
1111 },
1112 {
1113 path: `/wp/v2/${ CAMPAIGN_CPT }/${ ids.campaignId }?force=true`,
1114 },
1115 ] ) {
1116 try {
1117 await requestUtils.rest( { method: 'DELETE', path } );
1118 } catch ( e ) {
1119 // Ignore cleanup errors.
1120 }
1121 }
1122 }
1123
1124 // ─── Form Validation Test Data ──────────────────────────────────
1125
1126 /**
1127 * Get the form-validation default messages via REST API.
1128 *
1129 * @param {Object} requestUtils Playwright requestUtils fixture.
1130 * @return {Promise<Object>} API response: { success, settings: { ...messages } }.
1131 */
1132 async function getValidationSettings( requestUtils ) {
1133 return requestUtils.rest( {
1134 method: 'GET',
1135 path: `${ SD_API }/settings/validation`,
1136 } );
1137 }
1138
1139 /**
1140 * Update the form-validation default messages via REST API.
1141 *
1142 * @param {Object} requestUtils Playwright requestUtils fixture.
1143 * @param {Object} messages Map of message key => message.
1144 * @return {Promise<Object>} API response.
1145 */
1146 async function updateValidationSettings( requestUtils, messages ) {
1147 try {
1148 return await requestUtils.rest( {
1149 method: 'POST',
1150 path: `${ SD_API }/settings/validation`,
1151 data: messages,
1152 } );
1153 } catch ( e ) {
1154 // WordPress update_option returns false when the value is unchanged,
1155 // which can surface as an API error. Verify via GET instead.
1156 const current = await getValidationSettings( requestUtils );
1157 const settings = current.settings || current;
1158 const matches = Object.keys( messages ).every(
1159 ( key ) => settings[ key ] === messages[ key ]
1160 );
1161 if ( matches ) {
1162 return { success: true, settings };
1163 }
1164 throw e;
1165 }
1166 }
1167
1168 /**
1169 * Create a donation form page for field-validation tests.
1170 *
1171 * Builds on the default form (name, email, donation-amount, payment,
1172 * donate-button) and optionally injects a required number field (slug
1173 * "quantity") with min/max bounds and/or a per-field custom Error Message on
1174 * the name input. block_id attributes are injected so server-side validation
1175 * runs. Rendered via the [suredonation_form] shortcode.
1176 *
1177 * @param {Object} requestUtils Playwright requestUtils fixture.
1178 * @param {Object} options Options.
1179 * @param {string} options.inputErrorMsg Custom Error Message for the name field.
1180 * @param {number} options.minValue Number field minimum (default 5).
1181 * @param {number} options.maxValue Number field maximum (default 50).
1182 * @param {boolean} options.numberField Set false to omit the number field.
1183 * @return {Promise<Object>} { pageUrl, campaignId, formId, pageId }.
1184 */
1185 async function createValidationFormPage( requestUtils, options = {} ) {
1186 // Standalone so the form renders without a campaign dependency.
1187 const { campaignId, formId } = await prepareFormAndCampaign( requestUtils, {
1188 standalone: true,
1189 } );
1190
1191 const form = await requestUtils.rest( {
1192 method: 'GET',
1193 path: `/wp/v2/${ FORM_CPT }/${ formId }?context=edit`,
1194 } );
1195
1196 let content = form.content?.raw || '';
1197
1198 // Inject a block_id into every suredonation/* field block that lacks one.
1199 // The default template only sets slugs (the editor adds block_id on mount),
1200 // and server-side validation skips blocks without a block_id — so REST-built
1201 // forms need them injected for the stored block config to include the fields.
1202 content = content.replace(
1203 /<!-- wp:suredonation\/(\w[\w-]*) \{(?!"block_id")/g,
1204 ( match, blockName ) => {
1205 const id = Math.random().toString( 36 ).substring( 2, 9 );
1206 if ( blockName === 'payment' ) {
1207 return `<!-- wp:suredonation/${ blockName } {"block_id":"${ id }","formId":${ formId },`;
1208 }
1209 return `<!-- wp:suredonation/${ blockName } {"block_id":"${ id }",`;
1210 }
1211 );
1212
1213 // Set a per-field custom Error Message on the name (input) block. Default
1214 // forms omit the empty errorMsg attribute, so inject it into the opening tag.
1215 if ( options.inputErrorMsg ) {
1216 content = content.replace(
1217 /<!-- wp:suredonation\/input \{/,
1218 `<!-- wp:suredonation/input {"errorMsg":${ JSON.stringify(
1219 options.inputErrorMsg
1220 ) },`
1221 );
1222 }
1223
1224 // Set a per-field custom invalid-email message on the email block. It ships
1225 // with a non-empty default, so replace the existing value if present.
1226 if ( options.emailInvalidMsg ) {
1227 if ( /"invalidEmailMsg"\s*:/.test( content ) ) {
1228 content = content.replace(
1229 /"invalidEmailMsg"\s*:\s*"(?:[^"\\]|\\.)*"/,
1230 `"invalidEmailMsg":${ JSON.stringify( options.emailInvalidMsg ) }`
1231 );
1232 } else {
1233 content = content.replace(
1234 /<!-- wp:suredonation\/email \{/,
1235 `<!-- wp:suredonation/email {"invalidEmailMsg":${ JSON.stringify(
1236 options.emailInvalidMsg
1237 ) },`
1238 );
1239 }
1240 }
1241
1242 // Inject a required number field with explicit slug (so update_field_slugs
1243 // leaves it alone) and min/max bounds, before the donate button.
1244 if ( options.numberField !== false ) {
1245 const numberAttrs = {
1246 block_id: Math.random().toString( 36 ).substring( 2, 9 ),
1247 slug: 'quantity',
1248 label: 'Quantity',
1249 required: true,
1250 minValue: options.minValue ?? 5,
1251 maxValue: options.maxValue ?? 50,
1252 formId,
1253 };
1254 if ( options.numberErrorMsg ) {
1255 numberAttrs.errorMsg = options.numberErrorMsg;
1256 }
1257 const numberBlock = `<!-- wp:suredonation/number ${ JSON.stringify(
1258 numberAttrs
1259 ) } /-->`;
1260
1261 if ( content.includes( '<!-- wp:suredonation/donate-button' ) ) {
1262 content = content.replace(
1263 '<!-- wp:suredonation/donate-button',
1264 numberBlock + '\n<!-- wp:suredonation/donate-button'
1265 );
1266 } else {
1267 content += '\n' + numberBlock;
1268 }
1269 }
1270
1271 await requestUtils.rest( {
1272 method: 'PUT',
1273 path: `/wp/v2/${ FORM_CPT }/${ formId }`,
1274 data: { content },
1275 } );
1276
1277 const page = await requestUtils.rest( {
1278 method: 'POST',
1279 path: '/wp/v2/pages',
1280 data: {
1281 title: `E2E Field Validation ${ Date.now() }`,
1282 status: 'publish',
1283 content: `[suredonation_form id="${ formId }"]`,
1284 },
1285 } );
1286
1287 return { pageUrl: page.link, campaignId, formId, pageId: page.id };
1288 }
1289
1290 /**
1291 * Get PayPal settings via the plugin REST API.
1292 *
1293 * @param {Object} requestUtils Playwright requestUtils fixture.
1294 * @return {Promise<Object>} API response with settings.
1295 */
1296 async function getPayPalSettings( requestUtils ) {
1297 return requestUtils.rest( {
1298 method: 'GET',
1299 path: `${ SD_API }/payments/paypal/settings`,
1300 } );
1301 }
1302
1303 /**
1304 * Simulate PayPal disconnection via the plugin REST API.
1305 *
1306 * @param {Object} requestUtils Playwright requestUtils fixture.
1307 * @return {Promise<Object>} API response.
1308 */
1309 async function simulatePayPalDisconnection( requestUtils ) {
1310 try {
1311 return await requestUtils.rest( {
1312 method: 'POST',
1313 path: `${ SD_API }/payments/paypal/disconnect`,
1314 } );
1315 } catch ( e ) {
1316 // Disconnection may fail if already disconnected or no real PayPal
1317 // credentials are present. Verify via GET instead.
1318 const current = await getPayPalSettings( requestUtils );
1319 const settings = current.settings || current;
1320 if ( settings.connected === false ) {
1321 return { success: true, settings };
1322 }
1323 throw e;
1324 }
1325 }
1326
1327 /**
1328 * Seed PayPal donations test dataset.
1329 *
1330 * Creates 1 campaign and 3 donations:
1331 * 1. Completed one-time ($30, gateway: paypal, with transaction_id)
1332 * 2. Pending one-time ($45, gateway: paypal)
1333 * 3. Completed recurring with subscription ($50, gateway: paypal)
1334 *
1335 * @param {Object} requestUtils Playwright requestUtils fixture.
1336 * @return {Promise<Object>} IDs of created entities.
1337 */
1338 async function seedPayPalTestData( requestUtils ) {
1339 const campaignId = await createTestCampaign( requestUtils );
1340
1341 // 1. Completed one-time PayPal donation.
1342 const completed = await createTestDonation( requestUtils, {
1343 campaign_id: campaignId,
1344 donor_name: 'E2E PayPal Completed Donor',
1345 donor_email: `e2e-paypal-completed-${ Date.now() }@test.local`,
1346 amount: 30,
1347 payment_status: 'completed',
1348 donation_type: 'one-time',
1349 gateway: 'paypal',
1350 transaction_id: `PAYPAL_CAPTURE_${ Date.now() }`,
1351 } );
1352
1353 // 2. Pending one-time PayPal donation.
1354 const pending = await createTestDonation( requestUtils, {
1355 campaign_id: campaignId,
1356 donor_name: 'E2E PayPal Pending Donor',
1357 donor_email: `e2e-paypal-pending-${ Date.now() }@test.local`,
1358 amount: 45,
1359 payment_status: 'pending',
1360 donation_type: 'one-time',
1361 gateway: 'paypal',
1362 } );
1363
1364 // 3. Completed recurring PayPal donation with subscription.
1365 const subscription = await createTestDonation( requestUtils, {
1366 campaign_id: campaignId,
1367 donor_name: 'E2E PayPal Subscription Donor',
1368 donor_email: `e2e-paypal-sub-${ Date.now() }@test.local`,
1369 amount: 50,
1370 payment_status: 'completed',
1371 donation_type: 'recurring',
1372 gateway: 'paypal',
1373 transaction_id: `PAYPAL_CAPTURE_SUB_${ Date.now() }`,
1374 subscription_id: `I-FAKE_E2E_${ Date.now() }`,
1375 subscription_status: 'active',
1376 } );
1377
1378 return {
1379 campaignId,
1380 completedId: completed.id,
1381 pendingId: pending.id,
1382 subscriptionId: subscription.id,
1383 };
1384 }
1385
1386 /**
1387 * Clean up PayPal test data.
1388 *
1389 * @param {Object} requestUtils Playwright requestUtils fixture.
1390 * @param {Object} ids IDs from seedPayPalTestData().
1391 */
1392 async function cleanupPayPalTestData( requestUtils, ids ) {
1393 const donationIds = [
1394 ids.subscriptionId,
1395 ids.pendingId,
1396 ids.completedId,
1397 ].filter( Boolean );
1398
1399 for ( const id of donationIds ) {
1400 try {
1401 await requestUtils.rest( {
1402 method: 'DELETE',
1403 path: `${ SD_API }/donations/${ id }`,
1404 } );
1405 } catch ( e ) {
1406 // Ignore cleanup errors.
1407 }
1408 }
1409
1410 if ( ids.campaignId ) {
1411 try {
1412 await requestUtils.rest( {
1413 method: 'DELETE',
1414 path: `/wp/v2/${ CAMPAIGN_CPT }/${ ids.campaignId }?force=true`,
1415 } );
1416 } catch ( e ) {
1417 // Ignore cleanup errors.
1418 }
1419 }
1420 }
1421
1422 /**
1423 * Create a PayPal donation form page.
1424 *
1425 * Creates a campaign (which auto-generates a default form),
1426 * updates the payment block to PayPal-only mode, then creates
1427 * a published page using the [suredonation_form] shortcode.
1428 *
1429 * @param {Object} requestUtils Playwright requestUtils fixture.
1430 * @return {Promise<Object>} { pageUrl, campaignId, formId, pageId }.
1431 */
1432 async function createPayPalFormPage( requestUtils ) {
1433 // 1. Create a published campaign — auto-creates default form.
1434 const campaignId = await createTestCampaign( requestUtils );
1435
1436 // 2. Get the default form via the plugin's forms API.
1437 const formsResponse = await requestUtils.rest( {
1438 method: 'GET',
1439 path: `${ SD_API }/forms?campaign_id=${ campaignId }`,
1440 } );
1441
1442 const forms = formsResponse.forms || formsResponse;
1443 const formId = forms?.[ 0 ]?.id;
1444
1445 if ( ! formId ) {
1446 throw new Error(
1447 `Default form not created for campaign ${ campaignId }`
1448 );
1449 }
1450
1451 // 3. Update the payment block to use PayPal gateway only.
1452 const form = await requestUtils.rest( {
1453 method: 'GET',
1454 path: `/wp/v2/${ FORM_CPT }/${ formId }?context=edit`,
1455 } );
1456
1457 let updatedContent = form.content?.raw || '';
1458
1459 // Update the gateway attribute to paypal.
1460 updatedContent = updatedContent.replace(
1461 /"gateway"\s*:\s*"stripe"/,
1462 `"gateway":"paypal"`
1463 );
1464
1465 // Replace paymentMethods array or add it.
1466 if ( /"paymentMethods"/.test( updatedContent ) ) {
1467 updatedContent = updatedContent.replace(
1468 /"paymentMethods"\s*:\s*\[[^\]]*\]/,
1469 `"paymentMethods":["paypal"]`
1470 );
1471 } else {
1472 // Add paymentMethods next to paymentType.
1473 updatedContent = updatedContent.replace(
1474 /"paymentType"\s*:\s*"one-time"/,
1475 `"paymentType":"one-time","paymentMethods":["paypal"]`
1476 );
1477 }
1478
1479 // Inject block_id (and formId for payment block) into ALL suredonation/* blocks.
1480 updatedContent = updatedContent.replace(
1481 /<!-- wp:suredonation\/(\w[\w-]*) \{(?!"block_id")/g,
1482 ( match, blockName ) => {
1483 const id = Math.random().toString( 36 ).substring( 2, 9 );
1484 if ( blockName === 'payment' ) {
1485 return `<!-- wp:suredonation/${ blockName } {"block_id":"${ id }","formId":${ formId },`;
1486 }
1487 return `<!-- wp:suredonation/${ blockName } {"block_id":"${ id }",`;
1488 }
1489 );
1490
1491 await requestUtils.rest( {
1492 method: 'PUT',
1493 path: `/wp/v2/${ FORM_CPT }/${ formId }`,
1494 data: { content: updatedContent },
1495 } );
1496
1497 // 4. Create a page with the shortcode.
1498 const page = await requestUtils.rest( {
1499 method: 'POST',
1500 path: '/wp/v2/pages',
1501 data: {
1502 title: `E2E PayPal Donation ${ Date.now() }`,
1503 status: 'publish',
1504 content: `[suredonation_form id="${ formId }"]`,
1505 },
1506 } );
1507
1508 return {
1509 pageUrl: page.link,
1510 campaignId,
1511 formId,
1512 pageId: page.id,
1513 };
1514 }
1515
1516 /**
1517 * Clean up a PayPal form page and its associated content.
1518 *
1519 * @param {Object} requestUtils Playwright requestUtils fixture.
1520 * @param {Object} ids IDs from createPayPalFormPage().
1521 */
1522 async function cleanupPayPalFormPage( requestUtils, ids ) {
1523 for ( const { path } of [
1524 { path: `/wp/v2/pages/${ ids.pageId }?force=true` },
1525 {
1526 path: `/wp/v2/${ FORM_CPT }/${ ids.formId }?force=true`,
1527 },
1528 {
1529 path: `/wp/v2/${ CAMPAIGN_CPT }/${ ids.campaignId }?force=true`,
1530 },
1531 ] ) {
1532 try {
1533 await requestUtils.rest( { method: 'DELETE', path } );
1534 } catch ( e ) {
1535 // Ignore cleanup errors.
1536 }
1537 }
1538 }
1539
1540 // ─── Donor Management Test Data ──────────────────────────────────
1541
1542 /**
1543 * Seed a donor management test dataset.
1544 *
1545 * Creates 1 campaign and donations for 3 different donors:
1546 * - Donor A: 2 completed donations ($25 + $50)
1547 * - Donor B: 1 completed donation ($100)
1548 * - Donor C: 1 pending donation ($10)
1549 *
1550 * @param {Object} requestUtils Playwright requestUtils fixture.
1551 * @return {Promise<Object>} IDs and metadata of created entities.
1552 */
1553 async function seedDonorTestData( requestUtils ) {
1554 const ts = Date.now();
1555 const campaignId = await createTestCampaign( requestUtils );
1556
1557 const donorAEmail = `e2e-donor-a-${ ts }@test.local`;
1558 const donorBEmail = `e2e-donor-b-${ ts }@test.local`;
1559 const donorCEmail = `e2e-donor-c-${ ts }@test.local`;
1560
1561 // Donor A: 2 completed donations.
1562 const donationA1 = await createTestDonation( requestUtils, {
1563 campaign_id: campaignId,
1564 donor_name: 'E2E Donor Alpha',
1565 donor_email: donorAEmail,
1566 amount: 25,
1567 payment_status: 'completed',
1568 donation_type: 'one-time',
1569 gateway: 'stripe',
1570 transaction_id: `pi_donorA1_${ ts }`,
1571 } );
1572
1573 const donationA2 = await createTestDonation( requestUtils, {
1574 campaign_id: campaignId,
1575 donor_name: 'E2E Donor Alpha',
1576 donor_email: donorAEmail,
1577 amount: 50,
1578 payment_status: 'completed',
1579 donation_type: 'one-time',
1580 gateway: 'stripe',
1581 transaction_id: `pi_donorA2_${ ts }`,
1582 } );
1583
1584 // Donor B: 1 completed donation.
1585 const donationB = await createTestDonation( requestUtils, {
1586 campaign_id: campaignId,
1587 donor_name: 'E2E Donor Beta',
1588 donor_email: donorBEmail,
1589 amount: 100,
1590 payment_status: 'completed',
1591 donation_type: 'one-time',
1592 gateway: 'stripe',
1593 transaction_id: `pi_donorB_${ ts }`,
1594 } );
1595
1596 // Donor C: 1 pending donation.
1597 const donationC = await createTestDonation( requestUtils, {
1598 campaign_id: campaignId,
1599 donor_name: 'E2E Donor Gamma',
1600 donor_email: donorCEmail,
1601 amount: 10,
1602 payment_status: 'pending',
1603 donation_type: 'one-time',
1604 gateway: 'offline',
1605 } );
1606
1607 // Donor IDs may be 0 from REST API donation creation if get_or_create
1608 // doesn't work in the test environment. Get IDs from the donors list
1609 // by fetching all donors and matching by email, with a retry.
1610 let donorAId = donationA1.donor_id || null;
1611 let donorBId = donationB.donor_id || null;
1612 let donorCId = donationC.donor_id || null;
1613
1614 // If donor_id was 0, try fetching from the donors list API.
1615 if ( ! donorAId || ! donorBId || ! donorCId ) {
1616 try {
1617 const resp = await requestUtils.rest( {
1618 method: 'GET',
1619 path: `${ SD_API }/donors?per_page=50`,
1620 } );
1621 const donors = resp.donors || [];
1622 if ( ! donorAId ) {
1623 donorAId = donors.find( ( d ) => d.email === donorAEmail )?.id || null;
1624 }
1625 if ( ! donorBId ) {
1626 donorBId = donors.find( ( d ) => d.email === donorBEmail )?.id || null;
1627 }
1628 if ( ! donorCId ) {
1629 donorCId = donors.find( ( d ) => d.email === donorCEmail )?.id || null;
1630 }
1631 } catch ( e ) {
1632 // Ignore — IDs remain null, tests will skip.
1633 }
1634 }
1635
1636 return {
1637 campaignId,
1638 donorA: { id: donorAId, email: donorAEmail, name: 'E2E Donor Alpha' },
1639 donorB: { id: donorBId, email: donorBEmail, name: 'E2E Donor Beta' },
1640 donorC: { id: donorCId, email: donorCEmail, name: 'E2E Donor Gamma' },
1641 donationIds: [
1642 donationA1.id,
1643 donationA2.id,
1644 donationB.id,
1645 donationC.id,
1646 ],
1647 };
1648 }
1649
1650 /**
1651 * Clean up donor management test data.
1652 *
1653 * @param {Object} requestUtils Playwright requestUtils fixture.
1654 * @param {Object} data Data from seedDonorTestData().
1655 */
1656 async function cleanupDonorTestData( requestUtils, data ) {
1657 // Delete donations first (foreign key to donors).
1658 for ( const id of data.donationIds || [] ) {
1659 try {
1660 await requestUtils.rest( {
1661 method: 'DELETE',
1662 path: `${ SD_API }/donations/${ id }`,
1663 } );
1664 } catch ( e ) {
1665 // Ignore cleanup errors.
1666 }
1667 }
1668
1669 // Delete donors.
1670 for ( const donor of [ data.donorA, data.donorB, data.donorC ] ) {
1671 if ( donor?.id ) {
1672 try {
1673 await requestUtils.rest( {
1674 method: 'DELETE',
1675 path: `${ SD_API }/donors/${ donor.id }`,
1676 } );
1677 } catch ( e ) {
1678 // Ignore cleanup errors.
1679 }
1680 }
1681 }
1682
1683 // Delete campaign.
1684 if ( data.campaignId ) {
1685 try {
1686 await requestUtils.rest( {
1687 method: 'DELETE',
1688 path: `/wp/v2/${ CAMPAIGN_CPT }/${ data.campaignId }?force=true`,
1689 } );
1690 } catch ( e ) {
1691 // Ignore cleanup errors.
1692 }
1693 }
1694 }
1695
1696 module.exports = {
1697 unwrapSettings,
1698 uniqueEmail,
1699 getSpamProtectionSettings,
1700 enableHoneypot,
1701 disableHoneypot,
1702 createTestCampaign,
1703 createTestDonation,
1704 seedRecurringTestData,
1705 cleanupTestData,
1706 createRecurringFormPage,
1707 cleanupRecurringFormPage,
1708 enableOfflineDonations,
1709 disableOfflineDonations,
1710 getOfflineSettings,
1711 createOfflineFormPage,
1712 cleanupOfflineFormPage,
1713 seedOfflineTestData,
1714 cleanupOfflineTestData,
1715 getPaymentSettings,
1716 setPaymentMode,
1717 getFeeRecoverySettings,
1718 updateFeeRecoverySettings,
1719 createFeeRecoveryFormPage,
1720 cleanupFeeRecoveryFormPage,
1721 updateCampaignMeta,
1722 updateFormMeta,
1723 createEmbedBlockFormPage,
1724 createShortcodeFormPage,
1725 cleanupFormPage,
1726 getPayPalSettings,
1727 simulatePayPalDisconnection,
1728 seedPayPalTestData,
1729 cleanupPayPalTestData,
1730 createPayPalFormPage,
1731 cleanupPayPalFormPage,
1732 seedDonorTestData,
1733 cleanupDonorTestData,
1734 getValidationSettings,
1735 updateValidationSettings,
1736 createValidationFormPage,
1737 };
1738