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)
└─ 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`).
+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.
- 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
+1
View File
@@ -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'],
];
+1
View File
@@ -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'],
+1
View File
@@ -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,
+6
View File
@@ -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();
+1
View File
@@ -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 -2
View File
@@ -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">
+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');
});
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 () {
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));
+98 -9
View File
@@ -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 () {
throw new \Illuminate\Http\Client\ConnectionException('Could not connect');
});
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';
});
});