Skip to content
Open

Release #1097

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion includes/admin/helpers/class-rop-bluesky-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,13 @@ public function create_post( $did, $post, $post_type, $hashtags, $access_token =

$text = $post['content'] . $hashtags;

// Bluesky rejects records whose text exceeds 300 grapheme clusters.
if ( function_exists( 'grapheme_substr' ) ) {
$text = grapheme_substr( $text, 0, 300 );
} else {
$text = mb_substr( $text, 0, 300, 'UTF-8' );
}

$record = array(
'$type' => 'app.bsky.feed.post',
'text' => $text,
Expand Down Expand Up @@ -504,7 +511,7 @@ private function get_external_post_url( $post ) {
'external' => array(
'uri' => $post['post_url'],
'title' => isset( $post['title'] ) ? $post['title'] : '',
'description' => isset( $post['content'] ) ? $post['content'] : '',
'description' => '',
),
);
}
Expand Down
6 changes: 3 additions & 3 deletions includes/admin/helpers/class-rop-content-helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ public function token_truncate( $string, $new_length = false ) {

$length = 0;
for ( $last_part = 0; $last_part < $parts_count; ++ $last_part ) {
$length += strlen( $parts[ $last_part ] );
$length += mb_strlen( $parts[ $last_part ], 'UTF-8' );
if ( $length > $this->length ) {
break;
}
Expand All @@ -128,10 +128,10 @@ public function token_truncate( $string, $new_length = false ) {
* we should get the substring of it.
*/
if ( empty( $output ) && $parts_count === 1 ) {
$output = substr( $string, 0, $new_length );
$output = mb_substr( $string, 0, $new_length, 'UTF-8' );
}
// add ellipse only if set and originating text is longer than set length
if ( $this->end_ellipse && strlen( $string ) > $this->length ) {
if ( $this->end_ellipse && mb_strlen( $string, 'UTF-8' ) > $this->length ) {
$output .= ' ' . $this->ellipse;
}

Expand Down
2 changes: 1 addition & 1 deletion includes/admin/helpers/class-rop-post-format-helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ private function get_custom_length() {
* @return int String length.
*/
public function string_length( $string ) {
if ( function_exists( 'mb_strlen ' ) ) {
if ( function_exists( 'mb_strlen' ) ) {
return mb_strlen( $string );
}

Expand Down
6 changes: 6 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@
<testsuite name="bluesky-image">
<directory>./tests/test-bluesky-image.php</directory>
</testsuite>
<testsuite name="bluesky-grapheme">
<directory>./tests/test-bluesky-grapheme.php</directory>
</testsuite>
<testsuite name="bluesky-card">
<directory>./tests/test-bluesky-card.php</directory>
</testsuite>
<testsuite name="x-premium-limit">
<directory>./tests/test-x-premium-limit.php</directory>
</testsuite>
Expand Down
78 changes: 78 additions & 0 deletions tests/test-bluesky-card.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php
/**
* ROP Test Bluesky link card embed for PHPUnit.
*
* Reproduces GitHub issue Codeinwp/tweet-old-post-pro#676: the link card
* (app.bsky.embed.external) must not repeat the post caption as its
* description — Bluesky renders cards without a description as title + domain.
*
* @package ROP
* @subpackage Tests
* @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
*/

/**
* Test Bluesky link card. class.
*/
class Test_RopBlueskyCard extends WP_UnitTestCase {

/**
* The link card description must be empty, not a copy of the post body.
*
* @covers Rop_Bluesky_Api::create_post
*/
public function test_link_card_description_is_empty() {
$captured = null;

$interceptor = function ( $preempt, $args, $url ) use ( &$captured ) {
if ( false === strpos( $url, 'com.atproto.repo.createRecord' ) ) {
return $preempt;
}
$captured = json_decode( $args['body'], true );

return array(
'headers' => array(),
'body' => wp_json_encode(
array(
'uri' => 'at://did:plc:test/app.bsky.feed.post/test',
'cid' => 'test-cid',
)
),
'response' => array(
'code' => 200,
'message' => 'OK',
),
'cookies' => array(),
'filename' => null,
);
};
add_filter( 'pre_http_request', $interceptor, 10, 3 );

$api = new Rop_Bluesky_Api( 'test.bsky.social', 'test-app-password' );
$api->create_post(
'did:plc:test',
array(
'content' => 'My caption text for this share.',
'title' => 'My Post Title',
'post_url' => 'https://example.org/my-post/',
),
'link',
'',
'test-access-token'
);

remove_filter( 'pre_http_request', $interceptor, 10 );

$this->assertNotNull( $captured, 'create_post() should have attempted a createRecord request.' );

$embed = $captured['record']['embed'];
$this->assertSame( 'app.bsky.embed.external', $embed['$type'] );
$this->assertSame( 'https://example.org/my-post/', $embed['external']['uri'] );
$this->assertSame( 'My Post Title', $embed['external']['title'] );
$this->assertSame(
'',
$embed['external']['description'],
'Link card description must be empty, not a duplicate of the post caption (issue #676).'
);
}
}
144 changes: 144 additions & 0 deletions tests/test-bluesky-grapheme.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php
/**
* ROP Test Bluesky grapheme limit handling for PHPUnit.
*
* Bluesky (AT Protocol) rejects app.bsky.feed.post records whose text exceeds
* 300 grapheme clusters. These tests reproduce GitHub issue
* Codeinwp/tweet-old-post-pro#677: byte-based truncation and unchecked hashtag
* concatenation let the plugin submit records longer than 300 graphemes.
*
* @package ROP
* @subpackage Tests
* @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
*/

/**
* Test Bluesky grapheme limit. class.
*/
class Test_RopBlueskyGrapheme extends WP_UnitTestCase {

/**
* Bluesky enforces this limit in grapheme clusters, not bytes.
*/
const BLUESKY_MAX_GRAPHEMES = 300;

/**
* The body of the last request captured from create_post().
*
* @var array|null
*/
private $captured_body = null;

/**
* Call create_post() with the outbound HTTP request intercepted, and
* return the record that would have been submitted to Bluesky.
*
* @param string $content The post content (as produced by build_content).
* @param string $hashtags The hashtags string appended by the service.
* @return array The captured createRecord request body.
*/
private function capture_created_record( $content, $hashtags ) {
$this->captured_body = null;

$interceptor = function ( $preempt, $args, $url ) {
if ( false === strpos( $url, 'com.atproto.repo.createRecord' ) ) {
return $preempt;
}
$this->captured_body = json_decode( $args['body'], true );

return array(
'headers' => array(),
'body' => wp_json_encode(
array(
'uri' => 'at://did:plc:test/app.bsky.feed.post/test',
'cid' => 'test-cid',
)
),
'response' => array(
'code' => 200,
'message' => 'OK',
),
'cookies' => array(),
'filename' => null,
);
};

add_filter( 'pre_http_request', $interceptor, 10, 3 );

$api = new Rop_Bluesky_Api( 'test.bsky.social', 'test-app-password' );
$api->create_post(
'did:plc:test',
array( 'content' => $content ),
'text',
$hashtags,
'test-access-token'
);

remove_filter( 'pre_http_request', $interceptor, 10 );

$this->assertNotNull( $this->captured_body, 'create_post() should have attempted a createRecord request.' );

return $this->captured_body;
}

/**
* Multibyte content that passed the user-configured maximum length (which
* can be set above 300, e.g. 1000) must still be clamped to 300 graphemes
* before it is submitted to Bluesky.
*
* @covers Rop_Bluesky_Api::create_post
*/
public function test_create_post_clamps_multibyte_content_to_grapheme_limit() {
// 310 graphemes / 620 bytes — accepted by a byte-unaware 1000 limit upstream.
$content = str_repeat( "\u{00E9}", 310 );

$body = $this->capture_created_record( $content, '' );

$this->assertLessThanOrEqual(
self::BLUESKY_MAX_GRAPHEMES,
grapheme_strlen( $body['record']['text'] ),
'Record text exceeds the 300 grapheme Bluesky limit; the API would reject it with "grapheme too big".'
);
}

/**
* Hashtags are appended after upstream truncation, so content that fits
* the limit exactly must not overflow it once hashtags are concatenated.
*
* @covers Rop_Bluesky_Api::create_post
*/
public function test_create_post_clamps_appended_hashtags_to_grapheme_limit() {
// Plain ASCII content already at the 300 grapheme limit.
$content = rtrim( str_repeat( 'word ', 60 ) );
$hashtags = ' #wordpress #news';

$body = $this->capture_created_record( $content, $hashtags );

$this->assertLessThanOrEqual(
self::BLUESKY_MAX_GRAPHEMES,
grapheme_strlen( $body['record']['text'] ),
'Appending hashtags pushed the record text over the 300 grapheme Bluesky limit.'
);
}

/**
* token_truncate() must measure characters, not bytes: a 300-character
* multibyte string is within a 300-character limit and must survive
* truncation unchanged instead of being cut roughly in half.
*
* @covers Rop_Content_Helper::token_truncate
*/
public function test_token_truncate_measures_characters_not_bytes() {
$ch = new Rop_Content_Helper();

// 75 tokens of "ééé " = 300 characters (600 é bytes + 75 spaces).
$string = rtrim( str_repeat( "\u{00E9}\u{00E9}\u{00E9} ", 75 ) );
$this->assertSame( 299, mb_strlen( $string, 'UTF-8' ) );

$this->assertSame(
$string,
$ch->token_truncate( $string, 300 ),
'A 299-character string was truncated under a 300-character limit because lengths were counted in bytes.'
);
}
}
Loading