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.
* fix: LinkedIn company page post URL missing the post ID
LinkedInPagePublisher::postUrl() built a company/{username}/posts/
URL but never interpolated $postId into it, so the "view on LinkedIn"
link for company page posts always ended at the trailing slash.
Per LinkedIn's Posts API docs, the correct public URL for any
published post (member or organization) is always
feed/update/{postId} — so the override is removed and the class now
inherits the correct base implementation.
Closes#271
* address code review: backfill historical URLs, fix stale docblock, close test gaps
- Add social:backfill-linkedin-page-post-urls to recompute platform_url
for LinkedIn Page posts published before the fix, whose broken
company/{username}/posts/ URL was already persisted and never
recomputed. Supports --dry-run.
- Fix AbstractLinkedInPublisher::postUrl() docblock, which still
claimed company pages override it after the override was removed.
- LinkedInPagePublisherTest: assert result['url'] in the image and
carousel publish tests (previously only asserted result['id']);
add a null-postId case; collapse the two username-branch tests
(now dead code) into one dataset-driven test plus a dedicated
null-postId test.
The member and company-page publishers were ~95% identical (~1200 lines). Extract AbstractLinkedInPublisher holding the whole pipeline (media-inferred dispatch, post/carousel/document payloads, image/video/document upload, processing polling, error handling); each concrete publisher now only declares its platform, author URN, and (for pages) the post URL. ~640 duplicated lines removed, single source of truth.
Fix: multi-image posts sent each image URN under 'media', which LinkedIn's multiImage schema rejects with a 422 (unrecognized field 'media' / required 'id' missing). Send the URN under 'id'. Add assertions locking the 'id' field on both carousel tests, a company-page multi-image test that was missing, and a fail-fast assertion that a missing org id makes no HTTP call.
Collapse LinkedIn to one content type per account kind (linkedin_post, linkedin_page_post). Publishers infer the publish format from the attached media — text, single image/video, multi-image carousel, or PDF document — matching how facebook_post/x_post already work; PDF is exclusive of any other attachment. Removes the editor variant picker, keeping only the PDF document title field. Includes a data migration collapsing the retired carousel/document content types.
Replace the two LinkedIn account cards with a single Connect LinkedIn button: one unified OAuth grant (linkedin-openid driver, union of scopes) then a post-callback identity picker to post as the personal profile (linkedin) or a company page the member administers (linkedin-page). The chosen organization is validated against the admin-verified list from the OAuth grant. Per-capability gating via LINKEDIN_ENABLED / LINKEDIN_PAGE_ENABLED supports profile-only or org-only self-hosting. Removes LinkedInPageController, LinkedInTokenSynchronizer, the standalone linkedin-page connect routes, and the unused redirect_page config.
Review follow-ups before QA:
- Editor: getMediaIncompatibilityReason rejects a PDF on non-document content types, so the schedule gate and variant auto-snap match the backend rule (compliance i18n in en/es/pt-BR)
- MCP UpdatePostTool: validate effective content_type vs stored media on schedule, closing the schedule-without-content_type gap; share entriesForUpdate/errorsFor with the API path
- Tests: Platform allowedMediaTypes contains Document, URL-attach of a PDF (LinkedIn ok / TikTok rejected), multi-platform PDF rejection, document init-failure/missing-URN, Page publisher PROCESSING_FAILED
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
These tests use Http::fake, model factories, and DB — by Laravel/Pest
convention that's a feature test, not a unit test. Moving them to the
Feature suite to match the convention.
No code changes. Tests still pass: 1493 passed, 2 skipped, 0 failed.
2026-05-12 19:02:51 -03:00
Renamed from tests/Unit/Services/Social/LinkedInPagePublisherTest.php (Browse further)