/** * Test data seeding and cleanup utilities. * * Creates and removes test campaigns and donations via REST API * so E2E tests are self-contained and repeatable. * * @package SureDonation */ const SD_API = '/suredonation/v1'; const CAMPAIGN_CPT = 'suredonation_cmpgn'; /** * Unwrap a settings REST response. * * Endpoints return either `{ success, settings: {...} }` or the settings object * directly; this centralizes that shape assumption so it lives in one place. * * @param {Object} response REST response. * @return {Object} The settings object. */ function unwrapSettings( response ) { return ( response && response.settings ) || response || {}; } let uniqueEmailCounter = 0; /** * Generate a collision-free test email. * * `Date.now()` alone can repeat within the same millisecond across fast * sequential submits, so a monotonic counter is appended. * * @param {string} prefix Email local-part prefix. * @return {string} Unique `@test.local` email address. */ function uniqueEmail( prefix = 'e2e' ) { uniqueEmailCounter += 1; return `${ prefix }-${ Date.now() }-${ uniqueEmailCounter }@test.local`; } /** * Get current spam-protection (honeypot) settings via REST API. * * @param {Object} requestUtils Playwright requestUtils fixture. * @return {Promise} API response: { success, settings: { honeypot } }. */ async function getSpamProtectionSettings( requestUtils ) { return requestUtils.rest( { method: 'GET', path: `${ SD_API }/settings/spam-protection`, } ); } /** * Toggle the honeypot setting via REST API. * * @param {Object} requestUtils Playwright requestUtils fixture. * @param {boolean} enabled Whether the honeypot should be enabled. * @return {Promise} API response. */ async function setHoneypot( requestUtils, enabled ) { try { return await requestUtils.rest( { method: 'POST', path: `${ SD_API }/settings/spam-protection`, data: { honeypot: enabled }, } ); } catch ( e ) { // Defensive fallback (mirrors enableOfflineDonations): if the option // write reports unchanged and the API surfaces an error, verify via GET. const settings = unwrapSettings( await getSpamProtectionSettings( requestUtils ) ); if ( settings.honeypot === enabled ) { return { success: true, settings }; } throw e; } } /** * Enable the honeypot spam protection via REST API. * * @param {Object} requestUtils Playwright requestUtils fixture. * @return {Promise} API response. */ async function enableHoneypot( requestUtils ) { return setHoneypot( requestUtils, true ); } /** * Disable the honeypot spam protection via REST API. * * @param {Object} requestUtils Playwright requestUtils fixture. * @return {Promise} API response. */ async function disableHoneypot( requestUtils ) { return setHoneypot( requestUtils, false ); } /** * Create a test campaign via the WP REST API. * * @param {Object} requestUtils Playwright requestUtils fixture. * @return {Promise} Campaign post ID. */ async function createTestCampaign( requestUtils ) { const campaign = await requestUtils.rest( { method: 'POST', path: `/wp/v2/${ CAMPAIGN_CPT }`, data: { title: `E2E Test Campaign ${ Date.now() }`, status: 'publish', }, } ); return campaign.id; } /** * Create a test donation via the plugin REST API. * * Creates the donation via POST with required fields (campaign_id, amount), * then PUTs all remaining fields to ensure they're set correctly. * * @param {Object} requestUtils Playwright requestUtils fixture. * @param {Object} data Donation data. * @return {Promise} Created donation object (after update). */ async function createTestDonation( requestUtils, data ) { // POST with all non-subscription fields to create the record. const createData = { ...data }; const subscriptionFields = {}; for ( const key of [ 'subscription_id', 'subscription_status', 'parent_subscription_id', ] ) { if ( key in createData ) { subscriptionFields[ key ] = createData[ key ]; delete createData[ key ]; } } const response = await requestUtils.rest( { method: 'POST', path: `${ SD_API }/donations`, data: createData, } ); const donationId = response.donation?.id; if ( ! donationId ) { throw new Error( `Failed to create donation: ${ JSON.stringify( response ) }` ); } // PUT all fields (including subscription fields) to ensure data is correct. // This double-write guarantees fields are set even if POST body parsing // doesn't apply all values. const updateData = { ...data }; delete updateData.campaign_id; // Can't change campaign after creation. if ( Object.keys( updateData ).length > 0 ) { const updateResponse = await requestUtils.rest( { method: 'PUT', path: `${ SD_API }/donations/${ donationId }`, data: updateData, } ); return updateResponse.donation || response.donation; } return response.donation; } /** * Seed a full recurring-donations test dataset. * * Creates 1 campaign and 4 donations: * 1. One-time (completed, $25) * 2. Recurring/subscription (completed, $50, active, fake sub ID) * 3. Renewal #1 linked to #2 * 4. Renewal #2 linked to #2 * * @param {Object} requestUtils Playwright requestUtils fixture. * @return {Promise} IDs of created entities. */ async function seedRecurringTestData( requestUtils ) { const campaignId = await createTestCampaign( requestUtils ); // 1. One-time donation. const oneTime = await createTestDonation( requestUtils, { campaign_id: campaignId, donor_name: 'E2E One-Time Donor', donor_email: `e2e-onetime-${ Date.now() }@test.local`, amount: 25, payment_status: 'completed', donation_type: 'one-time', gateway: 'manual', } ); // 2. Recurring subscription. const recurring = await createTestDonation( requestUtils, { campaign_id: campaignId, donor_name: 'E2E Recurring Donor', donor_email: `e2e-recurring-${ Date.now() }@test.local`, amount: 50, payment_status: 'completed', donation_type: 'recurring', gateway: 'stripe', transaction_id: `pi_testE2eFake${ Date.now() }`, subscription_id: `sub_testE2eFake${ Date.now() }`, subscription_status: 'active', } ); // 3 & 4. Renewal donations linked to the recurring subscription. // Renewals must have the same subscription_id as parent (required by UI to show // the Parent Subscription link — guarded by `donation.subscription_id` check). const renewal1 = await createTestDonation( requestUtils, { campaign_id: campaignId, donor_name: 'E2E Recurring Donor', donor_email: recurring.donor_email, amount: 50, payment_status: 'completed', donation_type: 'renewal', gateway: 'stripe', transaction_id: `pi_testRenewal1_${ Date.now() }`, subscription_id: recurring.subscription_id, parent_subscription_id: recurring.id, } ); const renewal2 = await createTestDonation( requestUtils, { campaign_id: campaignId, donor_name: 'E2E Recurring Donor', donor_email: recurring.donor_email, amount: 50, payment_status: 'completed', donation_type: 'renewal', gateway: 'stripe', transaction_id: `pi_testRenewal2_${ Date.now() }`, subscription_id: recurring.subscription_id, parent_subscription_id: recurring.id, } ); return { campaignId, oneTimeDonationId: oneTime.id, recurringDonationId: recurring.id, renewalDonationIds: [ renewal1.id, renewal2.id ], }; } /** * Clean up seeded test data. * * @param {Object} requestUtils Playwright requestUtils fixture. * @param {Object} ids IDs from seedRecurringTestData(). */ async function cleanupTestData( requestUtils, ids ) { const donationIds = [ ...( ids.renewalDonationIds || [] ), ids.recurringDonationId, ids.oneTimeDonationId, ].filter( Boolean ); for ( const id of donationIds ) { try { await requestUtils.rest( { method: 'DELETE', path: `${ SD_API }/donations/${ id }`, } ); } catch ( e ) { // Ignore cleanup errors. } } if ( ids.campaignId ) { try { await requestUtils.rest( { method: 'DELETE', path: `/wp/v2/${ CAMPAIGN_CPT }/${ ids.campaignId }?force=true`, } ); } catch ( e ) { // Ignore cleanup errors. } } } const FORM_CPT = 'suredonation_form'; /** * Create a recurring donation form page. * * Creates a campaign (which auto-generates a default form with all fields), * updates the payment block to subscription mode, then creates a published * page using the [suredonation_form] shortcode. * * @param {Object} requestUtils Playwright requestUtils fixture. * @return {Promise} { pageUrl, campaignId, formId, pageId }. */ async function createRecurringFormPage( requestUtils ) { // 1. Create a published campaign — auto-creates default form with // amount, name, email, payment, and donate-button blocks. const campaignId = await createTestCampaign( requestUtils ); // 2. Get the default form via the plugin's forms API. const formsResponse = await requestUtils.rest( { method: 'GET', path: `${ SD_API }/forms?campaign_id=${ campaignId }`, } ); const forms = formsResponse.forms || formsResponse; const formId = forms?.[ 0 ]?.id; if ( ! formId ) { throw new Error( `Default form not created for campaign ${ campaignId }` ); } // 3. Update the payment block from one-time to subscription. const form = await requestUtils.rest( { method: 'GET', path: `/wp/v2/${ FORM_CPT }/${ formId }?context=edit`, } ); const updatedContent = ( form.content?.raw || '' ).replace( /"paymentType"\s*:\s*"one-time"/, `"paymentType":"subscription","subscriptionPlan":{"name":"Monthly Donation","interval":"month","billingCycles":"ongoing"}` ); await requestUtils.rest( { method: 'PUT', path: `/wp/v2/${ FORM_CPT }/${ formId }`, data: { content: updatedContent }, } ); // 4. Create a page with the shortcode. const page = await requestUtils.rest( { method: 'POST', path: '/wp/v2/pages', data: { title: `E2E Recurring Donation ${ Date.now() }`, status: 'publish', content: `[suredonation_form id="${ formId }"]`, }, } ); return { pageUrl: page.link, campaignId, formId, pageId: page.id, }; } /** * Clean up a recurring form page and its associated content. * * @param {Object} requestUtils Playwright requestUtils fixture. * @param {Object} ids IDs from createRecurringFormPage(). */ async function cleanupRecurringFormPage( requestUtils, ids ) { for ( const { path } of [ { path: `/wp/v2/pages/${ ids.pageId }?force=true` }, { path: `/wp/v2/${ FORM_CPT }/${ ids.formId }?force=true`, }, { path: `/wp/v2/${ CAMPAIGN_CPT }/${ ids.campaignId }?force=true`, }, ] ) { try { await requestUtils.rest( { method: 'DELETE', path } ); } catch ( e ) { // Ignore cleanup errors. } } } /** * Enable offline donations via REST API. * * @param {Object} requestUtils Playwright requestUtils fixture. * @param {string} instructions Optional custom instructions HTML. * @return {Promise} API response. */ async function enableOfflineDonations( requestUtils, instructions ) { const data = { enabled: true }; if ( instructions !== undefined ) { data.instructions = instructions; } try { return await requestUtils.rest( { method: 'POST', path: `${ SD_API }/payments/offline/settings`, data, } ); } catch ( e ) { // WordPress update_option returns false when value is unchanged, // causing the API to return 500. Verify via GET instead. const current = await getOfflineSettings( requestUtils ); const settings = current.settings || current; if ( settings.enabled === true ) { return { success: true, settings }; } throw e; } } /** * Disable offline donations via REST API. * * @param {Object} requestUtils Playwright requestUtils fixture. * @return {Promise} API response. */ async function disableOfflineDonations( requestUtils ) { try { return await requestUtils.rest( { method: 'POST', path: `${ SD_API }/payments/offline/settings`, data: { enabled: false }, } ); } catch ( e ) { // WordPress update_option returns false when value is unchanged. const current = await getOfflineSettings( requestUtils ); const settings = current.settings || current; if ( settings.enabled === false ) { return { success: true, settings }; } throw e; } } /** * Get current offline donation settings via REST API. * * @param {Object} requestUtils Playwright requestUtils fixture. * @return {Promise} API response with settings. */ async function getOfflineSettings( requestUtils ) { return requestUtils.rest( { method: 'GET', path: `${ SD_API }/payments/offline/settings`, } ); } /** * Create an offline donation form page. * * Creates a campaign (which auto-generates a default form), * updates the payment block to offline-only mode, then creates * a published page using the [suredonation_form] shortcode. * * @param {Object} requestUtils Playwright requestUtils fixture. * @return {Promise} { pageUrl, campaignId, formId, pageId }. */ async function createOfflineFormPage( requestUtils ) { // 1. Create a published campaign — auto-creates default form. const campaignId = await createTestCampaign( requestUtils ); // 2. Get the default form via the plugin's forms API. const formsResponse = await requestUtils.rest( { method: 'GET', path: `${ SD_API }/forms?campaign_id=${ campaignId }`, } ); const forms = formsResponse.forms || formsResponse; const formId = forms?.[ 0 ]?.id; if ( ! formId ) { throw new Error( `Default form not created for campaign ${ campaignId }` ); } // 3. Update the payment block to use offline gateway only. const form = await requestUtils.rest( { method: 'GET', path: `/wp/v2/${ FORM_CPT }/${ formId }?context=edit`, } ); let updatedContent = form.content?.raw || ''; // Update the gateway attribute to offline. updatedContent = updatedContent.replace( /"gateway"\s*:\s*"stripe"/, `"gateway":"offline"` ); // Replace paymentMethods array or add it. if ( /"paymentMethods"/.test( updatedContent ) ) { updatedContent = updatedContent.replace( /"paymentMethods"\s*:\s*\[[^\]]*\]/, `"paymentMethods":["offline"]` ); } else { // Add paymentMethods next to paymentType. updatedContent = updatedContent.replace( /"paymentType"\s*:\s*"one-time"/, `"paymentType":"one-time","paymentMethods":["offline"]` ); } // Inject block_id (and formId for payment block) into ALL suredonation/* blocks. // The Gutenberg editor normally sets block_id on mount via useEffect. // Server-side validation skips blocks without block_id, so we must inject them. updatedContent = updatedContent.replace( /`; // 7. Insert before the donate-button block. if ( updatedContent.includes( '`; const page = await requestUtils.rest( { method: 'POST', path: '/wp/v2/pages', data: { title: `E2E Embed Block ${ Date.now() }`, status: 'publish', content: blockContent, }, } ); return { pageUrl: page.link, campaignId, formId, pageId: page.id, }; } /** * Create a page with the donation form shortcode. * * @param {Object} requestUtils Playwright requestUtils fixture. * @param {Object} options Options (same shape as createEmbedBlockFormPage). * @return {Promise} Object with pageUrl, campaignId, formId, pageId. */ async function createShortcodeFormPage( requestUtils, options = {} ) { const { campaignId, formId } = await prepareFormAndCampaign( requestUtils, options ); const page = await requestUtils.rest( { method: 'POST', path: '/wp/v2/pages', data: { title: `E2E Shortcode Form ${ Date.now() }`, status: 'publish', content: `[suredonation_form id="${ formId }"]`, }, } ); return { pageUrl: page.link, campaignId, formId, pageId: page.id, }; } /** * Clean up a form page and its associated content. * * @param {Object} requestUtils Playwright requestUtils fixture. * @param {Object} ids IDs from createEmbedBlockFormPage/createShortcodeFormPage. */ async function cleanupFormPage( requestUtils, ids ) { for ( const { path } of [ { path: `/wp/v2/pages/${ ids.pageId }?force=true` }, { path: `/wp/v2/${ FORM_CPT }/${ ids.formId }?force=true`, }, { path: `/wp/v2/${ CAMPAIGN_CPT }/${ ids.campaignId }?force=true`, }, ] ) { try { await requestUtils.rest( { method: 'DELETE', path } ); } catch ( e ) { // Ignore cleanup errors. } } } // ─── Form Validation Test Data ────────────────────────────────── /** * Get the form-validation default messages via REST API. * * @param {Object} requestUtils Playwright requestUtils fixture. * @return {Promise} API response: { success, settings: { ...messages } }. */ async function getValidationSettings( requestUtils ) { return requestUtils.rest( { method: 'GET', path: `${ SD_API }/settings/validation`, } ); } /** * Update the form-validation default messages via REST API. * * @param {Object} requestUtils Playwright requestUtils fixture. * @param {Object} messages Map of message key => message. * @return {Promise} API response. */ async function updateValidationSettings( requestUtils, messages ) { try { return await requestUtils.rest( { method: 'POST', path: `${ SD_API }/settings/validation`, data: messages, } ); } catch ( e ) { // WordPress update_option returns false when the value is unchanged, // which can surface as an API error. Verify via GET instead. const current = await getValidationSettings( requestUtils ); const settings = current.settings || current; const matches = Object.keys( messages ).every( ( key ) => settings[ key ] === messages[ key ] ); if ( matches ) { return { success: true, settings }; } throw e; } } /** * Create a donation form page for field-validation tests. * * Builds on the default form (name, email, donation-amount, payment, * donate-button) and optionally injects a required number field (slug * "quantity") with min/max bounds and/or a per-field custom Error Message on * the name input. block_id attributes are injected so server-side validation * runs. Rendered via the [suredonation_form] shortcode. * * @param {Object} requestUtils Playwright requestUtils fixture. * @param {Object} options Options. * @param {string} options.inputErrorMsg Custom Error Message for the name field. * @param {number} options.minValue Number field minimum (default 5). * @param {number} options.maxValue Number field maximum (default 50). * @param {boolean} options.numberField Set false to omit the number field. * @return {Promise} { pageUrl, campaignId, formId, pageId }. */ async function createValidationFormPage( requestUtils, options = {} ) { // Standalone so the form renders without a campaign dependency. const { campaignId, formId } = await prepareFormAndCampaign( requestUtils, { standalone: true, } ); const form = await requestUtils.rest( { method: 'GET', path: `/wp/v2/${ FORM_CPT }/${ formId }?context=edit`, } ); let content = form.content?.raw || ''; // Inject a block_id into every suredonation/* field block that lacks one. // The default template only sets slugs (the editor adds block_id on mount), // and server-side validation skips blocks without a block_id — so REST-built // forms need them injected for the stored block config to include the fields. content = content.replace( /`; if ( content.includes( '