diff --git a/assets/js/src/site/plugins/pum-integrations.js b/assets/js/src/site/plugins/pum-integrations.js index c01d0163d..e28cb540c 100644 --- a/assets/js/src/site/plugins/pum-integrations.js +++ b/assets/js/src/site/plugins/pum-integrations.js @@ -7,15 +7,41 @@ // Ensure PUM exists globally window.PUM = window.PUM || {}; window.PUM.integrations = window.PUM.integrations || {}; + const PUM = window.PUM; + const pumVars = window.pum_vars; function filterNull( x ) { return x; } + function generateSubmissionId() { + if ( window.crypto && 'function' === typeof window.crypto.randomUUID ) { + return window.crypto.randomUUID(); + } + + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( + /[xy]/g, + ( character ) => { + const random = Math.floor( Math.random() * 16 ); + const value = 'x' === character ? random : ( random % 4 ) + 8; + + return value.toString( 16 ); + } + ); + } + + function validSubmissionId( submissionId ) { + if ( 'number' === typeof submissionId ) { + return Number.isFinite( submissionId ); + } + + return 'string' === typeof submissionId && '' !== submissionId; + } + $.extend( window.PUM.integrations, { - init: function () { - if ( 'undefined' !== typeof pum_vars.form_submission ) { - var submission = pum_vars.form_submission; + init() { + if ( pumVars && 'undefined' !== typeof pumVars.form_submission ) { + const submission = pumVars.form_submission; // Declare these are not AJAX submissions. submission.ajax = false; @@ -39,12 +65,16 @@ * @param {Object} form JavaScript DOM node or jQuery object for the form submitted * @param {Object} args { * @type {string} formProvider Such as gravityforms or ninjaforms - * @type {string|int} formId Usually an integer ID number such as 1 - * @type {int} formInstanceId Not all form plugins support this. + * @type {string|number} formId Usually an integer ID number such as 1 + * @type {number} formInstanceId Not all form plugins support this. + * @type {string|number} submissionId Stable submission or provider entry ID. + * @type {number} sourcePostId Optional post/page ID where the form was submitted. + * @type {string} sourceUrl URL where the form was submitted. + * @type {Object} context Extension-owned submission context. * } */ - formSubmission: function ( form, args ) { - var $popup = PUM.getPopup( form ); + formSubmission( form, args ) { + const $popup = PUM.getPopup( form ); args = $.extend( { @@ -52,6 +82,10 @@ formProvider: null, formId: null, formInstanceId: null, + submissionId: null, + sourcePostId: null, + sourceUrl: window.location.href, + context: {}, formKey: null, ajax: true, // Allows detecting submissions that may have already been counted. tracked: false, @@ -59,6 +93,41 @@ args ); + args.submissionId = validSubmissionId( args.submissionId ) + ? args.submissionId + : generateSubmissionId(); + args.context = + args.context && + 'object' === typeof args.context && + ! Array.isArray( args.context ) + ? args.context + : {}; + const canonicalSubmissionId = args.submissionId; + + /** + * Filters normalized form submission arguments before success handlers run. + * + * Extensions can append context without coupling to individual providers. + * + * @param {Object} args Normalized submission arguments. + * @param {Object} form Submitted form element or jQuery object. + */ + args = window.PUM.hooks.applyFilters( + 'pum.integration.form.submissionArgs', + args, + form + ); + + args.submissionId = validSubmissionId( args.submissionId ) + ? args.submissionId + : canonicalSubmissionId; + args.context = + args.context && + 'object' === typeof args.context && + ! Array.isArray( args.context ) + ? args.context + : {}; + // Generate unique formKey identifier. args.formKey = args.formKey || @@ -83,10 +152,14 @@ * @param {Object} form JavaScript DOM node or jQuery object for the form submitted * @param {Object} args { * @type {string} formProvider Such as gravityforms or ninjaforms - * @type {string|int} formId Usually an integer ID number such as 1 - * @type {int} formInstanceId Not all form plugins support this. + * @type {string|number} formId Usually an integer ID number such as 1 + * @type {number} formInstanceId Not all form plugins support this. + * @type {string|number} submissionId Stable submission or provider entry ID. + * @type {number} sourcePostId Optional post/page ID where the form was submitted. + * @type {string} sourceUrl URL where the form was submitted. + * @type {Object} context Extension-owned submission context. * @type {string} formKey Concatenation of provider, ID & Instance ID. - * @type {int} popupId The ID of the popup the form was in. + * @type {number} popupId The ID of the popup the form was in. * @type {Object} popup Usable jQuery object for the popup. * } */ @@ -96,14 +169,14 @@ args ); }, - checkFormKeyMatches: function ( + checkFormKeyMatches( formIdentifier, formInstanceId, submittedFormArgs ) { formInstanceId = '' === formInstanceId ? formInstanceId : false; // Check if the submitted form matches trigger requirements. - var checks = [ + const checks = [ // Any supported form. formIdentifier === 'any', @@ -138,28 +211,28 @@ * @since 1.9.0 * * @param {boolean} matchFound A boolean determining whether a match was found. - * @param {Object} args { + * @param {Object} args { * @type {string} formIdentifier gravityforms_any or ninjaforms_1 - * @type {int} formInstanceId Not all form plugins support this. + * @type {number} formInstanceId Not all form plugins support this. * @type {Object} submittedFormArgs{ * @type {string} formProvider Such as gravityforms or ninjaforms - * @type {string|int} formId Usually an integer ID number such as 1 - * @type {int} formInstanceId Not all form plugins support this. + * @type {string|number} formId Usually an integer ID number such as 1 + * @type {number} formInstanceId Not all form plugins support this. * @type {string} formKey Concatenation of provider, ID & Instance ID. - * @type {int} popupId The ID of the popup the form was in. + * @type {number} popupId The ID of the popup the form was in. * @type {Object} popup Usable jQuery object for the popup. * } * } * - * @returns {boolean} + * @return {boolean} */ return window.PUM.hooks.applyFilters( 'pum.integration.checkFormKeyMatches', matchFound, { - formIdentifier: formIdentifier, - formInstanceId: formInstanceId, - submittedFormArgs: submittedFormArgs, + formIdentifier, + formInstanceId, + submittedFormArgs, } ); }, diff --git a/classes/Integration/Form/FluentForms.php b/classes/Integration/Form/FluentForms.php index d61524a34..95c49cd77 100644 --- a/classes/Integration/Form/FluentForms.php +++ b/classes/Integration/Form/FluentForms.php @@ -115,6 +115,7 @@ public function on_success( $submission_id, $form_data, $form ) { 'popup_id' => $popup_id, 'form_provider' => $this->key, 'form_id' => $form_id, + 'submission_id' => is_scalar( $submission_id ) ? $submission_id : null, ] ); } diff --git a/classes/Integrations.php b/classes/Integrations.php index d29c61980..cc856fcdf 100644 --- a/classes/Integrations.php +++ b/classes/Integrations.php @@ -534,9 +534,24 @@ public static function pum_vars( $vars = [] ) { 'form_provider' => 'formProvider', 'form_id' => 'formId', 'form_instance_id' => 'formInstanceId', + 'submission_id' => 'submissionId', 'popup_id' => 'popupId', + 'source_post_id' => 'sourcePostId', + 'source_url' => 'sourceUrl', ] ); + + // remap_keys intentionally skips empty values, but these values are meaningful. + $preserved_keys = [ + 'submission_id' => 'submissionId', + 'source_url' => 'sourceUrl', + ]; + + foreach ( $preserved_keys as $php_key => $js_key ) { + if ( array_key_exists( $php_key, self::$form_submission ) ) { + $vars['form_submission'][ $js_key ] = self::$form_submission[ $php_key ]; + } + } } return $vars; diff --git a/docs/form-submission-context.md b/docs/form-submission-context.md new file mode 100644 index 000000000..a6709f6ce --- /dev/null +++ b/docs/form-submission-context.md @@ -0,0 +1,67 @@ +# Normalized form submission context + +Popup Maker form integrations report successful submissions through +`pum_integrated_form_submission()` in PHP and +`PUM.integrations.formSubmission()` in JavaScript. The normalized envelope is +provider-independent, and extensions can attach their own namespaced data to +`context` without changing each provider integration. + +## Contract + +PHP integrations use snake-cased keys: + +```php +pum_integrated_form_submission( [ + 'form_provider' => 'example', + 'form_id' => 12, + 'submission_id' => 'entry-456', + 'source_post_id' => 78, + 'source_url' => 'https://example.com/guide/', + 'context' => [ + 'my_extension' => [ + 'campaign_id' => 90, + ], + ], +] ); +``` + +JavaScript integrations receive the camel-cased equivalents. When a provider +does not supply `submission_id` / `submissionId`, Popup Maker generates a UUID +for that one normalized event. Provider-native submission IDs are the reliable +correlation key when matching independently observed server and frontend +callbacks; independently generated IDs are not a cross-transport deduplication +mechanism. + +`source_url` defaults to the sanitized request referrer in PHP and the current +page URL in JavaScript. PHP resolves `source_post_id` from the effective source +URL when an explicit numeric post ID is not supplied, including after an +extension replaces the URL. Both values remain nullable because referrers may +be unavailable and not every URL represents a WordPress post. A PHP `null` +source URL remains `null` when localized for browser replay rather than being +replaced with the post-redirect page URL. + +## Extension context + +PHP extensions can use the existing +`pum_integrated_form_submission_args` filter. Frontend extensions use the +`pum.integration.form.submissionArgs` filter, which runs after defaults and +popup resolution but before the form key, conversion event, and normalized +success action are produced. + +```js +PUM.hooks.addFilter( + 'pum.integration.form.submissionArgs', + ( args ) => ( { + ...args, + context: { + ...args.context, + myExtension: { campaignId: 90 }, + }, + } ) +); +``` + +Context and source values are descriptive metadata, not proof of identity or +authorization. Consumers must validate untrusted values before privileged +operations and apply their own privacy and retention policies before storing +submission data. diff --git a/includes/functions/developers.php b/includes/functions/developers.php index 2149dd4ee..e98624242 100644 --- a/includes/functions/developers.php +++ b/includes/functions/developers.php @@ -44,12 +44,21 @@ function pum_trigger_popup_form_success( $popup_id = null, $settings = [] ) { * @type string $form_provider Key indicating which form provider this form belongs to. * @type string|int $form_id Form ID, usually numeric, but can be hash based. * @type int $form_instance_id Optional form instance ID. + * @type string|int $submission_id Stable submission or provider entry ID. Generated when omitted. * @type int $popup_id Optional popup ID. + * @type int $source_post_id Optional post/page ID where the form was submitted. + * @type string $source_url Optional URL where the form was submitted. + * @type array $context Optional extension-owned submission context. * @type bool $ajax If the submission was processed via AJAX. Generally gonna be false outside of JavaScript. * @type bool $tracked Whether the submission has been handled by tracking code or not. Prevents duplicates. * } */ function pum_integrated_form_submission( $args = [] ) { + $args = is_array( $args ) ? $args : []; + $source_url = wp_get_raw_referer(); + $source_url = $source_url ? esc_url_raw( $source_url ) : null; + $source_post_id_was_explicit = isset( $args['source_post_id'] ) && is_scalar( $args['source_post_id'] ) && ! is_bool( $args['source_post_id'] ) && is_numeric( $args['source_post_id'] ) && absint( $args['source_post_id'] ) > 0; + $args = wp_parse_args( $args, [ @@ -57,13 +66,50 @@ function pum_integrated_form_submission( $args = [] ) { 'form_provider' => null, 'form_id' => null, 'form_instance_id' => null, + 'submission_id' => null, + 'source_post_id' => null, + 'source_url' => $source_url, + 'context' => [], 'ajax' => false, 'tracked' => false, ] ); + if ( ! isset( $args['submission_id'] ) || ( ! is_string( $args['submission_id'] ) && ! is_int( $args['submission_id'] ) ) || '' === (string) $args['submission_id'] ) { + $args['submission_id'] = wp_generate_uuid4(); + } + + $source_post_id = is_scalar( $args['source_post_id'] ) && ! is_bool( $args['source_post_id'] ) && is_numeric( $args['source_post_id'] ) ? absint( $args['source_post_id'] ) : 0; + $args['source_post_id'] = $source_post_id ? $source_post_id : null; + $args['source_url'] = ! empty( $args['source_url'] ) && is_string( $args['source_url'] ) ? esc_url_raw( $args['source_url'] ) : null; + $args['context'] = isset( $args['context'] ) && is_array( $args['context'] ) ? $args['context'] : []; + + if ( ! $source_post_id_was_explicit && $args['source_url'] ) { + $source_post_id = url_to_postid( $args['source_url'] ); + $args['source_post_id'] = $source_post_id ? $source_post_id : null; + } + + $submission_id = $args['submission_id']; + $source_post_id_before_filter = $args['source_post_id']; + $args = apply_filters( 'pum_integrated_form_submission_args', $args ); + if ( ! isset( $args['submission_id'] ) || ( ! is_string( $args['submission_id'] ) && ! is_int( $args['submission_id'] ) ) || '' === (string) $args['submission_id'] ) { + $args['submission_id'] = $submission_id; + } + + $filtered_source_post_id = isset( $args['source_post_id'] ) ? $args['source_post_id'] : null; + $filter_changed_source_post_id = $filtered_source_post_id !== $source_post_id_before_filter; + $source_post_id = is_scalar( $filtered_source_post_id ) && ! is_bool( $filtered_source_post_id ) && is_numeric( $filtered_source_post_id ) ? absint( $filtered_source_post_id ) : 0; + $args['source_post_id'] = $source_post_id ? $source_post_id : null; + $args['source_url'] = ! empty( $args['source_url'] ) && is_string( $args['source_url'] ) ? esc_url_raw( $args['source_url'] ) : null; + $args['context'] = isset( $args['context'] ) && is_array( $args['context'] ) ? $args['context'] : []; + + if ( ! $source_post_id_was_explicit && ! $filter_changed_source_post_id ) { + $source_post_id = $args['source_url'] ? url_to_postid( $args['source_url'] ) : 0; + $args['source_post_id'] = $source_post_id ? $source_post_id : null; + } + PUM_Integrations::$form_submission = $args; do_action( 'pum_integrated_form_submission', $args ); diff --git a/tests/php/tests/FormSubmissionContext_Test.php b/tests/php/tests/FormSubmissionContext_Test.php new file mode 100644 index 000000000..b5ab2ec51 --- /dev/null +++ b/tests/php/tests/FormSubmissionContext_Test.php @@ -0,0 +1,357 @@ +original_referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : null; + unset( $_SERVER['HTTP_REFERER'] ); + } + + /** + * Reset shared integration state after each test. + */ + public function tearDown(): void { + PUM_Integrations::$form_submission = null; + + if ( $this->context_filter ) { + remove_filter( 'pum_integrated_form_submission_args', $this->context_filter ); + } + if ( $this->submission_action ) { + remove_action( 'pum_integrated_form_submission', $this->submission_action ); + } + + if ( null === $this->original_referer ) { + unset( $_SERVER['HTTP_REFERER'] ); + } else { + $_SERVER['HTTP_REFERER'] = $this->original_referer; + } + + parent::tearDown(); + } + + /** + * Missing context receives portable defaults and a generated identifier. + */ + public function test_context_fields_have_defaults() { + pum_integrated_form_submission( + [ + 'form_provider' => 'gravityforms', + 'form_id' => 7, + ] + ); + + $submission = PUM_Integrations::$form_submission; + + $this->assertTrue( wp_is_uuid( $submission['submission_id'], 4 ) ); + $this->assertNull( $submission['source_post_id'] ); + $this->assertNull( $submission['source_url'] ); + $this->assertSame( [], $submission['context'] ); + } + + /** + * Extension filters receive a canonical submission identifier. + */ + public function test_filter_receives_generated_submission_id() { + $seen_submission_id = null; + + $this->context_filter = static function ( $args ) use ( &$seen_submission_id ) { + $seen_submission_id = $args['submission_id']; + + return $args; + }; + add_filter( 'pum_integrated_form_submission_args', $this->context_filter ); + + pum_integrated_form_submission(); + + $this->assertTrue( wp_is_uuid( $seen_submission_id, 4 ) ); + $this->assertSame( $seen_submission_id, PUM_Integrations::$form_submission['submission_id'] ); + } + + /** + * Provider identifiers and extension context survive normalized dispatch. + */ + public function test_extension_context_is_preserved() { + $received = null; + + $this->context_filter = static function ( $args ) { + $args['context']['example_extension'] = [ + 'campaign_id' => 42, + ]; + + return $args; + }; + add_filter( 'pum_integrated_form_submission_args', $this->context_filter ); + + $this->submission_action = static function ( $args ) use ( &$received ) { + $received = $args; + }; + add_action( 'pum_integrated_form_submission', $this->submission_action ); + + pum_integrated_form_submission( + [ + 'form_provider' => 'gravityforms', + 'form_id' => 7, + 'submission_id' => 'entry-99', + 'source_post_id' => 123, + 'source_url' => 'https://example.com/guide/', + ] + ); + + $this->assertSame( 'entry-99', $received['submission_id'] ); + $this->assertSame( 123, $received['source_post_id'] ); + $this->assertSame( 'https://example.com/guide/', $received['source_url'] ); + $this->assertSame( 42, $received['context']['example_extension']['campaign_id'] ); + } + + /** + * Request referrers resolve into source URLs and WordPress post IDs. + */ + public function test_source_context_is_resolved_from_request_referer() { + $post_id = self::factory()->post->create(); + $_SERVER['HTTP_REFERER'] = get_permalink( $post_id ); + + pum_integrated_form_submission(); + + $submission = PUM_Integrations::$form_submission; + + $this->assertSame( $post_id, $submission['source_post_id'] ); + $this->assertSame( get_permalink( $post_id ), $submission['source_url'] ); + } + + /** + * Provider URLs resolve their own post ID when no explicit ID is supplied. + */ + public function test_source_post_id_is_resolved_from_provider_url() { + $post_id = self::factory()->post->create(); + + pum_integrated_form_submission( [ 'source_url' => get_permalink( $post_id ) ] ); + + $this->assertSame( $post_id, PUM_Integrations::$form_submission['source_post_id'] ); + } + + /** + * Nullable and zero placeholders do not block source URL resolution. + * + * @dataProvider unresolved_source_post_id_provider + * + * @param int|string|null $source_post_id Unresolved source post ID placeholder. + */ + public function test_source_post_id_placeholder_is_resolved_from_provider_url( $source_post_id ) { + $post_id = self::factory()->post->create(); + + pum_integrated_form_submission( + [ + 'source_post_id' => $source_post_id, + 'source_url' => get_permalink( $post_id ), + ] + ); + + $this->assertSame( $post_id, PUM_Integrations::$form_submission['source_post_id'] ); + } + + /** + * Source post ID placeholders that still require URL resolution. + * + * @return array + */ + public function unresolved_source_post_id_provider() { + return [ + 'null' => [ null ], + 'integer zero' => [ 0 ], + 'string zero' => [ '0' ], + ]; + } + + /** + * A filter-replaced URL refreshes an implicitly derived post ID. + */ + public function test_filter_replaced_source_url_refreshes_implicit_post_id() { + $original_post_id = self::factory()->post->create(); + $filtered_post_id = self::factory()->post->create(); + + $this->context_filter = static function ( $args ) use ( $filtered_post_id ) { + $args['source_url'] = get_permalink( $filtered_post_id ); + + return $args; + }; + add_filter( 'pum_integrated_form_submission_args', $this->context_filter ); + + pum_integrated_form_submission( [ 'source_url' => get_permalink( $original_post_id ) ] ); + + $this->assertSame( $filtered_post_id, PUM_Integrations::$form_submission['source_post_id'] ); + $this->assertSame( get_permalink( $filtered_post_id ), PUM_Integrations::$form_submission['source_url'] ); + } + + /** + * Removing the source URL also clears its implicitly derived post ID. + */ + public function test_filter_removed_source_url_clears_implicit_post_id() { + $post_id = self::factory()->post->create(); + + $this->context_filter = static function ( $args ) { + $args['source_url'] = null; + + return $args; + }; + add_filter( 'pum_integrated_form_submission_args', $this->context_filter ); + + pum_integrated_form_submission( [ 'source_url' => get_permalink( $post_id ) ] ); + + $this->assertNull( PUM_Integrations::$form_submission['source_post_id'] ); + $this->assertNull( PUM_Integrations::$form_submission['source_url'] ); + } + + /** + * Invalid extension values cannot break the portable envelope. + */ + public function test_invalid_context_values_are_normalized() { + $this->context_filter = static function ( $args ) { + $args['submission_id'] = []; + $args['source_post_id'] = 'not-a-post'; + $args['source_url'] = []; + $args['context'] = 'not-an-array'; + + return $args; + }; + add_filter( 'pum_integrated_form_submission_args', $this->context_filter ); + + pum_integrated_form_submission(); + + $submission = PUM_Integrations::$form_submission; + + $this->assertTrue( wp_is_uuid( $submission['submission_id'], 4 ) ); + $this->assertNull( $submission['source_post_id'] ); + $this->assertNull( $submission['source_url'] ); + $this->assertSame( [], $submission['context'] ); + } + + /** + * Non-numeric source post IDs are never coerced into post 1. + * + * @dataProvider invalid_source_post_id_provider + * + * @param mixed $source_post_id Invalid source post ID. + */ + public function test_invalid_source_post_ids_are_rejected( $source_post_id ) { + $this->context_filter = static function ( $args ) use ( $source_post_id ) { + $args['source_post_id'] = $source_post_id; + + return $args; + }; + add_filter( 'pum_integrated_form_submission_args', $this->context_filter ); + + pum_integrated_form_submission(); + + $this->assertNull( PUM_Integrations::$form_submission['source_post_id'] ); + } + + /** + * Invalid values for source post ID normalization. + * + * @return array + */ + public function invalid_source_post_id_provider() { + return [ + 'array' => [ [ 1 ] ], + 'object' => [ new stdClass() ], + 'boolean' => [ true ], + ]; + } + + /** + * Non-AJAX submissions retain context when mapped into frontend arguments. + */ + public function test_submission_context_is_remapped_for_javascript() { + PUM_Integrations::$form_submission = [ + 'form_provider' => 'fluentforms', + 'form_id' => 4, + 'form_instance_id' => 2, + 'submission_id' => 'entry-12', + 'popup_id' => 55, + 'source_post_id' => 78, + 'source_url' => 'https://example.com/guide/', + 'context' => [ 'example_extension' => [ 'campaign_id' => 90 ] ], + ]; + + $vars = PUM_Integrations::pum_vars(); + $submission = $vars['form_submission']; + + $this->assertSame( 'fluentforms', $submission['formProvider'] ); + $this->assertSame( 4, $submission['formId'] ); + $this->assertSame( 2, $submission['formInstanceId'] ); + $this->assertSame( 'entry-12', $submission['submissionId'] ); + $this->assertSame( 55, $submission['popupId'] ); + $this->assertSame( 78, $submission['sourcePostId'] ); + $this->assertSame( 'https://example.com/guide/', $submission['sourceUrl'] ); + $this->assertSame( 90, $submission['context']['example_extension']['campaign_id'] ); + } + + /** + * A nullable source URL remains null in localized JavaScript arguments. + */ + public function test_null_source_url_is_preserved_for_javascript() { + PUM_Integrations::$form_submission = [ + 'form_provider' => 'fluentforms', + 'form_id' => 4, + 'source_url' => null, + ]; + + $submission = PUM_Integrations::pum_vars()['form_submission']; + + $this->assertArrayHasKey( 'sourceUrl', $submission ); + $this->assertNull( $submission['sourceUrl'] ); + } + + /** + * Zero-valued provider identifiers survive JavaScript remapping. + * + * @dataProvider zero_submission_id_provider + * + * @param int|string $submission_id Provider submission ID. + */ + public function test_zero_submission_id_is_preserved_for_javascript( $submission_id ) { + PUM_Integrations::$form_submission = [ + 'form_provider' => 'fluentforms', + 'form_id' => 4, + 'submission_id' => $submission_id, + ]; + + $submission = PUM_Integrations::pum_vars()['form_submission']; + + $this->assertArrayHasKey( 'submissionId', $submission ); + $this->assertSame( $submission_id, $submission['submissionId'] ); + } + + /** + * Accepted zero-valued submission identifiers. + * + * @return array + */ + public function zero_submission_id_provider() { + return [ + 'integer zero' => [ 0 ], + 'string zero' => [ '0' ], + ]; + } +} diff --git a/tests/unit/pum-integrations.test.js b/tests/unit/pum-integrations.test.js new file mode 100644 index 000000000..69c9a85fa --- /dev/null +++ b/tests/unit/pum-integrations.test.js @@ -0,0 +1,117 @@ +describe( 'PUM normalized form submission context', () => { + let applyFilters; + let doAction; + + beforeEach( () => { + jest.resetModules(); + + applyFilters = jest.fn( ( hookName, args ) => args ); + doAction = jest.fn(); + + window.pum_vars = {}; + window.PUM = { + getPopup: jest.fn( () => ( { length: 0 } ) ), + getSetting: jest.fn(), + hooks: { + applyFilters, + doAction, + }, + integrations: {}, + }; + window.jQuery = { + extend: ( target, ...sources ) => + Object.assign( target, ...sources ), + }; + + require( '../../assets/js/src/site/plugins/pum-integrations' ); + } ); + + test( 'generates portable defaults before dispatch', () => { + window.PUM.integrations.formSubmission( null, { + formProvider: 'gravityforms', + formId: 7, + } ); + + const [ hookName, form, args ] = doAction.mock.calls[ 0 ]; + + expect( hookName ).toBe( 'pum.integration.form.success' ); + expect( form ).toBeNull(); + expect( args.submissionId ).toEqual( expect.any( String ) ); + expect( args.sourcePostId ).toBeNull(); + expect( args.sourceUrl ).toBe( window.location.href ); + expect( args.context ).toEqual( {} ); + expect( args.formKey ).toBe( 'gravityforms_7' ); + expect( applyFilters.mock.calls[ 0 ][ 1 ].submissionId ).toEqual( + expect.any( String ) + ); + } ); + + test( 'filters context and preserves a provider submission ID', () => { + applyFilters.mockImplementation( ( hookName, args ) => ( { + ...args, + context: { + exampleExtension: { campaignId: 90 }, + }, + } ) ); + + window.PUM.integrations.formSubmission( null, { + formProvider: 'fluentforms', + formId: 4, + submissionId: 'entry-12', + } ); + + expect( applyFilters ).toHaveBeenCalledWith( + 'pum.integration.form.submissionArgs', + expect.objectContaining( { + submissionId: 'entry-12', + } ), + null + ); + + const args = doAction.mock.calls[ 0 ][ 2 ]; + expect( args.submissionId ).toBe( 'entry-12' ); + expect( args.context.exampleExtension.campaignId ).toBe( 90 ); + } ); + + test( 'retains the canonical ID when a filter returns invalid values', () => { + let canonicalSubmissionId; + + applyFilters.mockImplementation( ( hookName, args ) => { + canonicalSubmissionId = args.submissionId; + + return { + ...args, + submissionId: null, + context: [], + }; + } ); + + window.PUM.integrations.formSubmission( null, { + formProvider: 'gravityforms', + formId: 7, + } ); + + const args = doAction.mock.calls[ 0 ][ 2 ]; + expect( args.submissionId ).toBe( canonicalSubmissionId ); + expect( args.context ).toEqual( {} ); + } ); + + test( 'replaces non-finite numeric submission IDs', () => { + [ NaN, Infinity, -Infinity ].forEach( ( submissionId ) => { + window.PUM.integrations.formSubmission( null, { + formProvider: 'gravityforms', + formId: 7, + submissionId, + } ); + } ); + + const ids = doAction.mock.calls.map( + ( call ) => call[ 2 ].submissionId + ); + expect( ids ).toHaveLength( 3 ); + ids.forEach( ( submissionId ) => { + expect( submissionId ).toEqual( expect.any( String ) ); + expect( submissionId ).not.toHaveLength( 0 ); + } ); + } ); +} );