.
+ * Type-specific settings live in
+ * children. Only the active type's settings div is visible and has its [id]
+ * select named — preventing duplicate field names on form submit.
+ *
+ * @since x.x
+ */
+( function() {
+ /**
+ * Show the active type's settings panel and assign field names.
+ * Hide and strip names from all other type panels.
+ *
+ * Handles any number of `data-frm-gc-field` elements per type panel,
+ * assigning `name="${base}[${fieldKey}]"` when active and removing it when not.
+ *
+ * @param {HTMLElement} itemRow - A .frm_gc_item_row element.
+ */
+ function activateType( itemRow ) {
+ const typeSelect = itemRow.querySelector( '.frm-gc-item-type' );
+ const activeType = typeSelect.value;
+
+ // Derive the item base (e.g. "frm_form_action[X][post_content][items][2]")
+ // from the type select's name by stripping the trailing "[type]" segment.
+ const base = typeSelect.name.replace( /\[type\]$/, '' );
+
+ itemRow.querySelectorAll( '.frm-gc-type-settings' ).forEach( typeDiv => {
+ const isActive = typeDiv.dataset.type === activeType;
+ typeDiv.toggleAttribute( 'hidden', ! isActive );
+
+ // Assign or remove names for all fields in this type panel.
+ typeDiv.querySelectorAll( '[data-frm-gc-field]' ).forEach( field => {
+ const fieldKey = field.dataset.frmGcField;
+ if ( isActive ) {
+ field.name = `${ base }[${ fieldKey }]`;
+ } else {
+ field.removeAttribute( 'name' );
+ }
+ } );
+ } );
+ }
+
+ /**
+ * Re-index all item rows after an addition or removal.
+ *
+ * Keeps name and id attributes contiguous so the submitted PHP array has no
+ * gaps and for/id pairs remain unique. Called after every add or remove.
+ *
+ * @since x.x
+ *
+ * @param {HTMLElement} wrapper - The .frm_gated_content_settings element.
+ * @return {void}
+ */
+ function reindexItems( wrapper ) {
+ const addBtn = wrapper.querySelector( '.frm_gc_add_item' );
+ const fieldBase = addBtn?.dataset.fieldNameBase ?? '';
+ const rows = wrapper.querySelectorAll( '.frm_gc_item_row' );
+
+ rows.forEach( ( row, idx ) => {
+ const typeSelect = row.querySelector( '.frm-gc-item-type' );
+ if ( typeSelect && fieldBase ) {
+ typeSelect.name = `${ fieldBase }[${ idx }][type]`;
+ }
+ assignItemIds( row, wrapper.id, idx );
+ activateType( row );
+ } );
+
+ wrapper.dataset.itemCount = rows.length;
+ }
+
+ /**
+ * Assign id and for attributes to a cloned template row.
+ *
+ * Template labels use data-frm-gc-for="KEY" and selects use data-frm-gc-field="KEY"
+ * instead of for/id attributes to avoid duplicate IDs before cloning. This function
+ * assigns real id/for pairs using the wrapper ID and item index as a unique prefix.
+ *
+ * Handles any number of `data-frm-gc-field` elements per type panel so that
+ * Pro types with multiple selects (e.g. form_id + id for frm_file) work correctly.
+ *
+ * @param {HTMLElement} itemRow - The .frm_gc_item_row element already in the DOM.
+ * @param {string} wrapperBaseId - The wrapper element's id attribute value.
+ * @param {number} idx - Zero-based item index used for unique IDs.
+ */
+ function assignItemIds( itemRow, wrapperBaseId, idx ) {
+ // Type select.
+ const typeSelect = itemRow.querySelector( '[data-frm-gc-field="type"]' );
+ if ( typeSelect ) {
+ typeSelect.id = `${ wrapperBaseId }_type_${ idx }`;
+ const typeLabel = itemRow.querySelector( '[data-frm-gc-for="type"]' );
+ if ( typeLabel ) {
+ typeLabel.htmlFor = typeSelect.id;
+ }
+ }
+
+ // Per-type fields — each type panel can have multiple data-frm-gc-field elements.
+ itemRow.querySelectorAll( '.frm-gc-type-settings' ).forEach( typeDiv => {
+ const type = typeDiv.dataset.type;
+ typeDiv.querySelectorAll( '[data-frm-gc-field]' ).forEach( field => {
+ const fieldKey = field.dataset.frmGcField;
+ field.id = `${ wrapperBaseId }_${ fieldKey }_${ type }_${ idx }`;
+ const label = typeDiv.querySelector( `[data-frm-gc-for="${ fieldKey }"]` );
+ if ( label ) {
+ label.htmlFor = field.id;
+ }
+ } );
+ } );
+ }
+
+ /**
+ * Filter the file field select to show only options matching the selected form.
+ *
+ * When a .frm-gc-file-form-select changes, all options in the sibling file-field
+ * select ([data-frm-gc-field="id"]) are shown or hidden based on their data-form-id.
+ * Options without data-form-id are always shown (e.g. the empty placeholder).
+ *
+ * @param {HTMLElement} formSelect - A .frm-gc-file-form-select element.
+ */
+ function filterFileFields( formSelect ) {
+ const typeDiv = formSelect.closest( '.frm-gc-type-settings' );
+ const fieldSelect = typeDiv?.querySelector( '[data-frm-gc-field="id"]' );
+ if ( ! fieldSelect ) {
+ return;
+ }
+
+ const selectedFormId = formSelect.value;
+
+ Array.from( fieldSelect.options ).forEach( option => {
+ const optFormId = option.dataset.formId;
+ if ( ! optFormId ) {
+ // Placeholder option — always visible.
+ option.style.display = '';
+ return;
+ }
+ option.style.display = ( ! selectedFormId || optFormId !== selectedFormId ) ? 'none' : '';
+ } );
+
+ // If the currently selected field option is now hidden, reset the select.
+ const selected = fieldSelect.options[ fieldSelect.selectedIndex ];
+ if ( selected?.style.display === 'none' ) {
+ fieldSelect.value = '';
+ }
+ }
+
+ /**
+ * Temporarily swap the button icon and aria-label to confirm a successful copy.
+ *
+ * @param {HTMLElement} btn - The .frm_gc_copy_shortcode button element.
+ */
+ function showCopied( btn ) {
+ const use = btn.querySelector( 'use' );
+ const originalHref = use.getAttribute( 'href' );
+ const originalLabel = btn.getAttribute( 'aria-label' );
+
+ use.setAttribute( 'href', '#frm_checkmark_icon' );
+ btn.setAttribute( 'aria-label', btn.dataset.copiedLabel || 'Copied!' );
+
+ setTimeout( () => {
+ use.setAttribute( 'href', originalHref );
+ btn.setAttribute( 'aria-label', originalLabel );
+ }, 1500 );
+ }
+
+ document.addEventListener( 'click', function( event ) {
+ const addBtn = event.target.closest( '.frm_gc_add_item' );
+ if ( addBtn ) {
+ const wrapper = addBtn.closest( '.frm_gated_content_settings' );
+ const list = wrapper.querySelector( '.frm_gc_items_list' );
+ const template = wrapper.querySelector( '.frm_gc_item_template' );
+
+ list.append( template.content.cloneNode( true ) );
+ reindexItems( wrapper );
+ frmDom.autocomplete.initSelectionAutocomplete( list.lastElementChild );
+ return;
+ }
+
+ const removeBtn = event.target.closest( '.frm_gc_remove_item' );
+ if ( removeBtn ) {
+ const wrapper = removeBtn.closest( '.frm_gated_content_settings' );
+ removeBtn.closest( '.frm_gc_item_row' ).remove();
+ reindexItems( wrapper );
+ return;
+ }
+
+ const copyBtn = event.target.closest( '.frm_gc_copy_shortcode' );
+ if ( copyBtn ) {
+ const text = copyBtn.dataset.frmCopy;
+ if ( navigator.clipboard?.writeText ) {
+ navigator.clipboard.writeText( text ).then( () => showCopied( copyBtn ) );
+ } else {
+ // Fallback for browsers without Clipboard API.
+ const textarea = document.createElement( 'textarea' );
+ textarea.value = text;
+ textarea.style.cssText = 'position:fixed;opacity:0;';
+ document.body.append( textarea );
+ textarea.select();
+ document.execCommand( 'copy' );
+ textarea.remove();
+ showCopied( copyBtn );
+ }
+ }
+ } );
+
+ document.addEventListener( 'change', function( event ) {
+ const typeSelect = event.target.closest( '.frm-gc-item-type' );
+ if ( typeSelect ) {
+ activateType( typeSelect.closest( '.frm_gc_item_row' ) );
+ return;
+ }
+
+ const fileFormSelect = event.target.closest( '.frm-gc-file-form-select' );
+ if ( fileFormSelect ) {
+ filterFileFields( fileFormSelect );
+ }
+ } );
+
+ // Show/hide "Keep old token when entry is updated" when the event multi-select changes.
+ jQuery( document ).on( 'frm-multiselect-changed', 'select[id^="event_"]', function() {
+ const section = document.querySelector( `.frm_gc_update_section[data-frm-gc-event-id="${ this.id }"]` );
+ if ( ! section ) {
+ return;
+ }
+ const hasUpdate = Array.from( this.options ).some( function( o ) {
+ return o.selected && 'update' === o.value;
+ } );
+ section.hidden = ! hasUpdate;
+ } );
+}() );
+
window.frmImportCsv = formID => {
let urlVars = '';
if ( typeof __FRMURLVARS !== 'undefined' ) {
diff --git a/js/src/admin/components/dependent-updater-component.js b/js/src/admin/components/dependent-updater-component.js
index df0f035040..e990a9c80f 100644
--- a/js/src/admin/components/dependent-updater-component.js
+++ b/js/src/admin/components/dependent-updater-component.js
@@ -62,16 +62,16 @@ export default class frmStyleDependentUpdaterComponent {
* @return {string} The detected format: 'rgba', 'rgb', 'hsla', 'hsl', or 'hex'.
*/
detectColorFormat( value ) {
- if ( /^rgba/.test( value ) ) {
+ if ( value.startsWith( 'rgba' ) ) {
return 'rgba';
}
- if ( /^rgb/.test( value ) ) {
+ if ( value.startsWith( 'rgb' ) ) {
return 'rgb';
}
- if ( /^hsla/.test( value ) ) {
+ if ( value.startsWith( 'hsla' ) ) {
return 'hsla';
}
- if ( /^hsl/.test( value ) ) {
+ if ( value.startsWith( 'hsl' ) ) {
return 'hsl';
}
return 'hex';
diff --git a/resources/scss/admin/components/form/_form-actions.scss b/resources/scss/admin/components/form/_form-actions.scss
index f0d8e7f318..29c4e5c589 100644
--- a/resources/scss/admin/components/form/_form-actions.scss
+++ b/resources/scss/admin/components/form/_form-actions.scss
@@ -18,6 +18,35 @@
}
}
+// ── Gated Content action ─────────────────────────────────────────────────── //
+
+.frm-gc-item-settings {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+}
+
+.frm_gc_shortcode_table code {
+ background: none;
+}
+
+.frm_gc_shortcode_table th {
+ font-size: var(--text-sm);
+}
+
+.frm_gc_copy_shortcode .frmsvg {
+ color: var(--primary-500);
+}
+
+.frm-gc-type-settings {
+ flex: 1;
+}
+
+.frm-gc-item-delete {
+ flex-shrink: 0;
+ margin-top: 42px;
+}
+
// Search
#frm_email_addon_menu .frm-search {
float: unset;
diff --git a/tests/phpunit/helpers/test_FrmGatedTokenHelper.php b/tests/phpunit/helpers/test_FrmGatedTokenHelper.php
new file mode 100644
index 0000000000..08ff1bebbd
--- /dev/null
+++ b/tests/phpunit/helpers/test_FrmGatedTokenHelper.php
@@ -0,0 +1,311 @@
+ 'post',
+ 'id' => 99,
+ );
+
+ public function setUp(): void {
+ parent::setUp();
+
+ // Create a minimal gated content action post so get_post() resolves it.
+ $this->action_id = wp_insert_post(
+ array(
+ 'post_type' => 'frm_form_actions',
+ 'post_excerpt' => 'gated_content',
+ 'post_status' => 'publish',
+ 'post_content' => wp_json_encode(
+ array(
+ 'items' => array( $this->item ),
+ )
+ ),
+ )
+ );
+ $this->action = get_post( $this->action_id );
+ }
+
+ public function tearDown(): void {
+ parent::tearDown();
+
+ // Clean up any URL/cookie state and per-request caches.
+ unset( $_GET['access_code'] );
+
+ foreach ( array_keys( $_COOKIE ) as $name ) {
+ if ( str_starts_with( $name, 'frm_gc_' ) ) {
+ unset( $_COOKIE[ $name ] );
+ }
+ }
+ $this->reset_helper_caches();
+ wp_set_current_user( 0 );
+ }
+
+ /**
+ * Clear FrmGatedTokenHelper's per-request static caches so tests are isolated.
+ */
+ private function reset_helper_caches() {
+ $this->set_private_property( 'FrmGatedTokenHelper', 'row_cache', array() );
+ $this->set_private_property( 'FrmGatedTokenHelper', 'generated_tokens', array() );
+ }
+
+ // ── generate() ────────────────────────────────────────────────────────── //
+
+ /**
+ * @covers FrmGatedTokenHelper::generate
+ */
+ public function test_generate_returns_raw_token_string() {
+ $token = FrmGatedTokenHelper::generate( $this->action, (object) array( 'id' => 1 ), 'create' );
+
+ $this->assertIsString( $token );
+ // wp_generate_password(32, false) returns exactly 32 alphanumeric characters.
+ $this->assertSame( 32, strlen( $token ) );
+ }
+
+ /**
+ * @covers FrmGatedTokenHelper::generate
+ */
+ public function test_generate_persists_hash_to_db() {
+ global $wpdb;
+
+ $token = FrmGatedTokenHelper::generate( $this->action, (object) array( 'id' => 1 ), 'create' );
+
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT * FROM {$wpdb->prefix}frm_gated_tokens WHERE token_hash = %s",
+ FrmGatedTokenHelper::hash_token( $token )
+ )
+ );
+
+ $this->assertNotNull( $row, 'Token row not found in DB.' );
+ $this->assertEquals( $this->action_id, (int) $row->action_id );
+ }
+
+ /**
+ * @covers FrmGatedTokenHelper::generate
+ */
+ public function test_generate_caches_token_for_same_request() {
+ FrmGatedTokenHelper::generate( $this->action, (object) array( 'id' => 1 ), 'create' );
+
+ $cached = FrmGatedTokenHelper::get_raw_token_for_action( $this->action_id );
+ $this->assertNotNull( $cached, 'Token transient not set after generate().' );
+ $this->assertIsString( $cached );
+ }
+
+ // ── validate_access_code() ───────────────────────────────────────────── //
+
+ /**
+ * @covers FrmGatedTokenHelper::validate_access_code
+ */
+ public function test_validate_access_code_returns_token_for_valid_item() {
+ $token = FrmGatedTokenHelper::generate( $this->action, (object) array( 'id' => 1 ), 'create' );
+
+ $this->assertInstanceOf(
+ FrmGatedToken::class,
+ FrmGatedTokenHelper::validate_access_code( $token, FrmGatedItem::make( $this->item ) )
+ );
+ }
+
+ /**
+ * @covers FrmGatedTokenHelper::validate_access_code
+ */
+ public function test_validate_access_code_returns_null_for_wrong_item_id() {
+ $token = FrmGatedTokenHelper::generate( $this->action, (object) array( 'id' => 1 ), 'create' );
+
+ $wrong_id_item = FrmGatedItem::make(
+ array(
+ 'type' => $this->item['type'],
+ 'id' => 999,
+ )
+ );
+ $this->assertNotInstanceOf(
+ \FrmGatedToken::class,
+ FrmGatedTokenHelper::validate_access_code( $token, $wrong_id_item )
+ );
+ }
+
+ /**
+ * @covers FrmGatedTokenHelper::validate_access_code
+ */
+ public function test_validate_access_code_returns_null_for_wrong_item_type() {
+ $token = FrmGatedTokenHelper::generate( $this->action, (object) array( 'id' => 1 ), 'create' );
+
+ $wrong_type_item = FrmGatedItem::make(
+ array(
+ 'type' => 'frm_file',
+ 'id' => $this->item['id'],
+ )
+ );
+ $this->assertNotInstanceOf(
+ \FrmGatedToken::class,
+ FrmGatedTokenHelper::validate_access_code( $token, $wrong_type_item )
+ );
+ }
+
+ /**
+ * @covers FrmGatedTokenHelper::validate_access_code
+ */
+ public function test_validate_access_code_returns_false_for_expired_token() {
+ global $wpdb;
+
+ $token = FrmGatedTokenHelper::generate( $this->action, (object) array( 'id' => 1 ), 'create' );
+
+ // Back-date the expiry to the past.
+ $wpdb->update(
+ $wpdb->prefix . 'frm_gated_tokens',
+ array( 'expired_at' => time() - 3600 ),
+ array( 'token_hash' => FrmGatedTokenHelper::hash_token( $token ) ),
+ array( '%d' ),
+ array( '%s' )
+ );
+
+ // Evict the row cache so validate() reads the updated row.
+ $this->reset_helper_caches();
+
+ $this->assertNotInstanceOf(
+ \FrmGatedToken::class,
+ FrmGatedTokenHelper::validate_access_code( $token, FrmGatedItem::make( $this->item ) )
+ );
+ }
+
+ /**
+ * @covers FrmGatedTokenHelper::validate_access_code
+ */
+ public function test_validate_access_code_returns_null_for_nonexistent_token() {
+ $this->assertNotInstanceOf(
+ \FrmGatedToken::class,
+ FrmGatedTokenHelper::validate_access_code( 'not-a-real-token', FrmGatedItem::make( $this->item ) )
+ );
+ }
+
+ // ── get_valid_token() — resolution order ──────────────────────────────── //
+
+ /**
+ * When no token source is present, get_valid_token() must return null.
+ *
+ * @covers FrmGatedTokenHelper::get_valid_token
+ */
+ public function test_get_valid_token_returns_null_when_no_token_present() {
+ $result = FrmGatedTokenHelper::get_valid_token( FrmGatedItem::make( $this->item ) );
+ $this->assertNotInstanceOf( \FrmGatedToken::class, $result );
+ }
+
+ /**
+ * A raw token in the `access_code` URL param must be resolved and validated.
+ *
+ * @covers FrmGatedTokenHelper::get_valid_token
+ */
+ public function test_get_valid_token_resolves_via_url_param() {
+ $_GET['access_code'] = FrmGatedTokenHelper::generate( $this->action, (object) array( 'id' => 1 ), 'create' );
+ $this->reset_helper_caches();
+
+ $result = FrmGatedTokenHelper::get_valid_token( FrmGatedItem::make( $this->item ) );
+
+ $this->assertInstanceOf( 'FrmGatedToken', $result );
+ }
+
+ /**
+ * A wrong item ID in the URL param must cause get_valid_token() to return null.
+ *
+ * @covers FrmGatedTokenHelper::get_valid_token
+ */
+ public function test_get_valid_token_returns_null_for_url_param_with_wrong_item() {
+ $_GET['access_code'] = FrmGatedTokenHelper::generate( $this->action, (object) array( 'id' => 1 ), 'create' );
+ $this->reset_helper_caches();
+
+ // Request a different item ID than the one in the action.
+ $wrong_item = FrmGatedItem::make(
+ array(
+ 'type' => $this->item['type'],
+ 'id' => 9999,
+ )
+ );
+ $result = FrmGatedTokenHelper::get_valid_token( $wrong_item );
+
+ $this->assertNotInstanceOf( \FrmGatedToken::class, $result );
+ }
+
+ /**
+ * A hash stored in an frm_gc_* cookie must be resolved and validated.
+ *
+ * @covers FrmGatedTokenHelper::get_valid_token
+ */
+ public function test_get_valid_token_resolves_via_cookie() {
+ $_COOKIE[ 'frm_gc_' . $this->item['type'] . '_' . $this->item['id'] ] = FrmGatedTokenHelper::generate( $this->action, (object) array( 'id' => 1 ), 'create' );
+ $this->reset_helper_caches();
+
+ $result = FrmGatedTokenHelper::get_valid_token( FrmGatedItem::make( $this->item ) );
+
+ $this->assertInstanceOf( 'FrmGatedToken', $result );
+ }
+
+ /**
+ * A token stored in the DB under the current user's ID must be found and validated.
+ *
+ * @covers FrmGatedTokenHelper::get_valid_token
+ */
+ public function test_get_valid_token_resolves_via_user_db() {
+ $user_id = $this->factory->user->create();
+
+ // Log in as the user before generating so generate() captures the user_id.
+ wp_set_current_user( $user_id );
+ FrmGatedTokenHelper::generate( $this->action, (object) array( 'id' => 1 ), 'create' );
+
+ // Clear caches so the URL/cookie paths do not interfere.
+ $this->reset_helper_caches();
+
+ $result = FrmGatedTokenHelper::get_valid_token( FrmGatedItem::make( $this->item ) );
+
+ $this->assertInstanceOf( 'FrmGatedToken', $result );
+ }
+
+ /**
+ * When no core source finds a token, the frm_obtain_gated_token filter fires and
+ * its return value is used.
+ *
+ * @covers FrmGatedTokenHelper::get_valid_token
+ */
+ public function test_get_valid_token_falls_back_to_filter() {
+ $raw_token = FrmGatedTokenHelper::generate( $this->action, (object) array( 'id' => 1 ), 'create' );
+ $hash = FrmGatedTokenHelper::hash_token( $raw_token );
+ $row = FrmGatedTokenHelper::get_row_by_hash( $hash );
+ $stub_token = new FrmGatedToken( $row );
+
+ add_filter(
+ 'frm_obtain_gated_token',
+ static function () use ( $stub_token ) {
+ return $stub_token;
+ },
+ 10
+ );
+
+ $this->reset_helper_caches();
+ $result = FrmGatedTokenHelper::get_valid_token( FrmGatedItem::make( $this->item ) );
+
+ remove_all_filters( 'frm_obtain_gated_token' );
+
+ $this->assertSame( $stub_token, $result );
+ }
+}
diff --git a/tests/phpunit/misc/test_FrmGatedContentController.php b/tests/phpunit/misc/test_FrmGatedContentController.php
new file mode 100644
index 0000000000..aec21418b9
--- /dev/null
+++ b/tests/phpunit/misc/test_FrmGatedContentController.php
@@ -0,0 +1,135 @@
+set_private_property( 'FrmGatedContentController', 'unlocked_post_id', 0 );
+ }
+
+ // ── trigger() — create event ──────────────────────────────────────────── //
+
+ /**
+ * Calling trigger() must generate a token and store it in a transient so that
+ * [frm_gated_content] shortcodes on the same or a subsequent redirect request can use it.
+ *
+ * @covers FrmGatedContentController::trigger
+ */
+ public function test_trigger_generates_and_caches_token() {
+ $action_id = wp_insert_post(
+ array(
+ 'post_type' => 'frm_form_actions',
+ 'post_excerpt' => 'gated_content',
+ 'post_status' => 'publish',
+ 'post_content' => wp_json_encode( array( 'items' => array() ) ),
+ )
+ );
+
+ $action = get_post( $action_id );
+ $entry = (object) array(
+ 'id' => 1,
+ 'form_id' => 1,
+ );
+ $form = (object) array( 'id' => 1 );
+
+ FrmGatedContentController::trigger( $action, $entry, $form, 'create' );
+
+ $raw_token = FrmGatedTokenHelper::get_raw_token_for_action( $action_id );
+ $this->assertNotNull(
+ $raw_token,
+ 'trigger() must store the raw token via FrmGatedTokenHelper::get_raw_token_for_action().'
+ );
+ $this->assertSame( 32, strlen( $raw_token ) );
+ }
+
+ // ── payment-success event ─────────────────────────────────────────────── //
+
+ /**
+ * When a payment succeeds, FrmFormActionsController::trigger_actions() must
+ * dispatch frm_trigger_gated_content_action for any gated content action that
+ * has 'payment-success' in its event list, resulting in a token being generated.
+ *
+ * @covers FrmGatedContentAction::__construct
+ * @covers FrmGatedContentController::trigger
+ */
+ public function test_payment_success_event_generates_token() {
+ $form_id = $this->factory->form->create();
+
+ // Insert the action BEFORE creating the entry so that trigger_create_actions()
+ // (fired by FrmEntry::create) warms the frm_actions cache with the real action.
+ // If the action is inserted after entry creation the cache is primed with an
+ // empty result and the subsequent payment-success trigger never finds it.
+ $action_id = wp_insert_post(
+ array(
+ 'post_type' => 'frm_form_actions',
+ 'post_excerpt' => 'gated_content',
+ 'post_status' => 'publish',
+ 'menu_order' => $form_id,
+ 'post_content' => wp_json_encode(
+ array(
+ 'event' => array( 'payment-success' ),
+ 'items' => array(
+ array(
+ 'type' => 'post',
+ 'id' => 1,
+ ),
+ ),
+ )
+ ),
+ )
+ );
+
+ $entry_id = $this->factory->entry->create( array( 'form_id' => $form_id ) );
+
+ FrmFormActionsController::trigger_actions( 'payment-success', $form_id, $entry_id );
+
+ $raw_token = FrmGatedTokenHelper::get_raw_token_for_action( $action_id );
+ $this->assertNotNull(
+ $raw_token,
+ 'payment-success event must trigger token generation for a gated content action.'
+ );
+ $this->assertSame( 32, strlen( $raw_token ) );
+ }
+
+ /**
+ * A gated content action with only 'create' in its event list must NOT generate
+ * a token when the payment-success event fires.
+ *
+ * @covers FrmGatedContentController::trigger
+ */
+ public function test_payment_success_event_skips_non_matching_action() {
+ $form_id = $this->factory->form->create();
+ $entry_id = $this->factory->entry->create( array( 'form_id' => $form_id ) );
+
+ $action_id = wp_insert_post(
+ array(
+ 'post_type' => 'frm_form_actions',
+ 'post_excerpt' => 'gated_content',
+ 'post_status' => 'publish',
+ 'menu_order' => $form_id,
+ 'post_content' => wp_json_encode(
+ array(
+ 'event' => array( 'create' ),
+ 'items' => array(
+ array(
+ 'type' => 'post',
+ 'id' => 1,
+ ),
+ ),
+ )
+ ),
+ )
+ );
+
+ FrmFormActionsController::trigger_actions( 'payment-success', $form_id, $entry_id );
+
+ $this->assertNull(
+ FrmGatedTokenHelper::get_raw_token_for_action( $action_id ),
+ 'Actions without payment-success in their event list must not generate a token.'
+ );
+ }
+}