diff --git a/docs/superpowers/plans/2026-07-19-wordpress-publish.md b/docs/superpowers/plans/2026-07-19-wordpress-publish.md new file mode 100644 index 0000000..de56229 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-wordpress-publish.md @@ -0,0 +1,997 @@ +# WordPress Publishing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a Site be marked as WordPress-powered with Application Password credentials, and add `php artisan posts:publish {post}` to push an existing `Post` (type `blog`) to that site's WordPress REST API, updating the post's `status` and `published_url` based on the result. + +**Architecture:** Two additive migrations (`sites.cms_type`/`wp_username`/`wp_app_password`, `posts.published_url`), one new Artisan command (`app/Console/Commands/PublishPost.php`) using Laravel's `Http` facade (Guzzle under the hood, already a framework dependency — no new package) with HTTP Basic Auth against `{site.url}/wp-json/wp/v2/posts`, and updates to the existing Sites Livewire component/view, `SiteResource`, and `PostResource` to surface the new fields. No new package, no new controller, no new API routes. + +**Tech Stack:** Laravel 13, PHP 8.3+, Livewire 4.3, Pest 4 (`pestphp/pest-plugin-laravel`), `Illuminate\Support\Facades\Http` for the WP REST call (mocked in tests via `Http::fake()`). Laravel 13's slim skeleton auto-discovers commands placed in `app/Console/Commands` — no `Kernel.php` registration needed. + +## Global Constraints + +- No authentication/authorization anywhere (UI, API, or command) — local network only, per spec. +- `cms_type` enum: `wordpress`, `custom`, `other`; default `custom`. +- `wp_username` / `wp_app_password` are nullable columns; no DB-level constraint ties them to `cms_type = wordpress` (validated at UI/command level instead). +- `posts.published_url` is nullable, generic (not WP-specific), set on any successful publish. +- The command only ever requests WordPress `status: publish` (immediate live publish) — no draft/future support. +- Command validates, in order, before touching the Post's status: `post.type === 'blog'`, `post.title` present, `post.site.cms_type === 'wordpress'`, `site.wp_username` + `site.wp_app_password` both present. Any failure here exits 1 with a descriptive message and leaves `Post.status` untouched. +- Only after pre-flight passes and the HTTP call is actually attempted does the command write `status = 'published'` (+ `published_url`) or `status = 'failed'` to the Post. +- Sites API (`GET /api/sites`) returns `wp_app_password` in plain text — consistent with the existing precedent that Social Accounts' API returns plain-text passwords, since the AI agent needs credentials directly. +- WordPress auth is Application Passwords (HTTP Basic Auth with `wp_username` + `wp_app_password`) — no plugin-based schemes (JWT, API-key plugins). + +--- + +## File Structure + +- `database/migrations/xxxx_add_cms_fields_to_sites_table.php` — new +- `database/migrations/xxxx_add_published_url_to_posts_table.php` — new +- `app/Models/Site.php` — modify (add `CMS_TYPES` const, fillable fields) +- `app/Models/Post.php` — modify (add `published_url` to fillable) +- `database/factories/SiteFactory.php` — modify (default `cms_type = custom`, nullable WP fields) +- `app/Console/Commands/PublishPost.php` — new (the `posts:publish` command) +- `app/Http/Resources/SiteResource.php` — modify (add `cms_type`, `wp_username`, `wp_app_password`) +- `app/Http/Resources/PostResource.php` — modify (add `published_url`) +- `app/Livewire/Sites/Index.php` — modify (cms_type + WP credential fields, validation, reveal toggle) +- `resources/views/livewire/sites/index.blade.php` — modify (CMS Type column/dropdown, conditional WP fields, password reveal) +- `tests/Feature/PublishPostCommandTest.php` — new +- `tests/Feature/Api/SitesApiTest.php` — modify (assert new fields present) +- `tests/Feature/Api/PostsApiTest.php` — modify (assert `published_url` present) +- `tests/Feature/Livewire/SitesTest.php` — modify (cms_type/WP field coverage) +- `tests/Feature/SiteModelTest.php` — modify (default cms_type coverage) + +--- + +## Task 1: Add `cms_type` + WordPress credential columns to `sites` + +**Files:** +- Create: `database/migrations/xxxx_add_cms_fields_to_sites_table.php` +- Modify: `app/Models/Site.php` +- Modify: `database/factories/SiteFactory.php` +- Test: `tests/Feature/SiteModelTest.php` + +**Interfaces:** +- Produces: `Site::CMS_TYPES = ['wordpress', 'custom', 'other']` constant; `Site` fillable gains `cms_type`, `wp_username`, `wp_app_password`; DB columns `sites.cms_type` (string, default `'custom'`), `sites.wp_username` (string, nullable), `sites.wp_app_password` (string, nullable). + +- [ ] **Step 1: Write the failing test** + +Add to `tests/Feature/SiteModelTest.php`: + +```php +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'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `php artisan test --filter=SiteModelTest` +Expected: FAIL — `cms_type` column/attribute does not exist (SQL error or null mismatch). + +- [ ] **Step 3: Create the migration** + +Run: `php artisan make:migration add_cms_fields_to_sites_table --table=sites` + +Replace the generated file's contents with: + +```php +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']); + }); + } +}; +``` + +- [ ] **Step 4: Update the `Site` model** + +Replace `app/Models/Site.php` contents with: + +```php + 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(), + ]); + } +} +``` + +- [ ] **Step 6: Run the migration and tests** + +Run: `php artisan migrate && php artisan test --filter=SiteModelTest` +Expected: PASS (both new tests, plus the two pre-existing ones). + +- [ ] **Step 7: Commit** + +```bash +git add database/migrations app/Models/Site.php database/factories/SiteFactory.php tests/Feature/SiteModelTest.php +git commit -m "feat: add cms_type and WordPress credential fields to sites" +``` + +--- + +## Task 2: Add `published_url` column to `posts` + +**Files:** +- Create: `database/migrations/xxxx_add_published_url_to_posts_table.php` +- Modify: `app/Models/Post.php` +- Test: `tests/Feature/PostModelTest.php` + +**Interfaces:** +- Produces: `Post` fillable gains `published_url`; DB column `posts.published_url` (string, nullable). + +- [ ] **Step 1: Write the failing test** + +Add to `tests/Feature/PostModelTest.php`: + +```php +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(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `php artisan test --filter=PostModelTest` +Expected: FAIL — `published_url` column does not exist. + +- [ ] **Step 3: Create the migration** + +Run: `php artisan make:migration add_published_url_to_posts_table --table=posts` + +Replace the generated file's contents with: + +```php +string('published_url')->nullable()->after('scheduled_at'); + }); + } + + public function down(): void + { + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('published_url'); + }); + } +}; +``` + +- [ ] **Step 4: Update the `Post` model** + +In `app/Models/Post.php`, add `'published_url'` to the `$fillable` array (after `'scheduled_at'`): + +```php + protected $fillable = [ + 'site_id', + 'social_account_id', + 'type', + 'title', + 'body', + 'status', + 'scheduled_at', + 'published_url', + ]; +``` + +- [ ] **Step 5: Run the migration and tests** + +Run: `php artisan migrate && php artisan test --filter=PostModelTest` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add database/migrations app/Models/Post.php tests/Feature/PostModelTest.php +git commit -m "feat: add published_url column to posts" +``` + +--- + +## Task 3: Expose new fields in `SiteResource` and `PostResource` + +**Files:** +- Modify: `app/Http/Resources/SiteResource.php` +- Modify: `app/Http/Resources/PostResource.php` +- Modify: `tests/Feature/Api/SitesApiTest.php` +- Modify: `tests/Feature/Api/PostsApiTest.php` + +**Interfaces:** +- Consumes: `Site::CMS_TYPES` (Task 1), `Post.published_url` (Task 2). +- Produces: `GET /api/sites` and `GET /api/sites/{id}` responses include `cms_type`, `wp_username`, `wp_app_password`. `GET /api/posts` and `GET /api/posts/{id}` responses include `published_url`. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/Feature/Api/SitesApiTest.php`: + +```php +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'); +}); +``` + +Add to `tests/Feature/Api/PostsApiTest.php`: + +```php +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'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `php artisan test --filter=SitesApiTest && php artisan test --filter=PostsApiTest` +Expected: FAIL — `cms_type`/`wp_username`/`wp_app_password`/`published_url` keys missing from JSON response. + +- [ ] **Step 3: Update `SiteResource`** + +Replace `app/Http/Resources/SiteResource.php` contents with: + +```php + $this->id, + '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, + ]; + } +} +``` + +- [ ] **Step 4: Update `PostResource`** + +In `app/Http/Resources/PostResource.php`, add `'published_url' => $this->published_url,` after the `'scheduled_at'` line: + +```php + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'site_id' => $this->site_id, + 'social_account_id' => $this->social_account_id, + 'type' => $this->type, + 'title' => $this->title, + '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, + ]; + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `php artisan test --filter=SitesApiTest && php artisan test --filter=PostsApiTest` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add app/Http/Resources/SiteResource.php app/Http/Resources/PostResource.php tests/Feature/Api/SitesApiTest.php tests/Feature/Api/PostsApiTest.php +git commit -m "feat: expose cms_type/WordPress credentials and published_url in API responses" +``` + +--- + +## Task 4: `posts:publish` Artisan command + +**Files:** +- Create: `app/Console/Commands/PublishPost.php` +- Create: `tests/Feature/PublishPostCommandTest.php` + +**Interfaces:** +- Consumes: `Site::CMS_TYPES` (Task 1), `site.wp_username`/`site.wp_app_password` (Task 1), `Post::TYPES` including `'blog'` (existing), `Post.published_url` (Task 2). +- Produces: `php artisan posts:publish {post}` — signature `posts:publish {post}`. On success prints `{"success": true, "url": ""}` to stdout and exits 0, having set `Post.status = 'published'` and `Post.published_url`. On failure prints `{"success": false, "error": ""}` and exits 1. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/Feature/PublishPostCommandTest.php`: + +```php +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'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `php artisan test --filter=PublishPostCommandTest` +Expected: FAIL — command `posts:publish` does not exist. + +- [ ] **Step 3: Create the command** + +Create `app/Console/Commands/PublishPost.php`: + +```php +find($this->argument('post')); + + if (! $post) { + return $this->fail("Post {$this->argument('post')} not found."); + } + + if ($post->type !== 'blog') { + return $this->fail("Post {$post->id} has type '{$post->type}', expected 'blog' to publish to a CMS."); + } + + if (! $post->title) { + return $this->fail("Post {$post->id} has no title, required to publish to WordPress."); + } + + $site = $post->site; + + if ($site->cms_type !== 'wordpress') { + return $this->fail("Site '{$site->name}' is not configured as WordPress (cms_type={$site->cms_type})."); + } + + if (! $site->wp_username || ! $site->wp_app_password) { + return $this->fail("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 fail(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->fail($message); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `php artisan test --filter=PublishPostCommandTest` +Expected: PASS (all 8 tests). + +- [ ] **Step 5: Commit** + +```bash +git add app/Console/Commands/PublishPost.php tests/Feature/PublishPostCommandTest.php +git commit -m "feat: add posts:publish command for WordPress REST API publishing" +``` + +--- + +## Task 5: Sites UI — `cms_type` and WordPress credential fields + +**Files:** +- Modify: `app/Livewire/Sites/Index.php` +- Modify: `resources/views/livewire/sites/index.blade.php` +- Modify: `tests/Feature/Livewire/SitesTest.php` + +**Interfaces:** +- Consumes: `Site::CMS_TYPES` (Task 1). +- Produces: Livewire `Index` component public properties `cmsType`, `wpUsername`, `wpAppPassword`, `revealedId`; method `toggleReveal(int $id)`. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/Feature/Livewire/SitesTest.php`: + +```php +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); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `php artisan test --filter=SitesTest` +Expected: FAIL — `cmsType`/`wpUsername`/`wpAppPassword`/`toggleReveal` do not exist on the component. + +- [ ] **Step 3: Update the Livewire component** + +Replace `app/Livewire/Sites/Index.php` contents with: + +```php + Site::orderBy('name')->get(), + 'cmsTypes' => Site::CMS_TYPES, + ]); + } + + public function create(): void + { + $this->resetForm(); + $this->showForm = true; + } + + public function edit(int $id): void + { + $site = Site::findOrFail($id); + $this->editingId = $site->id; + $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; + } + + public function save(): void + { + $validated = $this->validate([ + '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], [ + '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; + } + + public function delete(int $id): void + { + 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(); + } +} +``` + +- [ ] **Step 4: Update the view** + +Replace `resources/views/livewire/sites/index.blade.php` contents with: + +```blade +
+
+

Sites

+ +
+ + + + + + + + + + + + + @foreach ($sites as $site) + + + + + + + + @endforeach + +
NameURLDescriptionCMS Type
{{ $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 +
+ + +
+ + @if ($showForm) +
+
+

{{ $editingId ? 'Edit Site' : 'Add Site' }}

+ +
+ + + @error('name') {{ $message }} @enderror +
+ +
+ + + @error('url') {{ $message }} @enderror +
+ +
+ + +
+ +
+ + + @error('cmsType') {{ $message }} @enderror +
+ + @if ($cmsType === 'wordpress') +
+ + + @error('wpUsername') {{ $message }} @enderror +
+ +
+ + + @error('wpAppPassword') {{ $message }} @enderror +
+ @endif + +
+ + +
+
+
+ @endif +
+``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `php artisan test --filter=SitesTest` +Expected: PASS (all tests, existing + new). + +- [ ] **Step 6: Commit** + +```bash +git add app/Livewire/Sites/Index.php resources/views/livewire/sites/index.blade.php tests/Feature/Livewire/SitesTest.php +git commit -m "feat: manage cms_type and WordPress credentials in Sites UI" +``` + +--- + +## Task 6: Full suite run + +**Files:** none (verification only) + +- [ ] **Step 1: Run the entire test suite** + +Run: `php artisan test` +Expected: All tests pass, no regressions in Sites/Posts/SocialAccounts model, API, or Livewire tests. + +- [ ] **Step 2: Manual smoke check of the command against real WordPress (optional, if a test WP site is available)** + +Run: +```bash +php artisan tinker --execute=" +\$site = App\Models\Site::create(['name' => 'Test WP', 'url' => 'https://your-test-site.example', 'cms_type' => 'wordpress', 'wp_username' => 'your-user', 'wp_app_password' => 'your-app-password']); +\$post = App\Models\Post::create(['site_id' => \$site->id, 'type' => 'blog', 'title' => 'Test Post', 'body' => 'Hello from posts:publish', 'status' => 'draft']); +echo \$post->id; +" +php artisan posts:publish +``` +Expected: JSON `{"success":true,"url":"..."}` printed, exit code 0, and the post visible live on the test WordPress site. + +This step is optional/manual since it requires a real WordPress instance with Application Passwords enabled — skip if none is available; the automated `Http::fake()` tests in Task 4 already cover the logic. \ No newline at end of file