feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use App\Enums\SocialAccount\Platform;
|
|
|
|
|
use App\Models\Media;
|
|
|
|
|
use App\Models\Post;
|
|
|
|
|
use App\Models\PostPlatform;
|
|
|
|
|
use App\Models\SocialAccount;
|
|
|
|
|
use App\Models\Workspace;
|
2026-06-29 16:41:47 +00:00
|
|
|
use Illuminate\Http\Client\ConnectionException;
|
2026-05-04 21:00:03 +00:00
|
|
|
use Illuminate\Http\UploadedFile;
|
feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
|
|
2026-07-17 18:40:15 +00:00
|
|
|
// A public IP literal as the host lets SafeHttpFetcher's SSRF guard pass without
|
|
|
|
|
// a real DNS lookup; Http::fake() intercepts the request before any network I/O.
|
|
|
|
|
|
feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
|
|
|
beforeEach(function () {
|
|
|
|
|
$result = createApiTestToken();
|
|
|
|
|
$this->user = $result['user'];
|
|
|
|
|
$this->workspace = $result['workspace'];
|
|
|
|
|
$this->plainToken = $result['plain_token'];
|
|
|
|
|
|
|
|
|
|
$this->socialAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::LinkedIn,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
PostPlatform::factory()->linkedin()->create([
|
|
|
|
|
'post_id' => $this->post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
'enabled' => true,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Storage::fake();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('attaches media from url', function () {
|
|
|
|
|
Http::fake([
|
refactor: move URL validation to the request layer with active_url
The MediaAttacher used to roll its own SSRF guard with DNS resolution
and a static fakeUrlSafety() flag for tests. Validating URLs is a
request-layer concern, not a service-layer one. Laravel ships
'active_url' which does the same DNS resolvability check via
dns_get_record — applying it at the FormRequest / MCP validate() level
catches dead URLs upfront with a proper 422 instead of letting the
download silently fail.
- Replace the inline 'urls.*' => ['url:http,https'] rule with
['url:http,https', 'active_url'] in both Api/PostController::attachMedia
and Mcp/Tools/Post/AttachMediaFromUrlTool.
- Drop isUrlSafe(), fakeUrlSafety(), resetUrlSafety(), $skipUrlSafety
from MediaAttacher. The remaining defenses (Http::sink streaming +
progress abort at MAX_BYTES, allow_redirects: false, MIME allowlist)
cover the operational concerns.
- Restore tests/TestCase to the original setUp — no SSRF bypass needed
anymore because active_url is satisfied by the test hosts.
- Swap synthetic test hosts (cdn.example.com / evil.example.com) for
example.com / example.org. Both are RFC-reserved AND have stable A
records, so active_url accepts them while Http::fake() still
intercepts the actual request.
For SSRF defense beyond 'active_url' (which doesn't block private IPs),
trypost relies on production network egress controls. Open-source
self-hosters who run without a firewall accept the corresponding risk;
that's a deployment concern, not a request validation concern.
2026-05-04 17:35:28 +00:00
|
|
|
'example.com/photo.png' => Http::response(
|
feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
|
|
|
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'image/png'],
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
2026-05-04 21:00:03 +00:00
|
|
|
->postJson(route('api.posts.attach-media-from-url', $this->post), [
|
Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.
Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.
Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.
Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.
Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 16:55:33 +00:00
|
|
|
'urls' => [['url' => 'https://example.com/photo.png']],
|
feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
|
|
|
])
|
|
|
|
|
->assertOk()
|
|
|
|
|
->assertJsonPath('attached_count', 1)
|
|
|
|
|
->assertJsonPath('failed_urls', []);
|
|
|
|
|
|
|
|
|
|
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1);
|
|
|
|
|
expect($this->post->fresh()->media)->toHaveCount(1);
|
|
|
|
|
});
|
|
|
|
|
|
Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.
Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.
Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.
Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.
Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 16:55:33 +00:00
|
|
|
it('attaches media from url with alt text', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com/photo.png' => Http::response(
|
|
|
|
|
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'image/png'],
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->postJson(route('api.posts.attach-media-from-url', $this->post), [
|
|
|
|
|
'urls' => [['url' => 'https://example.com/photo.png', 'alt' => 'A red bicycle by a wall']],
|
|
|
|
|
])
|
|
|
|
|
->assertOk()
|
|
|
|
|
->assertJsonPath('attached_count', 1);
|
|
|
|
|
|
|
|
|
|
expect(data_get($this->post->fresh()->media, '0.meta.alt_text'))->toBe('A red bicycle by a wall');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not store alt text on a non-image url', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com/deck.pdf' => Http::response(
|
|
|
|
|
"%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n",
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'application/pdf'],
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->postJson(route('api.posts.attach-media-from-url', $this->post), [
|
|
|
|
|
'urls' => [['url' => 'https://example.com/deck.pdf', 'alt' => 'alt is meaningless for a pdf']],
|
|
|
|
|
])
|
|
|
|
|
->assertOk()
|
|
|
|
|
->assertJsonPath('attached_count', 1);
|
|
|
|
|
|
|
|
|
|
expect(data_get($this->post->fresh()->media, '0.type'))->toBe('document')
|
|
|
|
|
->and(data_get($this->post->fresh()->media, '0.meta'))->toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects alt text over the max length on a url', function () {
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->postJson(route('api.posts.attach-media-from-url', $this->post), [
|
|
|
|
|
'urls' => [['url' => 'https://example.com/photo.png', 'alt' => str_repeat('a', 2001)]],
|
|
|
|
|
])
|
|
|
|
|
->assertUnprocessable()
|
|
|
|
|
->assertJsonValidationErrors(['urls.0.alt']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects the old bare-string urls shape', function () {
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->postJson(route('api.posts.attach-media-from-url', $this->post), [
|
|
|
|
|
'urls' => ['https://example.com/photo.png'],
|
|
|
|
|
])
|
|
|
|
|
->assertUnprocessable()
|
|
|
|
|
->assertJsonValidationErrors(['urls.0.url']);
|
|
|
|
|
});
|
|
|
|
|
|
feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
|
|
|
it('reports failures for unreachable urls', function () {
|
|
|
|
|
Http::fake([
|
refactor: move URL validation to the request layer with active_url
The MediaAttacher used to roll its own SSRF guard with DNS resolution
and a static fakeUrlSafety() flag for tests. Validating URLs is a
request-layer concern, not a service-layer one. Laravel ships
'active_url' which does the same DNS resolvability check via
dns_get_record — applying it at the FormRequest / MCP validate() level
catches dead URLs upfront with a proper 422 instead of letting the
download silently fail.
- Replace the inline 'urls.*' => ['url:http,https'] rule with
['url:http,https', 'active_url'] in both Api/PostController::attachMedia
and Mcp/Tools/Post/AttachMediaFromUrlTool.
- Drop isUrlSafe(), fakeUrlSafety(), resetUrlSafety(), $skipUrlSafety
from MediaAttacher. The remaining defenses (Http::sink streaming +
progress abort at MAX_BYTES, allow_redirects: false, MIME allowlist)
cover the operational concerns.
- Restore tests/TestCase to the original setUp — no SSRF bypass needed
anymore because active_url is satisfied by the test hosts.
- Swap synthetic test hosts (cdn.example.com / evil.example.com) for
example.com / example.org. Both are RFC-reserved AND have stable A
records, so active_url accepts them while Http::fake() still
intercepts the actual request.
For SSRF defense beyond 'active_url' (which doesn't block private IPs),
trypost relies on production network egress controls. Open-source
self-hosters who run without a firewall accept the corresponding risk;
that's a deployment concern, not a request validation concern.
2026-05-04 17:35:28 +00:00
|
|
|
'example.com/missing.png' => Http::response(null, 404),
|
feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
2026-05-04 21:00:03 +00:00
|
|
|
->postJson(route('api.posts.attach-media-from-url', $this->post), [
|
Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.
Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.
Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.
Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.
Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 16:55:33 +00:00
|
|
|
'urls' => [['url' => 'https://example.com/missing.png']],
|
feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
|
|
|
])
|
|
|
|
|
->assertOk()
|
|
|
|
|
->assertJsonPath('attached_count', 0)
|
refactor: move URL validation to the request layer with active_url
The MediaAttacher used to roll its own SSRF guard with DNS resolution
and a static fakeUrlSafety() flag for tests. Validating URLs is a
request-layer concern, not a service-layer one. Laravel ships
'active_url' which does the same DNS resolvability check via
dns_get_record — applying it at the FormRequest / MCP validate() level
catches dead URLs upfront with a proper 422 instead of letting the
download silently fail.
- Replace the inline 'urls.*' => ['url:http,https'] rule with
['url:http,https', 'active_url'] in both Api/PostController::attachMedia
and Mcp/Tools/Post/AttachMediaFromUrlTool.
- Drop isUrlSafe(), fakeUrlSafety(), resetUrlSafety(), $skipUrlSafety
from MediaAttacher. The remaining defenses (Http::sink streaming +
progress abort at MAX_BYTES, allow_redirects: false, MIME allowlist)
cover the operational concerns.
- Restore tests/TestCase to the original setUp — no SSRF bypass needed
anymore because active_url is satisfied by the test hosts.
- Swap synthetic test hosts (cdn.example.com / evil.example.com) for
example.com / example.org. Both are RFC-reserved AND have stable A
records, so active_url accepts them while Http::fake() still
intercepts the actual request.
For SSRF defense beyond 'active_url' (which doesn't block private IPs),
trypost relies on production network egress controls. Open-source
self-hosters who run without a firewall accept the corresponding risk;
that's a deployment concern, not a request validation concern.
2026-05-04 17:35:28 +00:00
|
|
|
->assertJsonPath('failed_urls.0', 'https://example.com/missing.png');
|
feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('cannot attach media to a post from another workspace', function () {
|
|
|
|
|
$other = Workspace::factory()->create();
|
|
|
|
|
$post = Post::factory()->create(['workspace_id' => $other->id, 'user_id' => $this->user->id]);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
2026-05-04 21:00:03 +00:00
|
|
|
->postJson(route('api.posts.attach-media-from-url', $post), [
|
Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.
Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.
Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.
Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.
Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 16:55:33 +00:00
|
|
|
'urls' => [['url' => 'https://example.com/photo.png']],
|
feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
|
|
|
])
|
|
|
|
|
->assertNotFound();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('previews per platform with sanitized content and length', function () {
|
|
|
|
|
$this->post->update(['content' => str_repeat('a', 500)]);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->getJson(route('api.posts.preview', $this->post))
|
|
|
|
|
->assertOk()
|
|
|
|
|
->assertJsonStructure([
|
|
|
|
|
'post_id',
|
|
|
|
|
'original_content',
|
|
|
|
|
'original_length',
|
|
|
|
|
'platforms' => [
|
|
|
|
|
'*' => [
|
|
|
|
|
'post_platform_id',
|
|
|
|
|
'platform',
|
|
|
|
|
'content_type',
|
|
|
|
|
'sanitized_content',
|
|
|
|
|
'sanitized_length',
|
|
|
|
|
'max_content_length',
|
|
|
|
|
'truncated',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
])
|
|
|
|
|
->assertJsonPath('original_length', 500);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns metrics shape including unsupported reason for unpublished platforms', function () {
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->getJson(route('api.posts.metrics', $this->post))
|
|
|
|
|
->assertOk()
|
|
|
|
|
->assertJsonStructure([
|
|
|
|
|
'post_id',
|
|
|
|
|
'platforms' => [
|
|
|
|
|
'*' => [
|
|
|
|
|
'post_platform_id',
|
|
|
|
|
'platform',
|
|
|
|
|
'status',
|
|
|
|
|
'platform_post_id',
|
|
|
|
|
'platform_url',
|
|
|
|
|
'metrics',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
])
|
|
|
|
|
->assertJsonPath('platforms.0.metrics.unsupported', true)
|
|
|
|
|
->assertJsonPath('platforms.0.metrics.reason', 'not_published');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('cannot get metrics from another workspace post', function () {
|
|
|
|
|
$other = Workspace::factory()->create();
|
|
|
|
|
$post = Post::factory()->create(['workspace_id' => $other->id, 'user_id' => $this->user->id]);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->getJson(route('api.posts.metrics', $post))
|
|
|
|
|
->assertNotFound();
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->getJson(route('api.posts.preview', $post))
|
|
|
|
|
->assertNotFound();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects attach media payload without urls', function () {
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
2026-05-04 21:00:03 +00:00
|
|
|
->postJson(route('api.posts.attach-media-from-url', $this->post), [])
|
feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
|
|
|
->assertUnprocessable()
|
|
|
|
|
->assertJsonValidationErrors(['urls']);
|
|
|
|
|
});
|
2026-05-04 21:00:03 +00:00
|
|
|
|
|
|
|
|
it('uploads a media file and attaches it to the post', function () {
|
|
|
|
|
$file = UploadedFile::fake()->createWithContent(
|
|
|
|
|
'photo.png',
|
|
|
|
|
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken, 'Accept' => 'application/json'])
|
|
|
|
|
->post(route('api.posts.store-media', $this->post), ['media' => $file])
|
|
|
|
|
->assertOk();
|
|
|
|
|
|
|
|
|
|
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1);
|
|
|
|
|
expect($this->post->fresh()->media)->toHaveCount(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects upload of an unsupported mime type', function () {
|
|
|
|
|
$file = UploadedFile::fake()->createWithContent('doc.txt', 'plain text content');
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken, 'Accept' => 'application/json'])
|
|
|
|
|
->post(route('api.posts.store-media', $this->post), ['media' => $file])
|
|
|
|
|
->assertUnprocessable()
|
|
|
|
|
->assertJsonValidationErrors(['media']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects upload when the file type is not supported by enabled platforms', function () {
|
|
|
|
|
$tiktokAccount = SocialAccount::factory()->tiktok()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$tiktokOnlyPost = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
PostPlatform::factory()->tiktok()->create([
|
|
|
|
|
'post_id' => $tiktokOnlyPost->id,
|
|
|
|
|
'social_account_id' => $tiktokAccount->id,
|
|
|
|
|
'enabled' => true,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$file = UploadedFile::fake()->createWithContent(
|
|
|
|
|
'photo.png',
|
|
|
|
|
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken, 'Accept' => 'application/json'])
|
|
|
|
|
->post(route('api.posts.store-media', $tiktokOnlyPost), ['media' => $file])
|
|
|
|
|
->assertUnprocessable()
|
|
|
|
|
->assertJsonValidationErrors(['media']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('cannot upload media to a post from another workspace', function () {
|
|
|
|
|
$other = Workspace::factory()->create();
|
|
|
|
|
$post = Post::factory()->create(['workspace_id' => $other->id, 'user_id' => $this->user->id]);
|
|
|
|
|
|
|
|
|
|
$file = UploadedFile::fake()->createWithContent(
|
|
|
|
|
'photo.png',
|
|
|
|
|
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken, 'Accept' => 'application/json'])
|
|
|
|
|
->post(route('api.posts.store-media', $post), ['media' => $file])
|
|
|
|
|
->assertNotFound();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects upload without a media file', function () {
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->postJson(route('api.posts.store-media', $this->post), [])
|
|
|
|
|
->assertUnprocessable()
|
|
|
|
|
->assertJsonValidationErrors(['media']);
|
|
|
|
|
});
|
2026-06-28 20:28:05 +00:00
|
|
|
|
|
|
|
|
it('downloads and hosts an external media url when creating a post', function () {
|
|
|
|
|
$this->socialAccount->update(['is_active' => true]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
2026-07-17 18:40:15 +00:00
|
|
|
'93.184.216.34/listing.jpg' => Http::response(
|
2026-06-28 20:28:05 +00:00
|
|
|
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'image/png'],
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->postJson(route('api.posts.store'), [
|
|
|
|
|
'content' => 'External media post',
|
2026-07-17 18:40:15 +00:00
|
|
|
'media' => [['url' => 'https://93.184.216.34/listing.jpg']],
|
2026-06-28 20:28:05 +00:00
|
|
|
'platforms' => [
|
|
|
|
|
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
|
|
|
|
|
],
|
|
|
|
|
])
|
|
|
|
|
->assertCreated();
|
|
|
|
|
|
|
|
|
|
$media = Post::where('content', 'External media post')->firstOrFail()->media;
|
|
|
|
|
|
|
|
|
|
expect($media)->toHaveCount(1)
|
|
|
|
|
->and(data_get($media, '0.path'))->not->toBeNull()
|
2026-07-17 18:40:15 +00:00
|
|
|
->and(data_get($media, '0.url'))->not->toContain('93.184.216.34');
|
2026-06-28 20:28:05 +00:00
|
|
|
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.
Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.
Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.
Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.
Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 16:55:33 +00:00
|
|
|
it('persists alt text submitted on a bare external media url', function () {
|
|
|
|
|
$this->socialAccount->update(['is_active' => true]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
2026-07-17 18:40:15 +00:00
|
|
|
'93.184.216.34/car.jpg' => Http::response(
|
Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.
Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.
Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.
Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.
Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 16:55:33 +00:00
|
|
|
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'image/png'],
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->postJson(route('api.posts.store'), [
|
|
|
|
|
'content' => 'External alt post',
|
|
|
|
|
'media' => [[
|
2026-07-17 18:40:15 +00:00
|
|
|
'url' => 'https://93.184.216.34/car.jpg',
|
Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.
Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.
Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.
Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.
Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 16:55:33 +00:00
|
|
|
'meta' => ['alt_text' => 'A red car parked on a hill'],
|
|
|
|
|
]],
|
|
|
|
|
'platforms' => [
|
|
|
|
|
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
|
|
|
|
|
],
|
|
|
|
|
])
|
|
|
|
|
->assertCreated();
|
|
|
|
|
|
|
|
|
|
$media = Post::where('content', 'External alt post')->firstOrFail()->media;
|
|
|
|
|
|
|
|
|
|
expect(data_get($media, '0.meta.alt_text'))->toBe('A red car parked on a hill')
|
|
|
|
|
->and(data_get($media, '0.path'))->not->toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-28 20:28:05 +00:00
|
|
|
it('rejects creating a post when an external media url cannot be fetched', function () {
|
|
|
|
|
$this->socialAccount->update(['is_active' => true]);
|
|
|
|
|
|
2026-07-17 18:40:15 +00:00
|
|
|
Http::fake(['93.184.216.34/missing.jpg' => Http::response(null, 404)]);
|
2026-06-28 20:28:05 +00:00
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->postJson(route('api.posts.store'), [
|
|
|
|
|
'content' => 'Broken media post',
|
2026-07-17 18:40:15 +00:00
|
|
|
'media' => [['url' => 'https://93.184.216.34/missing.jpg']],
|
2026-06-28 20:28:05 +00:00
|
|
|
'platforms' => [
|
|
|
|
|
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
|
|
|
|
|
],
|
|
|
|
|
])
|
|
|
|
|
->assertUnprocessable()
|
|
|
|
|
->assertJsonValidationErrors(['media']);
|
|
|
|
|
|
|
|
|
|
expect(Post::where('content', 'Broken media post')->exists())->toBeFalse();
|
|
|
|
|
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-28 23:59:21 +00:00
|
|
|
it('rolls back already-hosted media when another url in the batch fails', function () {
|
|
|
|
|
$this->socialAccount->update(['is_active' => true]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
2026-07-17 18:40:15 +00:00
|
|
|
'93.184.216.34/good.jpg' => Http::response(
|
2026-06-28 23:59:21 +00:00
|
|
|
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'image/png'],
|
|
|
|
|
),
|
2026-07-17 18:40:15 +00:00
|
|
|
'93.184.216.34/missing.jpg' => Http::response(null, 404),
|
2026-06-28 23:59:21 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->postJson(route('api.posts.store'), [
|
|
|
|
|
'content' => 'Partial media post',
|
|
|
|
|
'media' => [
|
2026-07-17 18:40:15 +00:00
|
|
|
['url' => 'https://93.184.216.34/good.jpg'],
|
|
|
|
|
['url' => 'https://93.184.216.34/missing.jpg'],
|
2026-06-28 23:59:21 +00:00
|
|
|
],
|
|
|
|
|
'platforms' => [
|
|
|
|
|
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
|
|
|
|
|
],
|
|
|
|
|
])
|
|
|
|
|
->assertUnprocessable()
|
|
|
|
|
->assertJsonValidationErrors(['media']);
|
|
|
|
|
|
|
|
|
|
expect(Post::where('content', 'Partial media post')->exists())->toBeFalse();
|
|
|
|
|
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-29 16:41:47 +00:00
|
|
|
it('rejects and rolls back when a media url connection fails (timeout/dns)', function () {
|
|
|
|
|
$this->socialAccount->update(['is_active' => true]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
2026-07-17 18:40:15 +00:00
|
|
|
'93.184.216.34/good.jpg' => Http::response(
|
2026-06-29 16:41:47 +00:00
|
|
|
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'image/png'],
|
|
|
|
|
),
|
2026-07-17 18:40:15 +00:00
|
|
|
'93.184.216.34/timeout.jpg' => fn () => throw new ConnectionException('Connection timed out'),
|
2026-06-29 16:41:47 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->postJson(route('api.posts.store'), [
|
|
|
|
|
'content' => 'Timeout media post',
|
|
|
|
|
'media' => [
|
2026-07-17 18:40:15 +00:00
|
|
|
['url' => 'https://93.184.216.34/good.jpg'],
|
|
|
|
|
['url' => 'https://93.184.216.34/timeout.jpg'],
|
2026-06-29 16:41:47 +00:00
|
|
|
],
|
|
|
|
|
'platforms' => [
|
|
|
|
|
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
|
|
|
|
|
],
|
|
|
|
|
])
|
|
|
|
|
->assertUnprocessable()
|
|
|
|
|
->assertJsonValidationErrors(['media']);
|
|
|
|
|
|
|
|
|
|
expect(Post::where('content', 'Timeout media post')->exists())->toBeFalse();
|
|
|
|
|
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-29 00:15:01 +00:00
|
|
|
it('rejects creating a post when an external media url is not a supported type', function () {
|
|
|
|
|
$this->socialAccount->update(['is_active' => true]);
|
|
|
|
|
|
|
|
|
|
// Downloads fine (200) but the bytes are not a supported media type.
|
2026-07-17 18:40:15 +00:00
|
|
|
Http::fake(['93.184.216.34/notes.txt' => Http::response('just some text', 200)]);
|
2026-06-29 00:15:01 +00:00
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->postJson(route('api.posts.store'), [
|
|
|
|
|
'content' => 'Bad type post',
|
2026-07-17 18:40:15 +00:00
|
|
|
'media' => [['url' => 'https://93.184.216.34/notes.txt']],
|
2026-06-29 00:15:01 +00:00
|
|
|
'platforms' => [
|
|
|
|
|
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
|
|
|
|
|
],
|
|
|
|
|
])
|
|
|
|
|
->assertUnprocessable()
|
|
|
|
|
->assertJsonValidationErrors(['media']);
|
|
|
|
|
|
|
|
|
|
expect(Post::where('content', 'Bad type post')->exists())->toBeFalse();
|
|
|
|
|
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps an already-hosted item and a freshly-hosted url in order', function () {
|
|
|
|
|
$this->socialAccount->update(['is_active' => true]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
2026-07-17 18:40:15 +00:00
|
|
|
'93.184.216.34/external.jpg' => Http::response(
|
2026-06-29 00:15:01 +00:00
|
|
|
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'image/png'],
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->postJson(route('api.posts.store'), [
|
|
|
|
|
'content' => 'Mixed media post',
|
|
|
|
|
'media' => [
|
|
|
|
|
['id' => 'hosted-1', 'path' => 'assets/already.jpg', 'url' => 'https://cdn.trypost.test/assets/already.jpg', 'type' => 'image'],
|
2026-07-17 18:40:15 +00:00
|
|
|
['url' => 'https://93.184.216.34/external.jpg'],
|
2026-06-29 00:15:01 +00:00
|
|
|
],
|
|
|
|
|
'platforms' => [
|
|
|
|
|
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
|
|
|
|
|
],
|
|
|
|
|
])
|
|
|
|
|
->assertCreated();
|
|
|
|
|
|
|
|
|
|
$media = Post::where('content', 'Mixed media post')->firstOrFail()->media;
|
|
|
|
|
|
|
|
|
|
expect($media)->toHaveCount(2)
|
|
|
|
|
->and(data_get($media, '0.path'))->toBe('assets/already.jpg')
|
2026-07-17 18:40:15 +00:00
|
|
|
->and(data_get($media, '1.url'))->not->toContain('93.184.216.34')
|
2026-06-29 00:15:01 +00:00
|
|
|
->and(data_get($media, '1.path'))->not->toBeNull();
|
|
|
|
|
// Only the external URL is hosted; the passed-through item creates no new row.
|
|
|
|
|
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-28 20:28:05 +00:00
|
|
|
it('passes already-hosted media through on create without downloading', function () {
|
|
|
|
|
$this->socialAccount->update(['is_active' => true]);
|
|
|
|
|
Http::preventStrayRequests();
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->postJson(route('api.posts.store'), [
|
|
|
|
|
'content' => 'Hosted media post',
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'media-1',
|
|
|
|
|
'path' => 'assets/foo.jpg',
|
|
|
|
|
'url' => 'https://cdn.trypost.test/assets/foo.jpg',
|
|
|
|
|
'type' => 'image',
|
|
|
|
|
]],
|
|
|
|
|
'platforms' => [
|
|
|
|
|
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
|
|
|
|
|
],
|
|
|
|
|
])
|
|
|
|
|
->assertCreated();
|
|
|
|
|
|
|
|
|
|
expect(data_get(Post::where('content', 'Hosted media post')->firstOrFail()->media, '0.path'))->toBe('assets/foo.jpg');
|
|
|
|
|
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('downloads and hosts an external media url when updating a post', function () {
|
|
|
|
|
Http::fake([
|
2026-07-17 18:40:15 +00:00
|
|
|
'93.184.216.34/listing.jpg' => Http::response(
|
2026-06-28 20:28:05 +00:00
|
|
|
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'image/png'],
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->putJson(route('api.posts.update', $this->post), [
|
|
|
|
|
'status' => 'draft',
|
2026-07-17 18:40:15 +00:00
|
|
|
'media' => [['url' => 'https://93.184.216.34/listing.jpg']],
|
2026-06-28 20:28:05 +00:00
|
|
|
])
|
|
|
|
|
->assertOk();
|
|
|
|
|
|
|
|
|
|
$media = $this->post->fresh()->media;
|
|
|
|
|
|
|
|
|
|
expect($media)->toHaveCount(1)
|
|
|
|
|
->and(data_get($media, '0.path'))->not->toBeNull()
|
2026-07-17 18:40:15 +00:00
|
|
|
->and(data_get($media, '0.url'))->not->toContain('93.184.216.34');
|
2026-06-28 20:28:05 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects updating a post when an external media url cannot be fetched', function () {
|
2026-07-17 18:40:15 +00:00
|
|
|
Http::fake(['93.184.216.34/missing.jpg' => Http::response(null, 404)]);
|
2026-06-28 20:28:05 +00:00
|
|
|
|
|
|
|
|
$original = $this->post->fresh()->media;
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->putJson(route('api.posts.update', $this->post), [
|
|
|
|
|
'status' => 'draft',
|
2026-07-17 18:40:15 +00:00
|
|
|
'media' => [['url' => 'https://93.184.216.34/missing.jpg']],
|
2026-06-28 20:28:05 +00:00
|
|
|
])
|
|
|
|
|
->assertUnprocessable()
|
|
|
|
|
->assertJsonValidationErrors(['media']);
|
|
|
|
|
|
|
|
|
|
expect($this->post->fresh()->media)->toBe($original);
|
|
|
|
|
});
|
2026-07-11 13:34:57 +00:00
|
|
|
|
|
|
|
|
it('accepts and persists media alt text on create', function () {
|
|
|
|
|
$this->socialAccount->update(['is_active' => true]);
|
|
|
|
|
Http::preventStrayRequests();
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->postJson(route('api.posts.store'), [
|
|
|
|
|
'content' => 'Alt text post',
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'media-1',
|
|
|
|
|
'path' => 'assets/foo.jpg',
|
|
|
|
|
'url' => 'https://cdn.trypost.test/assets/foo.jpg',
|
|
|
|
|
'type' => 'image',
|
|
|
|
|
'meta' => ['alt_text' => 'A description of the photo'],
|
|
|
|
|
]],
|
|
|
|
|
'platforms' => [
|
|
|
|
|
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
|
|
|
|
|
],
|
|
|
|
|
])
|
|
|
|
|
->assertCreated();
|
|
|
|
|
|
|
|
|
|
$media = Post::where('content', 'Alt text post')->firstOrFail()->media;
|
|
|
|
|
|
|
|
|
|
expect(data_get($media, '0.meta.alt_text'))->toBe('A description of the photo');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('accepts and persists media alt text on update', function () {
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->putJson(route('api.posts.update', $this->post), [
|
|
|
|
|
'status' => 'draft',
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'media-1',
|
|
|
|
|
'path' => 'assets/foo.jpg',
|
|
|
|
|
'url' => 'https://cdn.trypost.test/assets/foo.jpg',
|
|
|
|
|
'type' => 'image',
|
|
|
|
|
'meta' => ['alt_text' => 'Updated alt text'],
|
|
|
|
|
]],
|
|
|
|
|
])
|
|
|
|
|
->assertOk();
|
|
|
|
|
|
|
|
|
|
expect(data_get($this->post->fresh()->media, '0.meta.alt_text'))->toBe('Updated alt text');
|
|
|
|
|
});
|
|
|
|
|
|
Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.
Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.
Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.
Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.
Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 16:55:33 +00:00
|
|
|
it('preserves every media meta key on update, not just alt_text', function () {
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->putJson(route('api.posts.update', $this->post), [
|
|
|
|
|
'status' => 'draft',
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'media-1',
|
|
|
|
|
'path' => 'assets/foo.jpg',
|
|
|
|
|
'url' => 'https://cdn.trypost.test/assets/foo.jpg',
|
|
|
|
|
'type' => 'image',
|
|
|
|
|
'meta' => [
|
|
|
|
|
'width' => 1920,
|
|
|
|
|
'height' => 1080,
|
|
|
|
|
'duration' => 30,
|
|
|
|
|
'alt_text' => 'A description of the photo',
|
|
|
|
|
],
|
|
|
|
|
]],
|
|
|
|
|
])
|
|
|
|
|
->assertOk();
|
|
|
|
|
|
|
|
|
|
expect(data_get($this->post->fresh()->media, '0.meta'))->toMatchArray([
|
|
|
|
|
'width' => 1920,
|
|
|
|
|
'height' => 1080,
|
|
|
|
|
'duration' => 30,
|
|
|
|
|
'alt_text' => 'A description of the photo',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-11 13:34:57 +00:00
|
|
|
it('rejects media alt text over 2000 characters', function () {
|
|
|
|
|
$this->socialAccount->update(['is_active' => true]);
|
|
|
|
|
Http::preventStrayRequests();
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->postJson(route('api.posts.store'), [
|
|
|
|
|
'content' => 'Alt text too long post',
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'media-1',
|
|
|
|
|
'path' => 'assets/foo.jpg',
|
|
|
|
|
'url' => 'https://cdn.trypost.test/assets/foo.jpg',
|
|
|
|
|
'type' => 'image',
|
|
|
|
|
'meta' => ['alt_text' => str_repeat('a', 2001)],
|
|
|
|
|
]],
|
|
|
|
|
'platforms' => [
|
|
|
|
|
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
|
|
|
|
|
],
|
|
|
|
|
])
|
|
|
|
|
->assertUnprocessable()
|
Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.
Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.
Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.
Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.
Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 16:55:33 +00:00
|
|
|
->assertJsonValidationErrors(['media.0.meta']);
|
2026-07-11 13:34:57 +00:00
|
|
|
|
|
|
|
|
expect(Post::where('content', 'Alt text too long post')->exists())->toBeFalse();
|
|
|
|
|
});
|