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/tests/Feature/PublishPostCommandTest.php b/tests/Feature/PublishPostCommandTest.php new file mode 100644 index 0000000..8a20f1f --- /dev/null +++ b/tests/Feature/PublishPostCommandTest.php @@ -0,0 +1,114 @@ +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'); +});