Add an image field, required for WordPress posts

Posts get a nullable image_url. WordPressPublisher now downloads it
and uploads it to WordPress's media endpoint before creating the
post, setting it as featured_media — and rejects publishing (without
touching status) if a blog post has no image set, mirroring the
existing title requirement. Every other network treats it as optional.

Not enforced at save time, same as title — you can still draft an
incomplete WordPress post, it just can't be published until an image
is set. Exposed through the UI form (with a thumbnail/""missing""
indicator in the table), the JSON API, and the Python CLI wrapper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
m105
2026-07-21 20:03:25 +03:00
co-authored by Claude Sonnet 5
parent fd185ab813
commit 42abc658f2
14 changed files with 259 additions and 28 deletions
+21 -13
View File
@@ -37,6 +37,8 @@ SocialAccount (login for a social network)
Post (one piece of content, belongs to a Site, optionally a SocialAccount) Post (one piece of content, belongs to a Site, optionally a SocialAccount)
└─ type: same vocabulary as SocialAccount.network, plus "blog" └─ type: same vocabulary as SocialAccount.network, plus "blog"
└─ target: nullable, network-specific destination (e.g. a subreddit for type=reddit) └─ target: nullable, network-specific destination (e.g. a subreddit for type=reddit)
└─ image_url: nullable URL. Required to publish type=blog (WordPress) —
used as the featured image. Optional for every other type.
└─ status: draft -> scheduled -> publishing -> published | failed └─ status: draft -> scheduled -> publishing -> published | failed
└─ published_url: set once it's actually live somewhere └─ published_url: set once it's actually live somewhere
└─ publish_error: set to the failure reason when status=failed (or when a └─ publish_error: set to the failure reason when status=failed (or when a
@@ -145,6 +147,7 @@ Body:
"title": "Optional title", "title": "Optional title",
"body": "Required content", "body": "Required content",
"target": null, "target": null,
"image_url": null,
"status": "draft", "status": "draft",
"scheduled_at": null "scheduled_at": null
} }
@@ -153,8 +156,10 @@ Validation: `site_id` required, must exist. `social_account_id` nullable,
must exist if given. `type` required, one of `twitter`, `instagram`, must exist if given. `type` required, one of `twitter`, `instagram`,
`facebook`, `reddit`, `tiktok`, `linkedin`, `youtube`, `blog`, `other`. `facebook`, `reddit`, `tiktok`, `linkedin`, `youtube`, `blog`, `other`.
`body` required. `title` nullable. `target` nullable — a network-specific `body` required. `title` nullable. `target` nullable — a network-specific
destination, e.g. the subreddit name for `type=reddit`. `status` defaults to destination, e.g. the subreddit name for `type=reddit`. `image_url` nullable
`draft` if omitted. `scheduled_at` nullable datetime. URL — not enforced at save time (you can draft a `type=blog` post without one),
but `WordPressPublisher` will reject publishing it until it's set. `status`
defaults to `draft` if omitted. `scheduled_at` nullable datetime.
Note: `type` and the linked Social Account's `network` are **not** Note: `type` and the linked Social Account's `network` are **not**
cross-validated — you can link a `type=blog` post to a Twitter account, or cross-validated — you can link a `type=blog` post to a Twitter account, or
@@ -192,7 +197,7 @@ who/what is driving:
- **Manually**, for anything without a wired-in publisher yet: do it - **Manually**, for anything without a wired-in publisher yet: do it
yourself and `PATCH` the result back (see below). yourself and `PATCH` the result back (see below).
Note the asymmetry: WordPress is synchronous (a single HTTP call, fast Note the asymmetry: WordPress is synchronous (a few plain HTTP calls, fast
enough to run inline), Reddit is not (it's real browser automation via enough to run inline), Reddit is not (it's real browser automation via
Playwright, tens of seconds) — which is exactly why the UI path is Playwright, tens of seconds) — which is exactly why the UI path is
queue-backed rather than a direct synchronous call. queue-backed rather than a direct synchronous call.
@@ -212,17 +217,20 @@ php artisan posts:publish {post_id}
What it does: What it does:
1. Loads the Post and its Site. 1. Loads the Post and its Site.
2. Validates: `post.type` must be `blog`, `post.title` must be set, 2. Validates: `post.type` must be `blog`, `post.title` must be set,
`site.cms_type` must be `wordpress`, and the site must have `post.image_url` must be set, `site.cms_type` must be `wordpress`, and
`wp_username`/`wp_app_password` set. Any failure here exits 1 with a the site must have `wp_username`/`wp_app_password` set. Any failure here
descriptive message and **does not touch** `Post.status`. exits 1 with a descriptive message and **does not touch** `Post.status`.
3. POSTs to `{site.url}/wp-json/wp/v2/posts` using HTTP Basic Auth with 3. Downloads `post.image_url`, then POSTs it to
the site's WordPress Application Password, `status: publish` (goes live `{site.url}/wp-json/wp/v2/media` (HTTP Basic Auth with the site's
immediately, not a WP draft). WordPress Application Password) to upload it as media and get back a
4. On success: sets `Post.status = published`, `Post.published_url` to the media id.
4. POSTs to `{site.url}/wp-json/wp/v2/posts` with `status: publish` (goes
live immediately, not a WP draft) and `featured_media` set to that id.
5. On success: sets `Post.status = published`, `Post.published_url` to the
new post's permalink, prints `{"success": true, "url": "..."}`, exits 0. new post's permalink, prints `{"success": true, "url": "..."}`, exits 0.
5. On failure (bad credentials, unreachable site, WordPress error): sets 6. On failure at either HTTP step (bad credentials, unreachable site,
`Post.status = failed`, prints `{"success": false, "error": "..."}`, unreachable/rejected image, WordPress error): sets `Post.status = failed`,
exits 1. prints `{"success": false, "error": "..."}`, exits 1.
This is a **shell command**, not an HTTP endpoint — run it from wherever This is a **shell command**, not an HTTP endpoint — run it from wherever
you have shell access to this app (e.g. `php artisan posts:publish 42`). you have shell access to this app (e.g. `php artisan posts:publish 42`).
+4 -2
View File
@@ -26,8 +26,10 @@ Built with Laravel 13, Livewire, and Tailwind CSS.
(e.g. Reddit's browser automation) don't block the page. (e.g. Reddit's browser automation) don't block the page.
- Built-in publishers (`app/Support/Publishing/`), one per network, resolved - Built-in publishers (`app/Support/Publishing/`), one per network, resolved
by `Post.type`: by `Post.type`:
- **WordPress**: posts directly to a site's WordPress REST API. Also - **WordPress**: posts directly to a site's WordPress REST API, uploading
available synchronously from the CLI: `php artisan posts:publish {post_id}`. `Post.image_url` as the featured image first — required to publish a
WordPress post, optional for every other post type. Also available
synchronously from the CLI: `php artisan posts:publish {post_id}`.
- **Reddit**: drives a standalone Playwright sidecar at - **Reddit**: drives a standalone Playwright sidecar at
`sidecars/reddit-post/` that logs in and creates a post. `sidecars/reddit-post/` that logs in and creates a post.
- Anything else: no publisher yet — publish however fits, then report - Anything else: no publisher yet — publish however fits, then report
+1
View File
@@ -21,6 +21,7 @@ class StorePostRequest extends FormRequest
'title' => ['nullable', 'string', 'max:255'], 'title' => ['nullable', 'string', 'max:255'],
'body' => ['required', 'string'], 'body' => ['required', 'string'],
'target' => ['nullable', 'string', 'max:255'], 'target' => ['nullable', 'string', 'max:255'],
'image_url' => ['nullable', 'url', 'max:2048'],
'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)], 'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)],
'scheduled_at' => ['nullable', 'date'], 'scheduled_at' => ['nullable', 'date'],
]; ];
+1
View File
@@ -21,6 +21,7 @@ class UpdatePostRequest extends FormRequest
'title' => ['nullable', 'string', 'max:255'], 'title' => ['nullable', 'string', 'max:255'],
'body' => ['sometimes', 'required', 'string'], 'body' => ['sometimes', 'required', 'string'],
'target' => ['nullable', 'string', 'max:255'], 'target' => ['nullable', 'string', 'max:255'],
'image_url' => ['nullable', 'url', 'max:2048'],
'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)], 'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)],
'scheduled_at' => ['nullable', 'date'], 'scheduled_at' => ['nullable', 'date'],
'published_url' => ['nullable', 'string', 'max:255'], 'published_url' => ['nullable', 'string', 'max:255'],
+1
View File
@@ -17,6 +17,7 @@ class PostResource extends JsonResource
'title' => $this->title, 'title' => $this->title,
'body' => $this->body, 'body' => $this->body,
'target' => $this->target, 'target' => $this->target,
'image_url' => $this->image_url,
'status' => $this->status, 'status' => $this->status,
'scheduled_at' => $this->scheduled_at, 'scheduled_at' => $this->scheduled_at,
'published_url' => $this->published_url, 'published_url' => $this->published_url,
+6
View File
@@ -29,6 +29,8 @@ class Index extends Component
public string $target = ''; public string $target = '';
public string $imageUrl = '';
public string $status = 'draft'; public string $status = 'draft';
public string $scheduledAt = ''; public string $scheduledAt = '';
@@ -82,6 +84,7 @@ class Index extends Component
$this->title = (string) $post->title; $this->title = (string) $post->title;
$this->body = $post->body; $this->body = $post->body;
$this->target = (string) $post->target; $this->target = (string) $post->target;
$this->imageUrl = (string) $post->image_url;
$this->status = $post->status; $this->status = $post->status;
$this->scheduledAt = $post->scheduled_at?->format('Y-m-d\TH:i') ?? ''; $this->scheduledAt = $post->scheduled_at?->format('Y-m-d\TH:i') ?? '';
$this->showForm = true; $this->showForm = true;
@@ -96,6 +99,7 @@ class Index extends Component
'title' => ['nullable', 'string', 'max:255'], 'title' => ['nullable', 'string', 'max:255'],
'body' => ['required', 'string'], 'body' => ['required', 'string'],
'target' => ['nullable', 'string', 'max:255'], 'target' => ['nullable', 'string', 'max:255'],
'imageUrl' => ['nullable', 'url', 'max:2048'],
'status' => ['required', 'string', 'in:' . implode(',', Post::STATUSES)], 'status' => ['required', 'string', 'in:' . implode(',', Post::STATUSES)],
'scheduledAt' => ['nullable', 'date'], 'scheduledAt' => ['nullable', 'date'],
]); ]);
@@ -107,6 +111,7 @@ class Index extends Component
'title' => $validated['title'] ?: null, 'title' => $validated['title'] ?: null,
'body' => $validated['body'], 'body' => $validated['body'],
'target' => $validated['target'] ?: null, 'target' => $validated['target'] ?: null,
'image_url' => $validated['imageUrl'] ?: null,
'status' => $validated['status'], 'status' => $validated['status'],
'scheduled_at' => $validated['scheduledAt'] ?: null, 'scheduled_at' => $validated['scheduledAt'] ?: null,
]); ]);
@@ -129,6 +134,7 @@ class Index extends Component
$this->title = ''; $this->title = '';
$this->body = ''; $this->body = '';
$this->target = ''; $this->target = '';
$this->imageUrl = '';
$this->status = 'draft'; $this->status = 'draft';
$this->scheduledAt = ''; $this->scheduledAt = '';
$this->resetValidation(); $this->resetValidation();
+1
View File
@@ -24,6 +24,7 @@ class Post extends Model
'title', 'title',
'body', 'body',
'target', 'target',
'image_url',
'status', 'status',
'scheduled_at', 'scheduled_at',
'published_url', 'published_url',
@@ -3,6 +3,7 @@
namespace App\Support\Publishing; namespace App\Support\Publishing;
use App\Models\Post; use App\Models\Post;
use App\Models\Site;
use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@@ -19,6 +20,10 @@ class WordPressPublisher implements Publisher
return PublishResult::rejected("Post {$post->id} has no title, required to publish to WordPress."); return PublishResult::rejected("Post {$post->id} has no title, required to publish to WordPress.");
} }
if (! $post->image_url) {
return PublishResult::rejected("Post {$post->id} has no image, required to publish to WordPress.");
}
$site = $post->site; $site = $post->site;
if ($site->cms_type !== 'wordpress') { if ($site->cms_type !== 'wordpress') {
@@ -29,12 +34,19 @@ class WordPressPublisher implements Publisher
return PublishResult::rejected("Site '{$site->name}' is missing WordPress credentials."); return PublishResult::rejected("Site '{$site->name}' is missing WordPress credentials.");
} }
$mediaId = $this->uploadFeaturedImage($post, $site);
if ($mediaId instanceof PublishResult) {
return $mediaId;
}
try { try {
$response = Http::withBasicAuth($site->wp_username, $site->wp_app_password) $response = Http::withBasicAuth($site->wp_username, $site->wp_app_password)
->post(rtrim($site->url, '/').'/wp-json/wp/v2/posts', [ ->post(rtrim($site->url, '/').'/wp-json/wp/v2/posts', [
'title' => $post->title, 'title' => $post->title,
'content' => $post->body, 'content' => $post->body,
'status' => 'publish', 'status' => 'publish',
'featured_media' => $mediaId,
]); ]);
} catch (ConnectionException $e) { } catch (ConnectionException $e) {
return PublishResult::failed("could not connect to site: {$e->getMessage()}"); return PublishResult::failed("could not connect to site: {$e->getMessage()}");
@@ -52,4 +64,41 @@ class WordPressPublisher implements Publisher
return PublishResult::success($link); return PublishResult::success($link);
} }
private function uploadFeaturedImage(Post $post, Site $site): int|PublishResult
{
try {
$image = Http::get($post->image_url);
} catch (ConnectionException $e) {
return PublishResult::failed("could not download image: {$e->getMessage()}");
}
if ($image->failed()) {
return PublishResult::failed('could not download image: unexpected response from image URL');
}
$filename = basename(parse_url($post->image_url, PHP_URL_PATH) ?: '') ?: 'image.jpg';
$contentType = $image->header('Content-Type') ?: 'application/octet-stream';
try {
$response = Http::withBasicAuth($site->wp_username, $site->wp_app_password)
->withBody($image->body(), $contentType)
->withHeaders(['Content-Disposition' => "attachment; filename=\"{$filename}\""])
->post(rtrim($site->url, '/').'/wp-json/wp/v2/media');
} catch (ConnectionException $e) {
return PublishResult::failed("could not connect to site: {$e->getMessage()}");
}
if ($response->failed()) {
return PublishResult::failed($response->json('message') ?? 'unexpected response from WordPress while uploading the image');
}
$mediaId = $response->json('id');
if (! $mediaId) {
return PublishResult::failed('unexpected response from WordPress: missing media id');
}
return $mediaId;
}
} }
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->string('image_url')->nullable()->after('target');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->dropColumn('image_url');
});
}
};
+6 -2
View File
@@ -6,10 +6,10 @@ Usage:
python3 posts.py get POST_ID python3 posts.py get POST_ID
python3 posts.py create --site-id ID --type TYPE --body BODY python3 posts.py create --site-id ID --type TYPE --body BODY
[--social-account-id ID] [--title TITLE] [--target TARGET] [--social-account-id ID] [--title TITLE] [--target TARGET]
[--status STATUS] [--scheduled-at DATETIME] [--image-url URL] [--status STATUS] [--scheduled-at DATETIME]
python3 posts.py update POST_ID [--site-id ID] [--social-account-id ID] python3 posts.py update POST_ID [--site-id ID] [--social-account-id ID]
[--type TYPE] [--title TITLE] [--body BODY] [--target TARGET] [--type TYPE] [--title TITLE] [--body BODY] [--target TARGET]
[--status STATUS] [--scheduled-at DATETIME] [--image-url URL] [--status STATUS] [--scheduled-at DATETIME]
[--published-url URL] [--published-url URL]
python3 posts.py delete POST_ID python3 posts.py delete POST_ID
@@ -47,6 +47,7 @@ def main() -> None:
create_parser.add_argument("--social-account-id", type=int) create_parser.add_argument("--social-account-id", type=int)
create_parser.add_argument("--title") create_parser.add_argument("--title")
create_parser.add_argument("--target", help="Network-specific destination, e.g. a subreddit for Reddit posts") create_parser.add_argument("--target", help="Network-specific destination, e.g. a subreddit for Reddit posts")
create_parser.add_argument("--image-url", help="Image URL — required for WordPress (type=blog), optional otherwise")
create_parser.add_argument("--status", choices=STATUSES) create_parser.add_argument("--status", choices=STATUSES)
create_parser.add_argument("--scheduled-at", help="ISO 8601 datetime, e.g. 2026-07-20T09:00:00") create_parser.add_argument("--scheduled-at", help="ISO 8601 datetime, e.g. 2026-07-20T09:00:00")
@@ -58,6 +59,7 @@ def main() -> None:
update_parser.add_argument("--title") update_parser.add_argument("--title")
update_parser.add_argument("--body") update_parser.add_argument("--body")
update_parser.add_argument("--target", help="Network-specific destination, e.g. a subreddit for Reddit posts") update_parser.add_argument("--target", help="Network-specific destination, e.g. a subreddit for Reddit posts")
update_parser.add_argument("--image-url", help="Image URL — required for WordPress (type=blog), optional otherwise")
update_parser.add_argument("--status", choices=STATUSES) update_parser.add_argument("--status", choices=STATUSES)
update_parser.add_argument("--scheduled-at", help="ISO 8601 datetime") update_parser.add_argument("--scheduled-at", help="ISO 8601 datetime")
update_parser.add_argument("--published-url") update_parser.add_argument("--published-url")
@@ -88,6 +90,7 @@ def main() -> None:
"title": args.title, "title": args.title,
"body": args.body, "body": args.body,
"target": args.target, "target": args.target,
"image_url": args.image_url,
"status": args.status, "status": args.status,
"scheduled_at": args.scheduled_at, "scheduled_at": args.scheduled_at,
} }
@@ -102,6 +105,7 @@ def main() -> None:
"title": args.title, "title": args.title,
"body": args.body, "body": args.body,
"target": args.target, "target": args.target,
"image_url": args.image_url,
"status": args.status, "status": args.status,
"scheduled_at": args.scheduled_at, "scheduled_at": args.scheduled_at,
"published_url": args.published_url, "published_url": args.published_url,
@@ -11,6 +11,7 @@
<table class="w-full bg-white border border-gray-200 rounded"> <table class="w-full bg-white border border-gray-200 rounded">
<thead> <thead>
<tr class="text-left border-b border-gray-200"> <tr class="text-left border-b border-gray-200">
<th class="p-3">Image</th>
<th class="p-3">Site</th> <th class="p-3">Site</th>
<th class="p-3">Type</th> <th class="p-3">Type</th>
<th class="p-3">Title</th> <th class="p-3">Title</th>
@@ -23,6 +24,15 @@
<tbody> <tbody>
@foreach ($posts as $post) @foreach ($posts as $post)
<tr wire:key="post-{{ $post->id }}" class="border-b border-gray-100"> <tr wire:key="post-{{ $post->id }}" class="border-b border-gray-100">
<td class="p-3">
@if ($post->image_url)
<img src="{{ $post->image_url }}" alt="" class="w-10 h-10 object-cover rounded">
@elseif ($post->type === 'blog')
<span class="text-xs text-amber-600">missing</span>
@else
<span class="text-gray-400">—</span>
@endif
</td>
<td class="p-3">{{ $post->site->name }}</td> <td class="p-3">{{ $post->site->name }}</td>
<td class="p-3">{{ ucfirst($post->type) }}</td> <td class="p-3">{{ ucfirst($post->type) }}</td>
<td class="p-3">{{ $post->title ?: \Illuminate\Support\Str::limit($post->body, 40) }}</td> <td class="p-3">{{ $post->title ?: \Illuminate\Support\Str::limit($post->body, 40) }}</td>
@@ -106,6 +116,14 @@
@error('target') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror @error('target') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div> </div>
<div>
<label class="block text-sm font-medium">
Image URL {{ $type === 'blog' ? '(required to publish to WordPress)' : '(optional)' }}
</label>
<input type="url" wire:model="imageUrl" placeholder="https://..." class="w-full border rounded p-2">
@error('imageUrl') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
<div> <div>
<label class="block text-sm font-medium">Status</label> <label class="block text-sm font-medium">Status</label>
<select wire:model="status" class="w-full border rounded p-2"> <select wire:model="status" class="w-full border rounded p-2">
+19
View File
@@ -142,3 +142,22 @@ test('it includes published_url in the post response', function () {
$response->assertOk()->assertJsonPath('data.published_url', 'https://example.com/my-post'); $response->assertOk()->assertJsonPath('data.published_url', 'https://example.com/my-post');
}); });
test('image_url can be set on create and update', function () {
$site = Site::factory()->create();
$response = $this->postJson('/api/posts', [
'site_id' => $site->id,
'type' => 'blog',
'body' => 'content',
'image_url' => 'https://example.com/photo.jpg',
]);
$response->assertCreated()->assertJsonPath('data.image_url', 'https://example.com/photo.jpg');
$post = Post::latest('id')->first();
$this->patchJson("/api/posts/{$post->id}", ['image_url' => 'https://example.com/new-photo.jpg'])
->assertOk()
->assertJsonPath('data.image_url', 'https://example.com/new-photo.jpg');
});
+6 -2
View File
@@ -10,11 +10,13 @@ use Illuminate\Support\Facades\Process;
test('it publishes and updates the post on success', function () { test('it publishes and updates the post on success', function () {
Http::fake([ Http::fake([
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
'*/wp-json/wp/v2/media' => Http::response(['id' => 99], 201),
'*/wp-json/wp/v2/posts' => Http::response(['link' => 'https://wp.example.com/post'], 201), '*/wp-json/wp/v2/posts' => Http::response(['link' => 'https://wp.example.com/post'], 201),
]); ]);
$site = Site::factory()->wordpress()->create(); $site = Site::factory()->wordpress()->create();
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'publishing']); $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => 'https://images.example.com/photo.jpg', 'status' => 'publishing']);
(new PublishPostJob($post->id))->handle(app(PublisherManager::class)); (new PublishPostJob($post->id))->handle(app(PublisherManager::class));
@@ -26,11 +28,13 @@ test('it publishes and updates the post on success', function () {
test('it marks the post failed when the publish attempt fails', function () { test('it marks the post failed when the publish attempt fails', function () {
Http::fake([ Http::fake([
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
'*/wp-json/wp/v2/media' => Http::response(['id' => 99], 201),
'*/wp-json/wp/v2/posts' => Http::response(['message' => 'nope'], 401), '*/wp-json/wp/v2/posts' => Http::response(['message' => 'nope'], 401),
]); ]);
$site = Site::factory()->wordpress()->create(); $site = Site::factory()->wordpress()->create();
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'publishing']); $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => 'https://images.example.com/photo.jpg', 'status' => 'publishing']);
(new PublishPostJob($post->id))->handle(app(PublisherManager::class)); (new PublishPostJob($post->id))->handle(app(PublisherManager::class));
+97 -8
View File
@@ -29,9 +29,20 @@ test('it fails when the post has no title', function () {
->assertExitCode(1); ->assertExitCode(1);
}); });
test('it fails when the post has no image', function () {
$site = Site::factory()->wordpress()->create();
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => null]);
$this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('no image')
->assertExitCode(1);
expect($post->fresh()->status)->toBe($post->status);
});
test('it fails when the site is not configured as WordPress', function () { test('it fails when the site is not configured as WordPress', function () {
$site = Site::factory()->create(['cms_type' => 'custom']); $site = Site::factory()->create(['cms_type' => 'custom']);
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi']); $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => 'https://example.com/image.jpg']);
$this->artisan('posts:publish', ['post' => $post->id]) $this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('not configured as WordPress') ->expectsOutputToContain('not configured as WordPress')
@@ -40,15 +51,17 @@ test('it fails when the site is not configured as WordPress', function () {
test('it fails when the WordPress site is missing credentials', function () { test('it fails when the WordPress site is missing credentials', function () {
$site = Site::factory()->create(['cms_type' => 'wordpress', 'wp_username' => null, 'wp_app_password' => null]); $site = Site::factory()->create(['cms_type' => 'wordpress', 'wp_username' => null, 'wp_app_password' => null]);
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi']); $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => 'https://example.com/image.jpg']);
$this->artisan('posts:publish', ['post' => $post->id]) $this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('missing WordPress credentials') ->expectsOutputToContain('missing WordPress credentials')
->assertExitCode(1); ->assertExitCode(1);
}); });
test('it publishes successfully and updates the post', function () { test('it publishes successfully, uploading the image as the featured media', function () {
Http::fake([ Http::fake([
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
'*/wp-json/wp/v2/media' => Http::response(['id' => 99], 201),
'*/wp-json/wp/v2/posts' => Http::response([ '*/wp-json/wp/v2/posts' => Http::response([
'id' => 42, 'id' => 42,
'link' => 'https://wp.example.com/2026/07/hello-world/', 'link' => 'https://wp.example.com/2026/07/hello-world/',
@@ -61,6 +74,7 @@ test('it publishes successfully and updates the post', function () {
'type' => 'blog', 'type' => 'blog',
'title' => 'Hello World', 'title' => 'Hello World',
'body' => 'Post content', 'body' => 'Post content',
'image_url' => 'https://images.example.com/photo.jpg',
'status' => 'scheduled', 'status' => 'scheduled',
]); ]);
@@ -72,16 +86,69 @@ test('it publishes successfully and updates the post', function () {
->status->toBe('published') ->status->toBe('published')
->published_url->toBe('https://wp.example.com/2026/07/hello-world/'); ->published_url->toBe('https://wp.example.com/2026/07/hello-world/');
Http::assertSent(function ($request) {
return $request->url() === 'https://wp.example.com/wp-json/wp/v2/media'
&& $request->hasHeader('Content-Disposition', 'attachment; filename="photo.jpg"')
&& $request->body() === 'fake-image-bytes';
});
Http::assertSent(function ($request) use ($site) { Http::assertSent(function ($request) use ($site) {
return $request->url() === "{$site->url}/wp-json/wp/v2/posts" return $request->url() === "{$site->url}/wp-json/wp/v2/posts"
&& $request['title'] === 'Hello World' && $request['title'] === 'Hello World'
&& $request['content'] === 'Post content' && $request['content'] === 'Post content'
&& $request['status'] === 'publish'; && $request['status'] === 'publish'
&& $request['featured_media'] === 99;
}); });
}); });
test('it marks the post as failed when the image cannot be downloaded', function () {
Http::fake([
'https://images.example.com/*' => Http::response('not found', 404),
'*/wp-json/*' => Http::response([], 500),
]);
$site = Site::factory()->wordpress()->create();
$post = Post::factory()->create([
'site_id' => $site->id,
'type' => 'blog',
'title' => 'Hi',
'image_url' => 'https://images.example.com/missing.jpg',
'status' => 'draft',
]);
$this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('could not download image')
->assertExitCode(1);
expect($post->fresh()->status)->toBe('failed');
});
test('it marks the post as failed when WordPress rejects the image upload', function () {
Http::fake([
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
'*/wp-json/wp/v2/media' => Http::response(['message' => 'Sorry, you are not allowed to upload files.'], 401),
]);
$site = Site::factory()->wordpress()->create();
$post = Post::factory()->create([
'site_id' => $site->id,
'type' => 'blog',
'title' => 'Hi',
'image_url' => 'https://images.example.com/photo.jpg',
'status' => 'draft',
]);
$this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('not allowed to upload files')
->assertExitCode(1);
expect($post->fresh()->status)->toBe('failed');
});
test('it marks the post as failed when WordPress returns an error', function () { test('it marks the post as failed when WordPress returns an error', function () {
Http::fake([ Http::fake([
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
'*/wp-json/wp/v2/media' => Http::response(['id' => 99], 201),
'*/wp-json/wp/v2/posts' => Http::response([ '*/wp-json/wp/v2/posts' => Http::response([
'code' => 'rest_cannot_create', 'code' => 'rest_cannot_create',
'message' => 'Sorry, you are not allowed to create posts.', 'message' => 'Sorry, you are not allowed to create posts.',
@@ -89,7 +156,13 @@ test('it marks the post as failed when WordPress returns an error', function ()
]); ]);
$site = Site::factory()->wordpress()->create(); $site = Site::factory()->wordpress()->create();
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'draft']); $post = Post::factory()->create([
'site_id' => $site->id,
'type' => 'blog',
'title' => 'Hi',
'image_url' => 'https://images.example.com/photo.jpg',
'status' => 'draft',
]);
$this->artisan('posts:publish', ['post' => $post->id]) $this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('not allowed to create posts') ->expectsOutputToContain('not allowed to create posts')
@@ -99,12 +172,21 @@ test('it marks the post as failed when WordPress returns an error', function ()
}); });
test('it marks the post as failed when the site is unreachable', function () { test('it marks the post as failed when the site is unreachable', function () {
Http::fake(function () { Http::fake([
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
'*/wp-json/*' => function () {
throw new \Illuminate\Http\Client\ConnectionException('Could not connect'); throw new \Illuminate\Http\Client\ConnectionException('Could not connect');
}); },
]);
$site = Site::factory()->wordpress()->create(); $site = Site::factory()->wordpress()->create();
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'draft']); $post = Post::factory()->create([
'site_id' => $site->id,
'type' => 'blog',
'title' => 'Hi',
'image_url' => 'https://images.example.com/photo.jpg',
'status' => 'draft',
]);
$this->artisan('posts:publish', ['post' => $post->id]) $this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('could not connect') ->expectsOutputToContain('could not connect')
@@ -115,6 +197,8 @@ test('it marks the post as failed when the site is unreachable', function () {
test('it correctly builds the WordPress endpoint URL when the site URL has a trailing slash', function () { test('it correctly builds the WordPress endpoint URL when the site URL has a trailing slash', function () {
Http::fake([ Http::fake([
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
'*/wp-json/wp/v2/media' => Http::response(['id' => 99], 201),
'*/wp-json/wp/v2/posts' => Http::response([ '*/wp-json/wp/v2/posts' => Http::response([
'id' => 42, 'id' => 42,
'link' => 'https://wp.example.com/2026/07/hello-world/', 'link' => 'https://wp.example.com/2026/07/hello-world/',
@@ -127,6 +211,7 @@ test('it correctly builds the WordPress endpoint URL when the site URL has a tra
'type' => 'blog', 'type' => 'blog',
'title' => 'Hello World', 'title' => 'Hello World',
'body' => 'Post content', 'body' => 'Post content',
'image_url' => 'https://images.example.com/photo.jpg',
'status' => 'draft', 'status' => 'draft',
]); ]);
@@ -136,4 +221,8 @@ test('it correctly builds the WordPress endpoint URL when the site URL has a tra
Http::assertSent(function ($request) { Http::assertSent(function ($request) {
return $request->url() === 'https://wp.example.com/wp-json/wp/v2/posts'; return $request->url() === 'https://wp.example.com/wp-json/wp/v2/posts';
}); });
Http::assertSent(function ($request) {
return $request->url() === 'https://wp.example.com/wp-json/wp/v2/media';
});
}); });