Merge branch 'worktree-wordpress-publish'

This commit is contained in:
Doar Mihai
2026-07-19 17:11:32 +03:00
16 changed files with 498 additions and 1 deletions
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Console\Commands;
use App\Models\Post;
use Illuminate\Console\Command;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
class PublishPost extends Command
{
protected $signature = 'posts:publish {post}';
protected $description = 'Publish an existing blog post to its site\'s WordPress REST API';
public function handle(): int
{
$post = Post::with('site')->find($this->argument('post'));
if (! $post) {
return $this->failWith("Post {$this->argument('post')} not found.");
}
if ($post->type !== 'blog') {
return $this->failWith("Post {$post->id} has type '{$post->type}', expected 'blog' to publish to a CMS.");
}
if (! $post->title) {
return $this->failWith("Post {$post->id} has no title, required to publish to WordPress.");
}
$site = $post->site;
if ($site->cms_type !== 'wordpress') {
return $this->failWith("Site '{$site->name}' is not configured as WordPress (cms_type={$site->cms_type}).");
}
if (! $site->wp_username || ! $site->wp_app_password) {
return $this->failWith("Site '{$site->name}' is missing WordPress credentials.");
}
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',
]);
} catch (ConnectionException $e) {
return $this->publishFailed($post, "could not connect to site: {$e->getMessage()}");
}
if ($response->failed()) {
$message = $response->json('message') ?? 'unexpected response from WordPress';
return $this->publishFailed($post, $message);
}
$link = $response->json('link');
if (! $link) {
return $this->publishFailed($post, 'unexpected response from WordPress: missing post link');
}
$post->update(['status' => 'published', 'published_url' => $link]);
$this->line(json_encode(['success' => true, 'url' => $link]));
return self::SUCCESS;
}
private function failWith(string $message): int
{
$this->line(json_encode(['success' => false, 'error' => $message]));
return self::FAILURE;
}
private function publishFailed(Post $post, string $message): int
{
$post->update(['status' => 'failed']);
return $this->failWith($message);
}
}
+1
View File
@@ -18,6 +18,7 @@ class PostResource extends JsonResource
'body' => $this->body,
'status' => $this->status,
'scheduled_at' => $this->scheduled_at,
'published_url' => $this->published_url,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
+3
View File
@@ -14,6 +14,9 @@ class SiteResource extends JsonResource
'name' => $this->name,
'url' => $this->url,
'description' => $this->description,
'cms_type' => $this->cms_type,
'wp_username' => $this->wp_username,
'wp_app_password' => $this->wp_app_password,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
+31 -1
View File
@@ -13,16 +13,25 @@ class Index extends Component
public ?int $editingId = null;
public ?int $revealedId = null;
public string $name = '';
public string $url = '';
public ?string $description = null;
public string $cmsType = 'custom';
public string $wpUsername = '';
public string $wpAppPassword = '';
public function render()
{
return view('livewire.sites.index', [
'sites' => Site::orderBy('name')->get(),
'cmsTypes' => Site::CMS_TYPES,
]);
}
@@ -39,6 +48,9 @@ class Index extends Component
$this->name = $site->name;
$this->url = $site->url;
$this->description = $site->description;
$this->cmsType = $site->cms_type;
$this->wpUsername = $site->wp_username ?? '';
$this->wpAppPassword = $site->wp_app_password ?? '';
$this->showForm = true;
}
@@ -48,9 +60,19 @@ class Index extends Component
'name' => ['required', 'string', 'max:255'],
'url' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'cmsType' => ['required', 'string', 'in:' . implode(',', Site::CMS_TYPES)],
'wpUsername' => ['required_if:cmsType,wordpress', 'nullable', 'string', 'max:255'],
'wpAppPassword' => ['required_if:cmsType,wordpress', 'nullable', 'string', 'max:255'],
]);
Site::updateOrCreate(['id' => $this->editingId], $validated);
Site::updateOrCreate(['id' => $this->editingId], [
'name' => $validated['name'],
'url' => $validated['url'],
'description' => $validated['description'],
'cms_type' => $validated['cmsType'],
'wp_username' => $validated['cmsType'] === 'wordpress' ? $validated['wpUsername'] : null,
'wp_app_password' => $validated['cmsType'] === 'wordpress' ? $validated['wpAppPassword'] : null,
]);
$this->resetForm();
$this->showForm = false;
@@ -61,12 +83,20 @@ class Index extends Component
Site::findOrFail($id)->delete();
}
public function toggleReveal(int $id): void
{
$this->revealedId = $this->revealedId === $id ? null : $id;
}
public function resetForm(): void
{
$this->editingId = null;
$this->name = '';
$this->url = '';
$this->description = null;
$this->cmsType = 'custom';
$this->wpUsername = '';
$this->wpAppPassword = '';
$this->resetValidation();
}
}
+1
View File
@@ -25,6 +25,7 @@ class Post extends Model
'body',
'status',
'scheduled_at',
'published_url',
];
protected $casts = [
+5
View File
@@ -9,9 +9,14 @@ class Site extends Model
{
use HasFactory;
public const CMS_TYPES = ['wordpress', 'custom', 'other'];
protected $fillable = [
'name',
'url',
'description',
'cms_type',
'wp_username',
'wp_app_password',
];
}
+12
View File
@@ -15,6 +15,18 @@ class SiteFactory extends Factory
'name' => fake()->company(),
'url' => fake()->url(),
'description' => fake()->optional()->sentence(),
'cms_type' => 'custom',
'wp_username' => null,
'wp_app_password' => null,
];
}
public function wordpress(): static
{
return $this->state(fn () => [
'cms_type' => 'wordpress',
'wp_username' => fake()->userName(),
'wp_app_password' => fake()->password(),
]);
}
}
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('sites', function (Blueprint $table) {
$table->string('cms_type')->default('custom')->after('description');
$table->string('wp_username')->nullable()->after('cms_type');
$table->string('wp_app_password')->nullable()->after('wp_username');
});
}
public function down(): void
{
Schema::table('sites', function (Blueprint $table) {
$table->dropColumn(['cms_type', 'wp_username', 'wp_app_password']);
});
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->string('published_url')->nullable()->after('scheduled_at');
});
}
public function down(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->dropColumn('published_url');
});
}
};
@@ -10,6 +10,7 @@
<th class="p-3">Name</th>
<th class="p-3">URL</th>
<th class="p-3">Description</th>
<th class="p-3">CMS Type</th>
<th class="p-3"></th>
</tr>
</thead>
@@ -19,6 +20,22 @@
<td class="p-3">{{ $site->name }}</td>
<td class="p-3"><a href="{{ $site->url }}" target="_blank" class="text-blue-600">{{ $site->url }}</a></td>
<td class="p-3">{{ $site->description }}</td>
<td class="p-3">
{{ ucfirst($site->cms_type) }}
@if ($site->cms_type === 'wordpress')
<div class="text-xs text-gray-500">
{{ $site->wp_username }} /
@if ($revealedId === $site->id)
{{ $site->wp_app_password }}
@else
••••••••
@endif
<button wire:click="toggleReveal({{ $site->id }})" class="text-xs text-blue-600 ml-1">
{{ $revealedId === $site->id ? 'Hide' : 'Show' }}
</button>
</div>
@endif
</td>
<td class="p-3 text-right space-x-2">
<button wire:click="edit({{ $site->id }})" class="text-blue-600">Edit</button>
<button wire:click="delete({{ $site->id }})" wire:confirm="Delete this site?" class="text-red-600">Delete</button>
@@ -50,6 +67,30 @@
<textarea wire:model="description" class="w-full border rounded p-2"></textarea>
</div>
<div>
<label class="block text-sm font-medium">CMS Type</label>
<select wire:model.live="cmsType" class="w-full border rounded p-2">
@foreach ($cmsTypes as $type)
<option value="{{ $type }}">{{ ucfirst($type) }}</option>
@endforeach
</select>
@error('cmsType') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
@if ($cmsType === 'wordpress')
<div>
<label class="block text-sm font-medium">WP Username</label>
<input type="text" wire:model="wpUsername" class="w-full border rounded p-2">
@error('wpUsername') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-sm font-medium">WP Application Password</label>
<input type="text" wire:model="wpAppPassword" class="w-full border rounded p-2">
@error('wpAppPassword') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
@endif
<div class="flex justify-end gap-2">
<button wire:click="$set('showForm', false)" class="px-4 py-2">Cancel</button>
<button wire:click="save" class="bg-blue-600 text-white px-4 py-2 rounded">Save</button>
+8
View File
@@ -134,3 +134,11 @@ test('it deletes a post', function () {
$response->assertNoContent();
expect(Post::find($post->id))->toBeNull();
});
test('it includes published_url in the post response', function () {
$post = Post::factory()->create(['published_url' => 'https://example.com/my-post']);
$response = $this->getJson("/api/posts/{$post->id}");
$response->assertOk()->assertJsonPath('data.published_url', 'https://example.com/my-post');
});
+14
View File
@@ -32,3 +32,17 @@ test('it shows a single site', function () {
test('it returns 404 for a missing site', function () {
$this->getJson('/api/sites/999')->assertNotFound();
});
test('it includes WordPress fields in the site response', function () {
$site = Site::factory()->wordpress()->create([
'wp_username' => 'admin',
'wp_app_password' => 'abcd 1234 efgh 5678',
]);
$response = $this->getJson("/api/sites/{$site->id}");
$response->assertOk()
->assertJsonPath('data.cms_type', 'wordpress')
->assertJsonPath('data.wp_username', 'admin')
->assertJsonPath('data.wp_app_password', 'abcd 1234 efgh 5678');
});
+71
View File
@@ -48,3 +48,74 @@ test('a site can be deleted', function () {
expect(Site::find($site->id))->toBeNull();
});
test('a new WordPress site can be created with credentials', function () {
Livewire::test(Index::class)
->set('name', 'WP Site')
->set('url', 'https://wp.example.com')
->set('cmsType', 'wordpress')
->set('wpUsername', 'admin')
->set('wpAppPassword', 'abcd 1234 efgh 5678')
->call('save');
$site = Site::where('name', 'WP Site')->first();
expect($site)
->cms_type->toBe('wordpress')
->wp_username->toBe('admin')
->wp_app_password->toBe('abcd 1234 efgh 5678');
});
test('WordPress username and app password are required together when cms_type is wordpress', function () {
Livewire::test(Index::class)
->set('name', 'WP Site')
->set('url', 'https://wp.example.com')
->set('cmsType', 'wordpress')
->set('wpUsername', '')
->set('wpAppPassword', '')
->call('save')
->assertHasErrors(['wpUsername', 'wpAppPassword']);
});
test('a site defaults to custom cms_type with no WordPress fields required', function () {
Livewire::test(Index::class)
->set('name', 'Plain Site')
->set('url', 'https://plain.example.com')
->call('save')
->assertHasNoErrors();
expect(Site::where('name', 'Plain Site')->first()->cms_type)->toBe('custom');
});
test('editing a site loads its WordPress fields', function () {
$site = Site::factory()->wordpress()->create(['wp_username' => 'editor']);
Livewire::test(Index::class)
->call('edit', $site->id)
->assertSet('cmsType', 'wordpress')
->assertSet('wpUsername', 'editor');
});
test('WordPress app password can be revealed and hidden in the table', function () {
$site = Site::factory()->wordpress()->create();
Livewire::test(Index::class)
->call('toggleReveal', $site->id)
->assertSet('revealedId', $site->id)
->call('toggleReveal', $site->id)
->assertSet('revealedId', null);
});
test('switching a WordPress site to a non-WordPress cms_type clears its credentials', function () {
$site = Site::factory()->wordpress()->create(['wp_username' => 'admin']);
Livewire::test(Index::class)
->call('edit', $site->id)
->set('cmsType', 'custom')
->call('save');
expect($site->fresh())
->cms_type->toBe('custom')
->wp_username->toBeNull()
->wp_app_password->toBeNull();
});
+14
View File
@@ -83,3 +83,17 @@ test('deleting a social account nulls out the post link but keeps the post', fun
->not->toBeNull()
->social_account_id->toBeNull();
});
test('a post can store a published_url', function () {
$post = Post::factory()->create(['published_url' => null]);
$post->update(['published_url' => 'https://example.com/2026/07/my-post']);
expect($post->fresh()->published_url)->toBe('https://example.com/2026/07/my-post');
});
test('published_url defaults to null', function () {
$post = Post::factory()->create();
expect($post->fresh()->published_url)->toBeNull();
});
+139
View File
@@ -0,0 +1,139 @@
<?php
use App\Models\Post;
use App\Models\Site;
use Illuminate\Support\Facades\Http;
test('it fails when the post is not found', function () {
$this->artisan('posts:publish', ['post' => 999])
->assertExitCode(1);
});
test('it fails when the post type is not blog', function () {
$site = Site::factory()->wordpress()->create();
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'twitter', 'title' => 'Hi']);
$this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain("expected 'blog'")
->assertExitCode(1);
expect($post->fresh()->status)->toBe($post->status);
});
test('it fails when the post has no title', function () {
$site = Site::factory()->wordpress()->create();
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => null]);
$this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('no title')
->assertExitCode(1);
});
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']);
$this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('not configured as WordPress')
->assertExitCode(1);
});
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']);
$this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('missing WordPress credentials')
->assertExitCode(1);
});
test('it publishes successfully and updates the post', function () {
Http::fake([
'*/wp-json/wp/v2/posts' => Http::response([
'id' => 42,
'link' => 'https://wp.example.com/2026/07/hello-world/',
], 201),
]);
$site = Site::factory()->wordpress()->create(['url' => 'https://wp.example.com']);
$post = Post::factory()->create([
'site_id' => $site->id,
'type' => 'blog',
'title' => 'Hello World',
'body' => 'Post content',
'status' => 'scheduled',
]);
$this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('"success":true')
->assertExitCode(0);
expect($post->fresh())
->status->toBe('published')
->published_url->toBe('https://wp.example.com/2026/07/hello-world/');
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';
});
});
test('it marks the post as failed when WordPress returns an error', function () {
Http::fake([
'*/wp-json/wp/v2/posts' => Http::response([
'code' => 'rest_cannot_create',
'message' => 'Sorry, you are not allowed to create posts.',
], 401),
]);
$site = Site::factory()->wordpress()->create();
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'draft']);
$this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('not allowed to create posts')
->assertExitCode(1);
expect($post->fresh()->status)->toBe('failed');
});
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');
});
$site = Site::factory()->wordpress()->create();
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'draft']);
$this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('could not connect')
->assertExitCode(1);
expect($post->fresh()->status)->toBe('failed');
});
test('it correctly builds the WordPress endpoint URL when the site URL has a trailing slash', function () {
Http::fake([
'*/wp-json/wp/v2/posts' => Http::response([
'id' => 42,
'link' => 'https://wp.example.com/2026/07/hello-world/',
], 201),
]);
$site = Site::factory()->wordpress()->create(['url' => 'https://wp.example.com/']);
$post = Post::factory()->create([
'site_id' => $site->id,
'type' => 'blog',
'title' => 'Hello World',
'body' => 'Post content',
'status' => 'draft',
]);
$this->artisan('posts:publish', ['post' => $post->id])
->assertExitCode(0);
Http::assertSent(function ($request) {
return $request->url() === 'https://wp.example.com/wp-json/wp/v2/posts';
});
});
+27
View File
@@ -23,3 +23,30 @@ test('a site can be created without a description', function () {
expect($site->fresh()->description)->toBeNull();
});
test('a site defaults to cms_type custom with null WordPress credentials', function () {
$site = Site::create([
'name' => 'My Blog',
'url' => 'https://example.com',
]);
expect($site->fresh())
->cms_type->toBe('custom')
->wp_username->toBeNull()
->wp_app_password->toBeNull();
});
test('a site can be created as WordPress with credentials', function () {
$site = Site::create([
'name' => 'My WP Blog',
'url' => 'https://wp.example.com',
'cms_type' => 'wordpress',
'wp_username' => 'admin',
'wp_app_password' => 'abcd 1234 efgh 5678',
]);
expect($site->fresh())
->cms_type->toBe('wordpress')
->wp_username->toBe('admin')
->wp_app_password->toBe('abcd 1234 efgh 5678');
});