diff --git a/includes/API/Client.php b/includes/API/Client.php index 8414e5d..8be53f1 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. * @@ -51,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 ); } /** @@ -80,6 +88,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. * @@ -125,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; @@ -139,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, @@ -157,6 +176,10 @@ 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; + 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 ); @@ -165,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 ); } } @@ -173,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 0077bfc..4b0aa92 100644 --- a/includes/API/Resources/Posts.php +++ b/includes/API/Resources/Posts.php @@ -138,11 +138,13 @@ public static function get( string $publication_id, string $post_id ): 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, retry_after: int|null} * @since 1.0.0 */ public static function delete( string $publication_id, string $post_id ): array { @@ -151,8 +153,10 @@ 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, + 'retry_after' => null, ]; } @@ -167,33 +171,175 @@ 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, + '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' => '', + 'success' => true, + 'error' => '', + 'status_code' => $status_code, + 'retry_after' => $retry_after, ]; } 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, + 'retry_after' => $retry_after, ]; } return [ - 'success' => true, - 'error' => '', + 'success' => true, + 'error' => '', + 'status_code' => $status_code, + 'retry_after' => $retry_after, ]; } + /** + * 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. 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, int $timeout = 0 ): 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, [], $timeout ); + + $wp_error = Client::get_last_wp_error(); + + if ( null !== $wp_error ) { + $result['state'] = 'pending'; + $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 || 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 ); + } 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. + * + * @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; + } + /** * Update an existing post in a beehiiv publication. * diff --git a/includes/Newsletter/Sender.php b/includes/Newsletter/Sender.php index 7382029..accbe8a 100644 --- a/includes/Newsletter/Sender.php +++ b/includes/Newsletter/Sender.php @@ -1009,7 +1009,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 __( @@ -1131,7 +1131,7 @@ private static function publication_fallback_message( * @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..994139e --- /dev/null +++ b/includes/Newsletter/TestSender.php @@ -0,0 +1,728 @@ +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 ) ) { + // The sync flow's stored flag wins: once beehiiv is known to have sent the + // newsletter, it is sent whatever the stored schedule says. + if ( Sender::is_newsletter_sent( $post_id ) || 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 ( '' === PublicationResolver::resolve_for_post( $post_id ) ) { + return new WP_Error( + 'beehiiv_missing_publication', + __( 'Choose a publication in beehiiv settings, then try again.', 'beehiiv' ) + ); + } + + 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', + __( '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; + } + + // The linked newsletter's publication, or the post's chosen one when unlinked -- + // the same publication a real send or sync of this post uses. + $publication_id = PublicationResolver::resolve_for_post( $post_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, $publication_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'] ); + } + + $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 = 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; + + try { + // 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. + if ( empty( $result['success'] ) && 404 === $result['status_code'] ) { + $delay = self::poll_delay( null ); + + if ( microtime( true ) + $delay < $deadline ) { + self::sleep_seconds( $delay ); + $result = Posts::test_send( $publication_id, $created['post_id'], $recipient_emails ); + } + } + } + } finally { + $leftover_draft = ! self::delete_temporary_draft( $post_id, $publication_id, $created['post_id'] ); + } + + if ( null !== $failure ) { + self::log( + $post_id, + sprintf( 'temporary draft %s not ready: %s', $created['post_id'], $failure->get_error_message() ) + ); + + return self::with_leftover( $failure, $leftover_draft ); + } + + 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. 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 $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 ) { + $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( + 'beehiiv_test_send_draft_failed', + __( "beehiiv couldn't prepare the test email. Try again.", 'beehiiv' ) + ); + } + + if ( 'pending' !== $state['state'] ) { + return true; + } + + $delay = self::poll_delay( $state['retry_after'] ); + + 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::sleep_seconds( $delay ); + } + } + + /** + * Delete a temporary beehiiv draft, retrying while beehiiv is still processing it. + * + * 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 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 checks. + * + * Clamped to 0.1–5 seconds. + * + * @since 1.0.0 + * + * @param float $seconds Seconds to wait, from beehiiv's Retry-After when given. + */ + 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. + * + * 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. + * @param string|null $publication_id Publication the draft is created in; defaults to the post's resolved one. + * @return array|WP_Error + * @since 1.0.0 + */ + public static function build_temporary_draft_payload( int $post_id, ?string $publication_id = null ) { + $settings = PostSettingsBuilder::get_post_settings( $post_id, true, $publication_id ); + + 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'] ) ) { + $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' => $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 ); + + 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 { + // 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( + '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 + ) + ); + } + + /** + * 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. + * + * @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 10a0547..c15a98f 100644 --- a/includes/Plugin.php +++ b/includes/Plugin.php @@ -46,6 +46,7 @@ public static function init(): void { add_action( 'rest_api_init', [ REST\PostTemplatesController::class, 'register_routes' ] ); add_action( 'rest_api_init', [ REST\PublicationsController::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..b104ffd --- /dev/null +++ b/includes/REST/TestSendController.php @@ -0,0 +1,196 @@ + 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', + 'sanitize_callback' => 'sanitize_textarea_field', + ], + ], + ] + ); + } + + /** + * 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 ) ) { + $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 ); + } + + /** + * 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. + * @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, + bool $leftover_draft = false + ): WP_REST_Response { + + $body = [ + 'code' => $code, + 'message' => $message, + ]; + + if ( $leftover_draft ) { + $body['leftover_draft'] = true; + } + + return new WP_REST_Response( $body, 400 ); + } +} diff --git a/languages/beehiiv.pot b/languages/beehiiv.pot index a9bf1d5..62edea6 100644 --- a/languages/beehiiv.pot +++ b/languages/beehiiv.pot @@ -2,14 +2,14 @@ # This file is distributed under the GPL-2.0-or-later. msgid "" msgstr "" -"Project-Id-Version: beehiiv 1.0.2\n" +"Project-Id-Version: beehiiv 1.1.0\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-10-01T14:45:46+00:00\n" +"POT-Creation-Date: 2026-10-16T15:42:07+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" @@ -19,7 +19,7 @@ msgstr "" #: beehiiv.php #: includes/Admin/Menu.php:32 #: includes/Blocks/Registry.php:45 -#: src/js/editor/post-settings/index.js:236 +#: src/js/editor/post-settings/index.js:314 msgid "beehiiv" msgstr "" @@ -33,10 +33,14 @@ msgid "beehiiv Settings" msgstr "" #: includes/Admin/Registrar.php:63 -#: includes/Admin/Registrar.php:70 +#: src/js/editor/post-settings/components/newsletter-publication-select.js:92 msgid "Publication" msgstr "" +#: includes/Admin/Registrar.php:70 +msgid "Default publication" +msgstr "" + #: includes/Admin/Registrar.php:81 msgid "Default post template" msgstr "" @@ -46,30 +50,38 @@ msgid "Default beehiiv publication and post template for newsletters sent from t msgstr "" #: includes/Admin/Registrar.php:121 +#: src/js/admin/settings/index.js:259 +#: src/js/editor/post-settings/components/newsletter-publication-select.js:78 msgid "Select a publication" msgstr "" -#: includes/Admin/Registrar.php:151 -msgid "The publication to use for newsletters sent from this site." +#: includes/Admin/Registrar.php:155 +#: src/js/admin/settings/index.js:25 +#: src/js/editor/post-settings/components/newsletter-publication-select.js:115 +msgid "Refresh publications" +msgstr "" + +#: includes/Admin/Registrar.php:160 +msgid "Preselected on new posts. Editors can choose a different publication for each post in the beehiiv sidebar." msgstr "" -#: includes/Admin/Registrar.php:180 -#: src/js/admin/settings/index.js:110 +#: includes/Admin/Registrar.php:190 +#: src/js/admin/settings/index.js:138 msgid "No default template" msgstr "" -#: includes/Admin/Registrar.php:215 -#: src/js/admin/settings/index.js:17 -#: src/js/editor/post-settings/components/newsletter-template-select.js:177 +#: includes/Admin/Registrar.php:225 +#: src/js/admin/settings/index.js:21 +#: src/js/editor/post-settings/components/newsletter-template-select.js:201 msgid "Refresh templates" msgstr "" -#: includes/Admin/Registrar.php:220 -#: src/js/admin/settings/index.js:47 +#: includes/Admin/Registrar.php:230 +#: src/js/admin/settings/index.js:59 msgid "This publication has no post templates. Create a template in beehiiv, then refresh." msgstr "" -#: includes/Admin/Registrar.php:229 +#: includes/Admin/Registrar.php:239 msgid "Default newsletter template for this site. Refresh after changing templates in beehiiv." msgstr "" @@ -188,107 +200,181 @@ msgstr "" msgid "%1$s (%2$s)" msgstr "" -#: includes/Newsletter/PostSettingsBuilder.php:527 -#: includes/Newsletter/PostSettingsBuilder.php:576 -#: includes/Newsletter/Sender.php:692 +#: includes/Newsletter/PostSettingsBuilder.php:618 +#: includes/Newsletter/PostSettingsBuilder.php:667 +#: includes/Newsletter/Sender.php:1046 msgid "That send date has already passed. Pick a future date and time in the newsletter schedule." msgstr "" -#: includes/Newsletter/PostSettingsBuilder.php:551 -#: includes/Newsletter/PostSettingsBuilder.php:606 -#: includes/Newsletter/PostSettingsBuilder.php:647 -#: includes/Newsletter/Sender.php:703 +#: includes/Newsletter/PostSettingsBuilder.php:642 +#: includes/Newsletter/PostSettingsBuilder.php:697 +#: includes/Newsletter/PostSettingsBuilder.php:738 +#: includes/Newsletter/Sender.php:1057 msgid "That send date isn't valid. Open the newsletter schedule and choose a different date and time." msgstr "" -#: includes/Newsletter/PostSettingsBuilder.php:594 -#: includes/Newsletter/Sender.php:697 +#: includes/Newsletter/PostSettingsBuilder.php:685 +#: includes/Newsletter/Sender.php:1051 msgid "The newsletter can't send before this post publishes. Choose a later send time, or schedule the post first." msgstr "" -#: includes/Newsletter/Sender.php:211 +#: includes/Newsletter/Sender.php:359 msgid "We couldn't cancel the scheduled newsletter in beehiiv. Try saving the post again, or cancel it directly in beehiiv." msgstr "" -#: includes/Newsletter/Sender.php:340 -#: includes/Newsletter/Sender.php:427 -#: includes/Newsletter/Sender.php:690 +#: includes/Newsletter/Sender.php:497 +#: includes/Newsletter/Sender.php:619 +#: includes/Newsletter/Sender.php:1044 +#: includes/Newsletter/TestSender.php:125 msgid "This post no longer exists. Save or reload the editor and try again." msgstr "" -#: includes/Newsletter/Sender.php:665 +#: includes/Newsletter/Sender.php:1015 msgid "Choose a default post template in beehiiv settings, or pick one for this post in the beehiiv sidebar." msgstr "" -#: includes/Newsletter/Sender.php:671 +#: includes/Newsletter/Sender.php:1021 +msgid "Pick a post template for this post's publication in the beehiiv sidebar." +msgstr "" + +#: includes/Newsletter/Sender.php:1025 msgid "Add a title and body content before sending this newsletter." msgstr "" -#: includes/Newsletter/Sender.php:673 +#: includes/Newsletter/Sender.php:1027 msgid "This post doesn't include any blocks beehiiv can send. Add supported content and try again." msgstr "" -#: includes/Newsletter/Sender.php:678 +#: includes/Newsletter/Sender.php:1032 #: src/js/editor/post-settings/components/incomplete-advertisement-notice.js:25 msgid "Select an advertisement for the Advertisement block, or remove the block, to send this newsletter." msgstr "" -#: includes/Newsletter/Sender.php:684 +#: includes/Newsletter/Sender.php:1038 msgid "The selected advertisement is no longer available in beehiiv. Choose a different advertisement or remove the Advertisement block, then try again." msgstr "" -#: includes/Newsletter/Sender.php:721 +#: includes/Newsletter/Sender.php:1075 msgid "Connect your beehiiv account in beehiiv settings to send this newsletter." msgstr "" -#: includes/Newsletter/Sender.php:734 +#: includes/Newsletter/Sender.php:1088 +#: includes/Newsletter/TestSender.php:168 msgid "Choose a publication in beehiiv settings, then try again." msgstr "" -#: includes/Newsletter/Sender.php:754 -#: includes/Newsletter/Sender.php:789 +#: includes/Newsletter/Sender.php:1098 +msgid "Choose a publication for this post in the beehiiv sidebar." +msgstr "" + +#. translators: 1: removed publication name, 2: default publication name. +#: includes/Newsletter/Sender.php:1115 +#, php-format +msgid "The publication \"%1$s\" is no longer connected, so this newsletter was sent to the default publication \"%2$s\" instead." +msgstr "" + +#: includes/Newsletter/Sender.php:1142 +#: includes/Newsletter/Sender.php:1177 msgid "Post not found." msgstr "" -#: includes/Newsletter/Sender.php:758 +#: includes/Newsletter/Sender.php:1146 msgid "Your beehiiv connection expired. Reconnect in beehiiv settings." msgstr "" #. translators: %s: error message from the beehiiv API. -#: includes/Newsletter/Sender.php:767 +#: includes/Newsletter/Sender.php:1155 #, php-format msgid "beehiiv rejected this newsletter: %s" msgstr "" -#: includes/Newsletter/Sender.php:773 +#: includes/Newsletter/Sender.php:1161 +#: includes/Newsletter/TestSender.php:631 msgid "Too many requests. Try again in a moment." msgstr "" -#: includes/Newsletter/Sender.php:777 +#: includes/Newsletter/Sender.php:1165 msgid "beehiiv is temporarily unavailable. Try again later." msgstr "" -#: includes/Newsletter/Sender.php:781 -#: includes/Newsletter/Sender.php:793 +#: includes/Newsletter/Sender.php:1169 +#: includes/Newsletter/Sender.php:1181 msgid "Something went wrong. Try saving the post again." msgstr "" -#: includes/Newsletter/Sender.php:797 +#: includes/Newsletter/Sender.php:1185 msgid "beehiiv took too long to respond. Try again." msgstr "" -#: includes/Newsletter/Sender.php:801 +#: includes/Newsletter/Sender.php:1189 msgid "Couldn't reach beehiiv. Try again." msgstr "" -#: includes/Newsletter/Sender.php:818 +#: includes/Newsletter/Sender.php:1206 msgid "Something went wrong and the newsletter wasn't sent. Try saving the post again." msgstr "" -#: includes/Newsletter/Sender.php:835 +#: includes/Newsletter/Sender.php:1223 msgid "Something went wrong and the newsletter couldn't be updated. Try saving the post again." msgstr "" +#: 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:142 +#: 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:149 +#: 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:161 +msgid "Connect your beehiiv account in beehiiv settings to send a test email." +msgstr "" + +#: includes/Newsletter/TestSender.php:175 +msgid "Choose a default post template in beehiiv settings to send a test email." +msgstr "" + +#: includes/Newsletter/TestSender.php:182 +msgid "Test emails need beehiiv Send API access, available on the Max and Enterprise plans." +msgstr "" + +#: includes/Newsletter/TestSender.php:312 +msgid "beehiiv couldn't prepare the test email. Try again." +msgstr "" + +#: includes/Newsletter/TestSender.php:325 +msgid "beehiiv is still preparing the test email. Try again in a moment." +msgstr "" + +#: includes/Newsletter/TestSender.php:496 +#: includes/Newsletter/TestSender.php:658 +#: 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:616 +#, php-format +msgid "You've used all test sends for today. They reset on %s." +msgstr "" + +#: includes/Newsletter/TestSender.php:624 +msgid "You've used all test sends for today." +msgstr "" + +#. translators: %s: error message from beehiiv. +#: includes/Newsletter/TestSender.php:666 +#, php-format +msgid "The test email wasn't sent: %s" +msgstr "" + #: includes/OAuth/AdminActions.php:91 msgid "Disconnected from beehiiv." msgstr "" @@ -378,6 +464,7 @@ msgstr "" #: includes/REST/AdvertisementOpportunitiesController.php:101 #: includes/REST/PostTemplatesController.php:94 +#: includes/REST/PublicationsController.php:89 msgid "beehiiv is not connected." msgstr "" @@ -389,17 +476,36 @@ msgstr "" msgid "Publication ID is required." msgstr "" -#: src/js/admin/settings/index.js:77 -#: src/js/editor/post-settings/components/newsletter-template-select.js:184 -msgid "Templates updated from beehiiv." +#: 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: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 "" -#: src/js/admin/settings/index.js:158 +#: src/js/admin/settings/index.js:186 +#: src/js/admin/settings/index.js:302 #: src/js/blocks/advertisement/edit.js:219 -#: src/js/editor/post-settings/components/newsletter-template-select.js:176 +#: src/js/editor/post-settings/components/newsletter-publication-select.js:114 +#: src/js/editor/post-settings/components/newsletter-template-select.js:200 msgid "Refreshing…" msgstr "" +#: src/js/admin/settings/index.js:213 +#: src/js/editor/post-settings/components/newsletter-template-select.js:208 +msgid "Templates updated from beehiiv." +msgstr "" + +#: src/js/admin/settings/index.js:316 +#: src/js/editor/post-settings/components/newsletter-publication-select.js:122 +msgid "Publications updated from beehiiv." +msgstr "" + #: src/js/blocks/advertisement/edit.js:106 msgid "Select an advertisement…" msgstr "" @@ -526,57 +632,71 @@ msgstr "" msgid "Preview in beehiiv" msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:30 +#. translators: %s: beehiiv publication ID +#: src/js/editor/post-settings/components/newsletter-publication-select.js:69 +#, js-format +msgid "%s (no longer connected)" +msgstr "" + +#: src/js/editor/post-settings/components/newsletter-publication-select.js:99 +msgid "The newsletter has been sent, so its publication can no longer change." +msgstr "" + +#: src/js/editor/post-settings/components/newsletter-status-notices.js:26 msgid "Connect your beehiiv account in beehiiv settings before you can send newsletters." msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:44 +#: src/js/editor/post-settings/components/newsletter-status-notices.js:40 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." +#: src/js/editor/post-settings/components/newsletter-status-notices.js:57 +msgid "Choose a publication in beehiiv settings to send newsletters." +msgstr "" + +#. translators: %s: default beehiiv publication name +#: src/js/editor/post-settings/components/newsletter-status-notices.js:113 +#, js-format +msgid "The publication chosen for this post is no longer connected. The newsletter will be sent to the default publication \"%s\" instead." msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:65 -msgid "Choose a publication in beehiiv settings to send newsletters." +#: src/js/editor/post-settings/components/newsletter-status-notices.js:126 +msgid "Choose a publication for this post." msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:70 -msgid "Choose a default post template in beehiiv settings to send newsletters." +#: src/js/editor/post-settings/components/newsletter-status-notices.js:134 +msgid "Pick a post template for this post." msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:93 +#: src/js/editor/post-settings/components/newsletter-status-notices.js:167 msgid "Could not save this post to beehiiv:" msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:101 +#: src/js/editor/post-settings/components/newsletter-status-notices.js:175 msgid "Could not send this post to beehiiv:" msgstr "" -#: src/js/editor/post-settings/components/newsletter-template-select.js:88 -msgid "Default (from beehiiv settings)" -msgstr "" - -#. translators: %s: post template name from beehiiv settings -#: src/js/editor/post-settings/components/newsletter-template-select.js:128 +#. translators: %s: post template name +#. translators: %s: post template ID +#: src/js/editor/post-settings/components/newsletter-template-select.js:122 +#: src/js/editor/post-settings/components/newsletter-template-select.js:140 #, js-format -msgid "Uses %s from beehiiv settings." +msgid "%s (default)" msgstr "" -#: src/js/editor/post-settings/components/newsletter-template-select.js:134 -msgid "Uses the template configured in beehiiv settings." +#: src/js/editor/post-settings/components/newsletter-template-select.js:156 +msgid "Select a template" msgstr "" -#: src/js/editor/post-settings/components/newsletter-template-select.js:140 -msgid "No site default is set. Choose a template or configure one in beehiiv settings." +#: src/js/editor/post-settings/components/newsletter-template-select.js:178 +msgid "Post template" msgstr "" -#: src/js/editor/post-settings/components/newsletter-template-select.js:161 -msgid "Post template" +#: src/js/editor/post-settings/components/newsletter-template-select.js:184 +msgid "The template can't be changed once the newsletter is in beehiiv." msgstr "" -#: src/js/editor/post-settings/components/newsletter-template-select.js:188 +#: src/js/editor/post-settings/components/newsletter-template-select.js:212 msgid "Templates are cached. Refresh to pull the latest from beehiiv after adding, renaming, or deleting one." msgstr "" @@ -596,60 +716,102 @@ msgstr "" msgid "Queue this post for delivery via beehiiv when published." msgstr "" +#: 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:105 +#, 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:125 +#, js-format +msgid "Test sends reset on %s." +msgstr "" + +#: 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:242 +msgid "Separate addresses with commas or new lines." +msgstr "" + +#: 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:267 +msgid "Send test email" +msgstr "" + +#: 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 "" + #: 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:88 +#: src/js/editor/post-settings/index.js:157 msgid "Newsletter title" msgstr "" -#: src/js/editor/post-settings/index.js:89 +#: src/js/editor/post-settings/index.js:158 msgid "Used as the email subject line. Leave empty to use the post title." msgstr "" -#: src/js/editor/post-settings/index.js:101 +#: src/js/editor/post-settings/index.js:170 msgid "Newsletter subtitle" msgstr "" -#: src/js/editor/post-settings/index.js:102 +#: src/js/editor/post-settings/index.js:171 msgid "Shown as the beehiiv post subtitle. Leave empty for no subtitle." msgstr "" -#: src/js/editor/post-settings/index.js:113 +#: src/js/editor/post-settings/index.js:182 msgid "Show title and subtitle in email" msgstr "" -#: src/js/editor/post-settings/index.js:114 +#: src/js/editor/post-settings/index.js:183 msgid "Shows the newsletter title and subtitle in the beehiiv email. Off by default." msgstr "" -#: src/js/editor/post-settings/index.js:151 +#: src/js/editor/post-settings/index.js:229 msgid "The newsletter can only be sent once and cannot be undone." msgstr "" -#: src/js/editor/post-settings/index.js:174 +#: src/js/editor/post-settings/index.js:252 msgid "Snippet newsletter" msgstr "" -#: src/js/editor/post-settings/index.js:177 +#: src/js/editor/post-settings/index.js:255 msgid "Send a snippet newsletter with a \"Read More\" button to the full post." msgstr "" -#: src/js/editor/post-settings/index.js:185 +#: src/js/editor/post-settings/index.js:263 msgid "Insert the \"More\" block in your post to mark where the snippet ends." msgstr "" -#: src/js/editor/post-settings/index.js:280 +#: src/js/editor/post-settings/index.js:278 +msgid "Test email" +msgstr "" + +#: src/js/editor/post-settings/index.js:357 msgid "Yes" msgstr "" -#: src/js/editor/post-settings/index.js:281 +#: src/js/editor/post-settings/index.js:358 msgid "No" msgstr "" -#: src/js/editor/post-settings/index.js:288 +#: src/js/editor/post-settings/index.js:365 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..a7ecee5 --- /dev/null +++ b/src/js/editor/post-settings/components/test-email-send.js @@ -0,0 +1,290 @@ +/** + * 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; + + // 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( + meta.beehiivScheduledAt, + meta.sendToNewsletterDate + ) + ) { + return __( + "This post's newsletter was already sent, so it can't send a test email.", + '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' + ), + leftoverDraft: !! error?.leftover_draft, + } ); + } ) + .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 0246ec5..22077a3 100644 --- a/src/js/editor/post-settings/editor.scss +++ b/src/js/editor/post-settings/editor.scss @@ -3,3 +3,4 @@ @use "css/newsletter-template"; @use "css/newsletter-wording"; @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 fcfba2c..6a36893 100644 --- a/src/js/editor/post-settings/index.js +++ b/src/js/editor/post-settings/index.js @@ -24,6 +24,7 @@ import NewsletterPublicationSelect from './components/newsletter-publication-sel 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'; @@ -273,6 +274,11 @@ function BeehiivPostSettingsPanel() { ) } + { isNewsletterReady && ( + + + + ) } ); } diff --git a/tests/e2e/plugins/beehiiv-options.php b/tests/e2e/plugins/beehiiv-options.php index af24c8e..56fe2ee 100644 --- a/tests/e2e/plugins/beehiiv-options.php +++ b/tests/e2e/plugins/beehiiv-options.php @@ -59,11 +59,22 @@ static function ( $preempt, $parsed_args, $url ) { $method = strtoupper( (string) ( $parsed_args['method'] ?? 'GET' ) ); foreach ( $mocks as $key => $mock ) { + if ( ! is_string( $key ) || '' === $key || ! is_array( $mock ) ) { + continue; + } + // A mock may carry its own `needle` (so several mocks can share one // URL substring, told apart by `method`); otherwise the key is the needle. $needle = isset( $mock['needle'] ) && is_string( $mock['needle'] ) ? $mock['needle'] : $key; - if ( ! is_string( $needle ) || '' === $needle || false === strpos( $url, $needle ) ) { + // Optional `pattern` (regex) replaces the substring match -- 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 ( '' === $needle || false === strpos( $url, $needle ) ) { continue; } @@ -71,12 +82,31 @@ static function ( $preempt, $parsed_args, $url ) { continue; } - beehiiv_e2e_log_http( $method, $url, $parsed_args['body'] ?? null ); + beehiiv_e2e_log_http( $method, $url, $parsed_args['body'] ?? null, $key ); + + if ( ! empty( $mock['delay_ms'] ) ) { + 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[ $key ]['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, @@ -113,22 +143,23 @@ function beehiiv_e2e_mock_http( string $url_substring, array $response ): void { } /** - * Records one mocked outbound request (method, URL, decoded JSON body) so - * specs can assert on the exact payload the plugin sent to beehiiv. Only - * requests answered by a registered mock are logged -- nothing that reaches - * the real network is captured. + * Records one mocked outbound request (method, URL, decoded JSON body, and + * the key of the mock that answered it) so specs can assert what the plugin + * actually sent to beehiiv. * - * @param string $method HTTP method. - * @param string $url Request URL. - * @param mixed $body Raw request body (JSON string for POST/PATCH). + * @param string $method HTTP method. + * @param string $url Request URL. + * @param mixed $body Raw request body (JSON string for POST/PATCH). + * @param string $mock Key of the mock that answered. */ -function beehiiv_e2e_log_http( string $method, string $url, $body ): void { +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 : []; $log[] = [ 'method' => $method, 'url' => $url, 'body' => is_string( $body ) && '' !== $body ? json_decode( $body, true ) : $body, + 'mock' => $mock, ]; update_option( 'beehiiv_e2e_http_log', $log, false ); } @@ -238,6 +269,8 @@ function beehiiv_e2e_reset_all(): void { beehiiv_e2e_stop_http_log(); 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(); } @@ -291,3 +324,43 @@ function beehiiv_e2e_stop_http_log(): void { delete_option( 'beehiiv_e2e_http_log_enabled' ); 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 +); + +/** + * 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 new file mode 100644 index 0000000..190e1b8 --- /dev/null +++ b/tests/e2e/specs/test-email-send.spec.js @@ -0,0 +1,1525 @@ +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. + * + * @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. + * + * @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. + * + * @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 )`; + +/** + * 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 = { + 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. + * + * @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}. + * @param {Object} root0.get Response for GET …/posts/{id} (creation state: + * 200 ready, 202 still creating, 404 failed). + */ +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: '' }, + get = { status: 200, body: { data: { id: TMP_ID } } }, +} = {} ) { + 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, + }, + get_post: { + pattern: '#/publications/[^/]+/posts/[^/?]+(\\?|$)#', + method: 'GET', + ...get, + }, + }; + 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). + * + * @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, + 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. + * + * @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 } );` ); +} + +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. + * + * @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( + 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 {} + 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', + 'GET get_post', + 'POST test_sends', + 'DELETE delete_post', + ] ); + expect( calls[ 2 ].url ).toContain( `/posts/${ TMP_ID }/test_sends` ); + expect( calls[ 3 ].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', + 'GET get_post', + 'POST test_sends', + 'DELETE delete_post', + ] ); + expect( calls[ 3 ].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 ); + } + } ); + + // ----- 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/e2e/utils/auth.js b/tests/e2e/utils/auth.js index 95c32a6..f8a40a5 100644 --- a/tests/e2e/utils/auth.js +++ b/tests/e2e/utils/auth.js @@ -41,10 +41,14 @@ async function loginAs( page, username, password ) { page.locator( '#user_login:focus, #user_pass:focus' ) ).toHaveCount( 1 ); - await user.fill( username ); - await pass.fill( password ); - await expect( user ).toHaveValue( username ); - await expect( pass ).toHaveValue( password ); + // Re-fill until both fields hold the expected values, in case a late focus + // change still lands between the two fills. + await expect( async () => { + await user.fill( username ); + await pass.fill( password ); + await expect( user ).toHaveValue( username, { timeout: 1000 } ); + await expect( pass ).toHaveValue( password, { timeout: 1000 } ); + } ).toPass( { timeout: 10000 } ); await page.click( '#wp-submit' ); await page.waitForURL( '**/wp-admin/**' ); diff --git a/tests/phpunit/NewsletterTestSenderTest.php b/tests/phpunit/NewsletterTestSenderTest.php new file mode 100644 index 0000000..9f7b93b --- /dev/null +++ b/tests/phpunit/NewsletterTestSenderTest.php @@ -0,0 +1,957 @@ +|null}> + */ + private $requests = []; + + /** + * Per-route responses keyed by "METHOD path-suffix". + * + * @var array}> + */ + 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(); + + $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' => [], + ], + '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 ); + $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', + [ + '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_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 ); + 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, $key ]; + } + } + } + + $response = null === $match ? [ + 'status' => 404, + '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' => $response['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', + 'GET /posts/' . self::TEMP_POST_ID, + '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[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' ); + } + + /** + * 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-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. + */ + 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->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() ); + } + + /** + * 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' ); + + $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'] ); + $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 ) ); + } + + /** + * 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' ); + + $this->responses[ 'GET /posts/' . self::TEMP_POST_ID ] = [ + 'sequence' => [ + [ + 'status' => 202, + 'headers' => [ 'retry-after' => '3' ], + '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( [ 3.0, 1.0 ], $this->polls, 'Retry-After is honored; the default applies without it.' ); + $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() + ); + } + + /** + * A Retry-After given as an HTTP date is honored too. + */ + 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->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.' ); + } + + /** + * 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', + 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' => [], + ]; + + $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->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 exactly 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'] ); + $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'] ); + } + + /** + * A post set to its own publication creates its temporary draft and sends the + * test there, with that publication's template, not the site-wide default. + */ + public function test_unlinked_post_uses_its_own_publication(): void { + \Beehiiv\API\Cache::set_publications( + [ + [ + 'id' => self::PUBLICATION_ID, + 'name' => 'Default', + ], + [ + 'id' => 'pub_other', + 'name' => 'Other', + ], + ] + ); + \Beehiiv\API\Cache::set_post_templates( + 'pub_other', + [ + [ + 'id' => 'tpl_other', + 'name' => 'Other template', + ], + ] + ); + + $post_id = $this->create_post( + 'draft', + [ + Meta::BEEHIIV_PUBLICATION_ID => 'pub_other', + Meta::BEEHIIV_POST_TEMPLATE_ID => 'tpl_other', + ] + ); + + $this->assertIsArray( TestSender::send( $post_id, [ 'a@example.com' ] ) ); + + $beehiiv_calls = array_values( + array_filter( + $this->requests, + static function ( $request ) { + return false !== strpos( $request['url'], '/publications/' ); + } + ) + ); + + $this->assertNotEmpty( $beehiiv_calls ); + + foreach ( $beehiiv_calls as $request ) { + $this->assertStringContainsString( '/publications/pub_other/', $request['url'] ); + } + + $this->assertSame( 'tpl_other', $beehiiv_calls[0]['body']['post_template_id'] ); + } + + /** + * A linked newsletter is tested in the publication it was created in. + */ + public function test_linked_newsletter_uses_its_linked_publication(): 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::BEEHIIV_LINKED_PUBLICATION_ID => 'pub_linked', + ] + ); + + $this->assertIsArray( TestSender::send( $post_id, [ 'a@example.com' ] ) ); + + $sends = array_values( + array_filter( + $this->requests, + static function ( $request ) { + return false !== strpos( $request['url'], '/test_sends' ); + } + ) + ); + + $this->assertCount( 1, $sends ); + $this->assertStringContainsString( + '/publications/pub_linked/posts/' . self::LINKED_POST_ID . '/test_sends', + $sends[0]['url'] + ); + } + + /** + * AC-013: once the sync flow has recorded that beehiiv sent the newsletter, the + * post can't send a test, even while its stored schedule is still in the future. + */ + public function test_sent_flag_from_sync_refuses_test_send(): 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::BEEHIIV_NEWSLETTER_SENT => '1', + ] + ); + + $result = TestSender::send( $post_id, [ 'a@example.com' ] ); + + $this->assertWPError( $result ); + $this->assertSame( 'beehiiv_test_send_already_sent', $result->get_error_code() ); + $this->assertSame( 0, $this->count_requests( 'POST /posts/' . self::LINKED_POST_ID . '/test_sends' ) ); + } + + /** + * 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'] ); + } +}