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:
@@ -37,6 +37,8 @@ SocialAccount (login for a social network)
|
||||
Post (one piece of content, belongs to a Site, optionally a SocialAccount)
|
||||
└─ type: same vocabulary as SocialAccount.network, plus "blog"
|
||||
└─ 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
|
||||
└─ published_url: set once it's actually live somewhere
|
||||
└─ publish_error: set to the failure reason when status=failed (or when a
|
||||
@@ -145,6 +147,7 @@ Body:
|
||||
"title": "Optional title",
|
||||
"body": "Required content",
|
||||
"target": null,
|
||||
"image_url": null,
|
||||
"status": "draft",
|
||||
"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`,
|
||||
`facebook`, `reddit`, `tiktok`, `linkedin`, `youtube`, `blog`, `other`.
|
||||
`body` required. `title` nullable. `target` nullable — a network-specific
|
||||
destination, e.g. the subreddit name for `type=reddit`. `status` defaults to
|
||||
`draft` if omitted. `scheduled_at` nullable datetime.
|
||||
destination, e.g. the subreddit name for `type=reddit`. `image_url` nullable
|
||||
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**
|
||||
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
|
||||
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
|
||||
Playwright, tens of seconds) — which is exactly why the UI path is
|
||||
queue-backed rather than a direct synchronous call.
|
||||
@@ -212,17 +217,20 @@ php artisan posts:publish {post_id}
|
||||
What it does:
|
||||
1. Loads the Post and its Site.
|
||||
2. Validates: `post.type` must be `blog`, `post.title` must be set,
|
||||
`site.cms_type` must be `wordpress`, and the site must have
|
||||
`wp_username`/`wp_app_password` set. Any failure here 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
|
||||
the site's WordPress Application Password, `status: publish` (goes live
|
||||
immediately, not a WP draft).
|
||||
4. On success: sets `Post.status = published`, `Post.published_url` to the
|
||||
`post.image_url` must be set, `site.cms_type` must be `wordpress`, and
|
||||
the site must have `wp_username`/`wp_app_password` set. Any failure here
|
||||
exits 1 with a descriptive message and **does not touch** `Post.status`.
|
||||
3. Downloads `post.image_url`, then POSTs it to
|
||||
`{site.url}/wp-json/wp/v2/media` (HTTP Basic Auth with the site's
|
||||
WordPress Application Password) to upload it as media and get back a
|
||||
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.
|
||||
5. On failure (bad credentials, unreachable site, WordPress error): sets
|
||||
`Post.status = failed`, prints `{"success": false, "error": "..."}`,
|
||||
exits 1.
|
||||
6. On failure at either HTTP step (bad credentials, unreachable site,
|
||||
unreachable/rejected image, WordPress error): sets `Post.status = failed`,
|
||||
prints `{"success": false, "error": "..."}`, exits 1.
|
||||
|
||||
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`).
|
||||
|
||||
@@ -26,8 +26,10 @@ Built with Laravel 13, Livewire, and Tailwind CSS.
|
||||
(e.g. Reddit's browser automation) don't block the page.
|
||||
- Built-in publishers (`app/Support/Publishing/`), one per network, resolved
|
||||
by `Post.type`:
|
||||
- **WordPress**: posts directly to a site's WordPress REST API. Also
|
||||
available synchronously from the CLI: `php artisan posts:publish {post_id}`.
|
||||
- **WordPress**: posts directly to a site's WordPress REST API, uploading
|
||||
`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
|
||||
`sidecars/reddit-post/` that logs in and creates a post.
|
||||
- Anything else: no publisher yet — publish however fits, then report
|
||||
|
||||
@@ -21,6 +21,7 @@ class StorePostRequest extends FormRequest
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'body' => ['required', 'string'],
|
||||
'target' => ['nullable', 'string', 'max:255'],
|
||||
'image_url' => ['nullable', 'url', 'max:2048'],
|
||||
'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)],
|
||||
'scheduled_at' => ['nullable', 'date'],
|
||||
];
|
||||
|
||||
@@ -21,6 +21,7 @@ class UpdatePostRequest extends FormRequest
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'body' => ['sometimes', 'required', 'string'],
|
||||
'target' => ['nullable', 'string', 'max:255'],
|
||||
'image_url' => ['nullable', 'url', 'max:2048'],
|
||||
'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)],
|
||||
'scheduled_at' => ['nullable', 'date'],
|
||||
'published_url' => ['nullable', 'string', 'max:255'],
|
||||
|
||||
@@ -17,6 +17,7 @@ class PostResource extends JsonResource
|
||||
'title' => $this->title,
|
||||
'body' => $this->body,
|
||||
'target' => $this->target,
|
||||
'image_url' => $this->image_url,
|
||||
'status' => $this->status,
|
||||
'scheduled_at' => $this->scheduled_at,
|
||||
'published_url' => $this->published_url,
|
||||
|
||||
@@ -29,6 +29,8 @@ class Index extends Component
|
||||
|
||||
public string $target = '';
|
||||
|
||||
public string $imageUrl = '';
|
||||
|
||||
public string $status = 'draft';
|
||||
|
||||
public string $scheduledAt = '';
|
||||
@@ -82,6 +84,7 @@ class Index extends Component
|
||||
$this->title = (string) $post->title;
|
||||
$this->body = $post->body;
|
||||
$this->target = (string) $post->target;
|
||||
$this->imageUrl = (string) $post->image_url;
|
||||
$this->status = $post->status;
|
||||
$this->scheduledAt = $post->scheduled_at?->format('Y-m-d\TH:i') ?? '';
|
||||
$this->showForm = true;
|
||||
@@ -96,6 +99,7 @@ class Index extends Component
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'body' => ['required', 'string'],
|
||||
'target' => ['nullable', 'string', 'max:255'],
|
||||
'imageUrl' => ['nullable', 'url', 'max:2048'],
|
||||
'status' => ['required', 'string', 'in:' . implode(',', Post::STATUSES)],
|
||||
'scheduledAt' => ['nullable', 'date'],
|
||||
]);
|
||||
@@ -107,6 +111,7 @@ class Index extends Component
|
||||
'title' => $validated['title'] ?: null,
|
||||
'body' => $validated['body'],
|
||||
'target' => $validated['target'] ?: null,
|
||||
'image_url' => $validated['imageUrl'] ?: null,
|
||||
'status' => $validated['status'],
|
||||
'scheduled_at' => $validated['scheduledAt'] ?: null,
|
||||
]);
|
||||
@@ -129,6 +134,7 @@ class Index extends Component
|
||||
$this->title = '';
|
||||
$this->body = '';
|
||||
$this->target = '';
|
||||
$this->imageUrl = '';
|
||||
$this->status = 'draft';
|
||||
$this->scheduledAt = '';
|
||||
$this->resetValidation();
|
||||
|
||||
@@ -24,6 +24,7 @@ class Post extends Model
|
||||
'title',
|
||||
'body',
|
||||
'target',
|
||||
'image_url',
|
||||
'status',
|
||||
'scheduled_at',
|
||||
'published_url',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Support\Publishing;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\Site;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
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.");
|
||||
}
|
||||
|
||||
if (! $post->image_url) {
|
||||
return PublishResult::rejected("Post {$post->id} has no image, required to publish to WordPress.");
|
||||
}
|
||||
|
||||
$site = $post->site;
|
||||
|
||||
if ($site->cms_type !== 'wordpress') {
|
||||
@@ -29,12 +34,19 @@ class WordPressPublisher implements Publisher
|
||||
return PublishResult::rejected("Site '{$site->name}' is missing WordPress credentials.");
|
||||
}
|
||||
|
||||
$mediaId = $this->uploadFeaturedImage($post, $site);
|
||||
|
||||
if ($mediaId instanceof PublishResult) {
|
||||
return $mediaId;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withBasicAuth($site->wp_username, $site->wp_app_password)
|
||||
->post(rtrim($site->url, '/').'/wp-json/wp/v2/posts', [
|
||||
'title' => $post->title,
|
||||
'content' => $post->body,
|
||||
'status' => 'publish',
|
||||
'featured_media' => $mediaId,
|
||||
]);
|
||||
} catch (ConnectionException $e) {
|
||||
return PublishResult::failed("could not connect to site: {$e->getMessage()}");
|
||||
@@ -52,4 +64,41 @@ class WordPressPublisher implements Publisher
|
||||
|
||||
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,10 +6,10 @@ Usage:
|
||||
python3 posts.py get POST_ID
|
||||
python3 posts.py create --site-id ID --type TYPE --body BODY
|
||||
[--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]
|
||||
[--type TYPE] [--title TITLE] [--body BODY] [--target TARGET]
|
||||
[--status STATUS] [--scheduled-at DATETIME]
|
||||
[--image-url URL] [--status STATUS] [--scheduled-at DATETIME]
|
||||
[--published-url URL]
|
||||
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("--title")
|
||||
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("--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("--body")
|
||||
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("--scheduled-at", help="ISO 8601 datetime")
|
||||
update_parser.add_argument("--published-url")
|
||||
@@ -88,6 +90,7 @@ def main() -> None:
|
||||
"title": args.title,
|
||||
"body": args.body,
|
||||
"target": args.target,
|
||||
"image_url": args.image_url,
|
||||
"status": args.status,
|
||||
"scheduled_at": args.scheduled_at,
|
||||
}
|
||||
@@ -102,6 +105,7 @@ def main() -> None:
|
||||
"title": args.title,
|
||||
"body": args.body,
|
||||
"target": args.target,
|
||||
"image_url": args.image_url,
|
||||
"status": args.status,
|
||||
"scheduled_at": args.scheduled_at,
|
||||
"published_url": args.published_url,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<table class="w-full bg-white border border-gray-200 rounded">
|
||||
<thead>
|
||||
<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">Type</th>
|
||||
<th class="p-3">Title</th>
|
||||
@@ -23,6 +24,15 @@
|
||||
<tbody>
|
||||
@foreach ($posts as $post)
|
||||
<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">{{ ucfirst($post->type) }}</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
|
||||
</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>
|
||||
<label class="block text-sm font-medium">Status</label>
|
||||
<select wire:model="status" class="w-full border rounded p-2">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -10,11 +10,13 @@ use Illuminate\Support\Facades\Process;
|
||||
|
||||
test('it publishes and updates the post on success', function () {
|
||||
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),
|
||||
]);
|
||||
|
||||
$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));
|
||||
|
||||
@@ -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 () {
|
||||
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),
|
||||
]);
|
||||
|
||||
$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));
|
||||
|
||||
|
||||
@@ -29,9 +29,20 @@ test('it fails when the post has no title', function () {
|
||||
->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 () {
|
||||
$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])
|
||||
->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 () {
|
||||
$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])
|
||||
->expectsOutputToContain('missing WordPress credentials')
|
||||
->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([
|
||||
'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([
|
||||
'id' => 42,
|
||||
'link' => 'https://wp.example.com/2026/07/hello-world/',
|
||||
@@ -61,6 +74,7 @@ test('it publishes successfully and updates the post', function () {
|
||||
'type' => 'blog',
|
||||
'title' => 'Hello World',
|
||||
'body' => 'Post content',
|
||||
'image_url' => 'https://images.example.com/photo.jpg',
|
||||
'status' => 'scheduled',
|
||||
]);
|
||||
|
||||
@@ -72,16 +86,69 @@ test('it publishes successfully and updates the post', function () {
|
||||
->status->toBe('published')
|
||||
->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) {
|
||||
return $request->url() === "{$site->url}/wp-json/wp/v2/posts"
|
||||
&& $request['title'] === 'Hello World'
|
||||
&& $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 () {
|
||||
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([
|
||||
'code' => 'rest_cannot_create',
|
||||
'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();
|
||||
$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])
|
||||
->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 () {
|
||||
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');
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
$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])
|
||||
->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 () {
|
||||
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([
|
||||
'id' => 42,
|
||||
'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',
|
||||
'title' => 'Hello World',
|
||||
'body' => 'Post content',
|
||||
'image_url' => 'https://images.example.com/photo.jpg',
|
||||
'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) {
|
||||
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';
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user