diff --git a/app/Console/Commands/PublishPost.php b/app/Console/Commands/PublishPost.php new file mode 100644 index 0000000..d0b3305 --- /dev/null +++ b/app/Console/Commands/PublishPost.php @@ -0,0 +1,85 @@ +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); + } +} diff --git a/app/Http/Resources/PostResource.php b/app/Http/Resources/PostResource.php index b10ebd0..8981fa0 100644 --- a/app/Http/Resources/PostResource.php +++ b/app/Http/Resources/PostResource.php @@ -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, ]; diff --git a/app/Http/Resources/SiteResource.php b/app/Http/Resources/SiteResource.php index 2319a5a..049823e 100644 --- a/app/Http/Resources/SiteResource.php +++ b/app/Http/Resources/SiteResource.php @@ -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, ]; diff --git a/app/Livewire/Sites/Index.php b/app/Livewire/Sites/Index.php index 9b9906f..b29b8e8 100644 --- a/app/Livewire/Sites/Index.php +++ b/app/Livewire/Sites/Index.php @@ -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(); } } diff --git a/app/Models/Post.php b/app/Models/Post.php index a1e9043..c1802a1 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -25,6 +25,7 @@ class Post extends Model 'body', 'status', 'scheduled_at', + 'published_url', ]; protected $casts = [ diff --git a/app/Models/Site.php b/app/Models/Site.php index cd39e75..c26a0c6 100644 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -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', ]; } diff --git a/database/factories/SiteFactory.php b/database/factories/SiteFactory.php index c23064b..d62e2f8 100644 --- a/database/factories/SiteFactory.php +++ b/database/factories/SiteFactory.php @@ -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(), + ]); + } } diff --git a/database/migrations/2026_07_19_134201_add_cms_fields_to_sites_table.php b/database/migrations/2026_07_19_134201_add_cms_fields_to_sites_table.php new file mode 100644 index 0000000..0bb3811 --- /dev/null +++ b/database/migrations/2026_07_19_134201_add_cms_fields_to_sites_table.php @@ -0,0 +1,24 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_07_19_134706_add_published_url_to_posts_table.php b/database/migrations/2026_07_19_134706_add_published_url_to_posts_table.php new file mode 100644 index 0000000..e505253 --- /dev/null +++ b/database/migrations/2026_07_19_134706_add_published_url_to_posts_table.php @@ -0,0 +1,22 @@ +string('published_url')->nullable()->after('scheduled_at'); + }); + } + + public function down(): void + { + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('published_url'); + }); + } +}; diff --git a/resources/views/livewire/sites/index.blade.php b/resources/views/livewire/sites/index.blade.php index b0223dd..c21f917 100644 --- a/resources/views/livewire/sites/index.blade.php +++ b/resources/views/livewire/sites/index.blade.php @@ -10,6 +10,7 @@ Name URL Description + CMS Type @@ -19,6 +20,22 @@ {{ $site->name }} {{ $site->url }} {{ $site->description }} + + {{ ucfirst($site->cms_type) }} + @if ($site->cms_type === 'wordpress') +
+ {{ $site->wp_username }} / + @if ($revealedId === $site->id) + {{ $site->wp_app_password }} + @else + •••••••• + @endif + +
+ @endif + @@ -50,6 +67,30 @@ +
+ + + @error('cmsType') {{ $message }} @enderror +
+ + @if ($cmsType === 'wordpress') +
+ + + @error('wpUsername') {{ $message }} @enderror +
+ +
+ + + @error('wpAppPassword') {{ $message }} @enderror +
+ @endif +
diff --git a/tests/Feature/Api/PostsApiTest.php b/tests/Feature/Api/PostsApiTest.php index 0328c9a..25101ce 100644 --- a/tests/Feature/Api/PostsApiTest.php +++ b/tests/Feature/Api/PostsApiTest.php @@ -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'); +}); diff --git a/tests/Feature/Api/SitesApiTest.php b/tests/Feature/Api/SitesApiTest.php index 720f9c0..e434686 100644 --- a/tests/Feature/Api/SitesApiTest.php +++ b/tests/Feature/Api/SitesApiTest.php @@ -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'); +}); diff --git a/tests/Feature/Livewire/SitesTest.php b/tests/Feature/Livewire/SitesTest.php index 8055b5b..373a18d 100644 --- a/tests/Feature/Livewire/SitesTest.php +++ b/tests/Feature/Livewire/SitesTest.php @@ -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(); +}); diff --git a/tests/Feature/PostModelTest.php b/tests/Feature/PostModelTest.php index f473e3a..0c9ae21 100644 --- a/tests/Feature/PostModelTest.php +++ b/tests/Feature/PostModelTest.php @@ -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(); +}); diff --git a/tests/Feature/PublishPostCommandTest.php b/tests/Feature/PublishPostCommandTest.php new file mode 100644 index 0000000..f1ed47f --- /dev/null +++ b/tests/Feature/PublishPostCommandTest.php @@ -0,0 +1,139 @@ +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'; + }); +}); diff --git a/tests/Feature/SiteModelTest.php b/tests/Feature/SiteModelTest.php index 889b0a9..6c9b7ff 100644 --- a/tests/Feature/SiteModelTest.php +++ b/tests/Feature/SiteModelTest.php @@ -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'); +});