From c26e80fd3e919df4c2748c879ba561d122692269 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Fri, 2 Oct 2026 02:11:35 +0800 Subject: [PATCH 1/7] feat(editor-integration): send beehiiv test emails from the post editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature: editor-integration/newsletter-preview/test-email-send Implements: - US-001: Send a test email of my saved post - US-002: Know when test send is available - US-003: See the daily limit and understand failures Verified: - AC-001 to AC-006: test email section in the beehiiv panel, typed recipients, save-first, disabled while saving or sending ✓ - AC-007 to AC-011: linked unsent newsletter or a temporary draft that is always a draft and always deleted; post newsletter state untouched ✓ - AC-012 to AC-017: draft, pending, and scheduled posts only; publish and edit rights; readiness and sync checks enforced on the server ✓ - AC-018 to AC-024: sends left and reset time, remembered reset time on the daily limit, rate-limit and generic errors, addresses kept, leftover-draft note ✓ --- includes/API/Resources/Posts.php | 97 +++- includes/Newsletter/Sender.php | 4 +- includes/Newsletter/TestSender.php | 398 ++++++++++++++ includes/Plugin.php | 1 + includes/REST/TestSendController.php | 181 +++++++ languages/beehiiv.pot | 267 ++++++++-- .../components/newsletter-linked-notice.js | 4 +- .../components/test-email-send.js | 288 ++++++++++ .../editor/post-settings/css/_test-email.scss | 18 + src/js/editor/post-settings/editor.scss | 1 + src/js/editor/post-settings/index.js | 6 + tests/phpunit/NewsletterTestSenderTest.php | 493 ++++++++++++++++++ 12 files changed, 1685 insertions(+), 73 deletions(-) create mode 100644 includes/Newsletter/TestSender.php create mode 100644 includes/REST/TestSendController.php create mode 100644 src/js/editor/post-settings/components/test-email-send.js create mode 100644 src/js/editor/post-settings/css/_test-email.scss create mode 100644 tests/phpunit/NewsletterTestSenderTest.php diff --git a/includes/API/Resources/Posts.php b/includes/API/Resources/Posts.php index 4653c54..dd46e89 100644 --- a/includes/API/Resources/Posts.php +++ b/includes/API/Resources/Posts.php @@ -83,11 +83,13 @@ public static function create( string $publication_id, array $payload ): array { /** * Delete or archive a post in a beehiiv publication. * - * Confirmed posts are archived; drafts are permanently deleted. + * Confirmed posts are archived; drafts are permanently deleted. beehiiv answers + * 202 while a Send API post is still processing, in which case nothing was + * deleted yet; `status_code` lets callers tell that apart from a 204. * * @param string $publication_id Publication ID. * @param string $post_id beehiiv post ID. - * @return array{success: bool, error: string} + * @return array{success: bool, error: string, status_code: int|null} * @since 1.0.0 */ public static function delete( string $publication_id, string $post_id ): array { @@ -96,8 +98,9 @@ public static function delete( string $publication_id, string $post_id ): array if ( '' === $publication_id || '' === $post_id ) { return [ - 'success' => false, - 'error' => 'Publication ID or post ID is empty.', + 'success' => false, + 'error' => 'Publication ID or post ID is empty.', + 'status_code' => null, ]; } @@ -112,8 +115,9 @@ public static function delete( string $publication_id, string $post_id ): array if ( null !== $wp_error ) { return [ - 'success' => false, - 'error' => $wp_error, + 'success' => false, + 'error' => $wp_error, + 'status_code' => null, ]; } @@ -121,22 +125,91 @@ public static function delete( string $publication_id, string $post_id ): array if ( null !== $status_code && 204 === $status_code ) { return [ - 'success' => true, - 'error' => '', + 'success' => true, + 'error' => '', + 'status_code' => $status_code, ]; } if ( null !== $status_code && $status_code > 299 ) { return [ - 'success' => false, - 'error' => self::format_error_message( $response, $status_code ), + 'success' => false, + 'error' => self::format_error_message( $response, $status_code ), + 'status_code' => $status_code, ]; } return [ - 'success' => true, - 'error' => '', + 'success' => true, + 'error' => '', + 'status_code' => $status_code, + ]; + } + + /** + * Send a test email of a beehiiv post to one or more addresses. + * + * @link https://developers.beehiiv.com/api-reference/posts/test-send + * + * @param string $publication_id Publication ID. + * @param string $post_id beehiiv post ID. + * @param array $recipient_emails Addresses to send the test to. + * @return array{ + * success: bool, + * status_code: int|null, + * remaining_test_sends: int|null, + * reset_at: int|null, + * error: string + * } + * @since 1.0.0 + */ + public static function test_send( string $publication_id, string $post_id, array $recipient_emails ): array { + $publication_id = trim( $publication_id ); + $post_id = trim( $post_id ); + $result = [ + 'success' => false, + 'status_code' => null, + 'remaining_test_sends' => null, + 'reset_at' => null, + 'error' => '', ]; + + if ( '' === $publication_id || '' === $post_id ) { + $result['error'] = 'Publication ID or post ID is empty.'; + return $result; + } + + $path = sprintf( + '/publications/%s/posts/%s/test_sends', + rawurlencode( $publication_id ), + rawurlencode( $post_id ) + ); + $response = Client::post( $path, [ 'recipient_emails' => array_values( $recipient_emails ) ] ); + + $wp_error = Client::get_last_wp_error(); + + if ( null !== $wp_error ) { + $result['error'] = $wp_error; + return $result; + } + + $status_code = Client::get_last_status_code(); + $result['status_code'] = $status_code; + + if ( null !== $status_code && $status_code > 299 ) { + $result['error'] = self::format_error_message( $response, $status_code ); + return $result; + } + + $data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : []; + $remaining = $data['remaining_test_sends'] ?? null; + $reset_at = $data['reset_at'] ?? null; + + $result['success'] = true; + $result['remaining_test_sends'] = is_numeric( $remaining ) ? (int) $remaining : null; + $result['reset_at'] = is_numeric( $reset_at ) ? (int) $reset_at : null; + + return $result; } /** diff --git a/includes/Newsletter/Sender.php b/includes/Newsletter/Sender.php index 026b908..d616aa5 100644 --- a/includes/Newsletter/Sender.php +++ b/includes/Newsletter/Sender.php @@ -659,7 +659,7 @@ private static function clear_error( int $post_id ): void { * @return string * @since 1.0.0 */ - private static function format_save_error_message( \WP_Error $error ): string { + public static function format_save_error_message( \WP_Error $error ): string { switch ( $error->get_error_code() ) { case 'beehiiv_post_template_id_empty': return __( @@ -743,7 +743,7 @@ private static function no_publication_message(): string { * @return string Mapped message, or empty when input is empty. * @since 1.0.0 */ - private static function format_api_error_message( string $error ): string { + public static function format_api_error_message( string $error ): string { $error = trim( $error ); if ( '' === $error ) { diff --git a/includes/Newsletter/TestSender.php b/includes/Newsletter/TestSender.php new file mode 100644 index 0000000..e6cd94d --- /dev/null +++ b/includes/Newsletter/TestSender.php @@ -0,0 +1,398 @@ +post_type ) { + return new WP_Error( + 'beehiiv_test_send_post_not_found', + __( 'This post no longer exists. Save or reload the editor and try again.', 'beehiiv' ) + ); + } + + if ( ! in_array( $post->post_status, self::ELIGIBLE_STATUSES, true ) ) { + return new WP_Error( + 'beehiiv_test_send_ineligible_status', + __( 'Test emails are available for drafts, pending posts, and scheduled posts.', 'beehiiv' ) + ); + } + + if ( Sender::has_beehiiv_post_id( $post_id ) ) { + if ( self::is_linked_newsletter_sent( $post_id ) ) { + return new WP_Error( + 'beehiiv_test_send_already_sent', + __( "This post's newsletter was already sent, so it can't send a test email.", 'beehiiv' ) + ); + } + + if ( self::has_newsletter_error( $post_id ) ) { + return new WP_Error( + 'beehiiv_test_send_out_of_sync', + __( + // phpcs:ignore Generic.Files.LineLength.MaxExceeded,Generic.Files.LineLength.TooLong -- Single string for translators / i18n tools. + "This post's newsletter isn't in sync with beehiiv. Save the post again, then send a test email.", + 'beehiiv' + ) + ); + } + } + + if ( ! Manager::is_connected() ) { + return new WP_Error( + 'beehiiv_not_connected', + __( 'Connect your beehiiv account in beehiiv settings to send a test email.', 'beehiiv' ) + ); + } + + if ( '' === Sender::get_publication_id() ) { + return new WP_Error( + 'beehiiv_missing_publication', + __( 'Choose a publication in beehiiv settings, then try again.', 'beehiiv' ) + ); + } + + if ( ! Workspace::can_write_posts() ) { + return new WP_Error( + 'beehiiv_send_api_unavailable', + __( 'Test emails need beehiiv Send API access, available on the Max and Enterprise plans.', 'beehiiv' ) + ); + } + + return true; + } + + /** + * Send a test email of the saved post. + * + * @param int $post_id Post ID. + * @param array $recipient_emails Validated, de-duplicated addresses. + * @return array{remaining_test_sends: int|null, reset_at: int|null, leftover_draft: bool}|WP_Error + * @since 1.0.0 + */ + public static function send( int $post_id, array $recipient_emails ) { + $eligibility = self::check_eligibility( $post_id ); + + if ( is_wp_error( $eligibility ) ) { + return $eligibility; + } + + $publication_id = Sender::get_publication_id(); + $beehiiv_post_id = get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true ); + $beehiiv_post_id = is_string( $beehiiv_post_id ) ? trim( $beehiiv_post_id ) : ''; + + if ( '' !== $beehiiv_post_id ) { + $result = Posts::test_send( $publication_id, $beehiiv_post_id, $recipient_emails ); + + return self::finish( $post_id, $publication_id, $result, false ); + } + + $payload = self::build_temporary_draft_payload( $post_id ); + + if ( is_wp_error( $payload ) ) { + return new WP_Error( $payload->get_error_code(), Sender::format_save_error_message( $payload ) ); + } + + $created = Posts::create( $publication_id, $payload ); + + if ( ! $created['success'] ) { + self::log( $post_id, sprintf( 'temporary draft could not be created: %s', $created['error'] ) ); + + return self::api_error( $created['error'] ); + } + + $leftover_draft = false; + + try { + $result = Posts::test_send( $publication_id, $created['post_id'], $recipient_emails ); + } finally { + $deleted = Posts::delete( $publication_id, $created['post_id'] ); + + if ( ! $deleted['success'] || 202 === $deleted['status_code'] ) { + $leftover_draft = true; + self::log( + $post_id, + sprintf( + 'temporary beehiiv draft %s was not deleted (HTTP %s): %s', + $created['post_id'], + null === $deleted['status_code'] ? 'n/a' : (string) $deleted['status_code'], + '' !== $deleted['error'] ? $deleted['error'] : 'still processing' + ) + ); + } + } + + return self::finish( $post_id, $publication_id, $result, $leftover_draft ); + } + + /** + * Build the create payload for a temporary beehiiv draft from the saved post. + * + * Uses the real send's payload builder in its no-schedule mode, then forces draft + * status after the public settings filter so a test can never become a real send. + * + * @param int $post_id Post ID. + * @return array|WP_Error + * @since 1.0.0 + */ + public static function build_temporary_draft_payload( int $post_id ) { + $settings = PostSettingsBuilder::get_post_settings( $post_id, true ); + + if ( is_wp_error( $settings ) ) { + return $settings; + } + + if ( ! is_array( $settings ) ) { + return new WP_Error( + 'beehiiv_test_send_invalid_settings', + __( "Something went wrong and the test email wasn't sent. Try again.", 'beehiiv' ) + ); + } + + $settings['status'] = 'draft'; + unset( $settings['scheduled_at'] ); + + return $settings; + } + + /** + * Whether a linked newsletter has already been sent. + * + * Mirrors the linked-status notice: the newsletter counts as scheduled while the + * stored beehiiv send time, or else the custom send date, is in the future. + * + * @param int $post_id Post ID. + * @return bool + * @since 1.0.0 + */ + public static function is_linked_newsletter_sent( int $post_id ): bool { + $candidates = [ + [ get_post_meta( $post_id, Meta::BEEHIIV_SCHEDULED_AT, true ), new DateTimeZone( 'UTC' ) ], + [ get_post_meta( $post_id, Meta::SEND_TO_NEWSLETTER_DATE, true ), wp_timezone() ], + ]; + + foreach ( $candidates as $candidate ) { + $value = is_string( $candidate[0] ) ? trim( $candidate[0] ) : ''; + + if ( '' === $value ) { + continue; + } + + try { + $send_time = new DateTimeImmutable( $value, $candidate[1] ); + } catch ( Exception $e ) { + continue; + } + + if ( $send_time->getTimestamp() > time() ) { + return false; + } + } + + return true; + } + + /** + * Last remembered test-send reset time for a publication. + * + * @param string $publication_id Publication ID. + * @return int|null Unix timestamp, or null when none is stored. + * @since 1.0.0 + */ + public static function get_remembered_reset_at( string $publication_id ): ?int { + $stored = get_option( self::RESET_AT_OPTION, [] ); + + if ( ! is_array( $stored ) || ! isset( $stored[ $publication_id ] ) ) { + return null; + } + + if ( ! is_numeric( $stored[ $publication_id ] ) ) { + return null; + } + + return (int) $stored[ $publication_id ]; + } + + /** + * Turn a test-send result into the REST response data or a classified error. + * + * @param int $post_id Post ID. + * @param string $publication_id Publication ID. + * @param array $result Result from {@see Posts::test_send()}. + * @param bool $leftover_draft Whether a temporary draft could not be deleted. + * @return array{remaining_test_sends: int|null, reset_at: int|null, leftover_draft: bool}|WP_Error + * @since 1.0.0 + */ + private static function finish( int $post_id, string $publication_id, array $result, bool $leftover_draft ) { + if ( ! empty( $result['success'] ) ) { + if ( null !== $result['reset_at'] ) { + self::remember_reset_at( $publication_id, (int) $result['reset_at'] ); + } + + return [ + 'remaining_test_sends' => $result['remaining_test_sends'], + 'reset_at' => $result['reset_at'], + 'leftover_draft' => $leftover_draft, + ]; + } + + self::log( $post_id, sprintf( 'test send failed: %s', (string) $result['error'] ) ); + + if ( 422 === $result['status_code'] ) { + $reset_at = self::get_remembered_reset_at( $publication_id ); + + if ( null !== $reset_at && $reset_at > time() ) { + return new WP_Error( + 'beehiiv_test_send_daily_limit', + sprintf( + /* translators: %s: date, time, and timezone when test sends reset. */ + __( "You've used all test sends for today. They reset on %s.", 'beehiiv' ), + wp_date( 'F j, g:i a T', $reset_at ) + ) + ); + } + + return new WP_Error( + 'beehiiv_test_send_daily_limit', + __( "You've used all test sends for today.", 'beehiiv' ) + ); + } + + if ( 429 === $result['status_code'] ) { + return new WP_Error( + 'beehiiv_test_send_rate_limited', + __( 'Too many requests. Try again in a moment.', 'beehiiv' ) + ); + } + + return self::api_error( (string) $result['error'] ); + } + + /** + * Generic test-send failure carrying beehiiv's (mapped) message. + * + * @param string $error Error string from the API client. + * @return WP_Error + * @since 1.0.0 + */ + private static function api_error( string $error ): WP_Error { + $mapped = Sender::format_api_error_message( $error ); + + if ( '' === $mapped ) { + return new WP_Error( + 'beehiiv_test_send_failed', + __( "Something went wrong and the test email wasn't sent. Try again.", 'beehiiv' ) + ); + } + + return new WP_Error( + 'beehiiv_test_send_failed', + sprintf( + /* translators: %s: error message from beehiiv. */ + __( "The test email wasn't sent: %s", 'beehiiv' ), + $mapped + ) + ); + } + + /** + * Store the latest test-send reset time for a publication. + * + * @param string $publication_id Publication ID. + * @param int $reset_at Unix timestamp. + * @return void + * @since 1.0.0 + */ + private static function remember_reset_at( string $publication_id, int $reset_at ): void { + $stored = get_option( self::RESET_AT_OPTION, [] ); + $stored = is_array( $stored ) ? $stored : []; + + $stored[ $publication_id ] = $reset_at; + + update_option( self::RESET_AT_OPTION, $stored, false ); + } + + /** + * Whether a prior newsletter save or send left an error on this post. + * + * @param int $post_id Post ID. + * @return bool + * @since 1.0.0 + */ + private static function has_newsletter_error( int $post_id ): bool { + $error = get_post_meta( $post_id, Meta::NEWSLETTER_ERROR, true ); + + return is_string( $error ) && '' !== trim( $error ); + } + + /** + * Write a test-send problem to the error log. + * + * @param int $post_id Post ID. + * @param string $message Log message. + * @return void + * @since 1.0.0 + */ + private static function log( int $post_id, string $message ): void { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( sprintf( 'beehiiv test email for post ID %d: %s', $post_id, $message ) ); + } +} diff --git a/includes/Plugin.php b/includes/Plugin.php index 53c855b..e59d5d2 100644 --- a/includes/Plugin.php +++ b/includes/Plugin.php @@ -45,6 +45,7 @@ public static function init(): void { add_action( 'admin_init', [ self::class, 'bootstrap_admin_features' ] ); add_action( 'rest_api_init', [ REST\PostTemplatesController::class, 'register_routes' ] ); add_action( 'rest_api_init', [ REST\AdvertisementOpportunitiesController::class, 'register_routes' ] ); + add_action( 'rest_api_init', [ REST\TestSendController::class, 'register_routes' ] ); } /** diff --git a/includes/REST/TestSendController.php b/includes/REST/TestSendController.php new file mode 100644 index 0000000..26e6b7c --- /dev/null +++ b/includes/REST/TestSendController.php @@ -0,0 +1,181 @@ + WP_REST_Server::CREATABLE, + 'callback' => [ self::class, 'send_test' ], + 'permission_callback' => [ self::class, 'permissions_check' ], + 'args' => [ + 'post_id' => [ + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + 'recipients' => [ + 'required' => true, + 'type' => 'string', + ], + ], + ] + ); + } + + /** + * Permission check for test-send requests. + * + * Requires publish rights and edit access to the referenced post. + * + * @since 1.0.0 + * + * @param WP_REST_Request $request REST request. + * @return bool + */ + public static function permissions_check( WP_REST_Request $request ): bool { + + $post_id = absint( $request->get_param( 'post_id' ) ); + + return $post_id + && current_user_can( 'edit_post', $post_id ) + && current_user_can( 'publish_posts' ); + } + + /** + * Send a test email of the saved post. + * + * @since 1.0.0 + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public static function send_test( WP_REST_Request $request ): WP_REST_Response { + + $recipients = self::parse_recipients( (string) $request->get_param( 'recipients' ) ); + + if ( empty( $recipients ) ) { + return self::error_response( + 'beehiiv_test_send_no_recipients', + __( 'Enter at least one email address.', 'beehiiv' ) + ); + } + + $invalid = array_values( + array_filter( + $recipients, + static function ( $email ) { + return ! is_email( $email ); + } + ) + ); + + if ( ! empty( $invalid ) ) { + return self::error_response( + 'beehiiv_test_send_invalid_recipients', + sprintf( + /* translators: %s: comma-separated list of invalid email addresses. */ + __( "These email addresses aren't valid: %s", 'beehiiv' ), + implode( ', ', $invalid ) + ) + ); + } + + $result = TestSender::send( absint( $request->get_param( 'post_id' ) ), $recipients ); + + if ( is_wp_error( $result ) ) { + return self::error_response( $result->get_error_code(), $result->get_error_message() ); + } + + return new WP_REST_Response( $result, 200 ); + } + + /** + * Split a recipients string on commas and new lines, trimmed and de-duplicated. + * + * Duplicates are matched case-insensitively; the first spelling is kept. + * + * @since 1.0.0 + * + * @param string $raw Raw recipients field value. + * @return array + */ + public static function parse_recipients( string $raw ): array { + + $parts = preg_split( '/[,\r\n]+/', $raw ); + $parts = is_array( $parts ) ? $parts : []; + $unique = []; + + foreach ( $parts as $part ) { + $email = trim( $part ); + + if ( '' === $email ) { + continue; + } + + $key = strtolower( $email ); + + if ( ! isset( $unique[ $key ] ) ) { + $unique[ $key ] = $email; + } + } + + return array_values( $unique ); + } + + /** + * Error response in the shape the other beehiiv REST controllers use. + * + * @since 1.0.0 + * + * @param string $code Error code. + * @param string $message Editor-facing message. + * @return WP_REST_Response + */ + private static function error_response( string $code, string $message ): WP_REST_Response { + + return new WP_REST_Response( + [ + 'code' => $code, + 'message' => $message, + ], + 400 + ); + } +} diff --git a/languages/beehiiv.pot b/languages/beehiiv.pot index 4f6fc05..f2536e0 100644 --- a/languages/beehiiv.pot +++ b/languages/beehiiv.pot @@ -2,28 +2,30 @@ # This file is distributed under the GPL-2.0-or-later. msgid "" msgstr "" -"Project-Id-Version: beehiiv 1.0.0\n" +"Project-Id-Version: beehiiv 1.0.2\n" "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/beehiiv\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-07-08T08:58:12+00:00\n" +"POT-Creation-Date: 2026-10-01T18:10:36+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"X-Generator: WP-CLI 2.7.1\n" +"X-Generator: WP-CLI 2.12.0\n" "X-Domain: beehiiv\n" #. Plugin Name of the plugin #. Author of the plugin +#: beehiiv.php #: includes/Admin/Menu.php:32 #: includes/Blocks/Registry.php:45 -#: src/js/editor/post-settings/index.js:168 +#: src/js/editor/post-settings/index.js:182 msgid "beehiiv" msgstr "" #. Description of the plugin -msgid "Official beehiiv WordPress plugin." +#: beehiiv.php +msgid "Official beehiiv WordPress plugin. Publish posts as newsletters and grow your audience with beehiiv." msgstr "" #: includes/Admin/Menu.php:31 @@ -71,73 +73,104 @@ msgstr "" msgid "Default newsletter template for this site. Refresh after changing templates in beehiiv." msgstr "" -#: includes/Admin/Views/connection.php:22 +#: includes/Admin/Views/connection.php:30 msgid "Connection" msgstr "" -#: includes/Admin/Views/connection.php:33 +#: includes/Admin/Views/connection.php:41 msgid "Disconnect" msgstr "" -#: includes/Admin/Views/connection.php:40 +#: includes/Admin/Views/connection.php:48 msgid "Connect to beehiiv" msgstr "" -#: includes/Admin/Views/connection.php:65 +#: includes/Admin/Views/connection.php:73 msgid "Create a beehiiv account now" msgstr "" #. translators: %s: link to beehiiv sign-up. -#: includes/Admin/Views/connection.php:69 +#: includes/Admin/Views/connection.php:77 +#, php-format msgid "Don't have a beehiiv account? %s" msgstr "" -#: includes/Admin/Views/connection.php:87 +#: includes/Admin/Views/connection.php:95 msgid "You are now connected to beehiiv. To send WordPress posts to your newsletter:" msgstr "" -#: includes/Admin/Views/connection.php:99 +#: includes/Admin/Views/connection.php:107 msgid "Create" msgstr "" -#: includes/Admin/Views/connection.php:104 +#: includes/Admin/Views/connection.php:112 msgid "edit" msgstr "" #. translators: 1: link to create a new post, 2: link to the posts list. -#: includes/Admin/Views/connection.php:109 +#: includes/Admin/Views/connection.php:117 +#, php-format msgid "%1$s or %2$s a post in the block editor." msgstr "" -#: includes/Admin/Views/connection.php:125 +#: includes/Admin/Views/connection.php:133 msgid "Open the beehiiv panel in the editor sidebar (beehiiv icon in the top toolbar)." msgstr "" -#: includes/Admin/Views/connection.php:134 +#: includes/Admin/Views/connection.php:142 msgid "Turn on Send to newsletter before you publish, and the post will be queued for delivery via beehiiv." msgstr "" -#: includes/Admin/Views/connection.php:148 +#: includes/Admin/Views/connection.php:156 msgid "plugin page" msgstr "" #. translators: %s: link to the plugin page on WordPress.org. -#: includes/Admin/Views/connection.php:153 +#: includes/Admin/Views/connection.php:161 +#, php-format msgid "Read more in the documentation on the %s." msgstr "" -#: includes/Admin/Views/settings-page.php:26 +#: includes/Admin/Views/settings-page.php:38 +#: src/js/blocks/advertisement/edit.js:157 +msgid "Learn more about plans." +msgstr "" + +#. translators: 1: Max plan, 2: Enterprise plan, 3: pricing link. +#: includes/Admin/Views/settings-page.php:53 +#, php-format +msgid "Your connected beehiiv account doesn't have access to send newsletters. This integration requires the %1$s or %2$s plan. %3$s" +msgstr "" + +#: includes/Admin/Views/settings-page.php:54 +#: includes/Admin/Views/settings-page.php:65 +msgid "Max" +msgstr "" + +#: includes/Admin/Views/settings-page.php:55 +#: includes/Admin/Views/settings-page.php:66 +msgid "Enterprise" +msgstr "" + +#. translators: 1: Max plan, 2: Enterprise plan, 3: pricing link. +#: includes/Admin/Views/settings-page.php:64 +#, php-format +msgid "The beehiiv integration is available to publications on the %1$s and %2$s plans. %3$s" +msgstr "" + +#: includes/Admin/Views/settings-page.php:84 msgid "Save settings" msgstr "" #: includes/API/Resources/AdvertisementOpportunities.php:153 -#: src/js/blocks/advertisement/edit.js:151 -#: src/js/blocks/advertisement/edit.js:159 +#: src/js/blocks/advertisement/edit.js:175 +#: src/js/blocks/advertisement/edit.js:183 msgid "Advertisement" msgstr "" #. translators: 1: send-by window start date, 2: send-by window end date. #: includes/API/Resources/AdvertisementOpportunities.php:198 +#, php-format msgid "%1$s – %2$s" msgstr "" @@ -151,6 +184,7 @@ msgstr "" #. translators: 1: user name, 2: email address #: includes/Connection/Manager.php:83 +#, php-format msgid "%1$s (%2$s)" msgstr "" @@ -179,6 +213,7 @@ msgstr "" #: includes/Newsletter/Sender.php:340 #: includes/Newsletter/Sender.php:427 #: includes/Newsletter/Sender.php:690 +#: includes/Newsletter/TestSender.php:68 msgid "This post no longer exists. Save or reload the editor and try again." msgstr "" @@ -208,6 +243,7 @@ msgid "Connect your beehiiv account in beehiiv settings to send this news msgstr "" #: includes/Newsletter/Sender.php:734 +#: includes/Newsletter/TestSender.php:109 msgid "Choose a publication in beehiiv settings, then try again." msgstr "" @@ -222,10 +258,12 @@ msgstr "" #. translators: %s: error message from the beehiiv API. #: includes/Newsletter/Sender.php:767 +#, php-format msgid "beehiiv rejected this newsletter: %s" msgstr "" #: includes/Newsletter/Sender.php:773 +#: includes/Newsletter/TestSender.php:322 msgid "Too many requests. Try again in a moment." msgstr "" @@ -254,6 +292,51 @@ msgstr "" msgid "Something went wrong and the newsletter couldn't be updated. Try saving the post again." msgstr "" +#: includes/Newsletter/TestSender.php:75 +#: src/js/editor/post-settings/components/test-email-send.js:75 +msgid "Test emails are available for drafts, pending posts, and scheduled posts." +msgstr "" + +#: includes/Newsletter/TestSender.php:83 +#: src/js/editor/post-settings/components/test-email-send.js:68 +msgid "This post's newsletter was already sent, so it can't send a test email." +msgstr "" + +#: includes/Newsletter/TestSender.php:90 +#: src/js/editor/post-settings/components/test-email-send.js:82 +msgid "This post's newsletter isn't in sync with beehiiv. Save the post again, then send a test email." +msgstr "" + +#: includes/Newsletter/TestSender.php:102 +msgid "Connect your beehiiv account in beehiiv settings to send a test email." +msgstr "" + +#: includes/Newsletter/TestSender.php:116 +msgid "Test emails need beehiiv Send API access, available on the Max and Enterprise plans." +msgstr "" + +#: includes/Newsletter/TestSender.php:206 +#: includes/Newsletter/TestSender.php:342 +#: src/js/editor/post-settings/components/test-email-send.js:224 +msgid "Something went wrong and the test email wasn't sent. Try again." +msgstr "" + +#. translators: %s: date, time, and timezone when test sends reset. +#: includes/Newsletter/TestSender.php:307 +#, php-format +msgid "You've used all test sends for today. They reset on %s." +msgstr "" + +#: includes/Newsletter/TestSender.php:315 +msgid "You've used all test sends for today." +msgstr "" + +#. translators: %s: error message from beehiiv. +#: includes/Newsletter/TestSender.php:350 +#, php-format +msgid "The test email wasn't sent: %s" +msgstr "" + #: includes/OAuth/AdminActions.php:91 msgid "Disconnected from beehiiv." msgstr "" @@ -303,19 +386,21 @@ msgstr "" msgid "beehiiv connection is not configured for this plugin build. Contact your site administrator." msgstr "" -#: includes/OAuth/ClientRegistrar.php:61 -msgid "Could not reach beehiiv to register this site. Please try again." +#. translators: %s: transport error message +#: includes/OAuth/ClientRegistrar.php:63 +#, php-format +msgid "Could not reach beehiiv to register this site. %s" msgstr "" -#: includes/OAuth/ClientRegistrar.php:68 +#: includes/OAuth/ClientRegistrar.php:72 msgid "beehiiv rejected client registration for this site. Please try again." msgstr "" -#: includes/OAuth/ClientRegistrar.php:79 +#: includes/OAuth/ClientRegistrar.php:83 msgid "beehiiv returned an invalid client registration response." msgstr "" -#: includes/OAuth/ClientRegistrar.php:86 +#: includes/OAuth/ClientRegistrar.php:90 msgid "Could not save beehiiv client credentials on this site." msgstr "" @@ -339,71 +424,89 @@ msgstr "" msgid "Could not save refreshed beehiiv credentials." msgstr "" -#: includes/REST/AdvertisementOpportunitiesController.php:96 -#: includes/REST/PostTemplatesController.php:92 +#: includes/REST/AdvertisementOpportunitiesController.php:101 +#: includes/REST/PostTemplatesController.php:94 msgid "beehiiv is not connected." msgstr "" -#: includes/REST/AdvertisementOpportunitiesController.php:109 +#: includes/REST/AdvertisementOpportunitiesController.php:114 msgid "Publication ID is not configured." msgstr "" -#: includes/REST/PostTemplatesController.php:104 +#: includes/REST/PostTemplatesController.php:106 msgid "Publication ID is required." msgstr "" +#: includes/REST/TestSendController.php:96 +#: src/js/editor/post-settings/components/test-email-send.js:180 +msgid "Enter at least one email address." +msgstr "" + +#. translators: %s: comma-separated list of invalid email addresses. +#: includes/REST/TestSendController.php:114 +#: src/js/editor/post-settings/components/test-email-send.js:194 +#, php-format,js-format +msgid "These email addresses aren't valid: %s" +msgstr "" + #: src/js/admin/settings/index.js:77 #: src/js/editor/post-settings/components/newsletter-template-select.js:184 msgid "Templates updated from beehiiv." msgstr "" #: src/js/admin/settings/index.js:158 -#: src/js/blocks/advertisement/edit.js:195 +#: src/js/blocks/advertisement/edit.js:219 #: src/js/editor/post-settings/components/newsletter-template-select.js:176 msgid "Refreshing…" msgstr "" -#: src/js/blocks/advertisement/edit.js:101 +#: src/js/blocks/advertisement/edit.js:106 msgid "Select an advertisement…" msgstr "" #. translators: %s: saved advertisement label. -#: src/js/blocks/advertisement/edit.js:119 +#: src/js/blocks/advertisement/edit.js:124 +#, js-format msgid "%s — no longer available" msgstr "" -#: src/js/blocks/advertisement/edit.js:142 +#: src/js/blocks/advertisement/edit.js:150 +#: src/js/blocks/advertisement/edit.js:250 +msgid "beehiiv Advertisement" +msgstr "" + +#: src/js/blocks/advertisement/edit.js:151 +msgid "Your connected beehiiv account doesn't have access to send newsletters. This integration requires the Max or Enterprise plan." +msgstr "" + +#: src/js/blocks/advertisement/edit.js:166 msgid "Choose an advertisement in the block settings sidebar. Nothing is shown on the website." msgstr "" -#: src/js/blocks/advertisement/edit.js:163 +#: src/js/blocks/advertisement/edit.js:187 msgid "Only ads not already used in another post are listed." msgstr "" -#: src/js/blocks/advertisement/edit.js:176 +#: src/js/blocks/advertisement/edit.js:200 msgid "The selected advertisement is no longer available in beehiiv and won’t be sent. Choose another or clear it." msgstr "" -#: src/js/blocks/advertisement/edit.js:184 +#: src/js/blocks/advertisement/edit.js:208 msgid "Clear selection" msgstr "" -#: src/js/blocks/advertisement/edit.js:196 +#: src/js/blocks/advertisement/edit.js:220 msgid "Refresh advertisements" msgstr "" -#: src/js/blocks/advertisement/edit.js:206 +#: src/js/blocks/advertisement/edit.js:230 msgid "Advertisements updated from beehiiv." msgstr "" -#: src/js/blocks/advertisement/edit.js:213 +#: src/js/blocks/advertisement/edit.js:237 msgid "Opportunities are cached. Refresh to pull the latest from beehiiv." msgstr "" -#: src/js/blocks/advertisement/edit.js:226 -msgid "beehiiv Advertisement" -msgstr "" - #: src/js/blocks/subscribe-form/edit.js:22 msgid "The subscribe form is only rendered on the front-end and cannot be previewed here. View the page to see your form. If the form does not appear, make sure the entered form ID is valid." msgstr "" @@ -456,6 +559,7 @@ msgstr "" #. translators: %s: selected send date or "On publish". #: src/js/editor/post-settings/components/newsletter-date-picker.js:215 +#, js-format msgid "Change newsletter send time: %s" msgstr "" @@ -474,6 +578,7 @@ msgstr "" #. translators: %s: formatted newsletter send date, time, and timezone. #: src/js/editor/post-settings/components/newsletter-linked-notice.js:92 +#, js-format msgid "This post is scheduled in beehiiv for %s." msgstr "" @@ -481,27 +586,31 @@ msgstr "" msgid "Preview in beehiiv" msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:22 +#: src/js/editor/post-settings/components/newsletter-status-notices.js:30 msgid "Connect your beehiiv account in beehiiv settings before you can send newsletters." msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:35 +#: src/js/editor/post-settings/components/newsletter-status-notices.js:44 +msgid "Your connected beehiiv account doesn't have access to send newsletters. This integration requires the Max or Enterprise plan. Learn more about plans." +msgstr "" + +#: src/js/editor/post-settings/components/newsletter-status-notices.js:60 msgid "Choose a publication and default post template in beehiiv settings." msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:40 +#: src/js/editor/post-settings/components/newsletter-status-notices.js:65 msgid "Choose a publication in beehiiv settings to send newsletters." msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:45 +#: src/js/editor/post-settings/components/newsletter-status-notices.js:70 msgid "Choose a default post template in beehiiv settings to send newsletters." msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:68 +#: src/js/editor/post-settings/components/newsletter-status-notices.js:93 msgid "Could not save this post to beehiiv:" msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:76 +#: src/js/editor/post-settings/components/newsletter-status-notices.js:101 msgid "Could not send this post to beehiiv:" msgstr "" @@ -511,6 +620,7 @@ msgstr "" #. translators: %s: post template name from beehiiv settings #: src/js/editor/post-settings/components/newsletter-template-select.js:128 +#, js-format msgid "Uses %s from beehiiv settings." msgstr "" @@ -532,6 +642,7 @@ msgstr "" #. translators: %d: number of omitted blocks. #: src/js/editor/post-settings/components/omitted-blocks-notice.js:27 +#, js-format msgid "%d block will be omitted from this newsletter." msgid_plural "%d blocks will be omitted from this newsletter." msgstr[0] "" @@ -545,36 +656,78 @@ msgstr "" msgid "Queue this post for delivery via beehiiv when published." msgstr "" +#: src/js/editor/post-settings/components/test-email-send.js:98 +msgid "Test email sent." +msgstr "" + +#. translators: %d: number of test sends left today. +#: src/js/editor/post-settings/components/test-email-send.js:104 +#, js-format +msgid "%d test send left today." +msgid_plural "%d test sends left today." +msgstr[0] "" +msgstr[1] "" + +#. translators: %s: date, time, and timezone when test sends reset. +#: src/js/editor/post-settings/components/test-email-send.js:124 +#, js-format +msgid "Test sends reset on %s." +msgstr "" + +#: src/js/editor/post-settings/components/test-email-send.js:239 +msgid "Send test email to" +msgstr "" + +#: src/js/editor/post-settings/components/test-email-send.js:240 +msgid "Separate addresses with commas or new lines." +msgstr "" + +#: src/js/editor/post-settings/components/test-email-send.js:251 +msgid "Save the post to include your latest edits." +msgstr "" + +#: src/js/editor/post-settings/components/test-email-send.js:265 +msgid "Send test email" +msgstr "" + +#: src/js/editor/post-settings/components/test-email-send.js:278 +msgid "A leftover test draft may still be in beehiiv. You can delete it there." +msgstr "" + #: src/js/editor/post-settings/filters/with-omitted-block-indicator.js:38 #: src/js/editor/post-settings/filters/with-omitted-block-indicator.js:42 msgid "Not included in beehiiv newsletter" msgstr "" -#: src/js/editor/post-settings/index.js:90 +#: src/js/editor/post-settings/index.js:99 msgid "The newsletter can only be sent once and cannot be undone." msgstr "" -#: src/js/editor/post-settings/index.js:111 +#: src/js/editor/post-settings/index.js:120 msgid "Snippet newsletter" msgstr "" -#: src/js/editor/post-settings/index.js:114 +#: src/js/editor/post-settings/index.js:123 msgid "Send a snippet newsletter with a \"Read More\" button to the full post." msgstr "" -#: src/js/editor/post-settings/index.js:122 +#: src/js/editor/post-settings/index.js:131 msgid "Insert the \"More\" block in your post to mark where the snippet ends." msgstr "" -#: src/js/editor/post-settings/index.js:209 +#: src/js/editor/post-settings/index.js:146 +msgid "Test email" +msgstr "" + +#: src/js/editor/post-settings/index.js:226 msgid "Yes" msgstr "" -#: src/js/editor/post-settings/index.js:210 +#: src/js/editor/post-settings/index.js:227 msgid "No" msgstr "" -#: src/js/editor/post-settings/index.js:217 +#: src/js/editor/post-settings/index.js:234 msgid "Send to newsletter:" msgstr "" diff --git a/src/js/editor/post-settings/components/newsletter-linked-notice.js b/src/js/editor/post-settings/components/newsletter-linked-notice.js index e27d703..9b2e1a7 100644 --- a/src/js/editor/post-settings/components/newsletter-linked-notice.js +++ b/src/js/editor/post-settings/components/newsletter-linked-notice.js @@ -18,7 +18,7 @@ import getBeehiivPostPreviewUrl from '../utils/get-beehiiv-post-preview-url'; * @param {string} dateValue ISO 8601 datetime string. * @return {string|null} Formatted date, time, and timezone abbreviation. */ -function formatSiteDateTime( dateValue ) { +export function formatSiteDateTime( dateValue ) { const timestamp = new Date( dateValue ).getTime(); if ( Number.isNaN( timestamp ) ) { @@ -38,7 +38,7 @@ function formatSiteDateTime( dateValue ) { * @param {string|null|undefined} sendToNewsletterDate Custom send datetime from post meta. * @return {string|null} Raw datetime string for a future send, or null when not scheduled. */ -function getFutureNewsletterSendDateString( +export function getFutureNewsletterSendDateString( scheduledAtUtc, sendToNewsletterDate ) { diff --git a/src/js/editor/post-settings/components/test-email-send.js b/src/js/editor/post-settings/components/test-email-send.js new file mode 100644 index 0000000..35b4b8f --- /dev/null +++ b/src/js/editor/post-settings/components/test-email-send.js @@ -0,0 +1,288 @@ +/** + * Send a beehiiv test email of the saved post from the beehiiv sidebar. + */ +import { __, _n, sprintf } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { Button, TextareaControl } from '@wordpress/components'; +import apiFetch from '@wordpress/api-fetch'; + +import PostSettingsNotice from './post-settings-notice'; +import { + formatSiteDateTime, + getFutureNewsletterSendDateString, +} from './newsletter-linked-notice'; +import { useBeehiivEditorConfig } from '../hooks/use-beehiiv-editor-config'; +import renderSettingsLinkMessage from '../utils/render-settings-link-message'; + +const ELIGIBLE_STATUSES = [ 'draft', 'pending', 'future' ]; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Split a recipients field on commas and new lines, trimmed and de-duplicated. + * + * Duplicates are matched case-insensitively; the first spelling is kept. + * + * @param {string} value Raw field value. + * @return {string[]} Addresses in entry order. + */ +export function parseRecipients( value ) { + const seen = new Set(); + + return ( value || '' ) + .split( /[,\r\n]+/ ) + .map( ( part ) => part.trim() ) + .filter( ( email ) => { + const key = email.toLowerCase(); + + if ( ! email || seen.has( key ) ) { + return false; + } + + seen.add( key ); + + return true; + } ); +} + +/** + * Why test send is unavailable for the saved post, or null when it is available. + * + * @param {Object} args + * @param {string} args.savedStatus Saved post status. + * @param {import('../hooks/use-beehiiv-post-meta').BeehiivPostMeta} args.meta beehiiv post meta. + * @return {string|null} Reason shown to the editor. + */ +function getUnavailableReason( { savedStatus, meta } ) { + const isLinked = meta.newsletterAlreadySent; + + if ( + isLinked && + ! getFutureNewsletterSendDateString( + meta.beehiivScheduledAt, + meta.sendToNewsletterDate + ) + ) { + return __( + "This post's newsletter was already sent, so it can't send a test email.", + 'beehiiv' + ); + } + + if ( ! ELIGIBLE_STATUSES.includes( savedStatus ) ) { + return __( + 'Test emails are available for drafts, pending posts, and scheduled posts.', + 'beehiiv' + ); + } + + if ( isLinked && meta.newsletterError ) { + return __( + "This post's newsletter isn't in sync with beehiiv. Save the post again, then send a test email.", + 'beehiiv' + ); + } + + return null; +} + +/** + * Success notice text for a completed test send. + * + * @param {{remaining_test_sends: number|null, reset_at: number|null}} data REST response. + * @return {string} Confirmation with sends left and the reset time when known. + */ +function getSuccessMessage( data ) { + const parts = [ __( 'Test email sent.', 'beehiiv' ) ]; + + if ( Number.isInteger( data?.remaining_test_sends ) ) { + parts.push( + sprintf( + /* translators: %d: number of test sends left today. */ + _n( + '%d test send left today.', + '%d test sends left today.', + data.remaining_test_sends, + 'beehiiv' + ), + data.remaining_test_sends + ) + ); + } + + if ( Number.isInteger( data?.reset_at ) ) { + const resetAt = formatSiteDateTime( + new Date( data.reset_at * 1000 ).toISOString() + ); + + if ( resetAt ) { + parts.push( + sprintf( + /* translators: %s: date, time, and timezone when test sends reset. */ + __( 'Test sends reset on %s.', 'beehiiv' ), + resetAt + ) + ); + } + } + + return parts.join( ' ' ); +} + +/** + * @param {Object} props + * @param {import('../hooks/use-beehiiv-post-meta').BeehiivPostMeta} props.beehiivMeta + */ +export default function TestEmailSend( { beehiivMeta } ) { + const { settingsUrl } = useBeehiivEditorConfig(); + const [ recipients, setRecipients ] = useState( '' ); + const [ isSending, setIsSending ] = useState( false ); + const [ result, setResult ] = useState( null ); + + const { postId, savedStatus, isDirty, isNew, isSaving } = useSelect( + ( select ) => { + const editor = select( editorStore ); + + return { + postId: editor.getCurrentPostId(), + savedStatus: editor.getCurrentPostAttribute( 'status' ), + isDirty: editor.isEditedPostDirty(), + isNew: editor.isEditedPostNew(), + isSaving: editor.isSavingPost(), + }; + }, + [] + ); + + const needsSave = isNew || isDirty; + const unavailableReason = isNew + ? null + : getUnavailableReason( { savedStatus, meta: beehiivMeta } ); + + if ( unavailableReason ) { + return ( +
+

+ { unavailableReason } +

+
+ ); + } + + const onSend = () => { + const addresses = parseRecipients( recipients ); + + if ( addresses.length === 0 ) { + setResult( { + status: 'error', + message: __( 'Enter at least one email address.', 'beehiiv' ), + } ); + return; + } + + const invalid = addresses.filter( + ( email ) => ! EMAIL_PATTERN.test( email ) + ); + + if ( invalid.length > 0 ) { + setResult( { + status: 'error', + message: sprintf( + /* translators: %s: comma-separated list of invalid email addresses. */ + __( "These email addresses aren't valid: %s", 'beehiiv' ), + invalid.join( ', ' ) + ), + } ); + return; + } + + setIsSending( true ); + setResult( null ); + + apiFetch( { + path: '/beehiiv/v1/test-send', + method: 'POST', + data: { + post_id: postId, + recipients: addresses.join( '\n' ), + }, + } ) + .then( ( data ) => { + setResult( { + status: 'success', + message: getSuccessMessage( data ), + leftoverDraft: !! data?.leftover_draft, + } ); + } ) + .catch( ( error ) => { + setResult( { + status: 'error', + message: + error?.message || + __( + "Something went wrong and the test email wasn't sent. Try again.", + 'beehiiv' + ), + } ); + } ) + .finally( () => { + setIsSending( false ); + } ); + }; + + return ( +
+ + + { needsSave && ( +

+ { __( + 'Save the post to include your latest edits.', + 'beehiiv' + ) } +

+ ) } + + + + { result && ( + +

+ { renderSettingsLinkMessage( + result.message, + settingsUrl + ) } +

+ { result.leftoverDraft && ( +

+ { __( + 'A leftover test draft may still be in beehiiv. You can delete it there.', + 'beehiiv' + ) } +

+ ) } +
+ ) } +
+ ); +} diff --git a/src/js/editor/post-settings/css/_test-email.scss b/src/js/editor/post-settings/css/_test-email.scss new file mode 100644 index 0000000..38727f0 --- /dev/null +++ b/src/js/editor/post-settings/css/_test-email.scss @@ -0,0 +1,18 @@ +/* stylelint-disable selector-class-pattern -- BEM + @wordpress/components */ +.beehiiv-test-email { + display: flex; + flex-direction: column; + gap: 12px; + + &__note, + &__reason { + color: #757575; + font-size: 12px; + margin: 0; + } + + .components-button { + align-self: flex-start; + } +} +/* stylelint-enable selector-class-pattern */ diff --git a/src/js/editor/post-settings/editor.scss b/src/js/editor/post-settings/editor.scss index 7b54ebf..07b4af2 100644 --- a/src/js/editor/post-settings/editor.scss +++ b/src/js/editor/post-settings/editor.scss @@ -2,3 +2,4 @@ @use "css/newsletter-date"; @use "css/newsletter-template"; @use "css/omitted-blocks"; +@use "css/test-email"; diff --git a/src/js/editor/post-settings/index.js b/src/js/editor/post-settings/index.js index cb83868..b0b0b26 100644 --- a/src/js/editor/post-settings/index.js +++ b/src/js/editor/post-settings/index.js @@ -23,6 +23,7 @@ import NewsletterStatusNotices from './components/newsletter-status-notices'; import NewsletterTemplateSelect from './components/newsletter-template-select'; import PostSettingsNotice from './components/post-settings-notice'; import SendNewsletterToggle from './components/send-newsletter-toggle'; +import TestEmailSend from './components/test-email-send'; import IncompleteAdvertisementNotice from './components/incomplete-advertisement-notice'; import { OmittedBlocksNoticeMessage } from './components/omitted-blocks-notice'; import { useBeehiivEditorConfig } from './hooks/use-beehiiv-editor-config'; @@ -141,6 +142,11 @@ function BeehiivPostSettingsPanel() { ) } + { isNewsletterReady && ( + + + + ) } ); } diff --git a/tests/phpunit/NewsletterTestSenderTest.php b/tests/phpunit/NewsletterTestSenderTest.php new file mode 100644 index 0000000..704795e --- /dev/null +++ b/tests/phpunit/NewsletterTestSenderTest.php @@ -0,0 +1,493 @@ +|null}> + */ + private $requests = []; + + /** + * Per-route responses keyed by "METHOD path-suffix". + * + * @var array}> + */ + private $responses = []; + + public function set_up(): void { + parent::set_up(); + + $this->requests = []; + $this->responses = [ + 'GET /workspaces/permissions' => [ + 'status' => 200, + 'body' => [ 'data' => [ 'posts' => [ 'read', 'write' ] ] ], + ], + 'POST /posts' => [ + 'status' => 201, + 'body' => [ 'data' => [ 'id' => self::TEMP_POST_ID ] ], + ], + 'POST /posts/' . self::TEMP_POST_ID . '/test_sends' => [ + 'status' => 200, + 'body' => [ + 'data' => [ + 'remaining_test_sends' => 9, + 'reset_at' => time() + 3600, + ], + ], + ], + 'POST /posts/' . self::LINKED_POST_ID . '/test_sends' => [ + 'status' => 200, + 'body' => [ + 'data' => [ + 'remaining_test_sends' => 4, + 'reset_at' => time() + 7200, + ], + ], + ], + 'DELETE /posts/' . self::TEMP_POST_ID => [ + 'status' => 204, + 'body' => [], + ], + ]; + + add_filter( 'pre_http_request', [ $this, 'stub_http' ], 10, 3 ); + + TokenStore::save_tokens( + 'qa-client', + [ + 'access_token' => 'qa-access', + 'refresh_token' => 'qa-refresh', + 'expires_in' => 3600, + ] + ); + + update_option( + Config::OPTION_NAME, + [ + 'publication_id' => self::PUBLICATION_ID, + 'post_template_id' => 'tpl_default', + ] + ); + + delete_option( TestSender::RESET_AT_OPTION ); + } + + public function tear_down(): void { + remove_filter( 'pre_http_request', [ $this, 'stub_http' ], 10 ); + remove_all_filters( 'beehiiv_newsletter_post_settings' ); + TokenStore::delete_all(); + delete_option( Config::OPTION_NAME ); + delete_option( TestSender::RESET_AT_OPTION ); + wp_set_current_user( 0 ); + + parent::tear_down(); + } + + /** + * Answer beehiiv API calls from $this->responses and record them. + * + * @param false|array $preempt Short-circuit value. + * @param array $args Request args. + * @param string $url Request URL. + * @return false|array + */ + public function stub_http( $preempt, $args, $url ) { + if ( false === strpos( $url, 'beehiiv.com' ) ) { + return $preempt; + } + + $method = strtoupper( (string) ( $args['method'] ?? 'GET' ) ); + $path = (string) wp_parse_url( $url, PHP_URL_PATH ); + $body = isset( $args['body'] ) ? json_decode( (string) $args['body'], true ) : null; + + $this->requests[] = [ + 'method' => $method, + 'url' => $url, + 'body' => is_array( $body ) ? $body : null, + ]; + + $match = null; + + foreach ( $this->responses as $key => $response ) { + list( $route_method, $suffix ) = explode( ' ', $key, 2 ); + + if ( $route_method === $method && substr( $path, -strlen( $suffix ) ) === $suffix ) { + // Prefer the longest (most specific) suffix match. + if ( null === $match || strlen( $suffix ) > $match[0] ) { + $match = [ strlen( $suffix ), $response ]; + } + } + } + + $response = null === $match ? [ + 'status' => 404, + 'body' => [ 'errors' => [ [ 'message' => 'not stubbed' ] ] ], + ] : $match[1]; + + return [ + 'headers' => [], + 'body' => wp_json_encode( $response['body'] ), + 'response' => [ + 'code' => $response['status'], + 'message' => 'stub', + ], + 'cookies' => [], + 'filename' => null, + ]; + } + + /** + * Methods and path suffixes of every recorded request, for order assertions. + * + * @return array + */ + private function request_log(): array { + return array_map( + static function ( $request ) { + $path = (string) wp_parse_url( $request['url'], PHP_URL_PATH ); + + $path = preg_replace( '#^/v2#', '', $path ); + + return $request['method'] . ' ' . preg_replace( '#^/publications/[^/]+#', '', $path ); + }, + $this->requests + ); + } + + private function create_post( string $status = 'draft', array $meta = [] ): int { + $args = [ + 'post_status' => $status, + 'post_title' => 'Weekly roundup', + 'post_content' => "\n

Hello subscribers.

\n", + ]; + + if ( 'future' === $status ) { + $args['post_date'] = gmdate( 'Y-m-d H:i:s', time() + DAY_IN_SECONDS ); + } + + $post_id = self::factory()->post->create( $args ); + + foreach ( $meta as $key => $value ) { + update_post_meta( $post_id, $key, $value ); + } + + return $post_id; + } + + private function all_post_meta( int $post_id ): array { + $meta = get_post_meta( $post_id ); + ksort( $meta ); + + return $meta; + } + + /** + * AC-008, AC-009, AC-010, AC-018, AC-019: an unlinked draft is tested via a + * temporary beehiiv draft that is deleted, forced to draft even when a filter + * tries to make it a real send, without touching the post's newsletter meta. + */ + public function test_unlinked_draft_uses_temporary_draft_and_leaves_post_untouched(): void { + $post_id = $this->create_post( 'draft', [ Meta::SEND_TO_NEWSLETTER => '1' ] ); + + add_filter( + 'beehiiv_newsletter_post_settings', + static function ( $settings ) { + $settings['status'] = 'confirmed'; + $settings['scheduled_at'] = '2030-01-01T00:00:00Z'; + + return $settings; + } + ); + + $before = $this->all_post_meta( $post_id ); + $result = TestSender::send( $post_id, [ 'a@example.com', 'b@example.com' ] ); + + $this->assertIsArray( $result, 'Expected a successful test send.' ); + $this->assertSame( 9, $result['remaining_test_sends'] ); + $this->assertFalse( $result['leftover_draft'] ); + + $this->assertSame( + [ + 'GET /workspaces/permissions', + 'POST /posts', + 'POST /posts/' . self::TEMP_POST_ID . '/test_sends', + 'DELETE /posts/' . self::TEMP_POST_ID, + ], + $this->request_log() + ); + + $create_body = $this->requests[1]['body']; + $this->assertSame( 'draft', $create_body['status'], 'AC-009: temporary post must always be a draft.' ); + $this->assertArrayNotHasKey( 'scheduled_at', $create_body, 'AC-009: temporary post must never be scheduled.' ); + $this->assertSame( [ 'a@example.com', 'b@example.com' ], $this->requests[2]['body']['recipient_emails'] ); + + $this->assertSame( $before, $this->all_post_meta( $post_id ), 'AC-010: test send must not change post meta.' ); + $this->assertSame( $result['reset_at'], TestSender::get_remembered_reset_at( self::PUBLICATION_ID ), 'AC-019' ); + } + + /** + * AC-011, AC-021: a failed test send still deletes the temporary draft, and + * rate limiting gets its own message. + */ + public function test_failed_test_send_still_deletes_temporary_draft(): void { + $post_id = $this->create_post( 'pending' ); + + $this->responses[ 'POST /posts/' . self::TEMP_POST_ID . '/test_sends' ] = [ + 'status' => 429, + 'body' => [ 'errors' => [ [ 'message' => 'Rate limited' ] ] ], + ]; + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertWPError( $result ); + $this->assertSame( 'beehiiv_test_send_rate_limited', $result->get_error_code() ); + $this->assertSame( 'Too many requests. Try again in a moment.', $result->get_error_message() ); + $this->assertContains( 'DELETE /posts/' . self::TEMP_POST_ID, $this->request_log(), 'AC-011' ); + } + + /** + * AC-020: the daily-limit message uses the remembered reset time when it is + * still in the future, and no time otherwise. + */ + public function test_daily_limit_message_uses_remembered_reset_time(): void { + $post_id = $this->create_post( 'draft' ); + + $this->responses[ 'POST /posts/' . self::TEMP_POST_ID . '/test_sends' ] = [ + 'status' => 422, + 'body' => [ 'errors' => [ [ 'message' => 'Daily limit reached' ] ] ], + ]; + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + $this->assertSame( "You've used all test sends for today.", $result->get_error_message() ); + + $reset_at = time() + 1800; + update_option( TestSender::RESET_AT_OPTION, [ self::PUBLICATION_ID => $reset_at ] ); + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + $this->assertSame( 'beehiiv_test_send_daily_limit', $result->get_error_code() ); + $this->assertStringContainsString( wp_date( 'F j, g:i a T', $reset_at ), $result->get_error_message() ); + + update_option( TestSender::RESET_AT_OPTION, [ self::PUBLICATION_ID => time() - 60 ] ); + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + $this->assertSame( "You've used all test sends for today.", $result->get_error_message() ); + } + + /** + * AC-007: a linked newsletter scheduled for later is tested directly, with no + * temporary draft. + */ + public function test_linked_scheduled_newsletter_is_tested_directly(): void { + $post_id = $this->create_post( + 'future', + [ + Meta::BEEHIIV_POST_ID => self::LINKED_POST_ID, + Meta::BEEHIIV_SCHEDULED_AT => gmdate( 'Y-m-d\TH:i:s\Z', time() + DAY_IN_SECONDS ), + ] + ); + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertIsArray( $result ); + $this->assertSame( 4, $result['remaining_test_sends'] ); + $this->assertSame( + [ 'GET /workspaces/permissions', 'POST /posts/' . self::LINKED_POST_ID . '/test_sends' ], + $this->request_log() + ); + } + + /** + * AC-013, AC-017: an already-sent linked newsletter can't be tested, and + * nothing is sent to beehiiv. + */ + public function test_already_sent_newsletter_is_refused(): void { + $post_id = $this->create_post( + 'future', + [ + Meta::BEEHIIV_POST_ID => self::LINKED_POST_ID, + Meta::BEEHIIV_SCHEDULED_AT => gmdate( 'Y-m-d\TH:i:s\Z', time() - HOUR_IN_SECONDS ), + ] + ); + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertWPError( $result ); + $this->assertSame( 'beehiiv_test_send_already_sent', $result->get_error_code() ); + $this->assertSame( [], $this->request_log() ); + } + + /** + * AC-016: a scheduled newsletter whose last sync failed can't be tested. + */ + public function test_out_of_sync_newsletter_is_refused(): void { + $post_id = $this->create_post( + 'future', + [ + Meta::BEEHIIV_POST_ID => self::LINKED_POST_ID, + Meta::BEEHIIV_SCHEDULED_AT => gmdate( 'Y-m-d\TH:i:s\Z', time() + DAY_IN_SECONDS ), + Meta::NEWSLETTER_ERROR => 'Something went wrong.', + ] + ); + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertSame( 'beehiiv_test_send_out_of_sync', $result->get_error_code() ); + $this->assertSame( [], $this->request_log() ); + } + + /** + * AC-012, AC-013: draft, pending and scheduled are eligible; published and + * private are not. + */ + public function test_eligible_statuses(): void { + foreach ( [ 'draft', 'pending', 'future' ] as $status ) { + $this->assertTrue( TestSender::check_eligibility( $this->create_post( $status ) ), $status ); + } + + foreach ( [ 'publish', 'private' ] as $status ) { + $result = TestSender::check_eligibility( $this->create_post( $status ) ); + $this->assertWPError( $result, $status ); + $this->assertSame( 'beehiiv_test_send_ineligible_status', $result->get_error_code(), $status ); + } + } + + /** + * AC-015, AC-017: readiness is enforced on the server. + */ + public function test_readiness_is_enforced(): void { + $post_id = $this->create_post( 'draft' ); + + $this->responses['GET /workspaces/permissions']['body'] = [ 'data' => [ 'posts' => [ 'read' ] ] ]; + $this->assertSame( 'beehiiv_send_api_unavailable', TestSender::check_eligibility( $post_id )->get_error_code() ); + + TokenStore::delete_all(); + $this->assertSame( 'beehiiv_not_connected', TestSender::check_eligibility( $post_id )->get_error_code() ); + } + + /** + * AC-022: content problems show the same message a real send would. + */ + public function test_content_problem_uses_real_send_message(): void { + $post_id = self::factory()->post->create( + [ + 'post_status' => 'draft', + 'post_title' => 'No body', + 'post_content' => '', + ] + ); + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertSame( 'Add a title and body content before sending this newsletter.', $result->get_error_message() ); + $this->assertNotContains( 'POST /posts', $this->request_log() ); + } + + /** + * AC-022: other beehiiv failures show a generic error with beehiiv's message. + */ + public function test_other_failure_includes_beehiiv_message(): void { + $post_id = $this->create_post( 'draft' ); + + $this->responses[ 'POST /posts/' . self::TEMP_POST_ID . '/test_sends' ] = [ + 'status' => 400, + 'body' => [ 'errors' => [ [ 'message' => 'Recipient list rejected' ] ] ], + ]; + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertSame( 'beehiiv_test_send_failed', $result->get_error_code() ); + $this->assertStringStartsWith( "The test email wasn't sent:", $result->get_error_message() ); + } + + /** + * AC-024: a 202 "still processing" delete leaves a draft behind; the test still + * counts as sent and the leftover is flagged. + */ + public function test_still_processing_delete_flags_leftover_draft(): void { + $post_id = $this->create_post( 'draft' ); + + $this->responses[ 'DELETE /posts/' . self::TEMP_POST_ID ] = [ + 'status' => 202, + 'body' => [], + ]; + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertIsArray( $result ); + $this->assertTrue( $result['leftover_draft'] ); + } + + /** + * AC-003: recipients split on commas and new lines, trimmed, de-duplicated. + */ + public function test_parse_recipients(): void { + $this->assertSame( + [ 'a@example.com', 'b@example.com', 'c@example.com' ], + TestSendController::parse_recipients( " a@example.com, b@example.com\nA@Example.com\r\n\n c@example.com ," ) + ); + } + + /** + * AC-004, AC-014, AC-017: the REST route needs publish rights and edit rights, + * and refuses invalid addresses without calling beehiiv. + */ + public function test_rest_route_permissions_and_validation(): void { + do_action( 'rest_api_init' ); + + $post_id = $this->create_post( 'draft' ); + + $contributor = self::factory()->user->create( [ 'role' => 'contributor' ] ); + wp_set_current_user( $contributor ); + + $request = new WP_REST_Request( 'POST', '/beehiiv/v1/test-send' ); + $request->set_param( 'post_id', $post_id ); + $request->set_param( 'recipients', 'a@example.com' ); + + $this->assertSame( 403, rest_do_request( $request )->get_status(), 'AC-014: contributors cannot send tests.' ); + + wp_set_current_user( self::factory()->user->create( [ 'role' => 'editor' ] ) ); + + $request->set_param( 'recipients', 'a@example.com, not-an-email' ); + $response = rest_do_request( $request ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'beehiiv_test_send_invalid_recipients', $response->get_data()['code'] ); + $this->assertStringContainsString( 'not-an-email', $response->get_data()['message'] ); + $this->assertSame( [], $this->request_log(), 'AC-004: nothing is sent when an address is invalid.' ); + + $request->set_param( 'recipients', 'a@example.com' ); + $response = rest_do_request( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 9, $response->get_data()['remaining_test_sends'] ); + } +} From b822d598c8b44bf45908931005c18bf391d1e124 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Fri, 2 Oct 2026 18:42:26 +0800 Subject: [PATCH 2/7] test(editor-integration): add independent E2E coverage for test email send Playwright spec for PRD-06.5.01 Test Email Send, one test per acceptance criterion. 22 of 24 ACs pass. AC-017 (server skips the default template check on the linked-newsletter path) and AC-022 (beehiiv's own error message is replaced with generic copy for 400/404/5xx) fail and are not fixed here. The test-only mu-plugin gains regex and method-aware HTTP mocks, an optional response delay, a request log, and a forced settings-status option for the draft-forcing check. Existing specs keep their behaviour. --- tests/e2e/plugins/beehiiv-options.php | 81 +- tests/e2e/specs/test-email-send.spec.js | 1024 +++++++++++++++++++++++ 2 files changed, 1104 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/specs/test-email-send.spec.js diff --git a/tests/e2e/plugins/beehiiv-options.php b/tests/e2e/plugins/beehiiv-options.php index 3877765..1f89b69 100644 --- a/tests/e2e/plugins/beehiiv-options.php +++ b/tests/e2e/plugins/beehiiv-options.php @@ -32,10 +32,34 @@ static function ( $preempt, $parsed_args, $url ) { } foreach ( $mocks as $needle => $mock ) { - if ( ! is_string( $needle ) || '' === $needle || false === strpos( $url, $needle ) ) { + if ( ! is_string( $needle ) || '' === $needle || ! is_array( $mock ) ) { continue; } + // Optional `pattern` (regex) replaces the plain substring match, and + // optional `method` restricts the mock to one HTTP verb -- needed when + // several endpoints share a URL prefix (e.g. beehiiv post create, + // delete, and test_sends all live under /publications/{id}/posts). + if ( isset( $mock['pattern'] ) && is_string( $mock['pattern'] ) ) { + if ( ! preg_match( $mock['pattern'], $url ) ) { + continue; + } + } elseif ( false === strpos( $url, $needle ) ) { + continue; + } + + $method = strtoupper( (string) ( $parsed_args['method'] ?? 'GET' ) ); + + if ( isset( $mock['method'] ) && strtoupper( (string) $mock['method'] ) !== $method ) { + continue; + } + + beehiiv_e2e_log_http( $method, $url, $parsed_args['body'] ?? null, $needle ); + + if ( ! empty( $mock['delay_ms'] ) ) { + usleep( (int) $mock['delay_ms'] * 1000 ); + } + $body = $mock['body'] ?? []; return [ @@ -167,6 +191,61 @@ function beehiiv_e2e_seed_post_templates( string $publication_id, array $templat */ function beehiiv_e2e_reset_all(): void { beehiiv_e2e_clear_http_mocks(); + beehiiv_e2e_clear_http_log(); + delete_option( 'beehiiv_e2e_force_settings_status' ); beehiiv_e2e_clear_connection(); \Beehiiv\API\Cache::flush_all(); } + +/** + * Records one mocked outbound request (method, URL, decoded JSON body, and + * the mock key that answered it) so specs can assert what the plugin + * actually sent to beehiiv. Only mocked requests are logged. + * + * @param string $method HTTP method. + * @param string $url Request URL. + * @param mixed $body Raw request body. + * @param string $mock Key of the mock that answered. + */ +function beehiiv_e2e_log_http( string $method, string $url, $body, string $mock ): void { + $log = get_option( 'beehiiv_e2e_http_log', [] ); + $log = is_array( $log ) ? $log : []; + $decoded = is_string( $body ) ? json_decode( $body, true ) : $body; + $log[] = [ + 'method' => $method, + 'url' => $url, + 'body' => null === $decoded ? $body : $decoded, + 'mock' => $mock, + ]; + update_option( 'beehiiv_e2e_http_log', $log, false ); +} + +/** Clears the mocked-request log. */ +function beehiiv_e2e_clear_http_log(): void { + delete_option( 'beehiiv_e2e_http_log' ); +} + +/** + * Third-party filter stand-in for BR-005: when the + * `beehiiv_e2e_force_settings_status` option is set, the public + * `beehiiv_newsletter_post_settings` filter rewrites the payload to that + * status and adds a `scheduled_at`, the way a hostile/careless third-party + * filter could -- so specs can check the plugin forces test drafts back to + * draft *after* this filter runs. + */ +add_filter( + 'beehiiv_newsletter_post_settings', + static function ( $settings ) { + $status = get_option( 'beehiiv_e2e_force_settings_status', '' ); + + if ( ! is_array( $settings ) || ! is_string( $status ) || '' === $status ) { + return $settings; + } + + $settings['status'] = $status; + $settings['scheduled_at'] = gmdate( 'c', time() + DAY_IN_SECONDS ); + + return $settings; + }, + 999 +); diff --git a/tests/e2e/specs/test-email-send.spec.js b/tests/e2e/specs/test-email-send.spec.js new file mode 100644 index 0000000..4217266 --- /dev/null +++ b/tests/e2e/specs/test-email-send.spec.js @@ -0,0 +1,1024 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin, loginAs } = require( '../utils/auth' ); +const { openPostEditor } = require( '../utils/editor' ); +const { + wpCli, + ensurePluginActive, + ensurePrettyPermalinks, +} = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/06-editor-integration/5-newsletter-preview/test-email-send.prd.md + * + * Behavioral coverage, authored independently of the build session from the + * PRD's Acceptance Criteria + Technical Approach and the current code at the + * PLAN/EXECUTION file lists: + * - includes/REST/TestSendController.php (POST /beehiiv/v1/test-send) + * - includes/Newsletter/TestSender.php (eligibility, temp draft, errors) + * - includes/API/Resources/Posts.php (create / test_send / delete) + * - src/js/editor/post-settings/components/test-email-send.js + * - src/js/editor/post-settings/index.js ("Test email" PanelBody) + * + * beehiiv is never contacted: every outbound call is answered by the + * test-only mu-plugin (tests/e2e/plugins/beehiiv-options.php, tests env + * only), which also logs each mocked request so specs can assert exactly + * what the plugin sent to beehiiv (create body, test_sends recipients, + * delete). The connection is seeded through the real TokenStore and the + * Send API check through a mocked /workspaces/permissions response. + * + * Assumption carried from the implementation (not stated by beehiiv's docs + * or the PRD): beehiiv signals "daily limit reached" with HTTP 422 and rate + * limiting with HTTP 429 on /test_sends; the mocks follow that. + */ + +const PUB = 'pub_e2e'; +const PUB_TWO = 'pub_e2e_two'; +const TPL = 'tpl_e2e'; +const TMP_ID = 'post_tmp_e2e'; +const LINKED_ID = 'post_linked_e2e'; +const SITE_TZ = 'America/New_York'; + +// Test-only users created in this spec's own tests environment. +const AUTHOR = { login: 'qa_e2e_author', role: 'author' }; +const CONTRIB = { login: 'qa_e2e_contrib', role: 'contributor' }; +const TEST_USER_PASS = 'qa-e2e-test-send-pass'; + +const CONTENT = + '

Hello from the beehiiv test send spec.

'; + +const NEWSLETTER_META_KEYS = [ + '_beehiiv_post_id', + '_beehiiv_scheduled_at', + '_beehiiv_send_to_newsletter', + '_beehiiv_send_to_newsletter_date', + '_beehiiv_newsletter_error', + '_beehiiv_newsletter_error_type', +]; + +const createdPostIds = []; + +// --------------------------------------------------------------------------- +// wp-cli helpers (tests-cli container only) +// --------------------------------------------------------------------------- + +const b64 = ( value ) => Buffer.from( value ).toString( 'base64' ); + +/** Runs arbitrary PHP inside the tests environment and returns its output. */ +function php( code ) { + return wpCli( `eval 'eval( base64_decode( "${ b64( code ) }" ) );'` ); +} + +/** Runs PHP that echoes JSON and returns it parsed. */ +function phpJson( code ) { + const out = php( code ); + const start = out.search( /[\[{"]|null|true|false|\d/ ); + return JSON.parse( out.slice( start ) ); +} + +/** Passes a JS value into PHP as a decoded array expression. */ +const phpArg = ( value ) => + `json_decode( base64_decode( "${ b64( JSON.stringify( value ) ) }" ), true )`; + +/** + * Connected, Send-API-enabled, publication + template configured, no mocks + * beyond permissions, empty request log, no remembered reset time. + */ +function seedReadyState( overrides = {} ) { + const settings = { + publication_id: PUB, + post_template_id: TPL, + ...( overrides.settings || {} ), + }; + const permissions = overrides.permissions || { + posts: [ 'read', 'write' ], + }; + php( ` + beehiiv_e2e_reset_all(); + ${ overrides.connected === false ? '' : 'beehiiv_e2e_seed_connection();' } + beehiiv_e2e_mock_permissions( ${ phpArg( permissions ) } ); + remove_all_filters( "sanitize_option_beehiiv_settings" ); + update_option( "beehiiv_settings", ${ phpArg( settings ) } ); + delete_option( "beehiiv_test_send_reset_at" ); + update_option( "timezone_string", "${ SITE_TZ }" ); + ` ); +} + +/** + * Registers beehiiv post endpoint mocks. Patterns are anchored so create, + * test_sends, and delete (which share a URL prefix) never cross-match. + */ +function mockBeehiivPosts( { + testSend = { + status: 200, + body: { data: { remaining_test_sends: 4, reset_at: null } }, + }, + create = { status: 201, body: { data: { id: TMP_ID } } }, + del = { status: 204, body: '' }, +} = {} ) { + const mocks = { + test_sends: { + pattern: '#/publications/[^/]+/posts/[^/?]+/test_sends(\\?|$)#', + method: 'POST', + ...testSend, + }, + create_post: { + pattern: '#/publications/[^/]+/posts(\\?|$)#', + method: 'POST', + ...create, + }, + delete_post: { + pattern: '#/publications/[^/]+/posts/[^/?]+(\\?|$)#', + method: 'DELETE', + ...del, + }, + }; + php( ` + foreach ( ${ phpArg( mocks ) } as $key => $mock ) { + beehiiv_e2e_mock_http( $key, $mock ); + } + ` ); +} + +/** Mocked outbound requests the plugin made since the last reset. */ +function readHttpLog() { + return phpJson( + 'echo wp_json_encode( array_values( (array) get_option( "beehiiv_e2e_http_log", [] ) ) );' + ); +} + +const beehiivPostCalls = ( log ) => + log.filter( ( entry ) => entry.mock !== '/workspaces/permissions' ); + +/** Creates a post directly in the DB (no REST, so no newsletter sync runs). */ +function createPost( { + title = 'Test send post', + content = CONTENT, + status = 'draft', + author = 1, + meta = {}, +} = {} ) { + const id = Number( + php( ` + $args = [ + "post_title" => ${ phpArg( title ) }, + "post_content" => ${ phpArg( content ) }, + "post_status" => "${ status }", + "post_author" => ${ Number( author ) }, + "post_type" => "post", + ]; + if ( "future" === $args["post_status"] ) { + $args["post_date"] = wp_date( "Y-m-d H:i:s", time() + 3 * DAY_IN_SECONDS ); + } + $id = wp_insert_post( $args, true ); + if ( is_wp_error( $id ) ) { echo 0; return; } + foreach ( ${ phpArg( meta ) } as $key => $value ) { + update_post_meta( $id, $key, $value ); + } + echo $id; + ` ).match( /\d+\s*$/ )[ 0 ] + ); + expect( id ).toBeGreaterThan( 0 ); + createdPostIds.push( id ); + return id; +} + +function readNewsletterMeta( postId ) { + return phpJson( ` + $out = []; + foreach ( ${ phpArg( NEWSLETTER_META_KEYS ) } as $key ) { + $out[ $key ] = get_post_meta( ${ postId }, $key, true ); + } + echo wp_json_encode( $out ); + ` ); +} + +function readRememberedResetAt() { + return phpJson( + 'echo wp_json_encode( get_option( "beehiiv_test_send_reset_at", null ) );' + ); +} + +/** wp_date() of a timestamp in the site timezone, without the zone abbreviation. */ +function siteDate( timestamp ) { + return php( `echo wp_date( "F j, g:i a", ${ timestamp } );` ); +} + +const isoUtc = ( offsetSeconds ) => + new Date( Date.now() + offsetSeconds * 1000 ) + .toISOString() + .replace( /\.\d{3}Z$/, 'Z' ); + +const futureLinkedMeta = () => ( { + _beehiiv_post_id: LINKED_ID, + _beehiiv_scheduled_at: isoUtc( 2 * 86400 ), +} ); + +const sentLinkedMeta = () => ( { + _beehiiv_post_id: LINKED_ID, + _beehiiv_scheduled_at: isoUtc( -2 * 86400 ), +} ); + +// --------------------------------------------------------------------------- +// Editor helpers +// --------------------------------------------------------------------------- + +async function openBeehiivPanel( page, postId ) { + await openPostEditor( page, postId ); + const toggle = page + .getByRole( 'button', { name: 'beehiiv', exact: true } ) + .first(); + await toggle.waitFor( { state: 'visible', timeout: 15000 } ); + const pressed = + ( await toggle.getAttribute( 'aria-expanded' ) ) === 'true' || + ( await toggle.getAttribute( 'aria-pressed' ) ) === 'true'; + if ( ! pressed ) { + await toggle.click(); + } + await expect( page.locator( '.beehiiv-post-settings-content' ) ).toBeVisible(); +} + +const testEmailSection = ( page ) => page.locator( '.beehiiv-test-email' ); +const recipientsField = ( page ) => page.getByLabel( 'Send test email to' ); +const sendButton = ( page ) => + page.getByRole( 'button', { name: 'Send test email', exact: true } ); + +async function expectSendEnabled( page ) { + await expect( sendButton( page ) ).toBeVisible(); + await expect( sendButton( page ) ).not.toHaveAttribute( 'aria-disabled', 'true' ); + await expect( sendButton( page ) ).toBeEnabled(); +} + +async function expectSendDisabled( page ) { + await expect( sendButton( page ) ).toBeVisible(); + const ariaDisabled = await sendButton( page ).getAttribute( 'aria-disabled' ); + const disabled = await sendButton( page ).isDisabled(); + expect( ariaDisabled === 'true' || disabled ).toBe( true ); +} + +async function sendFromUi( page, recipients ) { + await recipientsField( page ).fill( recipients ); + const response = page.waitForResponse( + ( res ) => + res.url().includes( 'beehiiv/v1/test-send' ) && + res.request().method() === 'POST', + { timeout: 20000 } + ); + await sendButton( page ).click(); + return response; +} + +/** + * POSTs straight to the REST route with the logged-in user's REST nonce, + * bypassing the editor UI entirely (AC-017). Call on any block editor page. + */ +async function restTestSend( page, postId, recipients = 'qa@example.com' ) { + return page.evaluate( + async ( { id, to } ) => { + const res = await window.fetch( + window.wpApiSettings.root + 'beehiiv/v1/test-send', + { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': window.wpApiSettings.nonce, + }, + body: JSON.stringify( { post_id: id, recipients: to } ), + } + ); + let body = null; + try { + body = await res.json(); + } catch ( e ) {} + return { status: res.status, body }; + }, + { id: postId, to: recipients } + ); +} + +// --------------------------------------------------------------------------- + +test.describe( 'Test Email Send (PRD-06.5)', () => { + test.describe.configure( { timeout: 120 * 1000 } ); + + test.beforeAll( () => { + ensurePluginActive(); + ensurePrettyPermalinks(); + php( ` + foreach ( ${ phpArg( [ AUTHOR, CONTRIB ] ) } as $u ) { + $existing = get_user_by( "login", $u["login"] ); + if ( $existing ) { + wp_set_password( "${ TEST_USER_PASS }", $existing->ID ); + $existing->set_role( $u["role"] ); + } else { + wp_insert_user( [ + "user_login" => $u["login"], + "user_pass" => "${ TEST_USER_PASS }", + "user_email" => $u["login"] . "@example.com", + "role" => $u["role"], + ] ); + } + } + ` ); + } ); + + test.beforeEach( () => { + seedReadyState(); + mockBeehiivPosts(); + } ); + + test.afterAll( () => { + php( ` + require_once ABSPATH . "wp-admin/includes/user.php"; + foreach ( ${ phpArg( createdPostIds ) } as $id ) { + delete_post_meta( $id, "_beehiiv_post_id" ); + wp_delete_post( $id, true ); + } + foreach ( ${ phpArg( [ AUTHOR.login, CONTRIB.login ] ) } as $login ) { + $u = get_user_by( "login", $login ); + if ( $u ) { wp_delete_user( $u->ID, 1 ); } + } + beehiiv_e2e_reset_all(); + delete_option( "beehiiv_settings" ); + delete_option( "beehiiv_test_send_reset_at" ); + update_option( "timezone_string", "" ); + ` ); + } ); + + // ----- US-001 ----------------------------------------------------------- + + test( 'AC-001: "Send test email" control is in the beehiiv editor panel and not on the posts list', async ( { page } ) => { + const postId = createPost( { title: 'AC-001 post' } ); + await loginAsAdmin( page ); + + await openBeehiivPanel( page, postId ); + await expect( + page.locator( '.beehiiv-post-settings-content' ).getByRole( 'button', { name: 'Test email', exact: true } ) + ).toBeVisible(); + await expect( recipientsField( page ) ).toBeVisible(); + await expectSendEnabled( page ); + + await page.goto( '/wp-admin/edit.php' ); + await expect( page.locator( `#post-${ postId }` ) ).toBeVisible(); + await page.locator( `#post-${ postId }` ).hover(); + await expect( page.locator( '#wpbody' ) ).not.toContainText( /test email/i ); + await expect( page.locator( '#wpbody' ) ).not.toContainText( /test send/i ); + } ); + + test( 'AC-002: recipients field is empty on every open and never prefilled', async ( { page } ) => { + const postId = createPost( { title: 'AC-002 post' } ); + await loginAsAdmin( page ); + + await openBeehiivPanel( page, postId ); + await expect( recipientsField( page ) ).toHaveValue( '' ); + + // A successful send, then reopening the editor: still empty. + await sendFromUi( page, 'first@example.com' ); + await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + + await openBeehiivPanel( page, postId ); + await expect( recipientsField( page ) ).toHaveValue( '' ); + + // Admin's own email is never used as a default either. + const adminEmail = php( 'echo get_option( "admin_email" );' ); + await expect( recipientsField( page ) ).not.toHaveValue( adminEmail ); + } ); + + test( 'AC-003: commas/new lines split, whitespace trimmed, duplicates removed before sending', async ( { page } ) => { + const postId = createPost( { title: 'AC-003 post', meta: futureLinkedMeta() } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + + const res = await sendFromUi( + page, + ' one@example.com , two@example.com\n\nthree@example.com,one@example.com\n TWO@example.com ' + ); + expect( res.status() ).toBe( 200 ); + await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + + const sends = readHttpLog().filter( ( e ) => e.mock === 'test_sends' ); + expect( sends ).toHaveLength( 1 ); + expect( sends[ 0 ].body.recipient_emails ).toEqual( [ + 'one@example.com', + 'two@example.com', + 'three@example.com', + ] ); + } ); + + test( 'AC-004: invalid addresses block the send and are named; no recipient cap', async ( { page } ) => { + const postId = createPost( { title: 'AC-004 post', meta: futureLinkedMeta() } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + + await recipientsField( page ).fill( 'good@example.com, not-an-email\nalso bad@x' ); + await sendButton( page ).click(); + await expect( testEmailSection( page ) ).toContainText( "These email addresses aren't valid" ); + await expect( testEmailSection( page ) ).toContainText( 'not-an-email' ); + await expect( testEmailSection( page ) ).toContainText( 'also bad@x' ); + await expect( testEmailSection( page ) ).not.toContainText( 'Test email sent.' ); + expect( readHttpLog().filter( ( e ) => e.mock === 'test_sends' ) ).toHaveLength( 0 ); + + // 75 distinct addresses all go through in one send. + const many = Array.from( { length: 75 }, ( _, i ) => `bulk${ i }@example.com` ); + const res = await sendFromUi( page, many.join( ', ' ) ); + expect( res.status() ).toBe( 200 ); + const sends = readHttpLog().filter( ( e ) => e.mock === 'test_sends' ); + expect( sends ).toHaveLength( 1 ); + expect( sends[ 0 ].body.recipient_emails ).toEqual( many ); + } ); + + test( 'AC-005: send is disabled with a save note while the post has unsaved changes; never auto-saves', async ( { page } ) => { + const postId = createPost( { title: 'AC-005 original title' } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + await expectSendEnabled( page ); + + const postWrites = []; + page.on( 'request', ( req ) => { + if ( + new RegExp( `/wp/v2/posts/${ postId }(\\?|$|/)` ).test( req.url() ) && + req.method() !== 'GET' + ) { + postWrites.push( req.url() ); + } + } ); + + const title = page + .frameLocator( 'iframe[name="editor-canvas"]' ) + .locator( '.editor-post-title__input, [aria-label="Add title"]' ) + .first(); + await title.click(); + await page.keyboard.press( 'End' ); + await page.keyboard.type( ' EDITED' ); + + await expect( testEmailSection( page ) ).toContainText( + 'Save the post to include your latest edits.' + ); + await expectSendDisabled( page ); + + await recipientsField( page ).fill( 'dirty@example.com' ); + await sendButton( page ).click( { force: true } ); + await page.waitForTimeout( 1500 ); + + expect( readHttpLog().filter( ( e ) => e.mock === 'test_sends' ) ).toHaveLength( 0 ); + expect( postWrites ).toHaveLength( 0 ); + expect( php( `echo get_post_field( "post_title", ${ postId } );` ) ).toBe( + 'AC-005 original title' + ); + } ); + + test( 'AC-006: send is disabled while the post is saving and while a test send is in progress', async ( { page } ) => { + const postId = createPost( { title: 'AC-006 post', meta: futureLinkedMeta() } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + await expectSendEnabled( page ); + + // In progress: beehiiv answers after 4s. + mockBeehiivPosts( { + testSend: { + status: 200, + delay_ms: 4000, + body: { data: { remaining_test_sends: 3, reset_at: null } }, + }, + } ); + await recipientsField( page ).fill( 'progress@example.com' ); + const done = page.waitForResponse( ( r ) => r.url().includes( 'beehiiv/v1/test-send' ), { + timeout: 20000, + } ); + await sendButton( page ).click(); + await expectSendDisabled( page ); + await done; + await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + await expectSendEnabled( page ); + + // Saving: hold the post save request for 4s and sample the editor's + // saving flag and the button state together, in the same tick. + await page.route( + new RegExp( `/wp/v2/posts/${ postId }(\\?|$)` ), + async ( route ) => { + if ( route.request().method() === 'GET' ) { + return route.continue(); + } + await new Promise( ( r ) => setTimeout( r, 4000 ) ); + return route.continue(); + } + ); + await page.evaluate( () => { + window.wp.data.dispatch( 'core/editor' ).savePost(); + } ); + await expect + .poll( () => page.evaluate( () => window.wp.data.select( 'core/editor' ).isSavingPost() ), { + timeout: 5000, + } ) + .toBe( true ); + const sample = await page.evaluate( () => { + const btn = [ ...document.querySelectorAll( '.beehiiv-test-email button' ) ].find( + ( b ) => b.textContent.trim() === 'Send test email' + ); + return { + saving: window.wp.data.select( 'core/editor' ).isSavingPost(), + dirty: window.wp.data.select( 'core/editor' ).isEditedPostDirty(), + disabled: !! btn && ( btn.disabled || btn.getAttribute( 'aria-disabled' ) === 'true' ), + }; + } ); + expect( sample.saving ).toBe( true ); + expect( sample.disabled ).toBe( true ); + await expect + .poll( + () => page.evaluate( () => window.wp.data.select( 'core/editor' ).isSavingPost() ), + { timeout: 15000 } + ) + .toBe( false ); + await expectSendEnabled( page ); + } ); + + test( 'AC-007: a linked, not-yet-sent newsletter is tested directly (no temporary draft)', async ( { page } ) => { + const postId = createPost( { title: 'AC-007 post', meta: futureLinkedMeta() } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + + const res = await sendFromUi( page, 'linked@example.com' ); + expect( res.status() ).toBe( 200 ); + await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + + const calls = beehiivPostCalls( readHttpLog() ); + expect( calls.map( ( c ) => c.mock ) ).toEqual( [ 'test_sends' ] ); + expect( calls[ 0 ].url ).toContain( `/publications/${ PUB }/posts/${ LINKED_ID }/test_sends` ); + } ); + + test( 'AC-008: no linked newsletter -> temporary draft from saved content, tested, then deleted', async ( { page } ) => { + const postId = createPost( { title: 'AC-008 saved title' } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + + const res = await sendFromUi( page, 'temp@example.com' ); + expect( res.status() ).toBe( 200 ); + await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + + const calls = beehiivPostCalls( readHttpLog() ); + expect( calls.map( ( c ) => `${ c.method } ${ c.mock }` ) ).toEqual( [ + 'POST create_post', + 'POST test_sends', + 'DELETE delete_post', + ] ); + expect( calls[ 1 ].url ).toContain( `/posts/${ TMP_ID }/test_sends` ); + expect( calls[ 2 ].url ).toMatch( new RegExp( `/posts/${ TMP_ID }(\\?|$)` ) ); + + // Same conversion/settings as a real send: compare to the real-send builder output. + const created = calls[ 0 ].body; + const realSend = phpJson( + `echo wp_json_encode( \\Beehiiv\\Newsletter\\PostSettingsBuilder::get_post_settings( ${ postId }, true ) );` + ); + expect( created.title ).toBe( 'AC-008 saved title' ); + expect( created.post_template_id ).toBe( TPL ); + expect( created.blocks ).toEqual( realSend.blocks ); + expect( created.blocks.length ).toBeGreaterThan( 0 ); + expect( created.email_settings ).toEqual( realSend.email_settings ); + expect( created.web_settings ).toEqual( realSend.web_settings ); + } ); + + test( 'AC-009: the temporary beehiiv post is always a draft with no send time, even if a filter says otherwise', async ( { page } ) => { + const postId = createPost( { + title: 'AC-009 post', + meta: { + _beehiiv_send_to_newsletter: '1', + _beehiiv_send_to_newsletter_date: php( + 'echo wp_date( "Y-m-d\\\\TH:i:s", time() + 5 * DAY_IN_SECONDS );' + ), + }, + } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + + // Plain run. + await sendFromUi( page, 'draft@example.com' ); + await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + let create = readHttpLog().find( ( e ) => e.mock === 'create_post' ); + expect( create.body.status ).toBe( 'draft' ); + expect( create.body ).not.toHaveProperty( 'scheduled_at' ); + + // Third-party filter forces confirmed + scheduled_at (BR-005). + php( 'beehiiv_e2e_clear_http_log(); update_option( "beehiiv_e2e_force_settings_status", "confirmed" );' ); + await sendFromUi( page, 'draft@example.com' ); + await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + create = readHttpLog().find( ( e ) => e.mock === 'create_post' ); + expect( create.body.status ).toBe( 'draft' ); + expect( create.body ).not.toHaveProperty( 'scheduled_at' ); + php( 'delete_option( "beehiiv_e2e_force_settings_status" );' ); + } ); + + test( 'AC-010: a test send never changes newsletter link, send toggle, send date, or error state', async ( { page } ) => { + const sendDate = php( 'echo wp_date( "Y-m-d\\\\TH:i:s", time() + 5 * DAY_IN_SECONDS );' ); + const linkedId = createPost( { + title: 'AC-010 linked', + meta: { ...futureLinkedMeta(), _beehiiv_send_to_newsletter: '1' }, + } ); + const tempId = createPost( { + title: 'AC-010 temp', + meta: { + _beehiiv_send_to_newsletter: '1', + _beehiiv_send_to_newsletter_date: sendDate, + }, + } ); + const beforeLinked = readNewsletterMeta( linkedId ); + const beforeTemp = readNewsletterMeta( tempId ); + + await loginAsAdmin( page ); + for ( const id of [ linkedId, tempId ] ) { + await openBeehiivPanel( page, id ); + await sendFromUi( page, 'meta@example.com' ); + await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + } + // A failed send must not leave an error state either. + mockBeehiivPosts( { testSend: { status: 500, body: { message: 'boom' } } } ); + await sendFromUi( page, 'meta@example.com' ); + await expect( testEmailSection( page ) ).toContainText( "wasn't sent" ); + + expect( readNewsletterMeta( linkedId ) ).toEqual( beforeLinked ); + expect( readNewsletterMeta( tempId ) ).toEqual( beforeTemp ); + // No PATCH/update of the real newsletter was attempted. + expect( readHttpLog().filter( ( e ) => e.method === 'PATCH' ) ).toHaveLength( 0 ); + } ); + + test( 'AC-011: the temporary draft is still deleted when the test send fails', async ( { page } ) => { + const postId = createPost( { title: 'AC-011 post' } ); + mockBeehiivPosts( { + testSend: { status: 500, body: { message: 'Internal error' } }, + } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + + await sendFromUi( page, 'fail@example.com' ); + await expect( testEmailSection( page ) ).toContainText( "wasn't sent" ); + + const calls = beehiivPostCalls( readHttpLog() ); + expect( calls.map( ( c ) => `${ c.method } ${ c.mock }` ) ).toEqual( [ + 'POST create_post', + 'POST test_sends', + 'DELETE delete_post', + ] ); + expect( calls[ 2 ].url ).toMatch( new RegExp( `/posts/${ TMP_ID }(\\?|$)` ) ); + } ); + + // ----- US-002 ----------------------------------------------------------- + + test( 'AC-012: available for saved draft, pending, and scheduled posts, with or without "Send to newsletter"', async ( { page } ) => { + await loginAsAdmin( page ); + for ( const status of [ 'draft', 'pending', 'future' ] ) { + for ( const toggle of [ '', '1' ] ) { + const postId = createPost( { + title: `AC-012 ${ status } toggle=${ toggle || 'off' }`, + status, + meta: { _beehiiv_send_to_newsletter: toggle }, + } ); + await openBeehiivPanel( page, postId ); + await expect( testEmailSection( page ) ).not.toContainText( 'Test emails are available for' ); + await expectSendEnabled( page ); + const res = await sendFromUi( page, 'avail@example.com' ); + expect( res.status(), `${ status } toggle=${ toggle }` ).toBe( 200 ); + await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + } + } + } ); + + test( 'AC-013: not available on published, private, or already-sent posts, with a short reason', async ( { page } ) => { + const cases = [ + { status: 'publish', meta: {}, reason: 'Test emails are available for drafts, pending posts, and scheduled posts.' }, + { status: 'private', meta: {}, reason: 'Test emails are available for drafts, pending posts, and scheduled posts.' }, + { status: 'draft', meta: sentLinkedMeta(), reason: "This post's newsletter was already sent, so it can't send a test email." }, + ]; + await loginAsAdmin( page ); + for ( const c of cases ) { + const postId = createPost( { title: `AC-013 ${ c.status }`, status: c.status, meta: c.meta } ); + await openBeehiivPanel( page, postId ); + await expect( testEmailSection( page ) ).toContainText( c.reason ); + await expect( sendButton( page ) ).toHaveCount( 0 ); + await expect( recipientsField( page ) ).toHaveCount( 0 ); + } + } ); + + test( 'AC-014: only users with publish rights who can edit the post see the control', async ( { page, browser } ) => { + // Author (publish_posts) on their own draft: sees and can use it. + const authorId = Number( php( `echo get_user_by( "login", "${ AUTHOR.login }" )->ID;` ) ); + const contribId = Number( php( `echo get_user_by( "login", "${ CONTRIB.login }" )->ID;` ) ); + const authorPost = createPost( { title: 'AC-014 author post', author: authorId } ); + const contribPost = createPost( { title: 'AC-014 contributor post', author: contribId } ); + + await loginAs( page, AUTHOR.login, TEST_USER_PASS ); + await openBeehiivPanel( page, authorPost ); + await expectSendEnabled( page ); + + // Contributor (no publish_posts) on their own draft: no beehiiv panel at all. + const ctx = await browser.newContext(); + const cpage = await ctx.newPage(); + await loginAs( cpage, CONTRIB.login, TEST_USER_PASS ); + await openPostEditor( cpage, contribPost ); + await cpage.waitForTimeout( 2000 ); + await expect( cpage.getByRole( 'button', { name: 'beehiiv', exact: true } ) ).toHaveCount( 0 ); + await expect( cpage.getByRole( 'button', { name: 'Send test email' } ) ).toHaveCount( 0 ); + await ctx.close(); + } ); + + test( 'AC-015: unavailable when beehiiv is not ready, showing the same readiness message as the toggle', async ( { page } ) => { + const postId = createPost( { title: 'AC-015 post' } ); + const cases = [ + { name: 'not connected', seed: { connected: false }, message: 'Connect your beehiiv account' }, + { name: 'no Send API', seed: { permissions: { posts: [ 'read' ] } }, message: "doesn't have access to send newsletters" }, + { name: 'no publication', seed: { settings: { publication_id: '' } }, message: 'Choose a publication in' }, + { name: 'no template', seed: { settings: { post_template_id: '' } }, message: 'Choose a default post template in' }, + ]; + await loginAsAdmin( page ); + for ( const c of cases ) { + seedReadyState( c.seed ); + mockBeehiivPosts(); + await openBeehiivPanel( page, postId ); + const panel = page.locator( '.beehiiv-post-settings-content' ); + await expect( panel, c.name ).toContainText( c.message ); + await expect( panel.getByRole( 'button', { name: 'Test email', exact: true } ), c.name ).toHaveCount( 0 ); + await expect( sendButton( page ), c.name ).toHaveCount( 0 ); + } + } ); + + test( 'AC-016: scheduled post whose last beehiiv sync failed shows the out-of-sync note', async ( { page } ) => { + const postId = createPost( { + title: 'AC-016 post', + status: 'future', + meta: { + ...futureLinkedMeta(), + _beehiiv_send_to_newsletter: '1', + _beehiiv_newsletter_error: 'beehiiv is temporarily unavailable. Try again later.', + _beehiiv_newsletter_error_type: 'save', + }, + } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + await expect( testEmailSection( page ) ).toContainText( "isn't in sync with beehiiv" ); + await expect( testEmailSection( page ) ).toContainText( 'Save the post again' ); + await expect( sendButton( page ) ).toHaveCount( 0 ); + } ); + + test( 'AC-017: server refuses ineligible posts and invalid input when the UI is bypassed', async ( { page } ) => { + const editable = createPost( { title: 'AC-017 host' } ); + const published = createPost( { title: 'AC-017 published', status: 'publish' } ); + const privatePost = createPost( { title: 'AC-017 private', status: 'private' } ); + const sent = createPost( { title: 'AC-017 sent', meta: sentLinkedMeta() } ); + const outOfSync = createPost( { + title: 'AC-017 out of sync', + status: 'future', + meta: { ...futureLinkedMeta(), _beehiiv_newsletter_error: 'sync failed', _beehiiv_newsletter_error_type: 'save' }, + } ); + + await loginAsAdmin( page ); + await openPostEditor( page, editable ); + + for ( const [ id, code ] of [ + [ published, 'beehiiv_test_send_ineligible_status' ], + [ privatePost, 'beehiiv_test_send_ineligible_status' ], + [ sent, 'beehiiv_test_send_already_sent' ], + [ outOfSync, 'beehiiv_test_send_out_of_sync' ], + ] ) { + const r = await restTestSend( page, id ); + expect( r.status, code ).toBeGreaterThanOrEqual( 400 ); + expect( r.body.code ).toBe( code ); + } + + const invalid = await restTestSend( page, editable, 'ok@example.com, nope' ); + expect( invalid.status ).toBe( 400 ); + expect( invalid.body.message ).toContain( 'nope' ); + + const empty = await restTestSend( page, editable, ' , \n ' ); + expect( empty.status ).toBe( 400 ); + + for ( const [ seed, code ] of [ + [ { connected: false }, 'beehiiv_not_connected' ], + [ { permissions: { posts: [ 'read' ] } }, 'beehiiv_send_api_unavailable' ], + [ { settings: { publication_id: '' } }, 'beehiiv_missing_publication' ], + ] ) { + seedReadyState( seed ); + mockBeehiivPosts(); + const r = await restTestSend( page, editable ); + expect( r.status, code ).toBeGreaterThanOrEqual( 400 ); + expect( r.body.code ).toBe( code ); + } + + expect( readHttpLog().filter( ( e ) => e.mock === 'test_sends' ) ).toHaveLength( 0 ); + } ); + + test( 'AC-017: server refuses users without publish or edit rights when the UI is bypassed', async ( { browser } ) => { + const authorId = Number( php( `echo get_user_by( "login", "${ AUTHOR.login }" )->ID;` ) ); + const contribId = Number( php( `echo get_user_by( "login", "${ CONTRIB.login }" )->ID;` ) ); + const adminPost = createPost( { title: 'AC-017 admin post', author: 1 } ); + const authorPost = createPost( { title: 'AC-017 author own', author: authorId } ); + const contribPost = createPost( { title: 'AC-017 contrib own', author: contribId } ); + + // Contributor: can edit own draft but lacks publish_posts. + let ctx = await browser.newContext(); + let p = await ctx.newPage(); + await loginAs( p, CONTRIB.login, TEST_USER_PASS ); + await openPostEditor( p, contribPost ); + let r = await restTestSend( p, contribPost ); + expect( r.status ).toBe( 403 ); + await ctx.close(); + + // Author: has publish_posts but cannot edit the admin's post. + ctx = await browser.newContext(); + p = await ctx.newPage(); + await loginAs( p, AUTHOR.login, TEST_USER_PASS ); + await openPostEditor( p, authorPost ); + r = await restTestSend( p, adminPost ); + expect( r.status ).toBe( 403 ); + await ctx.close(); + + expect( readHttpLog().filter( ( e ) => e.mock === 'test_sends' ) ).toHaveLength( 0 ); + } ); + + test( 'AC-017: server refuses when no post template is configured (same readiness rule as the toggle)', async ( { page } ) => { + const unlinked = createPost( { title: 'AC-017 no template unlinked' } ); + const linked = createPost( { title: 'AC-017 no template linked', meta: futureLinkedMeta() } ); + await loginAsAdmin( page ); + await openPostEditor( page, unlinked ); + + seedReadyState( { settings: { post_template_id: '' } } ); + mockBeehiivPosts(); + + const r1 = await restTestSend( page, unlinked ); + expect( r1.status, 'unlinked post, no template' ).toBeGreaterThanOrEqual( 400 ); + + const r2 = await restTestSend( page, linked ); + expect( r2.status, 'linked unsent post, no template' ).toBeGreaterThanOrEqual( 400 ); + + expect( readHttpLog().filter( ( e ) => e.mock === 'test_sends' ) ).toHaveLength( 0 ); + } ); + + // ----- US-003 ----------------------------------------------------------- + + test( 'AC-018: success shows confirmation, sends left today, and reset time in the site timezone', async ( { page } ) => { + const resetAt = Math.floor( Date.now() / 1000 ) + 6 * 3600 + 17 * 60; + mockBeehiivPosts( { + testSend: { status: 200, body: { data: { remaining_test_sends: 7, reset_at: resetAt } } }, + } ); + const postId = createPost( { title: 'AC-018 post', meta: futureLinkedMeta() } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + + await sendFromUi( page, 'success@example.com' ); + const section = testEmailSection( page ); + await expect( section ).toContainText( 'Test email sent.' ); + await expect( section ).toContainText( '7 test sends left today.' ); + const expectedLocal = siteDate( resetAt ); + await expect( section ).toContainText( `Test sends reset on ${ expectedLocal }` ); + + // Sanity: the site timezone rendering differs from a UTC rendering. + const utc = php( `echo gmdate( "F j, g:i a", ${ resetAt } );` ); + expect( utc ).not.toBe( expectedLocal ); + } ); + + test( 'AC-019: last reset time is remembered per publication from successful sends', async ( { page } ) => { + const resetOne = Math.floor( Date.now() / 1000 ) + 5 * 3600; + const resetTwo = resetOne + 3600; + const postId = createPost( { title: 'AC-019 post', meta: futureLinkedMeta() } ); + await loginAsAdmin( page ); + + mockBeehiivPosts( { testSend: { status: 200, body: { data: { remaining_test_sends: 2, reset_at: resetOne } } } } ); + await openBeehiivPanel( page, postId ); + await sendFromUi( page, 'r1@example.com' ); + await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + expect( readRememberedResetAt() ).toEqual( { [ PUB ]: resetOne } ); + + // Failed send must not overwrite it. + mockBeehiivPosts( { testSend: { status: 500, body: { message: 'x' } } } ); + await sendFromUi( page, 'r1@example.com' ); + await expect( testEmailSection( page ) ).toContainText( "wasn't sent" ); + expect( readRememberedResetAt() ).toEqual( { [ PUB ]: resetOne } ); + + // Second publication keeps its own value alongside the first. + php( ` + remove_all_filters( "sanitize_option_beehiiv_settings" ); + update_option( "beehiiv_settings", [ "publication_id" => "${ PUB_TWO }", "post_template_id" => "${ TPL }" ] ); + ` ); + mockBeehiivPosts( { testSend: { status: 200, body: { data: { remaining_test_sends: 1, reset_at: resetTwo } } } } ); + await openBeehiivPanel( page, postId ); + await sendFromUi( page, 'r2@example.com' ); + await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + expect( readRememberedResetAt() ).toEqual( { [ PUB ]: resetOne, [ PUB_TWO ]: resetTwo } ); + } ); + + test( 'AC-020: daily limit shows all sends used, with the remembered reset time only when still in the future', async ( { page } ) => { + const postId = createPost( { title: 'AC-020 post', meta: futureLinkedMeta() } ); + mockBeehiivPosts( { + testSend: { status: 422, body: { errors: [ { message: 'Daily test send limit reached' } ] } }, + } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + const section = testEmailSection( page ); + + // Remembered and in the future. + const future = Math.floor( Date.now() / 1000 ) + 4 * 3600; + php( `update_option( "beehiiv_test_send_reset_at", [ "${ PUB }" => ${ future } ] );` ); + await sendFromUi( page, 'limit@example.com' ); + await expect( section ).toContainText( "You've used all test sends for today." ); + await expect( section ).toContainText( `They reset on ${ siteDate( future ) }` ); + + // Remembered but already past: no time. + const past = Math.floor( Date.now() / 1000 ) - 3600; + php( `update_option( "beehiiv_test_send_reset_at", [ "${ PUB }" => ${ past } ] );` ); + await sendFromUi( page, 'limit@example.com' ); + await expect( section ).toContainText( "You've used all test sends for today." ); + await expect( section ).not.toContainText( 'They reset on' ); + + // Nothing remembered: no time. + php( 'delete_option( "beehiiv_test_send_reset_at" );' ); + await sendFromUi( page, 'limit@example.com' ); + await expect( section ).toContainText( "You've used all test sends for today." ); + await expect( section ).not.toContainText( 'They reset on' ); + } ); + + test( 'AC-021: rate limiting shows "too many requests, try again in a moment"', async ( { page } ) => { + const postId = createPost( { title: 'AC-021 post', meta: futureLinkedMeta() } ); + mockBeehiivPosts( { testSend: { status: 429, body: { message: 'Rate limited' } } } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + + await sendFromUi( page, 'rate@example.com' ); + await expect( testEmailSection( page ) ).toContainText( /too many requests\. try again in a moment\./i ); + } ); + + test( "AC-022: other failures show a generic error including beehiiv's message", async ( { page } ) => { + const postId = createPost( { title: 'AC-022 api post', meta: futureLinkedMeta() } ); + const beehiivMessage = 'Recipient list rejected by beehiiv'; + mockBeehiivPosts( { + testSend: { status: 400, body: { errors: [ { message: beehiivMessage } ] } }, + } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + + await sendFromUi( page, 'other@example.com' ); + const section = testEmailSection( page ); + await expect( section ).toContainText( "The test email wasn't sent" ); + await expect( section ).toContainText( beehiivMessage ); + } ); + + test( 'AC-022: content problems show the same message a real newsletter send would show', async ( { page } ) => { + const postId = createPost( { title: 'AC-022 empty content', content: '' } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + + const realSendMessage = php( ` + $settings = \\Beehiiv\\Newsletter\\PostSettingsBuilder::get_post_settings( ${ postId } ); + echo is_wp_error( $settings ) ? \\Beehiiv\\Newsletter\\Sender::format_save_error_message( $settings ) : "NO_ERROR"; + ` ); + expect( realSendMessage ).toBe( 'Add a title and body content before sending this newsletter.' ); + + await sendFromUi( page, 'content@example.com' ); + await expect( testEmailSection( page ) ).toContainText( realSendMessage ); + expect( beehiivPostCalls( readHttpLog() ) ).toHaveLength( 0 ); + } ); + + test( 'AC-023: typed addresses stay in the field after a failure', async ( { page } ) => { + const postId = createPost( { title: 'AC-023 post', meta: futureLinkedMeta() } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + const typed = 'keep-one@example.com,\nkeep-two@example.com'; + + for ( const testSend of [ + { status: 500, body: { message: 'boom' } }, + { status: 429, body: {} }, + { status: 422, body: {} }, + ] ) { + mockBeehiivPosts( { testSend } ); + await sendFromUi( page, typed ); + await expect( testEmailSection( page ) ).not.toContainText( 'Test email sent.' ); + await expect( testEmailSection( page ).locator( '.components-notice' ) ).toBeVisible(); + await expect( recipientsField( page ) ).toHaveValue( typed ); + } + + // Client-side invalid-address failure too. + await recipientsField( page ).fill( 'bad-address' ); + await sendButton( page ).click(); + await expect( testEmailSection( page ) ).toContainText( "aren't valid" ); + await expect( recipientsField( page ) ).toHaveValue( 'bad-address' ); + } ); + + test( 'AC-024: temporary draft delete failure or "still processing" still counts as sent, with a leftover-draft note', async ( { page } ) => { + const postId = createPost( { title: 'AC-024 post' } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + + for ( const del of [ + { status: 202, body: '' }, + { status: 500, body: { message: 'delete failed' } }, + ] ) { + mockBeehiivPosts( { + testSend: { status: 200, body: { data: { remaining_test_sends: 3, reset_at: null } } }, + del, + } ); + php( 'beehiiv_e2e_clear_http_log();' ); + const res = await sendFromUi( page, 'leftover@example.com' ); + expect( res.status(), `delete HTTP ${ del.status }` ).toBe( 200 ); + const section = testEmailSection( page ); + await expect( section ).toContainText( 'Test email sent.' ); + await expect( section ).toContainText( 'leftover test draft may still be in beehiiv' ); + expect( readHttpLog().some( ( e ) => e.mock === 'delete_post' ) ).toBe( true ); + } + } ); +} ); From cf2e06e98a0950d5ce6a8fcbb63efeefcc51594f Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Fri, 2 Oct 2026 21:25:47 +0800 Subject: [PATCH 3/7] fix(editor-integration): enforce template readiness and keep beehiiv's error text for test emails - Refuse a test send on the server when no default post template is set, the same readiness rule as the newsletter toggle, before any beehiiv call. The linked-newsletter path skipped this check (AC-015, AC-017). - Show beehiiv's own error message for request failures instead of the real send's generic "Try saving the post again" copy. Auth (401/403) and rate-limit (429) errors keep their own messages (AC-022, BR-007). - Strengthen the PHPUnit coverage: assert the full beehiiv message for 400 and 5xx, the reconnect message for 403, and the template refusal on the linked path. --- includes/Newsletter/TestSender.php | 31 +++++++++++++- languages/beehiiv.pot | 32 ++++++++------- tests/phpunit/NewsletterTestSenderTest.php | 48 +++++++++++++++++++++- 3 files changed, 95 insertions(+), 16 deletions(-) diff --git a/includes/Newsletter/TestSender.php b/includes/Newsletter/TestSender.php index e6cd94d..dcf161e 100644 --- a/includes/Newsletter/TestSender.php +++ b/includes/Newsletter/TestSender.php @@ -7,6 +7,7 @@ namespace Beehiiv\Newsletter; +use Beehiiv\Admin\Options; use Beehiiv\API\Resources\Posts; use Beehiiv\API\Resources\Workspace; use Beehiiv\Connection\Manager; @@ -110,6 +111,13 @@ public static function check_eligibility( int $post_id ) { ); } + if ( '' === self::get_default_post_template_id() ) { + return new WP_Error( + 'beehiiv_missing_post_template', + __( 'Choose a default post template in beehiiv settings to send a test email.', 'beehiiv' ) + ); + } + if ( ! Workspace::can_write_posts() ) { return new WP_Error( 'beehiiv_send_api_unavailable', @@ -334,7 +342,14 @@ private static function finish( int $post_id, string $publication_id, array $res * @since 1.0.0 */ private static function api_error( string $error ): WP_Error { - $mapped = Sender::format_api_error_message( $error ); + // Keep beehiiv's own wording for request errors; only auth and rate-limit + // statuses, and transport errors, use the shared editor-friendly copy. + if ( preg_match( '/^HTTP (\d+):\s*(.+)$/is', trim( $error ), $matches ) + && ! in_array( (int) $matches[1], [ 401, 403, 429 ], true ) ) { + $mapped = trim( $matches[2] ); + } else { + $mapped = Sender::format_api_error_message( $error ); + } if ( '' === $mapped ) { return new WP_Error( @@ -353,6 +368,20 @@ private static function api_error( string $error ): WP_Error { ); } + /** + * Site-wide default beehiiv post template from plugin settings. + * + * The same setting the editor's newsletter readiness rule checks. + * + * @return string + * @since 1.0.0 + */ + private static function get_default_post_template_id(): string { + $settings = Options::get(); + + return isset( $settings['post_template_id'] ) ? trim( (string) $settings['post_template_id'] ) : ''; + } + /** * Store the latest test-send reset time for a publication. * diff --git a/languages/beehiiv.pot b/languages/beehiiv.pot index f2536e0..578b1c5 100644 --- a/languages/beehiiv.pot +++ b/languages/beehiiv.pot @@ -9,7 +9,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-10-01T18:10:36+00:00\n" +"POT-Creation-Date: 2026-10-02T13:25:45+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "X-Generator: WP-CLI 2.12.0\n" "X-Domain: beehiiv\n" @@ -213,7 +213,7 @@ msgstr "" #: includes/Newsletter/Sender.php:340 #: includes/Newsletter/Sender.php:427 #: includes/Newsletter/Sender.php:690 -#: includes/Newsletter/TestSender.php:68 +#: includes/Newsletter/TestSender.php:69 msgid "This post no longer exists. Save or reload the editor and try again." msgstr "" @@ -243,7 +243,7 @@ msgid "Connect your beehiiv account in beehiiv settings to send this news msgstr "" #: includes/Newsletter/Sender.php:734 -#: includes/Newsletter/TestSender.php:109 +#: includes/Newsletter/TestSender.php:110 msgid "Choose a publication in beehiiv settings, then try again." msgstr "" @@ -263,7 +263,7 @@ msgid "beehiiv rejected this newsletter: %s" msgstr "" #: includes/Newsletter/Sender.php:773 -#: includes/Newsletter/TestSender.php:322 +#: includes/Newsletter/TestSender.php:330 msgid "Too many requests. Try again in a moment." msgstr "" @@ -292,47 +292,51 @@ msgstr "" msgid "Something went wrong and the newsletter couldn't be updated. Try saving the post again." msgstr "" -#: includes/Newsletter/TestSender.php:75 +#: includes/Newsletter/TestSender.php:76 #: src/js/editor/post-settings/components/test-email-send.js:75 msgid "Test emails are available for drafts, pending posts, and scheduled posts." msgstr "" -#: includes/Newsletter/TestSender.php:83 +#: includes/Newsletter/TestSender.php:84 #: src/js/editor/post-settings/components/test-email-send.js:68 msgid "This post's newsletter was already sent, so it can't send a test email." msgstr "" -#: includes/Newsletter/TestSender.php:90 +#: includes/Newsletter/TestSender.php:91 #: src/js/editor/post-settings/components/test-email-send.js:82 msgid "This post's newsletter isn't in sync with beehiiv. Save the post again, then send a test email." msgstr "" -#: includes/Newsletter/TestSender.php:102 +#: includes/Newsletter/TestSender.php:103 msgid "Connect your beehiiv account in beehiiv settings to send a test email." msgstr "" -#: includes/Newsletter/TestSender.php:116 +#: includes/Newsletter/TestSender.php:117 +msgid "Choose a default post template in beehiiv settings to send a test email." +msgstr "" + +#: includes/Newsletter/TestSender.php:124 msgid "Test emails need beehiiv Send API access, available on the Max and Enterprise plans." msgstr "" -#: includes/Newsletter/TestSender.php:206 -#: includes/Newsletter/TestSender.php:342 +#: includes/Newsletter/TestSender.php:214 +#: includes/Newsletter/TestSender.php:357 #: src/js/editor/post-settings/components/test-email-send.js:224 msgid "Something went wrong and the test email wasn't sent. Try again." msgstr "" #. translators: %s: date, time, and timezone when test sends reset. -#: includes/Newsletter/TestSender.php:307 +#: includes/Newsletter/TestSender.php:315 #, php-format msgid "You've used all test sends for today. They reset on %s." msgstr "" -#: includes/Newsletter/TestSender.php:315 +#: includes/Newsletter/TestSender.php:323 msgid "You've used all test sends for today." msgstr "" #. translators: %s: error message from beehiiv. -#: includes/Newsletter/TestSender.php:350 +#: includes/Newsletter/TestSender.php:365 #, php-format msgid "The test email wasn't sent: %s" msgstr "" diff --git a/tests/phpunit/NewsletterTestSenderTest.php b/tests/phpunit/NewsletterTestSenderTest.php index 704795e..47b0fcb 100644 --- a/tests/phpunit/NewsletterTestSenderTest.php +++ b/tests/phpunit/NewsletterTestSenderTest.php @@ -393,6 +393,28 @@ public function test_readiness_is_enforced(): void { $this->assertSame( 'beehiiv_not_connected', TestSender::check_eligibility( $post_id )->get_error_code() ); } + /** + * AC-015, AC-017, BR-002: without a default post template, a linked newsletter + * is refused on the server too, and nothing reaches beehiiv. + */ + public function test_missing_default_template_is_refused_on_linked_path(): void { + update_option( Config::OPTION_NAME, [ 'publication_id' => self::PUBLICATION_ID ] ); + + $post_id = $this->create_post( + 'future', + [ + Meta::BEEHIIV_POST_ID => self::LINKED_POST_ID, + Meta::BEEHIIV_SCHEDULED_AT => gmdate( 'Y-m-d\TH:i:s\Z', time() + DAY_IN_SECONDS ), + ] + ); + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertWPError( $result ); + $this->assertSame( 'beehiiv_missing_post_template', $result->get_error_code() ); + $this->assertSame( [], $this->request_log() ); + } + /** * AC-022: content problems show the same message a real send would. */ @@ -425,7 +447,31 @@ public function test_other_failure_includes_beehiiv_message(): void { $result = TestSender::send( $post_id, [ 'a@example.com' ] ); $this->assertSame( 'beehiiv_test_send_failed', $result->get_error_code() ); - $this->assertStringStartsWith( "The test email wasn't sent:", $result->get_error_message() ); + $this->assertSame( "The test email wasn't sent: Recipient list rejected", $result->get_error_message() ); + + $this->responses[ 'POST /posts/' . self::TEMP_POST_ID . '/test_sends' ] = [ + 'status' => 503, + 'body' => [ 'errors' => [ [ 'message' => 'Service unavailable' ] ] ], + ]; + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + $this->assertSame( "The test email wasn't sent: Service unavailable", $result->get_error_message() ); + } + + /** + * AC-022: auth failures keep the reconnect guidance instead of beehiiv's text. + */ + public function test_auth_failure_points_to_reconnect(): void { + $post_id = $this->create_post( 'draft' ); + + $this->responses[ 'POST /posts/' . self::TEMP_POST_ID . '/test_sends' ] = [ + 'status' => 403, + 'body' => [ 'errors' => [ [ 'message' => 'Forbidden' ] ] ], + ]; + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertStringContainsString( 'Reconnect in beehiiv settings', $result->get_error_message() ); } /** From 8b9bb2b7511086e07f75b1502b5b72ff994b33d2 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Mon, 5 Oct 2026 20:36:21 +0800 Subject: [PATCH 4/7] test(e2e): fix login race in the shared loginAs helper wp-login.php focuses and selects the username field on a timer after load. When it fired between the two fills, the password was typed into the username field, the empty password blocked the submit, and the waitForURL timed out (seen in oauth-authorization AC-005's beforeEach). Re-fill until both fields hold the expected values before submitting. --- tests/e2e/utils/auth.js | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/e2e/utils/auth.js b/tests/e2e/utils/auth.js index 04270d2..7102e22 100644 --- a/tests/e2e/utils/auth.js +++ b/tests/e2e/utils/auth.js @@ -1,3 +1,5 @@ +const { expect } = require( '@playwright/test' ); + const ADMIN_USERNAME = process.env.WP_ADMIN_USERNAME || 'admin'; const ADMIN_PASSWORD = process.env.WP_ADMIN_PASSWORD || 'password'; @@ -15,14 +17,29 @@ async function loginAsAdmin( page ) { * auth) -- for UI-driven tests where a REST Application Password wouldn't * apply. * + * wp-login.php focuses and selects the username field on a ~200ms timer + * after load. When that timer fires mid-fill, the password lands in the + * username field and the empty password blocks the submit. Re-filling + * until both fields hold the expected values sidesteps the race; the timer + * only fires once, so the values can't change again before the click. + * * @param {import('@playwright/test').Page} page - * @param {string} username - * @param {string} password + * @param {string} username + * @param {string} password */ async function loginAs( page, username, password ) { await page.goto( '/wp-login.php' ); - await page.fill( '#user_login', username ); - await page.fill( '#user_pass', password ); + + const userField = page.locator( '#user_login' ); + const passField = page.locator( '#user_pass' ); + + await expect( async () => { + await userField.fill( username ); + await passField.fill( password ); + await expect( userField ).toHaveValue( username, { timeout: 1000 } ); + await expect( passField ).toHaveValue( password, { timeout: 1000 } ); + } ).toPass( { timeout: 10000 } ); + await page.click( '#wp-submit' ); await page.waitForURL( '**/wp-admin/**' ); } From aa7809eaeeb20eac0250b928236cbf74a4137941 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Fri, 9 Oct 2026 00:41:07 +0800 Subject: [PATCH 5/7] Fix lint issues --- tests/e2e/specs/test-email-send.spec.js | 697 ++++++++++++++++++------ 1 file changed, 542 insertions(+), 155 deletions(-) diff --git a/tests/e2e/specs/test-email-send.spec.js b/tests/e2e/specs/test-email-send.spec.js index 4217266..19ff6f5 100644 --- a/tests/e2e/specs/test-email-send.spec.js +++ b/tests/e2e/specs/test-email-send.spec.js @@ -63,25 +63,44 @@ const createdPostIds = []; const b64 = ( value ) => Buffer.from( value ).toString( 'base64' ); -/** Runs arbitrary PHP inside the tests environment and returns its output. */ +/** + * Runs arbitrary PHP inside the tests environment and returns its output. + * + * @param {string} code PHP source, without an opening tag. + * @return {string} Output of the eval. + */ function php( code ) { return wpCli( `eval 'eval( base64_decode( "${ b64( code ) }" ) );'` ); } -/** Runs PHP that echoes JSON and returns it parsed. */ +/** + * Runs PHP that echoes JSON and returns it parsed. + * + * @param {string} code PHP source that echoes JSON. + * @return {*} Parsed value. + */ function phpJson( code ) { const out = php( code ); const start = out.search( /[\[{"]|null|true|false|\d/ ); return JSON.parse( out.slice( start ) ); } -/** Passes a JS value into PHP as a decoded array expression. */ +/** + * Passes a JS value into PHP as a decoded array expression. + * + * @param {*} value Any JSON-serialisable value. + * @return {string} PHP expression that evaluates to the value. + */ const phpArg = ( value ) => - `json_decode( base64_decode( "${ b64( JSON.stringify( value ) ) }" ), true )`; + `json_decode( base64_decode( "${ b64( + JSON.stringify( value ) + ) }" ), true )`; /** * Connected, Send-API-enabled, publication + template configured, no mocks * beyond permissions, empty request log, no remembered reset time. + * + * @param {Object} overrides Plugin settings to merge over the defaults. */ function seedReadyState( overrides = {} ) { const settings = { @@ -106,6 +125,11 @@ function seedReadyState( overrides = {} ) { /** * Registers beehiiv post endpoint mocks. Patterns are anchored so create, * test_sends, and delete (which share a URL prefix) never cross-match. + * + * @param {Object} root0 Mock responses, each `{ status, body }`. + * @param {Object} root0.testSend Response for POST …/test_sends. + * @param {Object} root0.create Response for POST …/posts (temporary draft). + * @param {Object} root0.del Response for DELETE …/posts/{id}. */ function mockBeehiivPosts( { testSend = { @@ -149,7 +173,17 @@ function readHttpLog() { const beehiivPostCalls = ( log ) => log.filter( ( entry ) => entry.mock !== '/workspaces/permissions' ); -/** Creates a post directly in the DB (no REST, so no newsletter sync runs). */ +/** + * Creates a post directly in the DB (no REST, so no newsletter sync runs). + * + * @param {Object} root0 Post fields. + * @param {string} root0.title Post title. + * @param {string} root0.content Post content (block markup). + * @param {string} root0.status Post status. + * @param {number} root0.author Author user ID. + * @param {Object} root0.meta Post meta to set, keyed by meta key. + * @return {number} The new post ID. + */ function createPost( { title = 'Test send post', content = CONTENT, @@ -198,7 +232,12 @@ function readRememberedResetAt() { ); } -/** wp_date() of a timestamp in the site timezone, without the zone abbreviation. */ +/** + * wp_date() of a timestamp in the site timezone, without the zone abbreviation. + * + * @param {number} timestamp Unix timestamp. + * @return {string} Formatted date and time. + */ function siteDate( timestamp ) { return php( `echo wp_date( "F j, g:i a", ${ timestamp } );` ); } @@ -234,7 +273,9 @@ async function openBeehiivPanel( page, postId ) { if ( ! pressed ) { await toggle.click(); } - await expect( page.locator( '.beehiiv-post-settings-content' ) ).toBeVisible(); + await expect( + page.locator( '.beehiiv-post-settings-content' ) + ).toBeVisible(); } const testEmailSection = ( page ) => page.locator( '.beehiiv-test-email' ); @@ -244,13 +285,17 @@ const sendButton = ( page ) => async function expectSendEnabled( page ) { await expect( sendButton( page ) ).toBeVisible(); - await expect( sendButton( page ) ).not.toHaveAttribute( 'aria-disabled', 'true' ); + await expect( sendButton( page ) ).not.toHaveAttribute( + 'aria-disabled', + 'true' + ); await expect( sendButton( page ) ).toBeEnabled(); } async function expectSendDisabled( page ) { await expect( sendButton( page ) ).toBeVisible(); - const ariaDisabled = await sendButton( page ).getAttribute( 'aria-disabled' ); + const ariaDisabled = + await sendButton( page ).getAttribute( 'aria-disabled' ); const disabled = await sendButton( page ).isDisabled(); expect( ariaDisabled === 'true' || disabled ).toBe( true ); } @@ -270,6 +315,11 @@ async function sendFromUi( page, recipients ) { /** * POSTs straight to the REST route with the logged-in user's REST nonce, * bypassing the editor UI entirely (AC-017). Call on any block editor page. + * + * @param {import('@playwright/test').Page} page Page on a block editor screen. + * @param {number} postId Post to send a test of. + * @param {string} recipients Raw recipients field value. + * @return {Promise<{status: number, body: *}>} HTTP status and parsed body. */ async function restTestSend( page, postId, recipients = 'qa@example.com' ) { return page.evaluate( @@ -289,7 +339,7 @@ async function restTestSend( page, postId, recipients = 'qa@example.com' ) { let body = null; try { body = await res.json(); - } catch ( e ) {} + } catch {} return { status: res.status, body }; }, { id: postId, to: recipients } @@ -347,13 +397,17 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { // ----- US-001 ----------------------------------------------------------- - test( 'AC-001: "Send test email" control is in the beehiiv editor panel and not on the posts list', async ( { page } ) => { + test( 'AC-001: "Send test email" control is in the beehiiv editor panel and not on the posts list', async ( { + page, + } ) => { const postId = createPost( { title: 'AC-001 post' } ); await loginAsAdmin( page ); await openBeehiivPanel( page, postId ); await expect( - page.locator( '.beehiiv-post-settings-content' ).getByRole( 'button', { name: 'Test email', exact: true } ) + page + .locator( '.beehiiv-post-settings-content' ) + .getByRole( 'button', { name: 'Test email', exact: true } ) ).toBeVisible(); await expect( recipientsField( page ) ).toBeVisible(); await expectSendEnabled( page ); @@ -361,11 +415,17 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { await page.goto( '/wp-admin/edit.php' ); await expect( page.locator( `#post-${ postId }` ) ).toBeVisible(); await page.locator( `#post-${ postId }` ).hover(); - await expect( page.locator( '#wpbody' ) ).not.toContainText( /test email/i ); - await expect( page.locator( '#wpbody' ) ).not.toContainText( /test send/i ); + await expect( page.locator( '#wpbody' ) ).not.toContainText( + /test email/i + ); + await expect( page.locator( '#wpbody' ) ).not.toContainText( + /test send/i + ); } ); - test( 'AC-002: recipients field is empty on every open and never prefilled', async ( { page } ) => { + test( 'AC-002: recipients field is empty on every open and never prefilled', async ( { + page, + } ) => { const postId = createPost( { title: 'AC-002 post' } ); await loginAsAdmin( page ); @@ -374,7 +434,9 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { // A successful send, then reopening the editor: still empty. await sendFromUi( page, 'first@example.com' ); - await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + await expect( testEmailSection( page ) ).toContainText( + 'Test email sent.' + ); await openBeehiivPanel( page, postId ); await expect( recipientsField( page ) ).toHaveValue( '' ); @@ -384,8 +446,13 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { await expect( recipientsField( page ) ).not.toHaveValue( adminEmail ); } ); - test( 'AC-003: commas/new lines split, whitespace trimmed, duplicates removed before sending', async ( { page } ) => { - const postId = createPost( { title: 'AC-003 post', meta: futureLinkedMeta() } ); + test( 'AC-003: commas/new lines split, whitespace trimmed, duplicates removed before sending', async ( { + page, + } ) => { + const postId = createPost( { + title: 'AC-003 post', + meta: futureLinkedMeta(), + } ); await loginAsAdmin( page ); await openBeehiivPanel( page, postId ); @@ -394,7 +461,9 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { ' one@example.com , two@example.com\n\nthree@example.com,one@example.com\n TWO@example.com ' ); expect( res.status() ).toBe( 200 ); - await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + await expect( testEmailSection( page ) ).toContainText( + 'Test email sent.' + ); const sends = readHttpLog().filter( ( e ) => e.mock === 'test_sends' ); expect( sends ).toHaveLength( 1 ); @@ -405,21 +474,39 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { ] ); } ); - test( 'AC-004: invalid addresses block the send and are named; no recipient cap', async ( { page } ) => { - const postId = createPost( { title: 'AC-004 post', meta: futureLinkedMeta() } ); + test( 'AC-004: invalid addresses block the send and are named; no recipient cap', async ( { + page, + } ) => { + const postId = createPost( { + title: 'AC-004 post', + meta: futureLinkedMeta(), + } ); await loginAsAdmin( page ); await openBeehiivPanel( page, postId ); - await recipientsField( page ).fill( 'good@example.com, not-an-email\nalso bad@x' ); + await recipientsField( page ).fill( + 'good@example.com, not-an-email\nalso bad@x' + ); await sendButton( page ).click(); - await expect( testEmailSection( page ) ).toContainText( "These email addresses aren't valid" ); - await expect( testEmailSection( page ) ).toContainText( 'not-an-email' ); + await expect( testEmailSection( page ) ).toContainText( + "These email addresses aren't valid" + ); + await expect( testEmailSection( page ) ).toContainText( + 'not-an-email' + ); await expect( testEmailSection( page ) ).toContainText( 'also bad@x' ); - await expect( testEmailSection( page ) ).not.toContainText( 'Test email sent.' ); - expect( readHttpLog().filter( ( e ) => e.mock === 'test_sends' ) ).toHaveLength( 0 ); + await expect( testEmailSection( page ) ).not.toContainText( + 'Test email sent.' + ); + expect( + readHttpLog().filter( ( e ) => e.mock === 'test_sends' ) + ).toHaveLength( 0 ); // 75 distinct addresses all go through in one send. - const many = Array.from( { length: 75 }, ( _, i ) => `bulk${ i }@example.com` ); + const many = Array.from( + { length: 75 }, + ( _, i ) => `bulk${ i }@example.com` + ); const res = await sendFromUi( page, many.join( ', ' ) ); expect( res.status() ).toBe( 200 ); const sends = readHttpLog().filter( ( e ) => e.mock === 'test_sends' ); @@ -427,7 +514,9 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { expect( sends[ 0 ].body.recipient_emails ).toEqual( many ); } ); - test( 'AC-005: send is disabled with a save note while the post has unsaved changes; never auto-saves', async ( { page } ) => { + test( 'AC-005: send is disabled with a save note while the post has unsaved changes; never auto-saves', async ( { + page, + } ) => { const postId = createPost( { title: 'AC-005 original title' } ); await loginAsAdmin( page ); await openBeehiivPanel( page, postId ); @@ -436,7 +525,9 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { const postWrites = []; page.on( 'request', ( req ) => { if ( - new RegExp( `/wp/v2/posts/${ postId }(\\?|$|/)` ).test( req.url() ) && + new RegExp( `/wp/v2/posts/${ postId }(\\?|$|/)` ).test( + req.url() + ) && req.method() !== 'GET' ) { postWrites.push( req.url() ); @@ -460,15 +551,22 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { await sendButton( page ).click( { force: true } ); await page.waitForTimeout( 1500 ); - expect( readHttpLog().filter( ( e ) => e.mock === 'test_sends' ) ).toHaveLength( 0 ); + expect( + readHttpLog().filter( ( e ) => e.mock === 'test_sends' ) + ).toHaveLength( 0 ); expect( postWrites ).toHaveLength( 0 ); - expect( php( `echo get_post_field( "post_title", ${ postId } );` ) ).toBe( - 'AC-005 original title' - ); + expect( + php( `echo get_post_field( "post_title", ${ postId } );` ) + ).toBe( 'AC-005 original title' ); } ); - test( 'AC-006: send is disabled while the post is saving and while a test send is in progress', async ( { page } ) => { - const postId = createPost( { title: 'AC-006 post', meta: futureLinkedMeta() } ); + test( 'AC-006: send is disabled while the post is saving and while a test send is in progress', async ( { + page, + } ) => { + const postId = createPost( { + title: 'AC-006 post', + meta: futureLinkedMeta(), + } ); await loginAsAdmin( page ); await openBeehiivPanel( page, postId ); await expectSendEnabled( page ); @@ -482,13 +580,18 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { }, } ); await recipientsField( page ).fill( 'progress@example.com' ); - const done = page.waitForResponse( ( r ) => r.url().includes( 'beehiiv/v1/test-send' ), { - timeout: 20000, - } ); + const done = page.waitForResponse( + ( r ) => r.url().includes( 'beehiiv/v1/test-send' ), + { + timeout: 20000, + } + ); await sendButton( page ).click(); await expectSendDisabled( page ); await done; - await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + await expect( testEmailSection( page ) ).toContainText( + 'Test email sent.' + ); await expectSendEnabled( page ); // Saving: hold the post save request for 4s and sample the editor's @@ -507,53 +610,80 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { window.wp.data.dispatch( 'core/editor' ).savePost(); } ); await expect - .poll( () => page.evaluate( () => window.wp.data.select( 'core/editor' ).isSavingPost() ), { - timeout: 5000, - } ) + .poll( + () => + page.evaluate( () => + window.wp.data.select( 'core/editor' ).isSavingPost() + ), + { + timeout: 5000, + } + ) .toBe( true ); const sample = await page.evaluate( () => { - const btn = [ ...document.querySelectorAll( '.beehiiv-test-email button' ) ].find( - ( b ) => b.textContent.trim() === 'Send test email' - ); + const btn = [ + ...document.querySelectorAll( '.beehiiv-test-email button' ), + ].find( ( b ) => b.textContent.trim() === 'Send test email' ); return { saving: window.wp.data.select( 'core/editor' ).isSavingPost(), - dirty: window.wp.data.select( 'core/editor' ).isEditedPostDirty(), - disabled: !! btn && ( btn.disabled || btn.getAttribute( 'aria-disabled' ) === 'true' ), + dirty: window.wp.data + .select( 'core/editor' ) + .isEditedPostDirty(), + disabled: + !! btn && + ( btn.disabled || + btn.getAttribute( 'aria-disabled' ) === 'true' ), }; } ); expect( sample.saving ).toBe( true ); expect( sample.disabled ).toBe( true ); await expect .poll( - () => page.evaluate( () => window.wp.data.select( 'core/editor' ).isSavingPost() ), + () => + page.evaluate( () => + window.wp.data.select( 'core/editor' ).isSavingPost() + ), { timeout: 15000 } ) .toBe( false ); await expectSendEnabled( page ); } ); - test( 'AC-007: a linked, not-yet-sent newsletter is tested directly (no temporary draft)', async ( { page } ) => { - const postId = createPost( { title: 'AC-007 post', meta: futureLinkedMeta() } ); + test( 'AC-007: a linked, not-yet-sent newsletter is tested directly (no temporary draft)', async ( { + page, + } ) => { + const postId = createPost( { + title: 'AC-007 post', + meta: futureLinkedMeta(), + } ); await loginAsAdmin( page ); await openBeehiivPanel( page, postId ); const res = await sendFromUi( page, 'linked@example.com' ); expect( res.status() ).toBe( 200 ); - await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + await expect( testEmailSection( page ) ).toContainText( + 'Test email sent.' + ); const calls = beehiivPostCalls( readHttpLog() ); expect( calls.map( ( c ) => c.mock ) ).toEqual( [ 'test_sends' ] ); - expect( calls[ 0 ].url ).toContain( `/publications/${ PUB }/posts/${ LINKED_ID }/test_sends` ); + expect( calls[ 0 ].url ).toContain( + `/publications/${ PUB }/posts/${ LINKED_ID }/test_sends` + ); } ); - test( 'AC-008: no linked newsletter -> temporary draft from saved content, tested, then deleted', async ( { page } ) => { + test( 'AC-008: no linked newsletter -> temporary draft from saved content, tested, then deleted', async ( { + page, + } ) => { const postId = createPost( { title: 'AC-008 saved title' } ); await loginAsAdmin( page ); await openBeehiivPanel( page, postId ); const res = await sendFromUi( page, 'temp@example.com' ); expect( res.status() ).toBe( 200 ); - await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + await expect( testEmailSection( page ) ).toContainText( + 'Test email sent.' + ); const calls = beehiivPostCalls( readHttpLog() ); expect( calls.map( ( c ) => `${ c.method } ${ c.mock }` ) ).toEqual( [ @@ -562,7 +692,9 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { 'DELETE delete_post', ] ); expect( calls[ 1 ].url ).toContain( `/posts/${ TMP_ID }/test_sends` ); - expect( calls[ 2 ].url ).toMatch( new RegExp( `/posts/${ TMP_ID }(\\?|$)` ) ); + expect( calls[ 2 ].url ).toMatch( + new RegExp( `/posts/${ TMP_ID }(\\?|$)` ) + ); // Same conversion/settings as a real send: compare to the real-send builder output. const created = calls[ 0 ].body; @@ -577,7 +709,9 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { expect( created.web_settings ).toEqual( realSend.web_settings ); } ); - test( 'AC-009: the temporary beehiiv post is always a draft with no send time, even if a filter says otherwise', async ( { page } ) => { + test( 'AC-009: the temporary beehiiv post is always a draft with no send time, even if a filter says otherwise', async ( { + page, + } ) => { const postId = createPost( { title: 'AC-009 post', meta: { @@ -592,23 +726,33 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { // Plain run. await sendFromUi( page, 'draft@example.com' ); - await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + await expect( testEmailSection( page ) ).toContainText( + 'Test email sent.' + ); let create = readHttpLog().find( ( e ) => e.mock === 'create_post' ); expect( create.body.status ).toBe( 'draft' ); expect( create.body ).not.toHaveProperty( 'scheduled_at' ); // Third-party filter forces confirmed + scheduled_at (BR-005). - php( 'beehiiv_e2e_clear_http_log(); update_option( "beehiiv_e2e_force_settings_status", "confirmed" );' ); + php( + 'beehiiv_e2e_clear_http_log(); update_option( "beehiiv_e2e_force_settings_status", "confirmed" );' + ); await sendFromUi( page, 'draft@example.com' ); - await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + await expect( testEmailSection( page ) ).toContainText( + 'Test email sent.' + ); create = readHttpLog().find( ( e ) => e.mock === 'create_post' ); expect( create.body.status ).toBe( 'draft' ); expect( create.body ).not.toHaveProperty( 'scheduled_at' ); php( 'delete_option( "beehiiv_e2e_force_settings_status" );' ); } ); - test( 'AC-010: a test send never changes newsletter link, send toggle, send date, or error state', async ( { page } ) => { - const sendDate = php( 'echo wp_date( "Y-m-d\\\\TH:i:s", time() + 5 * DAY_IN_SECONDS );' ); + test( 'AC-010: a test send never changes newsletter link, send toggle, send date, or error state', async ( { + page, + } ) => { + const sendDate = php( + 'echo wp_date( "Y-m-d\\\\TH:i:s", time() + 5 * DAY_IN_SECONDS );' + ); const linkedId = createPost( { title: 'AC-010 linked', meta: { ...futureLinkedMeta(), _beehiiv_send_to_newsletter: '1' }, @@ -627,20 +771,28 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { for ( const id of [ linkedId, tempId ] ) { await openBeehiivPanel( page, id ); await sendFromUi( page, 'meta@example.com' ); - await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + await expect( testEmailSection( page ) ).toContainText( + 'Test email sent.' + ); } // A failed send must not leave an error state either. - mockBeehiivPosts( { testSend: { status: 500, body: { message: 'boom' } } } ); + mockBeehiivPosts( { + testSend: { status: 500, body: { message: 'boom' } }, + } ); await sendFromUi( page, 'meta@example.com' ); await expect( testEmailSection( page ) ).toContainText( "wasn't sent" ); expect( readNewsletterMeta( linkedId ) ).toEqual( beforeLinked ); expect( readNewsletterMeta( tempId ) ).toEqual( beforeTemp ); // No PATCH/update of the real newsletter was attempted. - expect( readHttpLog().filter( ( e ) => e.method === 'PATCH' ) ).toHaveLength( 0 ); + expect( + readHttpLog().filter( ( e ) => e.method === 'PATCH' ) + ).toHaveLength( 0 ); } ); - test( 'AC-011: the temporary draft is still deleted when the test send fails', async ( { page } ) => { + test( 'AC-011: the temporary draft is still deleted when the test send fails', async ( { + page, + } ) => { const postId = createPost( { title: 'AC-011 post' } ); mockBeehiivPosts( { testSend: { status: 500, body: { message: 'Internal error' } }, @@ -657,12 +809,16 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { 'POST test_sends', 'DELETE delete_post', ] ); - expect( calls[ 2 ].url ).toMatch( new RegExp( `/posts/${ TMP_ID }(\\?|$)` ) ); + expect( calls[ 2 ].url ).toMatch( + new RegExp( `/posts/${ TMP_ID }(\\?|$)` ) + ); } ); // ----- US-002 ----------------------------------------------------------- - test( 'AC-012: available for saved draft, pending, and scheduled posts, with or without "Send to newsletter"', async ( { page } ) => { + test( 'AC-012: available for saved draft, pending, and scheduled posts, with or without "Send to newsletter"', async ( { + page, + } ) => { await loginAsAdmin( page ); for ( const status of [ 'draft', 'pending', 'future' ] ) { for ( const toggle of [ '', '1' ] ) { @@ -672,24 +828,48 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { meta: { _beehiiv_send_to_newsletter: toggle }, } ); await openBeehiivPanel( page, postId ); - await expect( testEmailSection( page ) ).not.toContainText( 'Test emails are available for' ); + await expect( testEmailSection( page ) ).not.toContainText( + 'Test emails are available for' + ); await expectSendEnabled( page ); const res = await sendFromUi( page, 'avail@example.com' ); - expect( res.status(), `${ status } toggle=${ toggle }` ).toBe( 200 ); - await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + expect( res.status(), `${ status } toggle=${ toggle }` ).toBe( + 200 + ); + await expect( testEmailSection( page ) ).toContainText( + 'Test email sent.' + ); } } } ); - test( 'AC-013: not available on published, private, or already-sent posts, with a short reason', async ( { page } ) => { + test( 'AC-013: not available on published, private, or already-sent posts, with a short reason', async ( { + page, + } ) => { const cases = [ - { status: 'publish', meta: {}, reason: 'Test emails are available for drafts, pending posts, and scheduled posts.' }, - { status: 'private', meta: {}, reason: 'Test emails are available for drafts, pending posts, and scheduled posts.' }, - { status: 'draft', meta: sentLinkedMeta(), reason: "This post's newsletter was already sent, so it can't send a test email." }, + { + status: 'publish', + meta: {}, + reason: 'Test emails are available for drafts, pending posts, and scheduled posts.', + }, + { + status: 'private', + meta: {}, + reason: 'Test emails are available for drafts, pending posts, and scheduled posts.', + }, + { + status: 'draft', + meta: sentLinkedMeta(), + reason: "This post's newsletter was already sent, so it can't send a test email.", + }, ]; await loginAsAdmin( page ); for ( const c of cases ) { - const postId = createPost( { title: `AC-013 ${ c.status }`, status: c.status, meta: c.meta } ); + const postId = createPost( { + title: `AC-013 ${ c.status }`, + status: c.status, + meta: c.meta, + } ); await openBeehiivPanel( page, postId ); await expect( testEmailSection( page ) ).toContainText( c.reason ); await expect( sendButton( page ) ).toHaveCount( 0 ); @@ -697,12 +877,25 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { } } ); - test( 'AC-014: only users with publish rights who can edit the post see the control', async ( { page, browser } ) => { + test( 'AC-014: only users with publish rights who can edit the post see the control', async ( { + page, + browser, + } ) => { // Author (publish_posts) on their own draft: sees and can use it. - const authorId = Number( php( `echo get_user_by( "login", "${ AUTHOR.login }" )->ID;` ) ); - const contribId = Number( php( `echo get_user_by( "login", "${ CONTRIB.login }" )->ID;` ) ); - const authorPost = createPost( { title: 'AC-014 author post', author: authorId } ); - const contribPost = createPost( { title: 'AC-014 contributor post', author: contribId } ); + const authorId = Number( + php( `echo get_user_by( "login", "${ AUTHOR.login }" )->ID;` ) + ); + const contribId = Number( + php( `echo get_user_by( "login", "${ CONTRIB.login }" )->ID;` ) + ); + const authorPost = createPost( { + title: 'AC-014 author post', + author: authorId, + } ); + const contribPost = createPost( { + title: 'AC-014 contributor post', + author: contribId, + } ); await loginAs( page, AUTHOR.login, TEST_USER_PASS ); await openBeehiivPanel( page, authorPost ); @@ -714,18 +907,40 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { await loginAs( cpage, CONTRIB.login, TEST_USER_PASS ); await openPostEditor( cpage, contribPost ); await cpage.waitForTimeout( 2000 ); - await expect( cpage.getByRole( 'button', { name: 'beehiiv', exact: true } ) ).toHaveCount( 0 ); - await expect( cpage.getByRole( 'button', { name: 'Send test email' } ) ).toHaveCount( 0 ); + await expect( + cpage.getByRole( 'button', { name: 'beehiiv', exact: true } ) + ).toHaveCount( 0 ); + await expect( + cpage.getByRole( 'button', { name: 'Send test email' } ) + ).toHaveCount( 0 ); await ctx.close(); } ); - test( 'AC-015: unavailable when beehiiv is not ready, showing the same readiness message as the toggle', async ( { page } ) => { + test( 'AC-015: unavailable when beehiiv is not ready, showing the same readiness message as the toggle', async ( { + page, + } ) => { const postId = createPost( { title: 'AC-015 post' } ); const cases = [ - { name: 'not connected', seed: { connected: false }, message: 'Connect your beehiiv account' }, - { name: 'no Send API', seed: { permissions: { posts: [ 'read' ] } }, message: "doesn't have access to send newsletters" }, - { name: 'no publication', seed: { settings: { publication_id: '' } }, message: 'Choose a publication in' }, - { name: 'no template', seed: { settings: { post_template_id: '' } }, message: 'Choose a default post template in' }, + { + name: 'not connected', + seed: { connected: false }, + message: 'Connect your beehiiv account', + }, + { + name: 'no Send API', + seed: { permissions: { posts: [ 'read' ] } }, + message: "doesn't have access to send newsletters", + }, + { + name: 'no publication', + seed: { settings: { publication_id: '' } }, + message: 'Choose a publication in', + }, + { + name: 'no template', + seed: { settings: { post_template_id: '' } }, + message: 'Choose a default post template in', + }, ]; await loginAsAdmin( page ); for ( const c of cases ) { @@ -734,38 +949,66 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { await openBeehiivPanel( page, postId ); const panel = page.locator( '.beehiiv-post-settings-content' ); await expect( panel, c.name ).toContainText( c.message ); - await expect( panel.getByRole( 'button', { name: 'Test email', exact: true } ), c.name ).toHaveCount( 0 ); + await expect( + panel.getByRole( 'button', { + name: 'Test email', + exact: true, + } ), + c.name + ).toHaveCount( 0 ); await expect( sendButton( page ), c.name ).toHaveCount( 0 ); } } ); - test( 'AC-016: scheduled post whose last beehiiv sync failed shows the out-of-sync note', async ( { page } ) => { + test( 'AC-016: scheduled post whose last beehiiv sync failed shows the out-of-sync note', async ( { + page, + } ) => { const postId = createPost( { title: 'AC-016 post', status: 'future', meta: { ...futureLinkedMeta(), _beehiiv_send_to_newsletter: '1', - _beehiiv_newsletter_error: 'beehiiv is temporarily unavailable. Try again later.', + _beehiiv_newsletter_error: + 'beehiiv is temporarily unavailable. Try again later.', _beehiiv_newsletter_error_type: 'save', }, } ); await loginAsAdmin( page ); await openBeehiivPanel( page, postId ); - await expect( testEmailSection( page ) ).toContainText( "isn't in sync with beehiiv" ); - await expect( testEmailSection( page ) ).toContainText( 'Save the post again' ); + await expect( testEmailSection( page ) ).toContainText( + "isn't in sync with beehiiv" + ); + await expect( testEmailSection( page ) ).toContainText( + 'Save the post again' + ); await expect( sendButton( page ) ).toHaveCount( 0 ); } ); - test( 'AC-017: server refuses ineligible posts and invalid input when the UI is bypassed', async ( { page } ) => { + test( 'AC-017: server refuses ineligible posts and invalid input when the UI is bypassed', async ( { + page, + } ) => { const editable = createPost( { title: 'AC-017 host' } ); - const published = createPost( { title: 'AC-017 published', status: 'publish' } ); - const privatePost = createPost( { title: 'AC-017 private', status: 'private' } ); - const sent = createPost( { title: 'AC-017 sent', meta: sentLinkedMeta() } ); + const published = createPost( { + title: 'AC-017 published', + status: 'publish', + } ); + const privatePost = createPost( { + title: 'AC-017 private', + status: 'private', + } ); + const sent = createPost( { + title: 'AC-017 sent', + meta: sentLinkedMeta(), + } ); const outOfSync = createPost( { title: 'AC-017 out of sync', status: 'future', - meta: { ...futureLinkedMeta(), _beehiiv_newsletter_error: 'sync failed', _beehiiv_newsletter_error_type: 'save' }, + meta: { + ...futureLinkedMeta(), + _beehiiv_newsletter_error: 'sync failed', + _beehiiv_newsletter_error_type: 'save', + }, } ); await loginAsAdmin( page ); @@ -782,7 +1025,11 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { expect( r.body.code ).toBe( code ); } - const invalid = await restTestSend( page, editable, 'ok@example.com, nope' ); + const invalid = await restTestSend( + page, + editable, + 'ok@example.com, nope' + ); expect( invalid.status ).toBe( 400 ); expect( invalid.body.message ).toContain( 'nope' ); @@ -791,8 +1038,14 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { for ( const [ seed, code ] of [ [ { connected: false }, 'beehiiv_not_connected' ], - [ { permissions: { posts: [ 'read' ] } }, 'beehiiv_send_api_unavailable' ], - [ { settings: { publication_id: '' } }, 'beehiiv_missing_publication' ], + [ + { permissions: { posts: [ 'read' ] } }, + 'beehiiv_send_api_unavailable', + ], + [ + { settings: { publication_id: '' } }, + 'beehiiv_missing_publication', + ], ] ) { seedReadyState( seed ); mockBeehiivPosts(); @@ -801,15 +1054,32 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { expect( r.body.code ).toBe( code ); } - expect( readHttpLog().filter( ( e ) => e.mock === 'test_sends' ) ).toHaveLength( 0 ); + expect( + readHttpLog().filter( ( e ) => e.mock === 'test_sends' ) + ).toHaveLength( 0 ); } ); - test( 'AC-017: server refuses users without publish or edit rights when the UI is bypassed', async ( { browser } ) => { - const authorId = Number( php( `echo get_user_by( "login", "${ AUTHOR.login }" )->ID;` ) ); - const contribId = Number( php( `echo get_user_by( "login", "${ CONTRIB.login }" )->ID;` ) ); - const adminPost = createPost( { title: 'AC-017 admin post', author: 1 } ); - const authorPost = createPost( { title: 'AC-017 author own', author: authorId } ); - const contribPost = createPost( { title: 'AC-017 contrib own', author: contribId } ); + test( 'AC-017: server refuses users without publish or edit rights when the UI is bypassed', async ( { + browser, + } ) => { + const authorId = Number( + php( `echo get_user_by( "login", "${ AUTHOR.login }" )->ID;` ) + ); + const contribId = Number( + php( `echo get_user_by( "login", "${ CONTRIB.login }" )->ID;` ) + ); + const adminPost = createPost( { + title: 'AC-017 admin post', + author: 1, + } ); + const authorPost = createPost( { + title: 'AC-017 author own', + author: authorId, + } ); + const contribPost = createPost( { + title: 'AC-017 contrib own', + author: contribId, + } ); // Contributor: can edit own draft but lacks publish_posts. let ctx = await browser.newContext(); @@ -829,12 +1099,19 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { expect( r.status ).toBe( 403 ); await ctx.close(); - expect( readHttpLog().filter( ( e ) => e.mock === 'test_sends' ) ).toHaveLength( 0 ); + expect( + readHttpLog().filter( ( e ) => e.mock === 'test_sends' ) + ).toHaveLength( 0 ); } ); - test( 'AC-017: server refuses when no post template is configured (same readiness rule as the toggle)', async ( { page } ) => { + test( 'AC-017: server refuses when no post template is configured (same readiness rule as the toggle)', async ( { + page, + } ) => { const unlinked = createPost( { title: 'AC-017 no template unlinked' } ); - const linked = createPost( { title: 'AC-017 no template linked', meta: futureLinkedMeta() } ); + const linked = createPost( { + title: 'AC-017 no template linked', + meta: futureLinkedMeta(), + } ); await loginAsAdmin( page ); await openPostEditor( page, unlinked ); @@ -842,22 +1119,38 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { mockBeehiivPosts(); const r1 = await restTestSend( page, unlinked ); - expect( r1.status, 'unlinked post, no template' ).toBeGreaterThanOrEqual( 400 ); + expect( + r1.status, + 'unlinked post, no template' + ).toBeGreaterThanOrEqual( 400 ); const r2 = await restTestSend( page, linked ); - expect( r2.status, 'linked unsent post, no template' ).toBeGreaterThanOrEqual( 400 ); - - expect( readHttpLog().filter( ( e ) => e.mock === 'test_sends' ) ).toHaveLength( 0 ); + expect( + r2.status, + 'linked unsent post, no template' + ).toBeGreaterThanOrEqual( 400 ); + + expect( + readHttpLog().filter( ( e ) => e.mock === 'test_sends' ) + ).toHaveLength( 0 ); } ); // ----- US-003 ----------------------------------------------------------- - test( 'AC-018: success shows confirmation, sends left today, and reset time in the site timezone', async ( { page } ) => { + test( 'AC-018: success shows confirmation, sends left today, and reset time in the site timezone', async ( { + page, + } ) => { const resetAt = Math.floor( Date.now() / 1000 ) + 6 * 3600 + 17 * 60; mockBeehiivPosts( { - testSend: { status: 200, body: { data: { remaining_test_sends: 7, reset_at: resetAt } } }, + testSend: { + status: 200, + body: { data: { remaining_test_sends: 7, reset_at: resetAt } }, + }, + } ); + const postId = createPost( { + title: 'AC-018 post', + meta: futureLinkedMeta(), } ); - const postId = createPost( { title: 'AC-018 post', meta: futureLinkedMeta() } ); await loginAsAdmin( page ); await openBeehiivPanel( page, postId ); @@ -866,27 +1159,43 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { await expect( section ).toContainText( 'Test email sent.' ); await expect( section ).toContainText( '7 test sends left today.' ); const expectedLocal = siteDate( resetAt ); - await expect( section ).toContainText( `Test sends reset on ${ expectedLocal }` ); + await expect( section ).toContainText( + `Test sends reset on ${ expectedLocal }` + ); // Sanity: the site timezone rendering differs from a UTC rendering. const utc = php( `echo gmdate( "F j, g:i a", ${ resetAt } );` ); expect( utc ).not.toBe( expectedLocal ); } ); - test( 'AC-019: last reset time is remembered per publication from successful sends', async ( { page } ) => { + test( 'AC-019: last reset time is remembered per publication from successful sends', async ( { + page, + } ) => { const resetOne = Math.floor( Date.now() / 1000 ) + 5 * 3600; const resetTwo = resetOne + 3600; - const postId = createPost( { title: 'AC-019 post', meta: futureLinkedMeta() } ); + const postId = createPost( { + title: 'AC-019 post', + meta: futureLinkedMeta(), + } ); await loginAsAdmin( page ); - mockBeehiivPosts( { testSend: { status: 200, body: { data: { remaining_test_sends: 2, reset_at: resetOne } } } } ); + mockBeehiivPosts( { + testSend: { + status: 200, + body: { data: { remaining_test_sends: 2, reset_at: resetOne } }, + }, + } ); await openBeehiivPanel( page, postId ); await sendFromUi( page, 'r1@example.com' ); - await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); + await expect( testEmailSection( page ) ).toContainText( + 'Test email sent.' + ); expect( readRememberedResetAt() ).toEqual( { [ PUB ]: resetOne } ); // Failed send must not overwrite it. - mockBeehiivPosts( { testSend: { status: 500, body: { message: 'x' } } } ); + mockBeehiivPosts( { + testSend: { status: 500, body: { message: 'x' } }, + } ); await sendFromUi( page, 'r1@example.com' ); await expect( testEmailSection( page ) ).toContainText( "wasn't sent" ); expect( readRememberedResetAt() ).toEqual( { [ PUB ]: resetOne } ); @@ -896,17 +1205,37 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { remove_all_filters( "sanitize_option_beehiiv_settings" ); update_option( "beehiiv_settings", [ "publication_id" => "${ PUB_TWO }", "post_template_id" => "${ TPL }" ] ); ` ); - mockBeehiivPosts( { testSend: { status: 200, body: { data: { remaining_test_sends: 1, reset_at: resetTwo } } } } ); + mockBeehiivPosts( { + testSend: { + status: 200, + body: { data: { remaining_test_sends: 1, reset_at: resetTwo } }, + }, + } ); await openBeehiivPanel( page, postId ); await sendFromUi( page, 'r2@example.com' ); - await expect( testEmailSection( page ) ).toContainText( 'Test email sent.' ); - expect( readRememberedResetAt() ).toEqual( { [ PUB ]: resetOne, [ PUB_TWO ]: resetTwo } ); + await expect( testEmailSection( page ) ).toContainText( + 'Test email sent.' + ); + expect( readRememberedResetAt() ).toEqual( { + [ PUB ]: resetOne, + [ PUB_TWO ]: resetTwo, + } ); } ); - test( 'AC-020: daily limit shows all sends used, with the remembered reset time only when still in the future', async ( { page } ) => { - const postId = createPost( { title: 'AC-020 post', meta: futureLinkedMeta() } ); + test( 'AC-020: daily limit shows all sends used, with the remembered reset time only when still in the future', async ( { + page, + } ) => { + const postId = createPost( { + title: 'AC-020 post', + meta: futureLinkedMeta(), + } ); mockBeehiivPosts( { - testSend: { status: 422, body: { errors: [ { message: 'Daily test send limit reached' } ] } }, + testSend: { + status: 422, + body: { + errors: [ { message: 'Daily test send limit reached' } ], + }, + }, } ); await loginAsAdmin( page ); await openBeehiivPanel( page, postId ); @@ -914,40 +1243,69 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { // Remembered and in the future. const future = Math.floor( Date.now() / 1000 ) + 4 * 3600; - php( `update_option( "beehiiv_test_send_reset_at", [ "${ PUB }" => ${ future } ] );` ); + php( + `update_option( "beehiiv_test_send_reset_at", [ "${ PUB }" => ${ future } ] );` + ); await sendFromUi( page, 'limit@example.com' ); - await expect( section ).toContainText( "You've used all test sends for today." ); - await expect( section ).toContainText( `They reset on ${ siteDate( future ) }` ); + await expect( section ).toContainText( + "You've used all test sends for today." + ); + await expect( section ).toContainText( + `They reset on ${ siteDate( future ) }` + ); // Remembered but already past: no time. const past = Math.floor( Date.now() / 1000 ) - 3600; - php( `update_option( "beehiiv_test_send_reset_at", [ "${ PUB }" => ${ past } ] );` ); + php( + `update_option( "beehiiv_test_send_reset_at", [ "${ PUB }" => ${ past } ] );` + ); await sendFromUi( page, 'limit@example.com' ); - await expect( section ).toContainText( "You've used all test sends for today." ); + await expect( section ).toContainText( + "You've used all test sends for today." + ); await expect( section ).not.toContainText( 'They reset on' ); // Nothing remembered: no time. php( 'delete_option( "beehiiv_test_send_reset_at" );' ); await sendFromUi( page, 'limit@example.com' ); - await expect( section ).toContainText( "You've used all test sends for today." ); + await expect( section ).toContainText( + "You've used all test sends for today." + ); await expect( section ).not.toContainText( 'They reset on' ); } ); - test( 'AC-021: rate limiting shows "too many requests, try again in a moment"', async ( { page } ) => { - const postId = createPost( { title: 'AC-021 post', meta: futureLinkedMeta() } ); - mockBeehiivPosts( { testSend: { status: 429, body: { message: 'Rate limited' } } } ); + test( 'AC-021: rate limiting shows "too many requests, try again in a moment"', async ( { + page, + } ) => { + const postId = createPost( { + title: 'AC-021 post', + meta: futureLinkedMeta(), + } ); + mockBeehiivPosts( { + testSend: { status: 429, body: { message: 'Rate limited' } }, + } ); await loginAsAdmin( page ); await openBeehiivPanel( page, postId ); await sendFromUi( page, 'rate@example.com' ); - await expect( testEmailSection( page ) ).toContainText( /too many requests\. try again in a moment\./i ); + await expect( testEmailSection( page ) ).toContainText( + /too many requests\. try again in a moment\./i + ); } ); - test( "AC-022: other failures show a generic error including beehiiv's message", async ( { page } ) => { - const postId = createPost( { title: 'AC-022 api post', meta: futureLinkedMeta() } ); + test( "AC-022: other failures show a generic error including beehiiv's message", async ( { + page, + } ) => { + const postId = createPost( { + title: 'AC-022 api post', + meta: futureLinkedMeta(), + } ); const beehiivMessage = 'Recipient list rejected by beehiiv'; mockBeehiivPosts( { - testSend: { status: 400, body: { errors: [ { message: beehiivMessage } ] } }, + testSend: { + status: 400, + body: { errors: [ { message: beehiivMessage } ] }, + }, } ); await loginAsAdmin( page ); await openBeehiivPanel( page, postId ); @@ -958,8 +1316,13 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { await expect( section ).toContainText( beehiivMessage ); } ); - test( 'AC-022: content problems show the same message a real newsletter send would show', async ( { page } ) => { - const postId = createPost( { title: 'AC-022 empty content', content: '' } ); + test( 'AC-022: content problems show the same message a real newsletter send would show', async ( { + page, + } ) => { + const postId = createPost( { + title: 'AC-022 empty content', + content: '', + } ); await loginAsAdmin( page ); await openBeehiivPanel( page, postId ); @@ -967,15 +1330,24 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { $settings = \\Beehiiv\\Newsletter\\PostSettingsBuilder::get_post_settings( ${ postId } ); echo is_wp_error( $settings ) ? \\Beehiiv\\Newsletter\\Sender::format_save_error_message( $settings ) : "NO_ERROR"; ` ); - expect( realSendMessage ).toBe( 'Add a title and body content before sending this newsletter.' ); + expect( realSendMessage ).toBe( + 'Add a title and body content before sending this newsletter.' + ); await sendFromUi( page, 'content@example.com' ); - await expect( testEmailSection( page ) ).toContainText( realSendMessage ); + await expect( testEmailSection( page ) ).toContainText( + realSendMessage + ); expect( beehiivPostCalls( readHttpLog() ) ).toHaveLength( 0 ); } ); - test( 'AC-023: typed addresses stay in the field after a failure', async ( { page } ) => { - const postId = createPost( { title: 'AC-023 post', meta: futureLinkedMeta() } ); + test( 'AC-023: typed addresses stay in the field after a failure', async ( { + page, + } ) => { + const postId = createPost( { + title: 'AC-023 post', + meta: futureLinkedMeta(), + } ); await loginAsAdmin( page ); await openBeehiivPanel( page, postId ); const typed = 'keep-one@example.com,\nkeep-two@example.com'; @@ -987,19 +1359,27 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { ] ) { mockBeehiivPosts( { testSend } ); await sendFromUi( page, typed ); - await expect( testEmailSection( page ) ).not.toContainText( 'Test email sent.' ); - await expect( testEmailSection( page ).locator( '.components-notice' ) ).toBeVisible(); + await expect( testEmailSection( page ) ).not.toContainText( + 'Test email sent.' + ); + await expect( + testEmailSection( page ).locator( '.components-notice' ) + ).toBeVisible(); await expect( recipientsField( page ) ).toHaveValue( typed ); } // Client-side invalid-address failure too. await recipientsField( page ).fill( 'bad-address' ); await sendButton( page ).click(); - await expect( testEmailSection( page ) ).toContainText( "aren't valid" ); + await expect( testEmailSection( page ) ).toContainText( + "aren't valid" + ); await expect( recipientsField( page ) ).toHaveValue( 'bad-address' ); } ); - test( 'AC-024: temporary draft delete failure or "still processing" still counts as sent, with a leftover-draft note', async ( { page } ) => { + test( 'AC-024: temporary draft delete failure or "still processing" still counts as sent, with a leftover-draft note', async ( { + page, + } ) => { const postId = createPost( { title: 'AC-024 post' } ); await loginAsAdmin( page ); await openBeehiivPanel( page, postId ); @@ -1009,7 +1389,10 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { { status: 500, body: { message: 'delete failed' } }, ] ) { mockBeehiivPosts( { - testSend: { status: 200, body: { data: { remaining_test_sends: 3, reset_at: null } } }, + testSend: { + status: 200, + body: { data: { remaining_test_sends: 3, reset_at: null } }, + }, del, } ); php( 'beehiiv_e2e_clear_http_log();' ); @@ -1017,8 +1400,12 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { expect( res.status(), `delete HTTP ${ del.status }` ).toBe( 200 ); const section = testEmailSection( page ); await expect( section ).toContainText( 'Test email sent.' ); - await expect( section ).toContainText( 'leftover test draft may still be in beehiiv' ); - expect( readHttpLog().some( ( e ) => e.mock === 'delete_post' ) ).toBe( true ); + await expect( section ).toContainText( + 'leftover test draft may still be in beehiiv' + ); + expect( + readHttpLog().some( ( e ) => e.mock === 'delete_post' ) + ).toBe( true ); } } ); } ); From 383d7a8e31b59a97ba9d025da29624cca3574433 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Fri, 16 Oct 2026 01:09:05 +0800 Subject: [PATCH 6/7] fix(editor-integration): wait for beehiiv to create the temporary draft before a test send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beehiiv creates Send API posts in the background: the create call returns the new post's id straight away, but the post doesn't exist until creation finishes. Test sends to an unlinked draft called test_sends immediately and failed with "Couldn't find post with 'id'=post_…" about two times in three (staging report on Teamwork task 19812449). - Poll the new draft until beehiiv reports it created (200), honoring Retry-After, for up to 20 seconds; report a clear message when creation fails (404) or doesn't finish in time. Retry a not-found test send once. The temporary draft is still always deleted. - Add Posts::get_creation_state() and keep the Retry-After header in Client. - PHPUnit: cover still-creating-then-ready, failed creation, the wait limit, and the not-found retry. E2E: mock the creation check and expect it in the request sequence. PRD-06.5.01 AC-025 (Q-004 answered). --- includes/API/Client.php | 20 ++++ includes/API/Resources/Posts.php | 62 ++++++++++ includes/Newsletter/TestSender.php | 130 +++++++++++++++++++- languages/beehiiv.pot | 38 +++--- tests/e2e/specs/test-email-send.spec.js | 16 ++- tests/phpunit/NewsletterTestSenderTest.php | 132 ++++++++++++++++++++- 6 files changed, 376 insertions(+), 22 deletions(-) diff --git a/includes/API/Client.php b/includes/API/Client.php index 8414e5d..995d4d9 100644 --- a/includes/API/Client.php +++ b/includes/API/Client.php @@ -41,6 +41,13 @@ final class Client { */ private static $allow_retry = true; + /** + * Retry-After header from the most recent response, in seconds, if any. + * + * @var int|null + */ + private static $last_retry_after = null; + /** * HTTP timeout in seconds for beehiiv API requests. * @@ -80,6 +87,16 @@ public static function get_last_wp_error(): ?string { return self::$last_wp_error; } + /** + * Retry-After header from the most recent response, in seconds. + * + * @return int|null Null when the response had no numeric Retry-After header. + * @since 1.0.0 + */ + public static function get_last_retry_after(): ?int { + return self::$last_retry_after; + } + /** * Make an authenticated POST request with a JSON body. * @@ -157,6 +174,9 @@ private static function send( string $method, string $path, array $query, ?array self::$last_status_code = is_wp_error( $res ) ? null : (int) wp_remote_retrieve_response_code( $res ); self::$last_wp_error = is_wp_error( $res ) ? $res->get_error_message() : null; + $retry_after = is_wp_error( $res ) ? '' : wp_remote_retrieve_header( $res, 'retry-after' ); + self::$last_retry_after = is_numeric( $retry_after ) ? (int) $retry_after : null; + $raw = is_wp_error( $res ) ? '' : (string) wp_remote_retrieve_body( $res ); $json = json_decode( $raw, true ); diff --git a/includes/API/Resources/Posts.php b/includes/API/Resources/Posts.php index dd46e89..5be0d5f 100644 --- a/includes/API/Resources/Posts.php +++ b/includes/API/Resources/Posts.php @@ -146,6 +146,68 @@ public static function delete( string $publication_id, string $post_id ): array ]; } + /** + * Whether a post created through the Send API has finished being created. + * + * The beehiiv API creates posts in the background: the create call returns + * the id right away, and fetching the post answers 202 until creation + * finishes, then 200. A 404 means creation failed. + * + * @link https://developers.beehiiv.com/api-reference/posts/get + * + * @param string $publication_id Publication ID. + * @param string $post_id beehiiv post ID. + * @return array{state: string, retry_after: int|null, status_code: int|null, error: string} + * The state is one of `ready`, `pending`, `failed`, or `unknown`. + * @since 1.0.0 + */ + public static function get_creation_state( string $publication_id, string $post_id ): array { + $publication_id = trim( $publication_id ); + $post_id = trim( $post_id ); + $result = [ + 'state' => 'unknown', + 'retry_after' => null, + 'status_code' => null, + 'error' => '', + ]; + + if ( '' === $publication_id || '' === $post_id ) { + $result['error'] = 'Publication ID or post ID is empty.'; + return $result; + } + + $path = sprintf( + '/publications/%s/posts/%s', + rawurlencode( $publication_id ), + rawurlencode( $post_id ) + ); + $response = Client::request( $path ); + + $wp_error = Client::get_last_wp_error(); + + if ( null !== $wp_error ) { + $result['error'] = $wp_error; + return $result; + } + + $status_code = Client::get_last_status_code(); + $result['status_code'] = $status_code; + $result['retry_after'] = Client::get_last_retry_after(); + + if ( 200 === $status_code ) { + $result['state'] = 'ready'; + } elseif ( 202 === $status_code ) { + $result['state'] = 'pending'; + } elseif ( 404 === $status_code ) { + $result['state'] = 'failed'; + $result['error'] = self::format_error_message( $response, $status_code ); + } elseif ( null !== $status_code ) { + $result['error'] = self::format_error_message( $response, $status_code ); + } + + return $result; + } + /** * Send a test email of a beehiiv post to one or more addresses. * diff --git a/includes/Newsletter/TestSender.php b/includes/Newsletter/TestSender.php index dcf161e..3fb5cdd 100644 --- a/includes/Newsletter/TestSender.php +++ b/includes/Newsletter/TestSender.php @@ -53,6 +53,27 @@ final class TestSender { */ private const POST_TYPE = 'post'; + /** + * Longest a test send waits for beehiiv to finish creating a temporary draft, in seconds. + * + * @since 1.0.0 + */ + private const CREATION_WAIT_SECONDS = 20; + + /** + * Pause between creation checks when beehiiv sends no Retry-After, in seconds. + * + * @since 1.0.0 + */ + private const CREATION_POLL_SECONDS = 1; + + /** + * Longest single pause between creation checks, in seconds. + * + * @since 1.0.0 + */ + private const CREATION_POLL_MAX_SECONDS = 5; + /** * Whether the saved post can send a test email right now. * @@ -168,9 +189,39 @@ public static function send( int $post_id, array $recipient_emails ) { } $leftover_draft = false; + $failure = null; + $result = []; + /** + * Filters how long a test send waits for beehiiv to create its temporary draft. + * + * @since 1.0.0 + * + * @param float $seconds Seconds to wait before giving up. + */ + $wait_seconds = (float) apply_filters( + 'beehiiv_test_send_creation_wait_seconds', + (float) self::CREATION_WAIT_SECONDS + ); + $deadline = microtime( true ) + $wait_seconds; try { - $result = Posts::test_send( $publication_id, $created['post_id'], $recipient_emails ); + // beehiiv creates Send API posts in the background, so the new draft can't be + // tested until it reports ready. + $ready = self::wait_until_created( $publication_id, $created['post_id'], $deadline ); + + if ( is_wp_error( $ready ) ) { + $failure = $ready; + } else { + $result = Posts::test_send( $publication_id, $created['post_id'], $recipient_emails ); + + // A ready post can still briefly be missing from the test-send lookup; retry once. + $not_found = empty( $result['success'] ) && 404 === $result['status_code']; + + if ( $not_found && microtime( true ) + self::CREATION_POLL_SECONDS < $deadline ) { + self::pause( self::CREATION_POLL_SECONDS ); + $result = Posts::test_send( $publication_id, $created['post_id'], $recipient_emails ); + } + } } finally { $deleted = Posts::delete( $publication_id, $created['post_id'] ); @@ -188,9 +239,86 @@ public static function send( int $post_id, array $recipient_emails ) { } } + if ( null !== $failure ) { + self::log( + $post_id, + sprintf( 'temporary draft %s not ready: %s', $created['post_id'], $failure->get_error_message() ) + ); + + return $failure; + } + return self::finish( $post_id, $publication_id, $result, $leftover_draft ); } + /** + * Wait until beehiiv has finished creating a post made through the Send API. + * + * Polls the post until it reports ready, honoring beehiiv's Retry-After. When the + * creation state can't be read (an unexpected status or a transport error), stops + * waiting and lets the test send report whatever beehiiv says. + * + * @param string $publication_id Publication ID. + * @param string $beehiiv_post_id beehiiv post ID returned by the create call. + * @param float $deadline Unix time (with microseconds) to stop waiting at. + * @return true|WP_Error True when ready (or unknown), an error when creation failed or timed out. + * @since 1.0.0 + */ + private static function wait_until_created( + string $publication_id, + string $beehiiv_post_id, + float $deadline + ) { + while ( true ) { + $state = Posts::get_creation_state( $publication_id, $beehiiv_post_id ); + + if ( 'failed' === $state['state'] ) { + return new WP_Error( + 'beehiiv_test_send_draft_failed', + __( "beehiiv couldn't prepare the test email. Try again.", 'beehiiv' ) + ); + } + + if ( 'pending' !== $state['state'] ) { + return true; + } + + $delay = null !== $state['retry_after'] ? $state['retry_after'] : self::CREATION_POLL_SECONDS; + $delay = max( 1, min( self::CREATION_POLL_MAX_SECONDS, $delay ) ); + + if ( microtime( true ) + $delay > $deadline ) { + return new WP_Error( + 'beehiiv_test_send_draft_pending', + __( 'beehiiv is still preparing the test email. Try again in a moment.', 'beehiiv' ) + ); + } + + self::pause( $delay ); + } + } + + /** + * Sleep between beehiiv creation checks. + * + * @param int $seconds Seconds to wait. + * @return void + * @since 1.0.0 + */ + private static function pause( int $seconds ): void { + /** + * Filters how long a test send pauses between beehiiv creation checks. + * + * @since 1.0.0 + * + * @param float $seconds Seconds to wait. + */ + $seconds = (float) apply_filters( 'beehiiv_test_send_poll_seconds', (float) $seconds ); + + if ( $seconds > 0 ) { + usleep( (int) round( $seconds * 1000000 ) ); + } + } + /** * Build the create payload for a temporary beehiiv draft from the saved post. * diff --git a/languages/beehiiv.pot b/languages/beehiiv.pot index 578b1c5..1ad9845 100644 --- a/languages/beehiiv.pot +++ b/languages/beehiiv.pot @@ -9,7 +9,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-10-02T13:25:45+00:00\n" +"POT-Creation-Date: 2026-10-15T16:59:46+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "X-Generator: WP-CLI 2.12.0\n" "X-Domain: beehiiv\n" @@ -213,7 +213,7 @@ msgstr "" #: includes/Newsletter/Sender.php:340 #: includes/Newsletter/Sender.php:427 #: includes/Newsletter/Sender.php:690 -#: includes/Newsletter/TestSender.php:69 +#: includes/Newsletter/TestSender.php:90 msgid "This post no longer exists. Save or reload the editor and try again." msgstr "" @@ -243,7 +243,7 @@ msgid "Connect your beehiiv account in beehiiv settings to send this news msgstr "" #: includes/Newsletter/Sender.php:734 -#: includes/Newsletter/TestSender.php:110 +#: includes/Newsletter/TestSender.php:131 msgid "Choose a publication in beehiiv settings, then try again." msgstr "" @@ -263,7 +263,7 @@ msgid "beehiiv rejected this newsletter: %s" msgstr "" #: includes/Newsletter/Sender.php:773 -#: includes/Newsletter/TestSender.php:330 +#: includes/Newsletter/TestSender.php:458 msgid "Too many requests. Try again in a moment." msgstr "" @@ -292,51 +292,59 @@ msgstr "" msgid "Something went wrong and the newsletter couldn't be updated. Try saving the post again." msgstr "" -#: includes/Newsletter/TestSender.php:76 +#: includes/Newsletter/TestSender.php:97 #: src/js/editor/post-settings/components/test-email-send.js:75 msgid "Test emails are available for drafts, pending posts, and scheduled posts." msgstr "" -#: includes/Newsletter/TestSender.php:84 +#: includes/Newsletter/TestSender.php:105 #: src/js/editor/post-settings/components/test-email-send.js:68 msgid "This post's newsletter was already sent, so it can't send a test email." msgstr "" -#: includes/Newsletter/TestSender.php:91 +#: includes/Newsletter/TestSender.php:112 #: src/js/editor/post-settings/components/test-email-send.js:82 msgid "This post's newsletter isn't in sync with beehiiv. Save the post again, then send a test email." msgstr "" -#: includes/Newsletter/TestSender.php:103 +#: includes/Newsletter/TestSender.php:124 msgid "Connect your beehiiv account in beehiiv settings to send a test email." msgstr "" -#: includes/Newsletter/TestSender.php:117 +#: includes/Newsletter/TestSender.php:138 msgid "Choose a default post template in beehiiv settings to send a test email." msgstr "" -#: includes/Newsletter/TestSender.php:124 +#: includes/Newsletter/TestSender.php:145 msgid "Test emails need beehiiv Send API access, available on the Max and Enterprise plans." msgstr "" -#: includes/Newsletter/TestSender.php:214 -#: includes/Newsletter/TestSender.php:357 +#: includes/Newsletter/TestSender.php:278 +msgid "beehiiv couldn't prepare the test email. Try again." +msgstr "" + +#: includes/Newsletter/TestSender.php:292 +msgid "beehiiv is still preparing the test email. Try again in a moment." +msgstr "" + +#: includes/Newsletter/TestSender.php:342 +#: includes/Newsletter/TestSender.php:485 #: src/js/editor/post-settings/components/test-email-send.js:224 msgid "Something went wrong and the test email wasn't sent. Try again." msgstr "" #. translators: %s: date, time, and timezone when test sends reset. -#: includes/Newsletter/TestSender.php:315 +#: includes/Newsletter/TestSender.php:443 #, php-format msgid "You've used all test sends for today. They reset on %s." msgstr "" -#: includes/Newsletter/TestSender.php:323 +#: includes/Newsletter/TestSender.php:451 msgid "You've used all test sends for today." msgstr "" #. translators: %s: error message from beehiiv. -#: includes/Newsletter/TestSender.php:365 +#: includes/Newsletter/TestSender.php:493 #, php-format msgid "The test email wasn't sent: %s" msgstr "" diff --git a/tests/e2e/specs/test-email-send.spec.js b/tests/e2e/specs/test-email-send.spec.js index 19ff6f5..521094f 100644 --- a/tests/e2e/specs/test-email-send.spec.js +++ b/tests/e2e/specs/test-email-send.spec.js @@ -130,6 +130,8 @@ function seedReadyState( overrides = {} ) { * @param {Object} root0.testSend Response for POST …/test_sends. * @param {Object} root0.create Response for POST …/posts (temporary draft). * @param {Object} root0.del Response for DELETE …/posts/{id}. + * @param {Object} root0.get Response for GET …/posts/{id} (creation state: + * 200 ready, 202 still creating, 404 failed). */ function mockBeehiivPosts( { testSend = { @@ -138,6 +140,7 @@ function mockBeehiivPosts( { }, create = { status: 201, body: { data: { id: TMP_ID } } }, del = { status: 204, body: '' }, + get = { status: 200, body: { data: { id: TMP_ID } } }, } = {} ) { const mocks = { test_sends: { @@ -155,6 +158,11 @@ function mockBeehiivPosts( { method: 'DELETE', ...del, }, + get_post: { + pattern: '#/publications/[^/]+/posts/[^/?]+(\\?|$)#', + method: 'GET', + ...get, + }, }; php( ` foreach ( ${ phpArg( mocks ) } as $key => $mock ) { @@ -688,11 +696,12 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { const calls = beehiivPostCalls( readHttpLog() ); expect( calls.map( ( c ) => `${ c.method } ${ c.mock }` ) ).toEqual( [ 'POST create_post', + 'GET get_post', 'POST test_sends', 'DELETE delete_post', ] ); - expect( calls[ 1 ].url ).toContain( `/posts/${ TMP_ID }/test_sends` ); - expect( calls[ 2 ].url ).toMatch( + expect( calls[ 2 ].url ).toContain( `/posts/${ TMP_ID }/test_sends` ); + expect( calls[ 3 ].url ).toMatch( new RegExp( `/posts/${ TMP_ID }(\\?|$)` ) ); @@ -806,10 +815,11 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { const calls = beehiivPostCalls( readHttpLog() ); expect( calls.map( ( c ) => `${ c.method } ${ c.mock }` ) ).toEqual( [ 'POST create_post', + 'GET get_post', 'POST test_sends', 'DELETE delete_post', ] ); - expect( calls[ 2 ].url ).toMatch( + expect( calls[ 3 ].url ).toMatch( new RegExp( `/posts/${ TMP_ID }(\\?|$)` ) ); } ); diff --git a/tests/phpunit/NewsletterTestSenderTest.php b/tests/phpunit/NewsletterTestSenderTest.php index 47b0fcb..22e7476 100644 --- a/tests/phpunit/NewsletterTestSenderTest.php +++ b/tests/phpunit/NewsletterTestSenderTest.php @@ -77,9 +77,14 @@ public function set_up(): void { 'status' => 204, 'body' => [], ], + 'GET /posts/' . self::TEMP_POST_ID => [ + 'status' => 200, + 'body' => [ 'data' => [ 'id' => self::TEMP_POST_ID ] ], + ], ]; add_filter( 'pre_http_request', [ $this, 'stub_http' ], 10, 3 ); + add_filter( 'beehiiv_test_send_poll_seconds', '__return_zero' ); TokenStore::save_tokens( 'qa-client', @@ -103,6 +108,8 @@ public function set_up(): void { public function tear_down(): void { remove_filter( 'pre_http_request', [ $this, 'stub_http' ], 10 ); + remove_all_filters( 'beehiiv_test_send_poll_seconds' ); + remove_all_filters( 'beehiiv_test_send_creation_wait_seconds' ); remove_all_filters( 'beehiiv_newsletter_post_settings' ); TokenStore::delete_all(); delete_option( Config::OPTION_NAME ); @@ -143,7 +150,7 @@ public function stub_http( $preempt, $args, $url ) { if ( $route_method === $method && substr( $path, -strlen( $suffix ) ) === $suffix ) { // Prefer the longest (most specific) suffix match. if ( null === $match || strlen( $suffix ) > $match[0] ) { - $match = [ strlen( $suffix ), $response ]; + $match = [ strlen( $suffix ), $response, $key ]; } } } @@ -153,8 +160,18 @@ public function stub_http( $preempt, $args, $url ) { 'body' => [ 'errors' => [ [ 'message' => 'not stubbed' ] ] ], ] : $match[1]; + // A `sequence` answers one response per call, repeating the last one. + if ( isset( $response['sequence'] ) ) { + $sequence = $response['sequence']; + $response = array_shift( $sequence ); + + if ( ! empty( $sequence ) ) { + $this->responses[ $match[2] ]['sequence'] = $sequence; + } + } + return [ - 'headers' => [], + 'headers' => $response['headers'] ?? [], 'body' => wp_json_encode( $response['body'] ), 'response' => [ 'code' => $response['status'], @@ -239,6 +256,7 @@ static function ( $settings ) { [ 'GET /workspaces/permissions', 'POST /posts', + 'GET /posts/' . self::TEMP_POST_ID, 'POST /posts/' . self::TEMP_POST_ID . '/test_sends', 'DELETE /posts/' . self::TEMP_POST_ID, ], @@ -248,7 +266,7 @@ static function ( $settings ) { $create_body = $this->requests[1]['body']; $this->assertSame( 'draft', $create_body['status'], 'AC-009: temporary post must always be a draft.' ); $this->assertArrayNotHasKey( 'scheduled_at', $create_body, 'AC-009: temporary post must never be scheduled.' ); - $this->assertSame( [ 'a@example.com', 'b@example.com' ], $this->requests[2]['body']['recipient_emails'] ); + $this->assertSame( [ 'a@example.com', 'b@example.com' ], $this->requests[3]['body']['recipient_emails'] ); $this->assertSame( $before, $this->all_post_meta( $post_id ), 'AC-010: test send must not change post meta.' ); $this->assertSame( $result['reset_at'], TestSender::get_remembered_reset_at( self::PUBLICATION_ID ), 'AC-019' ); @@ -492,6 +510,114 @@ public function test_still_processing_delete_flags_leftover_draft(): void { $this->assertTrue( $result['leftover_draft'] ); } + /** + * beehiiv creates Send API posts in the background: the test send waits until + * the temporary draft reports ready instead of failing with "Couldn't find post". + */ + public function test_waits_for_temporary_draft_to_finish_creating(): void { + $post_id = $this->create_post( 'draft' ); + + $this->responses[ 'GET /posts/' . self::TEMP_POST_ID ] = [ + 'sequence' => [ + [ + 'status' => 202, + 'headers' => [ 'retry-after' => '1' ], + 'body' => [ 'data' => [ 'state' => 'pending' ] ], + ], + [ + 'status' => 202, + 'body' => [ 'data' => [ 'state' => 'pending' ] ], + ], + [ + 'status' => 200, + 'body' => [ 'data' => [ 'id' => self::TEMP_POST_ID ] ], + ], + ], + ]; + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertIsArray( $result ); + $this->assertSame( + [ + 'GET /workspaces/permissions', + 'POST /posts', + 'GET /posts/' . self::TEMP_POST_ID, + 'GET /posts/' . self::TEMP_POST_ID, + 'GET /posts/' . self::TEMP_POST_ID, + 'POST /posts/' . self::TEMP_POST_ID . '/test_sends', + 'DELETE /posts/' . self::TEMP_POST_ID, + ], + $this->request_log() + ); + } + + /** + * When beehiiv reports the temporary draft's creation failed, nothing is sent, + * the editor gets a clear message, and the draft is still cleaned up. + */ + public function test_failed_draft_creation_is_reported(): void { + $post_id = $this->create_post( 'draft' ); + + $this->responses[ 'GET /posts/' . self::TEMP_POST_ID ] = [ + 'status' => 404, + 'body' => [ 'errors' => [ [ 'code' => 'POST_CREATION_FAILED' ] ] ], + ]; + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertWPError( $result ); + $this->assertSame( 'beehiiv_test_send_draft_failed', $result->get_error_code() ); + $this->assertNotContains( 'POST /posts/' . self::TEMP_POST_ID . '/test_sends', $this->request_log() ); + $this->assertContains( 'DELETE /posts/' . self::TEMP_POST_ID, $this->request_log() ); + } + + /** + * A draft still being created after the wait limit gives a "try again" message + * rather than a send against a post beehiiv can't find yet. + */ + public function test_draft_still_pending_after_wait_limit(): void { + $post_id = $this->create_post( 'draft' ); + + add_filter( 'beehiiv_test_send_creation_wait_seconds', '__return_zero' ); + $this->responses[ 'GET /posts/' . self::TEMP_POST_ID ] = [ + 'status' => 202, + 'body' => [ 'data' => [ 'state' => 'pending' ] ], + ]; + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertWPError( $result ); + $this->assertSame( 'beehiiv_test_send_draft_pending', $result->get_error_code() ); + $this->assertNotContains( 'POST /posts/' . self::TEMP_POST_ID . '/test_sends', $this->request_log() ); + $this->assertContains( 'DELETE /posts/' . self::TEMP_POST_ID, $this->request_log() ); + } + + /** + * A test send that still can't find a just-created draft is retried once. + */ + public function test_not_found_test_send_is_retried_once(): void { + $post_id = $this->create_post( 'draft' ); + + $this->responses[ 'POST /posts/' . self::TEMP_POST_ID . '/test_sends' ] = [ + 'sequence' => [ + [ + 'status' => 404, + 'body' => [ 'errors' => [ [ 'message' => "Couldn't find post with 'id'=" . self::TEMP_POST_ID ] ] ], + ], + [ + 'status' => 200, + 'body' => [ 'data' => [ 'remaining_test_sends' => 8 ] ], + ], + ], + ]; + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertIsArray( $result ); + $this->assertSame( 8, $result['remaining_test_sends'] ); + } + /** * AC-003: recipients split on commas and new lines, trimmed, de-duplicated. */ From ab1f909bdbcf7d765e345de45b1d07795a4bfa8b Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Fri, 16 Oct 2026 01:33:32 +0800 Subject: [PATCH 7/7] fix(editor-integration): harden test-send draft wait and cleanup Addresses the review of 383d7a8. - Retry deleting a temporary draft beehiiv is still processing (202), treat a 404 as already gone, and report a leftover draft on failure responses too; the editor shows the leftover note on errors (AC-024, AC-025, R-003). - Use one bounded delay for the deadline check and the pause; clamp the wait, poll and cleanup filters to finite ranges. - Keep waiting on 429, 5xx and transport errors from the creation check; cap each check's HTTP timeout to the time left (R-004). - Parse Retry-After given as an HTTP date. - Fall back to the remembered reset time when beehiiv omits it (AC-018). - Check eligibility in the same order in the editor as on the server, and sanitize the recipients REST argument. - Tests: Retry-After honored, a real non-zero wait budget, exactly two test sends on the not-found retry, cleanup retries, leftover reporting and the reset fallback (PHPUnit); AC-025 and R-003 E2E tests, with response sequences, headers and a wait override in the mock plugin. --- includes/API/Client.php | 44 ++- includes/API/Resources/Posts.php | 21 +- includes/Newsletter/TestSender.php | 254 +++++++++++++++--- includes/REST/TestSendController.php | 41 ++- languages/beehiiv.pot | 66 ++--- .../components/test-email-send.js | 16 +- tests/e2e/plugins/beehiiv-options.php | 33 ++- tests/e2e/specs/test-email-send.spec.js | 104 +++++++ tests/phpunit/NewsletterTestSenderTest.php | 223 +++++++++++++-- 9 files changed, 672 insertions(+), 130 deletions(-) diff --git a/includes/API/Client.php b/includes/API/Client.php index 995d4d9..8be53f1 100644 --- a/includes/API/Client.php +++ b/includes/API/Client.php @@ -58,13 +58,14 @@ final class Client { /** * Make an authenticated GET request to beehiiv v2. * - * @param string $path Relative API path (e.g. `/publications`). - * @param array $query Query parameters. + * @param string $path Relative API path (e.g. `/publications`). + * @param array $query Query parameters. + * @param int $timeout HTTP timeout in seconds; 0 uses the default. * @return array * @since 1.0.0 */ - public static function request( string $path, array $query = [] ): array { - return self::send( 'GET', $path, $query, null ); + public static function request( string $path, array $query = [], int $timeout = 0 ): array { + return self::send( 'GET', $path, $query, null, $timeout ); } /** @@ -142,10 +143,11 @@ public static function patch( string $path, array $body, array $query = [] ): ar * @param string $path Relative API path. * @param array $query Query parameters. * @param array|null $body JSON body for POST and PATCH; null for GET and DELETE. + * @param int $timeout HTTP timeout in seconds; 0 uses the default. * @return array * @since 1.0.0 */ - private static function send( string $method, string $path, array $query, ?array $body ): array { + private static function send( string $method, string $path, array $query, ?array $body, int $timeout = 0 ): array { $access_token = Manager::get_access_token(); $url = OAuthConfig::get_api_base_url() . $path; @@ -156,7 +158,7 @@ private static function send( string $method, string $path, array $query, ?array $args = [ 'method' => $method, - 'timeout' => self::REQUEST_TIMEOUT, + 'timeout' => $timeout > 0 ? $timeout : self::REQUEST_TIMEOUT, 'sslverify' => OAuthConfig::should_verify_ssl(), 'headers' => [ 'Authorization' => 'Bearer ' . $access_token, @@ -174,8 +176,9 @@ private static function send( string $method, string $path, array $query, ?array self::$last_status_code = is_wp_error( $res ) ? null : (int) wp_remote_retrieve_response_code( $res ); self::$last_wp_error = is_wp_error( $res ) ? $res->get_error_message() : null; - $retry_after = is_wp_error( $res ) ? '' : wp_remote_retrieve_header( $res, 'retry-after' ); - self::$last_retry_after = is_numeric( $retry_after ) ? (int) $retry_after : null; + self::$last_retry_after = is_wp_error( $res ) + ? null + : self::parse_retry_after( wp_remote_retrieve_header( $res, 'retry-after' ) ); $raw = is_wp_error( $res ) ? '' : (string) wp_remote_retrieve_body( $res ); $json = json_decode( $raw, true ); @@ -185,7 +188,7 @@ private static function send( string $method, string $path, array $query, ?array $refreshed = TokenRefresher::refresh(); if ( ! is_wp_error( $refreshed ) ) { - return self::send( $method, $path, $query, $body ); + return self::send( $method, $path, $query, $body, $timeout ); } } @@ -193,4 +196,27 @@ private static function send( string $method, string $path, array $query, ?array return is_array( $json ) ? $json : []; } + + /** + * Seconds to wait from a Retry-After header, given as seconds or as an HTTP date. + * + * @param string|array $value Header value. + * @return int|null Seconds (0 or more), or null when absent or unparsable. + * @since 1.0.0 + */ + private static function parse_retry_after( $value ): ?int { + $value = is_array( $value ) ? (string) reset( $value ) : trim( (string) $value ); + + if ( '' === $value ) { + return null; + } + + if ( is_numeric( $value ) ) { + return max( 0, (int) $value ); + } + + $timestamp = strtotime( $value ); + + return false === $timestamp ? null : max( 0, $timestamp - time() ); + } } diff --git a/includes/API/Resources/Posts.php b/includes/API/Resources/Posts.php index 5be0d5f..d0fe47f 100644 --- a/includes/API/Resources/Posts.php +++ b/includes/API/Resources/Posts.php @@ -89,7 +89,7 @@ public static function create( string $publication_id, array $payload ): array { * * @param string $publication_id Publication ID. * @param string $post_id beehiiv post ID. - * @return array{success: bool, error: string, status_code: int|null} + * @return array{success: bool, error: string, status_code: int|null, retry_after: int|null} * @since 1.0.0 */ public static function delete( string $publication_id, string $post_id ): array { @@ -101,6 +101,7 @@ public static function delete( string $publication_id, string $post_id ): array 'success' => false, 'error' => 'Publication ID or post ID is empty.', 'status_code' => null, + 'retry_after' => null, ]; } @@ -118,16 +119,19 @@ public static function delete( string $publication_id, string $post_id ): array 'success' => false, 'error' => $wp_error, 'status_code' => null, + 'retry_after' => null, ]; } $status_code = Client::get_last_status_code(); + $retry_after = Client::get_last_retry_after(); if ( null !== $status_code && 204 === $status_code ) { return [ 'success' => true, 'error' => '', 'status_code' => $status_code, + 'retry_after' => $retry_after, ]; } @@ -136,6 +140,7 @@ public static function delete( string $publication_id, string $post_id ): array 'success' => false, 'error' => self::format_error_message( $response, $status_code ), 'status_code' => $status_code, + 'retry_after' => $retry_after, ]; } @@ -143,6 +148,7 @@ public static function delete( string $publication_id, string $post_id ): array 'success' => true, 'error' => '', 'status_code' => $status_code, + 'retry_after' => $retry_after, ]; } @@ -151,17 +157,20 @@ public static function delete( string $publication_id, string $post_id ): array * * The beehiiv API creates posts in the background: the create call returns * the id right away, and fetching the post answers 202 until creation - * finishes, then 200. A 404 means creation failed. + * finishes, then 200. A 404 means creation failed. Rate limiting, server + * errors and transport errors also count as pending, so callers keep waiting + * rather than testing a post that may not exist yet. * * @link https://developers.beehiiv.com/api-reference/posts/get * * @param string $publication_id Publication ID. * @param string $post_id beehiiv post ID. + * @param int $timeout HTTP timeout in seconds; 0 uses the client default. * @return array{state: string, retry_after: int|null, status_code: int|null, error: string} * The state is one of `ready`, `pending`, `failed`, or `unknown`. * @since 1.0.0 */ - public static function get_creation_state( string $publication_id, string $post_id ): array { + public static function get_creation_state( string $publication_id, string $post_id, int $timeout = 0 ): array { $publication_id = trim( $publication_id ); $post_id = trim( $post_id ); $result = [ @@ -181,11 +190,12 @@ public static function get_creation_state( string $publication_id, string $post_ rawurlencode( $publication_id ), rawurlencode( $post_id ) ); - $response = Client::request( $path ); + $response = Client::request( $path, [], $timeout ); $wp_error = Client::get_last_wp_error(); if ( null !== $wp_error ) { + $result['state'] = 'pending'; $result['error'] = $wp_error; return $result; } @@ -196,8 +206,9 @@ public static function get_creation_state( string $publication_id, string $post_ if ( 200 === $status_code ) { $result['state'] = 'ready'; - } elseif ( 202 === $status_code ) { + } elseif ( 202 === $status_code || 429 === $status_code || ( null !== $status_code && $status_code >= 500 ) ) { $result['state'] = 'pending'; + $result['error'] = 202 === $status_code ? '' : self::format_error_message( $response, $status_code ); } elseif ( 404 === $status_code ) { $result['state'] = 'failed'; $result['error'] = self::format_error_message( $response, $status_code ); diff --git a/includes/Newsletter/TestSender.php b/includes/Newsletter/TestSender.php index 3fb5cdd..a529878 100644 --- a/includes/Newsletter/TestSender.php +++ b/includes/Newsletter/TestSender.php @@ -74,6 +74,41 @@ final class TestSender { */ private const CREATION_POLL_MAX_SECONDS = 5; + /** + * Shortest pause between beehiiv checks, in seconds, whatever a filter returns. + * + * @since 1.0.0 + */ + private const POLL_MIN_SECONDS = 0.1; + + /** + * Upper bound for the filtered creation wait, in seconds. + * + * @since 1.0.0 + */ + private const CREATION_WAIT_MAX_SECONDS = 60; + + /** + * How long cleanup keeps retrying a delete beehiiv answers "still processing", in seconds. + * + * @since 1.0.0 + */ + private const CLEANUP_WAIT_SECONDS = 10; + + /** + * Upper bound for the filtered cleanup wait, in seconds. + * + * @since 1.0.0 + */ + private const CLEANUP_WAIT_MAX_SECONDS = 30; + + /** + * Longest HTTP timeout for a single creation check, in seconds. + * + * @since 1.0.0 + */ + private const CHECK_TIMEOUT_MAX_SECONDS = 10; + /** * Whether the saved post can send a test email right now. * @@ -188,19 +223,23 @@ public static function send( int $post_id, array $recipient_emails ) { return self::api_error( $created['error'] ); } - $leftover_draft = false; - $failure = null; - $result = []; + $failure = null; + $result = []; + /** * Filters how long a test send waits for beehiiv to create its temporary draft. * + * Clamped to 0–60 seconds. + * * @since 1.0.0 * * @param float $seconds Seconds to wait before giving up. */ - $wait_seconds = (float) apply_filters( - 'beehiiv_test_send_creation_wait_seconds', - (float) self::CREATION_WAIT_SECONDS + $wait_seconds = self::bounded_seconds( + apply_filters( 'beehiiv_test_send_creation_wait_seconds', (float) self::CREATION_WAIT_SECONDS ), + 0, + self::CREATION_WAIT_MAX_SECONDS, + self::CREATION_WAIT_SECONDS ); $deadline = microtime( true ) + $wait_seconds; @@ -215,28 +254,17 @@ public static function send( int $post_id, array $recipient_emails ) { $result = Posts::test_send( $publication_id, $created['post_id'], $recipient_emails ); // A ready post can still briefly be missing from the test-send lookup; retry once. - $not_found = empty( $result['success'] ) && 404 === $result['status_code']; + if ( empty( $result['success'] ) && 404 === $result['status_code'] ) { + $delay = self::poll_delay( null ); - if ( $not_found && microtime( true ) + self::CREATION_POLL_SECONDS < $deadline ) { - self::pause( self::CREATION_POLL_SECONDS ); - $result = Posts::test_send( $publication_id, $created['post_id'], $recipient_emails ); + if ( microtime( true ) + $delay < $deadline ) { + self::sleep_seconds( $delay ); + $result = Posts::test_send( $publication_id, $created['post_id'], $recipient_emails ); + } } } } finally { - $deleted = Posts::delete( $publication_id, $created['post_id'] ); - - if ( ! $deleted['success'] || 202 === $deleted['status_code'] ) { - $leftover_draft = true; - self::log( - $post_id, - sprintf( - 'temporary beehiiv draft %s was not deleted (HTTP %s): %s', - $created['post_id'], - null === $deleted['status_code'] ? 'n/a' : (string) $deleted['status_code'], - '' !== $deleted['error'] ? $deleted['error'] : 'still processing' - ) - ); - } + $leftover_draft = ! self::delete_temporary_draft( $post_id, $publication_id, $created['post_id'] ); } if ( null !== $failure ) { @@ -245,7 +273,7 @@ public static function send( int $post_id, array $recipient_emails ) { sprintf( 'temporary draft %s not ready: %s', $created['post_id'], $failure->get_error_message() ) ); - return $failure; + return self::with_leftover( $failure, $leftover_draft ); } return self::finish( $post_id, $publication_id, $result, $leftover_draft ); @@ -254,11 +282,12 @@ public static function send( int $post_id, array $recipient_emails ) { /** * Wait until beehiiv has finished creating a post made through the Send API. * - * Polls the post until it reports ready, honoring beehiiv's Retry-After. When the - * creation state can't be read (an unexpected status or a transport error), stops - * waiting and lets the test send report whatever beehiiv says. + * Polls the post until it reports ready, honoring beehiiv's Retry-After. Rate + * limiting, server errors and transport errors keep it waiting; an auth failure + * or other unexpected status stops waiting and lets the test send report what + * beehiiv says. Each check's HTTP timeout is capped to the time left. * - * @param string $publication_id Publication ID. + * @param string $publication_id Publication ID. * @param string $beehiiv_post_id beehiiv post ID returned by the create call. * @param float $deadline Unix time (with microseconds) to stop waiting at. * @return true|WP_Error True when ready (or unknown), an error when creation failed or timed out. @@ -270,7 +299,8 @@ private static function wait_until_created( float $deadline ) { while ( true ) { - $state = Posts::get_creation_state( $publication_id, $beehiiv_post_id ); + $timeout = (int) max( 1, min( self::CHECK_TIMEOUT_MAX_SECONDS, ceil( $deadline - microtime( true ) ) ) ); + $state = Posts::get_creation_state( $publication_id, $beehiiv_post_id, $timeout ); if ( 'failed' === $state['state'] ) { return new WP_Error( @@ -283,8 +313,7 @@ private static function wait_until_created( return true; } - $delay = null !== $state['retry_after'] ? $state['retry_after'] : self::CREATION_POLL_SECONDS; - $delay = max( 1, min( self::CREATION_POLL_MAX_SECONDS, $delay ) ); + $delay = self::poll_delay( $state['retry_after'] ); if ( microtime( true ) + $delay > $deadline ) { return new WP_Error( @@ -293,32 +322,152 @@ private static function wait_until_created( ); } - self::pause( $delay ); + self::sleep_seconds( $delay ); } } /** - * Sleep between beehiiv creation checks. + * Delete a temporary beehiiv draft, retrying while beehiiv is still processing it. * - * @param int $seconds Seconds to wait. - * @return void + * A 404 counts as already gone (for example after creation failed). Anything left + * behind is logged. + * + * @param int $post_id WordPress post ID, for the log. + * @param string $publication_id Publication ID. + * @param string $beehiiv_post_id beehiiv post ID of the temporary draft. + * @return bool True when the draft is gone, false when it may be left behind. + * @since 1.0.0 + */ + private static function delete_temporary_draft( + int $post_id, + string $publication_id, + string $beehiiv_post_id + ): bool { + /** + * Filters how long cleanup retries deleting a draft beehiiv is still processing. + * + * Clamped to 0–30 seconds. + * + * @since 1.0.0 + * + * @param float $seconds Seconds to keep retrying. + */ + $deadline = microtime( true ) + self::bounded_seconds( + apply_filters( 'beehiiv_test_send_cleanup_wait_seconds', (float) self::CLEANUP_WAIT_SECONDS ), + 0, + self::CLEANUP_WAIT_MAX_SECONDS, + self::CLEANUP_WAIT_SECONDS + ); + + while ( true ) { + $deleted = Posts::delete( $publication_id, $beehiiv_post_id ); + + if ( 404 === $deleted['status_code'] || ( $deleted['success'] && 202 !== $deleted['status_code'] ) ) { + return true; + } + + $delay = self::poll_delay( $deleted['retry_after'] ); + + if ( 202 === $deleted['status_code'] && microtime( true ) + $delay <= $deadline ) { + self::sleep_seconds( $delay ); + continue; + } + + self::log( + $post_id, + sprintf( + 'temporary beehiiv draft %s was not deleted (HTTP %s): %s', + $beehiiv_post_id, + null === $deleted['status_code'] ? 'n/a' : (string) $deleted['status_code'], + '' !== $deleted['error'] ? $deleted['error'] : 'still processing' + ) + ); + + return false; + } + } + + /** + * Seconds to pause before the next beehiiv check. + * + * Starts from beehiiv's Retry-After (or a 1-second default), clamped to 1–5 seconds, + * then runs the result through a filter, clamped again to 0.1–5 seconds. The same + * value is used for the deadline check and the actual pause. + * + * @param int|null $retry_after Retry-After seconds from beehiiv, if any. + * @return float * @since 1.0.0 */ - private static function pause( int $seconds ): void { + private static function poll_delay( ?int $retry_after ): float { + $requested = null !== $retry_after ? $retry_after : self::CREATION_POLL_SECONDS; + $requested = (float) max( self::CREATION_POLL_SECONDS, min( self::CREATION_POLL_MAX_SECONDS, $requested ) ); + /** - * Filters how long a test send pauses between beehiiv creation checks. + * Filters how long a test send pauses between beehiiv checks. + * + * Clamped to 0.1–5 seconds. * * @since 1.0.0 * - * @param float $seconds Seconds to wait. + * @param float $seconds Seconds to wait, from beehiiv's Retry-After when given. */ - $seconds = (float) apply_filters( 'beehiiv_test_send_poll_seconds', (float) $seconds ); + return self::bounded_seconds( + apply_filters( 'beehiiv_test_send_poll_seconds', $requested ), + self::POLL_MIN_SECONDS, + self::CREATION_POLL_MAX_SECONDS, + $requested + ); + } + + /** + * Clamp a filtered number of seconds, falling back when it isn't a finite number. + * + * @param mixed $value Filtered value. + * @param float $min Smallest allowed value. + * @param float $max Largest allowed value. + * @param float $fallback Value used when the filtered value isn't a finite number. + * @return float + * @since 1.0.0 + */ + private static function bounded_seconds( $value, float $min, float $max, float $fallback ): float { + $seconds = is_numeric( $value ) ? (float) $value : $fallback; + + if ( ! is_finite( $seconds ) ) { + $seconds = $fallback; + } + + return max( $min, min( $max, $seconds ) ); + } + /** + * Sleep for a number of seconds. + * + * @param float $seconds Seconds to sleep. + * @return void + * @since 1.0.0 + */ + private static function sleep_seconds( float $seconds ): void { if ( $seconds > 0 ) { usleep( (int) round( $seconds * 1000000 ) ); } } + /** + * Mark an error as having left a temporary draft behind in beehiiv. + * + * @param WP_Error $error Error to return to the editor. + * @param bool $leftover_draft Whether a temporary draft could not be deleted. + * @return WP_Error + * @since 1.0.0 + */ + private static function with_leftover( WP_Error $error, bool $leftover_draft ): WP_Error { + if ( $leftover_draft ) { + $error->add_data( [ 'leftover_draft' => true ] ); + } + + return $error; + } + /** * Build the create payload for a temporary beehiiv draft from the saved post. * @@ -419,19 +568,38 @@ public static function get_remembered_reset_at( string $publication_id ): ?int { */ private static function finish( int $post_id, string $publication_id, array $result, bool $leftover_draft ) { if ( ! empty( $result['success'] ) ) { - if ( null !== $result['reset_at'] ) { - self::remember_reset_at( $publication_id, (int) $result['reset_at'] ); + $reset_at = $result['reset_at']; + + if ( null !== $reset_at ) { + self::remember_reset_at( $publication_id, (int) $reset_at ); + } else { + // beehiiv's reset time is optional; fall back to the last one it sent. + $remembered = self::get_remembered_reset_at( $publication_id ); + $reset_at = null !== $remembered && $remembered > time() ? $remembered : null; } return [ 'remaining_test_sends' => $result['remaining_test_sends'], - 'reset_at' => $result['reset_at'], + 'reset_at' => $reset_at, 'leftover_draft' => $leftover_draft, ]; } self::log( $post_id, sprintf( 'test send failed: %s', (string) $result['error'] ) ); + return self::with_leftover( self::classify_failure( $publication_id, $result ), $leftover_draft ); + } + + /** + * Classified error for a failed test send. + * + * @param string $publication_id Publication ID. + * @param array $result Result from {@see Posts::test_send()}. + * @return WP_Error + * @since 1.0.0 + */ + private static function classify_failure( string $publication_id, array $result ): WP_Error { + if ( 422 === $result['status_code'] ) { $reset_at = self::get_remembered_reset_at( $publication_id ); diff --git a/includes/REST/TestSendController.php b/includes/REST/TestSendController.php index 26e6b7c..b104ffd 100644 --- a/includes/REST/TestSendController.php +++ b/includes/REST/TestSendController.php @@ -51,8 +51,9 @@ public static function register_routes(): void { 'sanitize_callback' => 'absint', ], 'recipients' => [ - 'required' => true, - 'type' => 'string', + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_textarea_field', ], ], ] @@ -120,7 +121,13 @@ static function ( $email ) { $result = TestSender::send( absint( $request->get_param( 'post_id' ) ), $recipients ); if ( is_wp_error( $result ) ) { - return self::error_response( $result->get_error_code(), $result->get_error_message() ); + $data = $result->get_error_data(); + + return self::error_response( + $result->get_error_code(), + $result->get_error_message(), + is_array( $data ) && ! empty( $data['leftover_draft'] ) + ); } return new WP_REST_Response( $result, 200 ); @@ -164,18 +171,26 @@ public static function parse_recipients( string $raw ): array { * * @since 1.0.0 * - * @param string $code Error code. - * @param string $message Editor-facing message. + * @param string $code Error code. + * @param string $message Editor-facing message. + * @param bool $leftover_draft Whether a temporary beehiiv draft may be left behind. * @return WP_REST_Response */ - private static function error_response( string $code, string $message ): WP_REST_Response { + private static function error_response( + string $code, + string $message, + bool $leftover_draft = false + ): WP_REST_Response { + + $body = [ + 'code' => $code, + 'message' => $message, + ]; + + if ( $leftover_draft ) { + $body['leftover_draft'] = true; + } - return new WP_REST_Response( - [ - 'code' => $code, - 'message' => $message, - ], - 400 - ); + return new WP_REST_Response( $body, 400 ); } } diff --git a/languages/beehiiv.pot b/languages/beehiiv.pot index 1ad9845..f18cd11 100644 --- a/languages/beehiiv.pot +++ b/languages/beehiiv.pot @@ -9,7 +9,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-10-15T16:59:46+00:00\n" +"POT-Creation-Date: 2026-10-15T17:25:58+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "X-Generator: WP-CLI 2.12.0\n" "X-Domain: beehiiv\n" @@ -213,7 +213,7 @@ msgstr "" #: includes/Newsletter/Sender.php:340 #: includes/Newsletter/Sender.php:427 #: includes/Newsletter/Sender.php:690 -#: includes/Newsletter/TestSender.php:90 +#: includes/Newsletter/TestSender.php:125 msgid "This post no longer exists. Save or reload the editor and try again." msgstr "" @@ -243,7 +243,7 @@ msgid "Connect your beehiiv account in beehiiv settings to send this news msgstr "" #: includes/Newsletter/Sender.php:734 -#: includes/Newsletter/TestSender.php:131 +#: includes/Newsletter/TestSender.php:166 msgid "Choose a publication in beehiiv settings, then try again." msgstr "" @@ -263,7 +263,7 @@ msgid "beehiiv rejected this newsletter: %s" msgstr "" #: includes/Newsletter/Sender.php:773 -#: includes/Newsletter/TestSender.php:458 +#: includes/Newsletter/TestSender.php:626 msgid "Too many requests. Try again in a moment." msgstr "" @@ -292,59 +292,59 @@ msgstr "" msgid "Something went wrong and the newsletter couldn't be updated. Try saving the post again." msgstr "" -#: includes/Newsletter/TestSender.php:97 -#: src/js/editor/post-settings/components/test-email-send.js:75 +#: includes/Newsletter/TestSender.php:132 +#: src/js/editor/post-settings/components/test-email-send.js:63 msgid "Test emails are available for drafts, pending posts, and scheduled posts." msgstr "" -#: includes/Newsletter/TestSender.php:105 -#: src/js/editor/post-settings/components/test-email-send.js:68 +#: includes/Newsletter/TestSender.php:140 +#: src/js/editor/post-settings/components/test-email-send.js:76 msgid "This post's newsletter was already sent, so it can't send a test email." msgstr "" -#: includes/Newsletter/TestSender.php:112 -#: src/js/editor/post-settings/components/test-email-send.js:82 +#: includes/Newsletter/TestSender.php:147 +#: src/js/editor/post-settings/components/test-email-send.js:83 msgid "This post's newsletter isn't in sync with beehiiv. Save the post again, then send a test email." msgstr "" -#: includes/Newsletter/TestSender.php:124 +#: includes/Newsletter/TestSender.php:159 msgid "Connect your beehiiv account in beehiiv settings to send a test email." msgstr "" -#: includes/Newsletter/TestSender.php:138 +#: includes/Newsletter/TestSender.php:173 msgid "Choose a default post template in beehiiv settings to send a test email." msgstr "" -#: includes/Newsletter/TestSender.php:145 +#: includes/Newsletter/TestSender.php:180 msgid "Test emails need beehiiv Send API access, available on the Max and Enterprise plans." msgstr "" -#: includes/Newsletter/TestSender.php:278 +#: includes/Newsletter/TestSender.php:308 msgid "beehiiv couldn't prepare the test email. Try again." msgstr "" -#: includes/Newsletter/TestSender.php:292 +#: includes/Newsletter/TestSender.php:321 msgid "beehiiv is still preparing the test email. Try again in a moment." msgstr "" -#: includes/Newsletter/TestSender.php:342 -#: includes/Newsletter/TestSender.php:485 -#: src/js/editor/post-settings/components/test-email-send.js:224 +#: includes/Newsletter/TestSender.php:491 +#: includes/Newsletter/TestSender.php:653 +#: src/js/editor/post-settings/components/test-email-send.js:225 msgid "Something went wrong and the test email wasn't sent. Try again." msgstr "" #. translators: %s: date, time, and timezone when test sends reset. -#: includes/Newsletter/TestSender.php:443 +#: includes/Newsletter/TestSender.php:611 #, php-format msgid "You've used all test sends for today. They reset on %s." msgstr "" -#: includes/Newsletter/TestSender.php:451 +#: includes/Newsletter/TestSender.php:619 msgid "You've used all test sends for today." msgstr "" #. translators: %s: error message from beehiiv. -#: includes/Newsletter/TestSender.php:493 +#: includes/Newsletter/TestSender.php:661 #, php-format msgid "The test email wasn't sent: %s" msgstr "" @@ -449,14 +449,14 @@ msgstr "" msgid "Publication ID is required." msgstr "" -#: includes/REST/TestSendController.php:96 -#: src/js/editor/post-settings/components/test-email-send.js:180 +#: includes/REST/TestSendController.php:97 +#: src/js/editor/post-settings/components/test-email-send.js:181 msgid "Enter at least one email address." msgstr "" #. translators: %s: comma-separated list of invalid email addresses. -#: includes/REST/TestSendController.php:114 -#: src/js/editor/post-settings/components/test-email-send.js:194 +#: includes/REST/TestSendController.php:115 +#: src/js/editor/post-settings/components/test-email-send.js:195 #, php-format,js-format msgid "These email addresses aren't valid: %s" msgstr "" @@ -668,12 +668,12 @@ msgstr "" msgid "Queue this post for delivery via beehiiv when published." msgstr "" -#: src/js/editor/post-settings/components/test-email-send.js:98 +#: src/js/editor/post-settings/components/test-email-send.js:99 msgid "Test email sent." msgstr "" #. translators: %d: number of test sends left today. -#: src/js/editor/post-settings/components/test-email-send.js:104 +#: src/js/editor/post-settings/components/test-email-send.js:105 #, js-format msgid "%d test send left today." msgid_plural "%d test sends left today." @@ -681,28 +681,28 @@ msgstr[0] "" msgstr[1] "" #. translators: %s: date, time, and timezone when test sends reset. -#: src/js/editor/post-settings/components/test-email-send.js:124 +#: src/js/editor/post-settings/components/test-email-send.js:125 #, js-format msgid "Test sends reset on %s." msgstr "" -#: src/js/editor/post-settings/components/test-email-send.js:239 +#: src/js/editor/post-settings/components/test-email-send.js:241 msgid "Send test email to" msgstr "" -#: src/js/editor/post-settings/components/test-email-send.js:240 +#: src/js/editor/post-settings/components/test-email-send.js:242 msgid "Separate addresses with commas or new lines." msgstr "" -#: src/js/editor/post-settings/components/test-email-send.js:251 +#: src/js/editor/post-settings/components/test-email-send.js:253 msgid "Save the post to include your latest edits." msgstr "" -#: src/js/editor/post-settings/components/test-email-send.js:265 +#: src/js/editor/post-settings/components/test-email-send.js:267 msgid "Send test email" msgstr "" -#: src/js/editor/post-settings/components/test-email-send.js:278 +#: src/js/editor/post-settings/components/test-email-send.js:280 msgid "A leftover test draft may still be in beehiiv. You can delete it there." msgstr "" diff --git a/src/js/editor/post-settings/components/test-email-send.js b/src/js/editor/post-settings/components/test-email-send.js index 35b4b8f..a7ecee5 100644 --- a/src/js/editor/post-settings/components/test-email-send.js +++ b/src/js/editor/post-settings/components/test-email-send.js @@ -58,6 +58,14 @@ export function parseRecipients( value ) { function getUnavailableReason( { savedStatus, meta } ) { const isLinked = meta.newsletterAlreadySent; + // Same order as the server's eligibility check: status, then sent, then sync. + if ( ! ELIGIBLE_STATUSES.includes( savedStatus ) ) { + return __( + 'Test emails are available for drafts, pending posts, and scheduled posts.', + 'beehiiv' + ); + } + if ( isLinked && ! getFutureNewsletterSendDateString( @@ -71,13 +79,6 @@ function getUnavailableReason( { savedStatus, meta } ) { ); } - if ( ! ELIGIBLE_STATUSES.includes( savedStatus ) ) { - return __( - 'Test emails are available for drafts, pending posts, and scheduled posts.', - 'beehiiv' - ); - } - if ( isLinked && meta.newsletterError ) { return __( "This post's newsletter isn't in sync with beehiiv. Save the post again, then send a test email.", @@ -225,6 +226,7 @@ export default function TestEmailSend( { beehiivMeta } ) { "Something went wrong and the test email wasn't sent. Try again.", 'beehiiv' ), + leftoverDraft: !! error?.leftover_draft, } ); } ) .finally( () => { diff --git a/tests/e2e/plugins/beehiiv-options.php b/tests/e2e/plugins/beehiiv-options.php index 1f89b69..dfce3a3 100644 --- a/tests/e2e/plugins/beehiiv-options.php +++ b/tests/e2e/plugins/beehiiv-options.php @@ -60,10 +60,25 @@ static function ( $preempt, $parsed_args, $url ) { usleep( (int) $mock['delay_ms'] * 1000 ); } + // Optional `sequence`: one response per call, repeating the last one -- + // e.g. beehiiv answering 202 while a post is still being created, then 200. + if ( ! empty( $mock['sequence'] ) && is_array( $mock['sequence'] ) ) { + $sequence = array_values( $mock['sequence'] ); + $step = is_array( $sequence[0] ) ? $sequence[0] : []; + + if ( count( $sequence ) > 1 ) { + array_shift( $sequence ); + $mocks[ $needle ]['sequence'] = $sequence; + update_option( 'beehiiv_e2e_http_mocks', $mocks, false ); + } + + $mock = array_merge( $mock, $step ); + } + $body = $mock['body'] ?? []; return [ - 'headers' => [], + 'headers' => isset( $mock['headers'] ) && is_array( $mock['headers'] ) ? $mock['headers'] : [], 'body' => is_string( $body ) ? $body : wp_json_encode( $body ), 'response' => [ 'code' => $mock['status'] ?? 200, @@ -193,6 +208,7 @@ function beehiiv_e2e_reset_all(): void { beehiiv_e2e_clear_http_mocks(); beehiiv_e2e_clear_http_log(); delete_option( 'beehiiv_e2e_force_settings_status' ); + delete_option( 'beehiiv_e2e_test_send_wait_seconds' ); beehiiv_e2e_clear_connection(); \Beehiiv\API\Cache::flush_all(); } @@ -249,3 +265,18 @@ static function ( $settings ) { }, 999 ); + +/** + * Shortens how long a test send waits for beehiiv to create its temporary + * draft, so specs can reach the "still preparing" outcome without waiting the + * full 20 seconds. Opt-in: only applies while the + * `beehiiv_e2e_test_send_wait_seconds` option is set. + */ +add_filter( + 'beehiiv_test_send_creation_wait_seconds', + static function ( $seconds ) { + $override = get_option( 'beehiiv_e2e_test_send_wait_seconds', '' ); + + return is_numeric( $override ) ? (float) $override : $seconds; + } +); diff --git a/tests/e2e/specs/test-email-send.spec.js b/tests/e2e/specs/test-email-send.spec.js index 521094f..190e1b8 100644 --- a/tests/e2e/specs/test-email-send.spec.js +++ b/tests/e2e/specs/test-email-send.spec.js @@ -1418,4 +1418,108 @@ test.describe( 'Test Email Send (PRD-06.5)', () => { ).toBe( true ); } } ); + + // ----- AC-025: beehiiv creates the temporary draft in the background ------ + + test( 'AC-025: waits while beehiiv is still creating the temporary draft, then sends', async ( { + page, + } ) => { + const postId = createPost( { title: 'AC-025 ready post' } ); + mockBeehiivPosts( { + get: { + sequence: [ + { + status: 202, + headers: { 'retry-after': '1' }, + body: { data: { state: 'pending' } }, + }, + { status: 200, body: { data: { id: TMP_ID } } }, + ], + }, + } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + + const res = await sendFromUi( page, 'ready@example.com' ); + expect( res.status() ).toBe( 200 ); + await expect( testEmailSection( page ) ).toContainText( + 'Test email sent.' + ); + + const calls = beehiivPostCalls( readHttpLog() ); + expect( calls.map( ( c ) => `${ c.method } ${ c.mock }` ) ).toEqual( [ + 'POST create_post', + 'GET get_post', + 'GET get_post', + 'POST test_sends', + 'DELETE delete_post', + ] ); + } ); + + test( 'AC-025: when beehiiv fails to create the temporary draft, nothing is sent and the draft is cleaned up', async ( { + page, + } ) => { + const postId = createPost( { title: 'AC-025 failed post' } ); + mockBeehiivPosts( { + get: { + status: 404, + body: { errors: [ { code: 'POST_CREATION_FAILED' } ] }, + }, + } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + + const res = await sendFromUi( page, 'failed@example.com' ); + expect( res.status() ).toBe( 400 ); + await expect( testEmailSection( page ) ).toContainText( + "beehiiv couldn't prepare the test email. Try again." + ); + + const calls = beehiivPostCalls( readHttpLog() ); + expect( calls.some( ( c ) => c.mock === 'test_sends' ) ).toBe( false ); + expect( calls.some( ( c ) => c.mock === 'delete_post' ) ).toBe( true ); + } ); + + test( 'AC-025: when the temporary draft is still being created after the wait, the editor is asked to try again', async ( { + page, + } ) => { + const postId = createPost( { title: 'AC-025 pending post' } ); + php( 'update_option( "beehiiv_e2e_test_send_wait_seconds", 2 );' ); + mockBeehiivPosts( { + get: { status: 202, body: { data: { state: 'pending' } } }, + } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + + const res = await sendFromUi( page, 'pending@example.com' ); + expect( res.status() ).toBe( 400 ); + await expect( testEmailSection( page ) ).toContainText( + 'beehiiv is still preparing the test email. Try again in a moment.' + ); + expect( + beehiivPostCalls( readHttpLog() ).some( + ( c ) => c.mock === 'test_sends' + ) + ).toBe( false ); + } ); + + test( 'R-003: a failed test send that leaves a draft behind shows the leftover-draft note', async ( { + page, + } ) => { + const postId = createPost( { title: 'R-003 post' } ); + mockBeehiivPosts( { + testSend: { status: 500, body: { message: 'Internal error' } }, + del: { status: 202, body: '' }, + } ); + await loginAsAdmin( page ); + await openBeehiivPanel( page, postId ); + + const res = await sendFromUi( page, 'leftover-fail@example.com' ); + expect( res.status() ).toBe( 400 ); + const section = testEmailSection( page ); + await expect( section ).toContainText( "wasn't sent" ); + await expect( section ).toContainText( + 'leftover test draft may still be in beehiiv' + ); + } ); } ); diff --git a/tests/phpunit/NewsletterTestSenderTest.php b/tests/phpunit/NewsletterTestSenderTest.php index 22e7476..ef097fd 100644 --- a/tests/phpunit/NewsletterTestSenderTest.php +++ b/tests/phpunit/NewsletterTestSenderTest.php @@ -42,6 +42,25 @@ class NewsletterTestSenderTest extends WP_UnitTestCase { */ private $responses = []; + /** + * Seconds each pause asked for, before the test's zero override. + * + * @var array + */ + private $polls = []; + + /** + * Record a requested pause and skip the wait (clamped to the 0.1 s minimum). + * + * @param float $seconds Requested pause. + * @return int + */ + public function record_poll( $seconds ) { + $this->polls[] = (float) $seconds; + + return 0; + } + public function set_up(): void { parent::set_up(); @@ -84,7 +103,14 @@ public function set_up(): void { ]; add_filter( 'pre_http_request', [ $this, 'stub_http' ], 10, 3 ); - add_filter( 'beehiiv_test_send_poll_seconds', '__return_zero' ); + $this->polls = []; + add_filter( 'beehiiv_test_send_poll_seconds', [ $this, 'record_poll' ] ); + add_filter( + 'beehiiv_test_send_cleanup_wait_seconds', + static function () { + return 0.3; + } + ); TokenStore::save_tokens( 'qa-client', @@ -110,6 +136,7 @@ public function tear_down(): void { remove_filter( 'pre_http_request', [ $this, 'stub_http' ], 10 ); remove_all_filters( 'beehiiv_test_send_poll_seconds' ); remove_all_filters( 'beehiiv_test_send_creation_wait_seconds' ); + remove_all_filters( 'beehiiv_test_send_cleanup_wait_seconds' ); remove_all_filters( 'beehiiv_newsletter_post_settings' ); TokenStore::delete_all(); delete_option( Config::OPTION_NAME ); @@ -493,8 +520,25 @@ public function test_auth_failure_points_to_reconnect(): void { } /** - * AC-024: a 202 "still processing" delete leaves a draft behind; the test still - * counts as sent and the leftover is flagged. + * Count recorded requests matching "METHOD path-suffix". + * + * @param string $entry Request log entry. + * @return int + */ + private function count_requests( string $entry ): int { + return count( + array_filter( + $this->request_log(), + static function ( $logged ) use ( $entry ) { + return $logged === $entry; + } + ) + ); + } + + /** + * AC-024: a delete beehiiv keeps answering 202 "still processing" leaves a draft + * behind; the test still counts as sent and the leftover is flagged. */ public function test_still_processing_delete_flags_leftover_draft(): void { $post_id = $this->create_post( 'draft' ); @@ -508,11 +552,38 @@ public function test_still_processing_delete_flags_leftover_draft(): void { $this->assertIsArray( $result ); $this->assertTrue( $result['leftover_draft'] ); + $this->assertGreaterThan( 1, $this->count_requests( 'DELETE /posts/' . self::TEMP_POST_ID ), 'A 202 delete is retried.' ); + } + + /** + * A delete that answers 202 once and then 204 is retried and leaves nothing behind. + */ + public function test_still_processing_delete_is_retried_until_gone(): void { + $post_id = $this->create_post( 'draft' ); + + $this->responses[ 'DELETE /posts/' . self::TEMP_POST_ID ] = [ + 'sequence' => [ + [ + 'status' => 202, + 'body' => [], + ], + [ + 'status' => 204, + 'body' => [], + ], + ], + ]; + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertIsArray( $result ); + $this->assertFalse( $result['leftover_draft'] ); + $this->assertSame( 2, $this->count_requests( 'DELETE /posts/' . self::TEMP_POST_ID ) ); } /** - * beehiiv creates Send API posts in the background: the test send waits until - * the temporary draft reports ready instead of failing with "Couldn't find post". + * AC-025: the test send waits until the temporary draft reports ready, honoring + * beehiiv's Retry-After, instead of failing with "Couldn't find post". */ public function test_waits_for_temporary_draft_to_finish_creating(): void { $post_id = $this->create_post( 'draft' ); @@ -521,7 +592,7 @@ public function test_waits_for_temporary_draft_to_finish_creating(): void { 'sequence' => [ [ 'status' => 202, - 'headers' => [ 'retry-after' => '1' ], + 'headers' => [ 'retry-after' => '3' ], 'body' => [ 'data' => [ 'state' => 'pending' ] ], ], [ @@ -538,6 +609,7 @@ public function test_waits_for_temporary_draft_to_finish_creating(): void { $result = TestSender::send( $post_id, [ 'a@example.com' ] ); $this->assertIsArray( $result ); + $this->assertSame( [ 3.0, 1.0 ], $this->polls, 'Retry-After is honored; the default applies without it.' ); $this->assertSame( [ 'GET /workspaces/permissions', @@ -553,48 +625,115 @@ public function test_waits_for_temporary_draft_to_finish_creating(): void { } /** - * When beehiiv reports the temporary draft's creation failed, nothing is sent, - * the editor gets a clear message, and the draft is still cleaned up. + * A Retry-After given as an HTTP date is honored too. */ - public function test_failed_draft_creation_is_reported(): void { + public function test_retry_after_http_date_is_honored(): void { + $post_id = $this->create_post( 'draft' ); + + $this->responses[ 'GET /posts/' . self::TEMP_POST_ID ] = [ + 'sequence' => [ + [ + 'status' => 202, + 'headers' => [ 'retry-after' => gmdate( 'D, d M Y H:i:s', time() + 4 ) . ' GMT' ], + 'body' => [], + ], + [ + 'status' => 200, + 'body' => [ 'data' => [ 'id' => self::TEMP_POST_ID ] ], + ], + ], + ]; + + $this->assertIsArray( TestSender::send( $post_id, [ 'a@example.com' ] ) ); + $this->assertCount( 1, $this->polls ); + $this->assertGreaterThanOrEqual( 3.0, $this->polls[0] ); + $this->assertLessThanOrEqual( 4.0, $this->polls[0] ); + } + + /** + * Rate limiting on the creation check keeps waiting instead of sending early. + */ + public function test_rate_limited_creation_check_keeps_waiting(): void { $post_id = $this->create_post( 'draft' ); $this->responses[ 'GET /posts/' . self::TEMP_POST_ID ] = [ + 'sequence' => [ + [ + 'status' => 429, + 'body' => [ 'errors' => [ [ 'message' => 'Rate limited' ] ] ], + ], + [ + 'status' => 200, + 'body' => [ 'data' => [ 'id' => self::TEMP_POST_ID ] ], + ], + ], + ]; + + $this->assertIsArray( TestSender::send( $post_id, [ 'a@example.com' ] ) ); + $this->assertSame( 2, $this->count_requests( 'GET /posts/' . self::TEMP_POST_ID ) ); + } + + /** + * When beehiiv reports the temporary draft's creation failed, nothing is sent, the + * editor gets a clear message, and a 404 on cleanup counts as already gone. + */ + public function test_failed_draft_creation_is_reported(): void { + $post_id = $this->create_post( 'draft' ); + + $this->responses[ 'GET /posts/' . self::TEMP_POST_ID ] = [ 'status' => 404, 'body' => [ 'errors' => [ [ 'code' => 'POST_CREATION_FAILED' ] ] ], ]; + $this->responses[ 'DELETE /posts/' . self::TEMP_POST_ID ] = [ + 'status' => 404, + 'body' => [ 'errors' => [ [ 'message' => 'Not found' ] ] ], + ]; $result = TestSender::send( $post_id, [ 'a@example.com' ] ); $this->assertWPError( $result ); $this->assertSame( 'beehiiv_test_send_draft_failed', $result->get_error_code() ); - $this->assertNotContains( 'POST /posts/' . self::TEMP_POST_ID . '/test_sends', $this->request_log() ); - $this->assertContains( 'DELETE /posts/' . self::TEMP_POST_ID, $this->request_log() ); + $this->assertSame( 0, $this->count_requests( 'POST /posts/' . self::TEMP_POST_ID . '/test_sends' ) ); + $this->assertSame( 1, $this->count_requests( 'DELETE /posts/' . self::TEMP_POST_ID ) ); + $this->assertEmpty( $result->get_error_data(), 'A draft that was never created is not a leftover.' ); } /** - * A draft still being created after the wait limit gives a "try again" message - * rather than a send against a post beehiiv can't find yet. + * AC-025: a draft still being created when the (real, non-zero) wait runs out gives + * a "try again" message, never a send; a draft beehiiv is still processing can't be + * deleted yet, so the error carries the leftover flag. */ public function test_draft_still_pending_after_wait_limit(): void { $post_id = $this->create_post( 'draft' ); - add_filter( 'beehiiv_test_send_creation_wait_seconds', '__return_zero' ); - $this->responses[ 'GET /posts/' . self::TEMP_POST_ID ] = [ + add_filter( + 'beehiiv_test_send_creation_wait_seconds', + static function () { + return 0.35; + } + ); + $this->responses[ 'GET /posts/' . self::TEMP_POST_ID ] = [ 'status' => 202, 'body' => [ 'data' => [ 'state' => 'pending' ] ], ]; + $this->responses[ 'DELETE /posts/' . self::TEMP_POST_ID ] = [ + 'status' => 202, + 'body' => [], + ]; - $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + $started = microtime( true ); + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); $this->assertWPError( $result ); $this->assertSame( 'beehiiv_test_send_draft_pending', $result->get_error_code() ); - $this->assertNotContains( 'POST /posts/' . self::TEMP_POST_ID . '/test_sends', $this->request_log() ); - $this->assertContains( 'DELETE /posts/' . self::TEMP_POST_ID, $this->request_log() ); + $this->assertGreaterThanOrEqual( 3, $this->count_requests( 'GET /posts/' . self::TEMP_POST_ID ) ); + $this->assertSame( 0, $this->count_requests( 'POST /posts/' . self::TEMP_POST_ID . '/test_sends' ) ); + $this->assertSame( [ 'leftover_draft' => true ], $result->get_error_data() ); + $this->assertLessThan( 2.0, microtime( true ) - $started, 'The wait budget bounds the request.' ); } /** - * A test send that still can't find a just-created draft is retried once. + * A test send that still can't find a just-created draft is retried exactly once. */ public function test_not_found_test_send_is_retried_once(): void { $post_id = $this->create_post( 'draft' ); @@ -616,6 +755,52 @@ public function test_not_found_test_send_is_retried_once(): void { $this->assertIsArray( $result ); $this->assertSame( 8, $result['remaining_test_sends'] ); + $this->assertSame( 2, $this->count_requests( 'POST /posts/' . self::TEMP_POST_ID . '/test_sends' ) ); + } + + /** + * AC-018: when beehiiv leaves out the reset time, the remembered one is shown. + */ + public function test_missing_reset_time_falls_back_to_remembered(): void { + $post_id = $this->create_post( 'draft' ); + $remembered = time() + 5400; + update_option( TestSender::RESET_AT_OPTION, [ self::PUBLICATION_ID => $remembered ] ); + + $this->responses[ 'POST /posts/' . self::TEMP_POST_ID . '/test_sends' ] = [ + 'status' => 200, + 'body' => [ 'data' => [ 'remaining_test_sends' => 6 ] ], + ]; + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertSame( $remembered, $result['reset_at'] ); + } + + /** + * R-003: a failed test send that also leaves a draft behind tells the editor so. + */ + public function test_leftover_draft_is_reported_on_failure(): void { + do_action( 'rest_api_init' ); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'editor' ] ) ); + + $post_id = $this->create_post( 'draft' ); + + $this->responses[ 'POST /posts/' . self::TEMP_POST_ID . '/test_sends' ] = [ + 'status' => 500, + 'body' => [ 'errors' => [ [ 'message' => 'Internal error' ] ] ], + ]; + $this->responses[ 'DELETE /posts/' . self::TEMP_POST_ID ] = [ + 'status' => 202, + 'body' => [], + ]; + + $request = new WP_REST_Request( 'POST', '/beehiiv/v1/test-send' ); + $request->set_param( 'post_id', $post_id ); + $request->set_param( 'recipients', 'a@example.com' ); + $response = rest_do_request( $request ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertTrue( $response->get_data()['leftover_draft'] ); } /**