X bills a post containing a URL at a much higher rate than a plain post, and its algorithm demotes link posts. The X version of a post now rewrites every URL non-clickable (https://example.com/post becomes example(.)com/post): scheme and www. dropped, every dot of the host replaced with (.). Leaving a single dot intact would still leave a resolvable domain for X to detect, so all of them are broken. A scheme or www. proves a token is a URL on its own; a bare host only counts when its last label is a delegated TLD, which is the one thing telling acme.com apart from Node.js. That check runs against App\Support\LinkTlds, generated from the whole IANA root zone in every form a TLD can appear in a post -- ASCII, punycode and the Unicode it decodes to -- because whatever X links is what X bills, so a hand-picked subset would leave us paying for its gaps. If the regex engine bails out on pathological input the original content is returned instead of crashing the publisher. The transform lives in the Platform::X arm of ContentSanitizer, so it reaches publishing and the app/API/MCP previews from one place and cannot touch any other network. Off by default; opt in with X_DEFUSE_LINKS. The editor counts characters and renders its preview client-side and cannot ask the server on every keystroke, so the rewrite is mirrored in TypeScript. PHP stays the source of truth: a parity test fails if the two TLD sets drift, and a browser test drives the real editor so the mirror is covered rather than assumed. Without it the composer promised text the network never receives. Character limits now measure the text a reader will see: sanitized, then with markup resolved away. Measuring the raw draft blocked saving posts that publish fine and let through posts the network rejects, and counted the editor's HTML toward the limit. Measuring the sanitized form alone would have counted Telegram's escaped entities, rejecting messages Telegram accepts. Empty content is handled once inside the sanitizer instead of by a guard repeated at every call site.
92 lines
3.2 KiB
PHP
92 lines
3.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Enums\SocialAccount\Platform;
|
|
use App\Rules\ContentFitsPlatformLimits;
|
|
|
|
function runFitsRule(string $content, array $platforms): array
|
|
{
|
|
$errors = [];
|
|
$rule = new ContentFitsPlatformLimits(collect($platforms));
|
|
|
|
$rule->validate('content', $content, function (string $message) use (&$errors): void {
|
|
$errors[] = $message;
|
|
});
|
|
|
|
return $errors;
|
|
}
|
|
|
|
test('passes when content fits every platform cap', function () {
|
|
$errors = runFitsRule(str_repeat('a', 280), [Platform::X, Platform::Threads, Platform::Facebook]);
|
|
|
|
expect($errors)->toBe([]);
|
|
});
|
|
|
|
test('fails with the platform label, limit and overage when content exceeds a single platform', function () {
|
|
$errors = runFitsRule(str_repeat('a', 537), [Platform::Threads]);
|
|
|
|
expect($errors)->toHaveCount(1);
|
|
expect($errors[0])
|
|
->toContain('Threads')
|
|
->toContain('500')
|
|
->toContain('37');
|
|
});
|
|
|
|
test('emits one error per overflowing platform in a multi-platform set', function () {
|
|
// 320 chars: fine for Threads (500), over for X (280) and Bluesky (300).
|
|
$errors = runFitsRule(str_repeat('a', 320), [Platform::X, Platform::Bluesky, Platform::Threads]);
|
|
|
|
expect($errors)->toHaveCount(2);
|
|
expect($errors[0])->toContain('X');
|
|
expect($errors[1])->toContain('Bluesky');
|
|
});
|
|
|
|
test('deduplicates errors when the same platform appears twice in the collection', function () {
|
|
// Two Threads accounts selected, content 600 chars — should still produce ONE error.
|
|
$errors = runFitsRule(str_repeat('a', 600), [Platform::Threads, Platform::Threads]);
|
|
|
|
expect($errors)->toHaveCount(1);
|
|
});
|
|
|
|
test('passes for an empty platforms collection', function () {
|
|
$errors = runFitsRule(str_repeat('a', 10_000), []);
|
|
|
|
expect($errors)->toBe([]);
|
|
});
|
|
|
|
test('treats null content as an empty string and passes', function () {
|
|
$errors = runFitsRule('', [Platform::Threads, Platform::X]);
|
|
|
|
expect($errors)->toBe([]);
|
|
});
|
|
|
|
test('measures the defused length for x so a link post that will fit is accepted', function () {
|
|
config()->set('trypost.platforms.x.defuse_links', true);
|
|
$errors = runFitsRule(str_repeat('a', 263).' https://acme.com/x', [Platform::X]);
|
|
|
|
expect($errors)->toBe([]);
|
|
});
|
|
|
|
test('measures the defused length for x so a link post that will not fit is rejected', function () {
|
|
config()->set('trypost.platforms.x.defuse_links', true);
|
|
$errors = runFitsRule(str_repeat('a', 271).' acme.com', [Platform::X]);
|
|
|
|
expect($errors)->toHaveCount(1);
|
|
expect($errors[0])->toContain('X')->toContain('280')->toContain('2');
|
|
});
|
|
|
|
test('does not count html markup toward a platform cap', function () {
|
|
$errors = runFitsRule('<p>'.str_repeat('a', 275).'</p>', [Platform::X]);
|
|
|
|
expect($errors)->toBe([]);
|
|
});
|
|
|
|
test('measures telegram against the rendered text, not the escaped markup', function () {
|
|
// 3.597 characters, under Telegram's 4.096 cap. Escaping every ampersand for
|
|
// parse_mode=HTML nearly doubles that, but the reader still sees one character.
|
|
$content = implode(' & ', array_fill(0, 900, 'a'));
|
|
|
|
expect(mb_strlen($content))->toBeLessThan(Platform::Telegram->maxContentLength())
|
|
->and(runFitsRule($content, [Platform::Telegram]))->toBe([]);
|
|
});
|