From bdd43227221a6e087375039583df8179688613b2 Mon Sep 17 00:00:00 2001 From: Maggie Cabrera Date: Thu, 4 Jun 2026 15:37:49 +0200 Subject: [PATCH 1/3] fix the unlist button and the modal and add the text to the email sent to the user --- .../includes/notifications.php | 39 +++++++++++++++++++ .../src/pattern-post-type/details.js | 7 +++- .../pattern-post-type/unlist-button/index.js | 9 ++++- .../pattern-post-type/unlist-button/modal.js | 8 ++-- 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/public_html/wp-content/plugins/pattern-directory/includes/notifications.php b/public_html/wp-content/plugins/pattern-directory/includes/notifications.php index e61fac045..ebd5c862e 100644 --- a/public_html/wp-content/plugins/pattern-directory/includes/notifications.php +++ b/public_html/wp-content/plugins/pattern-directory/includes/notifications.php @@ -8,11 +8,44 @@ defined( 'WPINC' ) || die(); +/** + * The post meta key holding the moderator's message to the author, which is + * included in the "pattern unlisted" email. + */ +const UNLISTED_DETAIL_META = '_wporg_unlist_reason_detail'; + /** * Actions and filters. */ add_action( 'wp_after_insert_post', __NAMESPACE__ . '\trigger_notifications', 20, 4 ); add_action( 'wporg_unlist_pattern', __NAMESPACE__ . '\notify_pattern_flagged' ); +add_action( 'init', __NAMESPACE__ . '\register_unlisted_meta' ); + +/** + * Register the post meta used to store the moderator's message to the author. + * + * Editable by anyone who can edit the pattern (i.e. moderators), so it can be + * set from the Unlist modal in the block editor and read back when sending the + * "pattern unlisted" email. + * + * @return void + */ +function register_unlisted_meta() { + register_post_meta( + PATTERN, + UNLISTED_DETAIL_META, + array( + 'type' => 'string', + 'description' => 'A message from the moderator, included in the email sent to the author when a pattern is unlisted.', + 'single' => true, + 'show_in_rest' => true, + 'sanitize_callback' => 'sanitize_textarea_field', + 'auth_callback' => function( $allowed, $meta_key, $object_id ) { + return current_user_can( 'edit_post', $object_id ); + }, + ) + ); +} /** * Fire off relevant notification when a post is finished updating. @@ -211,6 +244,12 @@ function notify_pattern_unlisted( $post ) { $reason = get_default_reason_description(); } + // Append the moderator's message to the author, if one was provided. + $detail = get_post_meta( $post->ID, UNLISTED_DETAIL_META, true ); + if ( $detail ) { + $reason .= "\n\n" . $detail; + } + $subject = esc_html__( 'Pattern unlisted', 'wporg-patterns' ); $message = sprintf( diff --git a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js index 7c9a923f4..dd1a96819 100644 --- a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js +++ b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js @@ -2,7 +2,12 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { PluginDocumentSettingPanel } from '@wordpress/edit-post'; +// `PluginDocumentSettingPanel` moved from `@wordpress/edit-post` to `@wordpress/editor`. +// Import from both and use whichever the running WordPress version provides. +import { PluginDocumentSettingPanel as PluginDocumentSettingPanelFromEditor } from '@wordpress/editor'; +import { PluginDocumentSettingPanel as PluginDocumentSettingPanelFromEditPost } from '@wordpress/edit-post'; +const PluginDocumentSettingPanel = + PluginDocumentSettingPanelFromEditor || PluginDocumentSettingPanelFromEditPost; import { ComboboxControl, FormTokenField, TextControl, TextareaControl } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; diff --git a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js index 5ab72c2a1..2a9dd5aba 100644 --- a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js +++ b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js @@ -2,7 +2,11 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { PluginPostStatusInfo } from '@wordpress/edit-post'; +// `PluginPostStatusInfo` moved from `@wordpress/edit-post` to `@wordpress/editor`. +// Import from both and use whichever the running WordPress version provides. +import { PluginPostStatusInfo as PluginPostStatusInfoFromEditor } from '@wordpress/editor'; +import { PluginPostStatusInfo as PluginPostStatusInfoFromEditPost } from '@wordpress/edit-post'; +const PluginPostStatusInfo = PluginPostStatusInfoFromEditor || PluginPostStatusInfoFromEditPost; import { Button } from '@wordpress/components'; import { store as editorStore } from '@wordpress/editor'; import { useDispatch, useSelect } from '@wordpress/data'; @@ -23,10 +27,11 @@ export const UnlistButton = () => { const { editPost, savePost } = useDispatch( editorStore ); const [ showModal, setShowModal ] = useState( false ); - const onSubmit = ( reasonId ) => { + const onSubmit = ( reasonId, details = '' ) => { editPost( { status: UNLISTED_STATUS, 'wporg-pattern-flag-reason': [ reasonId ], + meta: { _wporg_unlist_reason_detail: details }, } ); savePost(); }; diff --git a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/modal.js b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/modal.js index e3838641c..80b34b8ec 100644 --- a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/modal.js +++ b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/modal.js @@ -74,7 +74,7 @@ const UnlistModal = ( { onClose, onSubmit } ) => { }, [] ); const submittedText = __( - 'The pattern has been unlisted, and your internal note has been saved.', + 'The pattern has been unlisted, and the author has been notified by email.', 'wporg-patterns' ); @@ -101,7 +101,7 @@ const UnlistModal = ( { onClose, onSubmit } ) => { note: details ? `UNLISTED: ${ reason.label } — ${ details }` : `UNLISTED: ${ reason.label }`, onSuccess: () => { if ( 'function' === typeof onSubmit ) { - onSubmit( selectedOption ); + onSubmit( selectedOption, details ); } dispatch( { status: 'NOTE_RECIEVED' } ); speak( submittedText ); @@ -155,9 +155,9 @@ const UnlistModal = ( { onClose, onSubmit } ) => { ) } Date: Thu, 4 Jun 2026 15:51:35 +0200 Subject: [PATCH 2/3] Fix lint: merge duplicate @wordpress/editor imports Combine the editor-package imports so there is a single import per package (no-duplicate-imports) and let prettier format them. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/pattern-post-type/details.js | 14 ++++++++------ .../src/pattern-post-type/unlist-button/index.js | 8 ++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js index dd1a96819..333a7f4e6 100644 --- a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js +++ b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js @@ -2,15 +2,17 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { ComboboxControl, FormTokenField, TextControl, TextareaControl } from '@wordpress/components'; +import { useDispatch, useSelect } from '@wordpress/data'; // `PluginDocumentSettingPanel` moved from `@wordpress/edit-post` to `@wordpress/editor`. // Import from both and use whichever the running WordPress version provides. -import { PluginDocumentSettingPanel as PluginDocumentSettingPanelFromEditor } from '@wordpress/editor'; +import { + PluginDocumentSettingPanel as PluginDocumentSettingPanelFromEditor, + store as editorStore, +} from '@wordpress/editor'; import { PluginDocumentSettingPanel as PluginDocumentSettingPanelFromEditPost } from '@wordpress/edit-post'; -const PluginDocumentSettingPanel = - PluginDocumentSettingPanelFromEditor || PluginDocumentSettingPanelFromEditPost; -import { ComboboxControl, FormTokenField, TextControl, TextareaControl } from '@wordpress/components'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { store as editorStore } from '@wordpress/editor'; + +const PluginDocumentSettingPanel = PluginDocumentSettingPanelFromEditor || PluginDocumentSettingPanelFromEditPost; const KEYWORD_SLUG = 'wpop_keywords'; const DESCRIPTION_SLUG = 'wpop_description'; diff --git a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js index 2a9dd5aba..087476063 100644 --- a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js +++ b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js @@ -2,13 +2,11 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; // `PluginPostStatusInfo` moved from `@wordpress/edit-post` to `@wordpress/editor`. // Import from both and use whichever the running WordPress version provides. -import { PluginPostStatusInfo as PluginPostStatusInfoFromEditor } from '@wordpress/editor'; +import { PluginPostStatusInfo as PluginPostStatusInfoFromEditor, store as editorStore } from '@wordpress/editor'; import { PluginPostStatusInfo as PluginPostStatusInfoFromEditPost } from '@wordpress/edit-post'; -const PluginPostStatusInfo = PluginPostStatusInfoFromEditor || PluginPostStatusInfoFromEditPost; -import { Button } from '@wordpress/components'; -import { store as editorStore } from '@wordpress/editor'; import { useDispatch, useSelect } from '@wordpress/data'; import { useState } from '@wordpress/element'; @@ -19,6 +17,8 @@ import { UNLISTED_STATUS } from '../settings'; import UnlistModal from './modal'; import './unlist.scss'; +const PluginPostStatusInfo = PluginPostStatusInfoFromEditor || PluginPostStatusInfoFromEditPost; + export const UnlistButton = () => { const status = useSelect( ( select ) => { const _post = select( editorStore ).getCurrentPost(); From 2933b48964cfef91b660c2de493046ee6d4288c2 Mon Sep 17 00:00:00 2001 From: Boro Sitnikovski Date: Mon, 5 Oct 2026 13:56:31 +0200 Subject: [PATCH 3/3] Unlist message: address review feedback - Only moderators can write the message, and it is exposed in the REST edit context only, so the public API never shows it. - Consume the message when the pattern is unlisted, so a later unlisting (list screen, bulk action) doesn't resend it. - Leave the message out of the meta control-character and directive checks, since it only goes into a plain-text email. - Wait for the post save and check `didPostSaveRequestFail()` before confirming; keep the modal retryable on failure. The confirmation no longer claims an email was sent. - Import the slot fills from `@wordpress/editor` only; the old location still works, so the fallback was never used. - Add tests for the message flow. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../includes/notifications.php | 18 +- .../includes/pattern-validation.php | 26 ++- .../src/pattern-post-type/details.js | 10 +- .../pattern-post-type/unlist-button/index.js | 16 +- .../pattern-post-type/unlist-button/modal.js | 46 ++-- .../phpunit/class-unlist-message-test.php | 221 ++++++++++++++++++ 6 files changed, 289 insertions(+), 48 deletions(-) create mode 100644 public_html/wp-content/plugins/pattern-directory/tests/phpunit/class-unlist-message-test.php diff --git a/public_html/wp-content/plugins/pattern-directory/includes/notifications.php b/public_html/wp-content/plugins/pattern-directory/includes/notifications.php index fbb51fab9..5a929f3d2 100644 --- a/public_html/wp-content/plugins/pattern-directory/includes/notifications.php +++ b/public_html/wp-content/plugins/pattern-directory/includes/notifications.php @@ -28,9 +28,9 @@ /** * Register the post meta used to store the moderator's message to the author. * - * Editable by anyone who can edit the pattern (i.e. moderators), so it can be - * set from the Unlist modal in the block editor and read back when sending the - * "pattern unlisted" email. + * Only moderators can write it, from the Unlist modal in the block editor. It is + * read in the edit context only, so the public API never exposes it, and it is + * deleted as soon as the pattern is unlisted. * * @return void */ @@ -42,10 +42,10 @@ function register_unlisted_meta() { 'type' => 'string', 'description' => 'A message from the moderator, included in the email sent to the author when a pattern is unlisted.', 'single' => true, - 'show_in_rest' => true, + 'show_in_rest' => array( 'schema' => array( 'context' => array( 'edit' ) ) ), 'sanitize_callback' => 'sanitize_textarea_field', - 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { - return current_user_can( 'edit_post', $object_id ); + 'auth_callback' => function () { + return current_user_can( get_post_type_object( PATTERN )->cap->edit_others_posts ); }, ) ); @@ -229,6 +229,11 @@ function ( \WP_Term $reason ) { * @return void */ function notify_pattern_unlisted( $post ) { + // The message is single-use: consume it before anything can bail out, so a + // later unlisting doesn't resend it. + $detail = get_post_meta( $post->ID, UNLISTED_DETAIL_META, true ); + delete_post_meta( $post->ID, UNLISTED_DETAIL_META ); + $author = get_user_by( 'id', $post->post_author ); if ( ! $author ) { return; @@ -255,7 +260,6 @@ function notify_pattern_unlisted( $post ) { } // Append the moderator's message to the author, if one was provided. - $detail = get_post_meta( $post->ID, UNLISTED_DETAIL_META, true ); if ( $detail ) { $reason .= "\n\n" . $detail; } diff --git a/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php b/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php index 20a7c345e..d39da6e3c 100644 --- a/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php +++ b/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php @@ -15,6 +15,7 @@ use function WordPressdotorg\Pattern_Directory\Pattern_Flag_Post_Type\has_reached_flag_threshold; use const WordPressdotorg\Pattern_Directory\Pattern_Post_Type\{ POST_TYPE, UNLISTED_STATUS, SPAM_STATUS }; use const WordPressdotorg\Pattern_Directory\Pattern_Flag_Post_Type\TAX_TYPE as FLAG_REASON; +use const WordPressdotorg\Pattern_Directory\Notifications\UNLISTED_DETAIL_META; /** * The rendered text fields, the ones the directory outputs and so has to check. @@ -57,7 +58,7 @@ function reject_control_characters( $prepared_post, $request ) { return $prepared_post; } - $values = array( $request['meta'] ?? null ); + $values = array( get_rendered_meta( $request ) ); foreach ( RENDERED_FIELDS as $field ) { $values[] = $prepared_post->$field ?? null; } @@ -73,6 +74,24 @@ function reject_control_characters( $prepared_post, $request ) { return $prepared_post; } +/** + * The submitted meta that the directory may render, and so has to check. + * + * The moderator's unlisting message is left out: it is only ever sent to the author as a plain-text + * email, so checking it would just block the unlisting it travels with. + * + * @param \WP_REST_Request $request Request being validated. + * @return mixed The submitted meta, without the unlisting message. + */ +function get_rendered_meta( $request ) { + $meta = $request['meta'] ?? null; + if ( is_array( $meta ) ) { + unset( $meta[ UNLISTED_DETAIL_META ] ); + } + + return $meta; +} + /** * Whether a value, or anything nested in one, carries a control character. * @@ -580,8 +599,9 @@ function validate_block_directives( $prepared_post, $request ) { * Meta never reaches `$prepared_post`, and `render_block_core_footnotes()` emits `footnotes` through * `wp_kses_post()`, which keeps `data-*`. */ - if ( ! $has_directive && is_array( $request['meta'] ?? null ) ) { - $has_directive = attribute_has_directive( $request['meta'] ); + $meta = get_rendered_meta( $request ); + if ( ! $has_directive && is_array( $meta ) ) { + $has_directive = attribute_has_directive( $meta ); } if ( $has_directive ) { diff --git a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js index 333a7f4e6..834dda85e 100644 --- a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js +++ b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js @@ -4,15 +4,7 @@ import { __ } from '@wordpress/i18n'; import { ComboboxControl, FormTokenField, TextControl, TextareaControl } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; -// `PluginDocumentSettingPanel` moved from `@wordpress/edit-post` to `@wordpress/editor`. -// Import from both and use whichever the running WordPress version provides. -import { - PluginDocumentSettingPanel as PluginDocumentSettingPanelFromEditor, - store as editorStore, -} from '@wordpress/editor'; -import { PluginDocumentSettingPanel as PluginDocumentSettingPanelFromEditPost } from '@wordpress/edit-post'; - -const PluginDocumentSettingPanel = PluginDocumentSettingPanelFromEditor || PluginDocumentSettingPanelFromEditPost; +import { PluginDocumentSettingPanel, store as editorStore } from '@wordpress/editor'; const KEYWORD_SLUG = 'wpop_keywords'; const DESCRIPTION_SLUG = 'wpop_description'; diff --git a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js index 087476063..e457fcd94 100644 --- a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js +++ b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js @@ -3,10 +3,7 @@ */ import { __ } from '@wordpress/i18n'; import { Button } from '@wordpress/components'; -// `PluginPostStatusInfo` moved from `@wordpress/edit-post` to `@wordpress/editor`. -// Import from both and use whichever the running WordPress version provides. -import { PluginPostStatusInfo as PluginPostStatusInfoFromEditor, store as editorStore } from '@wordpress/editor'; -import { PluginPostStatusInfo as PluginPostStatusInfoFromEditPost } from '@wordpress/edit-post'; +import { PluginPostStatusInfo, store as editorStore } from '@wordpress/editor'; import { useDispatch, useSelect } from '@wordpress/data'; import { useState } from '@wordpress/element'; @@ -17,23 +14,26 @@ import { UNLISTED_STATUS } from '../settings'; import UnlistModal from './modal'; import './unlist.scss'; -const PluginPostStatusInfo = PluginPostStatusInfoFromEditor || PluginPostStatusInfoFromEditPost; - export const UnlistButton = () => { const status = useSelect( ( select ) => { const _post = select( editorStore ).getCurrentPost(); return _post.status; } ); + const { didPostSaveRequestFail } = useSelect( editorStore ); const { editPost, savePost } = useDispatch( editorStore ); const [ showModal, setShowModal ] = useState( false ); - const onSubmit = ( reasonId, details = '' ) => { + const onSubmit = async ( reasonId, details = '' ) => { editPost( { status: UNLISTED_STATUS, 'wporg-pattern-flag-reason': [ reasonId ], meta: { _wporg_unlist_reason_detail: details }, } ); - savePost(); + // `savePost` resolves even when the request fails, so check the result. + await savePost(); + if ( didPostSaveRequestFail() ) { + throw new Error( __( 'The pattern could not be unlisted. Please try again.', 'wporg-patterns' ) ); + } }; const className = status === UNLISTED_STATUS ? 'wporg-patterns-unlist-notice' : 'wporg-patterns-unlist-button'; diff --git a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/modal.js b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/modal.js index a7754346c..1e3e81529 100644 --- a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/modal.js +++ b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/modal.js @@ -73,10 +73,22 @@ const UnlistModal = ( { onClose, onSubmit } ) => { } ); }, [] ); - const submittedText = __( - 'The pattern has been unlisted, and the author has been notified by email.', - 'wporg-patterns' - ); + const submittedText = __( 'The pattern has been unlisted.', 'wporg-patterns' ); + + const handleError = ( err ) => { + dispatch( { + status: 'ERROR', + message: err.message, + } ); + + speak( + sprintf( + /* translators: %s: Error message. */ + __( 'Error: %s', 'wporg-patterns' ), + err.message + ) + ); + }; const handleSubmit = ( event ) => { event.preventDefault(); @@ -99,28 +111,20 @@ const UnlistModal = ( { onClose, onSubmit } ) => { sendUnlistedNote( { url: apiUrl, note: details ? `UNLISTED: ${ reason.label } — ${ details }` : `UNLISTED: ${ reason.label }`, - onSuccess: () => { - if ( 'function' === typeof onSubmit ) { - onSubmit( selectedOption, details ); + onSuccess: async () => { + try { + if ( 'function' === typeof onSubmit ) { + await onSubmit( selectedOption, details ); + } + } catch ( err ) { + handleError( err ); + return; } dispatch( { status: 'NOTE_RECIEVED' } ); speak( submittedText ); container.current.closest( '[role="dialog"]' ).focus(); }, - onFailure: ( err ) => { - dispatch( { - status: 'ERROR', - message: err.message, - } ); - - speak( - sprintf( - /* translators: %s: Error message. */ - __( 'Error: %s', 'wporg-patterns' ), - err.message - ) - ); - }, + onFailure: handleError, } ); }; const handleClose = () => { diff --git a/public_html/wp-content/plugins/pattern-directory/tests/phpunit/class-unlist-message-test.php b/public_html/wp-content/plugins/pattern-directory/tests/phpunit/class-unlist-message-test.php new file mode 100644 index 000000000..2d05ccf7a --- /dev/null +++ b/public_html/wp-content/plugins/pattern-directory/tests/phpunit/class-unlist-message-test.php @@ -0,0 +1,221 @@ +user->create( array( 'role' => 'editor' ) ); + self::$member = $factory->user->create( array( 'role' => 'subscriber' ) ); + + self::$pattern_id = $factory->post->create( + array( + 'post_type' => POST_TYPE, + 'post_author' => self::$member, + 'post_title' => 'Stylized Quote and Citation', + 'post_content' => + "

A curated layout

\n\n" . + "

Some descriptive copy for the pattern.

\n\n" . + '
', + 'post_status' => 'publish', + ) + ); + } + + /** + * Clean up shared fixtures. + */ + public static function tear_down_after_class(): void { + wp_delete_post( self::$pattern_id, true ); + wp_delete_user( self::$moderator ); + wp_delete_user( self::$member ); + + parent::tear_down_after_class(); + } + + /** + * Register the meta, capture email without delivering it, and start each test from a published pattern. + */ + public function set_up(): void { + parent::set_up(); + + /* + * The test case resets post types between classes, which drops their registered meta. Register it + * again, and drop the cached controller and server so the REST schema picks it up. + */ + register_unlisted_meta(); + get_post_type_object( POST_TYPE )->rest_controller = null; + $GLOBALS['wp_rest_server'] = null; + + wp_update_post( + array( + 'ID' => self::$pattern_id, + 'post_status' => 'publish', + ) + ); + delete_post_meta( self::$pattern_id, UNLISTED_DETAIL_META ); + + $this->messages = array(); + add_filter( 'pre_wp_mail', array( $this, 'capture_mail' ), 10, 2 ); + } + + /** + * Stop capturing email and reset the current user. + */ + public function tear_down(): void { + remove_filter( 'pre_wp_mail', array( $this, 'capture_mail' ), 10 ); + wp_set_current_user( 0 ); + + parent::tear_down(); + } + + /** + * Record an email body instead of sending it. + * + * @param bool|null $result Short-circuit result. + * @param array $atts Mail arguments. + * @return bool + */ + public function capture_mail( ?bool $result, array $atts ): bool { + $this->messages[] = $atts['message']; + return true; + } + + /** + * Dispatch a pattern update with the given body. + * + * @param array $body Request body. + * @return \WP_REST_Response + */ + protected function update_pattern( array $body ): \WP_REST_Response { + $request = new WP_REST_Request( 'POST', '/wp/v2/' . POST_TYPE . '/' . self::$pattern_id ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $body ) ); + + return rest_do_request( $request ); + } + + /** + * The message reaches the author, even one that mentions a directive, and is consumed by it. + * + * @covers \WordPressdotorg\Pattern_Directory\Notifications\notify_pattern_unlisted + * @covers \WordPressdotorg\Pattern_Directory\Pattern_Validation\get_rendered_meta + */ + public function test_message_is_emailed_once(): void { + wp_set_current_user( self::$moderator ); + $detail = 'Please remove the data-wp-interactive attribute and resubmit.'; + + $response = $this->update_pattern( + array( + 'status' => UNLISTED_STATUS, + 'meta' => array( UNLISTED_DETAIL_META => $detail ), + ) + ); + + $this->assertFalse( $response->is_error() ); + $this->assertSame( UNLISTED_STATUS, get_post_status( self::$pattern_id ) ); + $this->assertCount( 1, $this->messages ); + $this->assertStringContainsString( $detail, $this->messages[0] ); + $this->assertSame( '', get_post_meta( self::$pattern_id, UNLISTED_DETAIL_META, true ) ); + + // Relisted, then unlisted again from the list screen: the earlier message is not resent. + wp_update_post( + array( + 'ID' => self::$pattern_id, + 'post_status' => 'publish', + ) + ); + wp_update_post( + array( + 'ID' => self::$pattern_id, + 'post_status' => UNLISTED_STATUS, + ) + ); + + $this->assertCount( 3, $this->messages ); + $this->assertStringNotContainsString( $detail, $this->messages[2] ); + } + + /** + * The author cannot set the message themselves. + * + * @covers \WordPressdotorg\Pattern_Directory\Notifications\register_unlisted_meta + */ + public function test_member_cannot_set_message(): void { + wp_set_current_user( self::$member ); + + $response = $this->update_pattern( + array( 'meta' => array( UNLISTED_DETAIL_META => 'Written by the author.' ) ) + ); + + $this->assertTrue( $response->is_error() ); + $this->assertSame( 403, $response->get_status() ); + $this->assertSame( '', get_post_meta( self::$pattern_id, UNLISTED_DETAIL_META, true ) ); + } + + /** + * The message is never in the public API. + * + * @covers \WordPressdotorg\Pattern_Directory\Notifications\register_unlisted_meta + */ + public function test_message_is_not_public(): void { + update_post_meta( self::$pattern_id, UNLISTED_DETAIL_META, 'For the author only.' ); + + $response = rest_do_request( new WP_REST_Request( 'GET', '/wp/v2/' . POST_TYPE . '/' . self::$pattern_id ) ); + + $this->assertFalse( $response->is_error() ); + $this->assertArrayNotHasKey( UNLISTED_DETAIL_META, $response->get_data()['meta'] ); + } +}