From 88aebe298cd9576f236399a88fce06c9fe4007d2 Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sun, 19 Jul 2026 16:21:46 +0300 Subject: [PATCH 1/8] docs: add design spec for WordPress publishing command Extends Sites with cms_type + WP Application Password credentials and adds a posts:publish Artisan command to push a Post to a WordPress site's REST API, mirroring the reddit-post sidecar's role for a native, non-browser-automation CMS target. --- .../2026-07-19-wordpress-publish-design.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-wordpress-publish-design.md diff --git a/docs/superpowers/specs/2026-07-19-wordpress-publish-design.md b/docs/superpowers/specs/2026-07-19-wordpress-publish-design.md new file mode 100644 index 0000000..2b4067f --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-wordpress-publish-design.md @@ -0,0 +1,94 @@ +# WordPress Publishing — Design + +## Purpose + +Extends the existing Sites & Posts data model so a Site can be marked as a WordPress-powered CMS with its own REST API credentials, and adds an Artisan command that publishes an existing draft/scheduled `Post` to that site via the WordPress REST API. This is the WordPress equivalent of the `reddit-post` sidecar, but implemented natively in Laravel rather than as an external Playwright process, since WordPress's REST API is a direct HTTP call and the Post/Site data already lives in this app's database. + +## Scope + +- Distinguish a Site's CMS by a `cms_type` field: `wordpress`, `custom`, or `other`. +- Store WordPress Application Password credentials (`wp_username` + `wp_app_password`) on a Site. +- Add `php artisan posts:publish {post}` — loads a `Post` by ID, publishes it to its Site's WordPress REST API, and updates the Post's `status` and `published_url`. +- Update the Sites Livewire UI to manage `cms_type` and the WordPress credential fields. +- Expose the new Site fields via the existing read-only Sites API, and the new Post field via the existing Posts API. + +Out of scope: +- Any CMS integration beyond WordPress — `custom` and `other` are labels only, with no publish path. +- Updating/editing an already-published WP post, deleting a WP post, or any WP endpoint beyond creating a post. +- Media/image uploads, categories, tags, excerpts, or any WP post field beyond title/content/status. +- Scheduled publishing via WP's own `future` post status — this command always publishes immediately (`status: publish`) when invoked; scheduling is this app's own `Post.scheduled_at` + AI-driven polling, unchanged from the existing design. +- Authentication/authorization on the command, UI, or API (consistent with the rest of the app — local network only). +- Any plugin-based WP auth scheme (JWT, API-key plugins) — Application Passwords (built into WordPress core 5.6+) only. + +## Data Model + +### `sites` table — new columns + +| Column | Type | Constraints | +|---|---|---| +| cms_type | string (enum) | required, default `custom` — one of: `wordpress`, `custom`, `other` | +| wp_username | string | nullable | +| wp_app_password | string | nullable, plain text (no hashing/encryption — same precedent as `social_accounts.password`) | + +`wp_username`/`wp_app_password` are only meaningful when `cms_type = wordpress`; no DB-level constraint enforces this pairing (validated at the UI/command level instead). + +### `posts` table — new column + +| Column | Type | Constraints | +|---|---|---| +| published_url | string | nullable — set on successful publish; generic across future publish targets, not WordPress-specific | + +## Command: `php artisan posts:publish {post}` + +**Arguments:** one required argument, the `Post` id. + +**Flow:** + +1. Load the `Post` by id (404/"post not found" error and exit 1 if missing). Eager-load its `site` relation. +2. Validate, in order, failing fast with a descriptive message and exit code 1 on the first failure: + - `post.type === 'blog'` — else `"Post {id} has type '{type}', expected 'blog' to publish to a CMS."` + - `post.site.cms_type === 'wordpress'` — else `"Site '{name}' is not configured as WordPress (cms_type={cms_type})."` + - `post.site.wp_username` and `post.site.wp_app_password` are both present — else `"Site '{name}' is missing WordPress credentials."` + - These three pre-flight checks happen before any DB write to the Post — a mismatched post/site pairing is a usage error, not a publish failure, so the Post's `status` is left untouched in these cases. +3. Send `POST {site.url}/wp-json/wp/v2/posts` via Guzzle, HTTP Basic Auth (`wp_username` / `wp_app_password`), JSON body: + ```json + { "title": post.title, "content": post.body, "status": "publish" } + ``` +4. **On success** (2xx response with a `link` field in the JSON body): + - Update the Post: `status = 'published'`, `published_url = response.link`. + - Print `{"success": true, "url": ""}` to stdout, exit 0. +5. **On failure** (network/connection error, non-2xx response, or malformed response missing `link`): + - Update the Post: `status = 'failed'`. + - Print `{"success": false, "error": ""}` to stdout, exit 1. + - Error description includes the WP error message when the response body has one (WP REST errors return `{code, message, data}`), otherwise a generic description of the failure (e.g. "could not connect to site", "unexpected response from WordPress"). + +**Title handling:** `Post.title` is nullable in the existing schema, but WordPress requires a title. If `post.title` is null/empty at step 3, this is a pre-flight validation failure (same category as step 2): `"Post {id} has no title, required to publish to WordPress."` — exit 1, Post status untouched. + +## UI (Livewire) + +Sites create/edit form gains: +- **CMS Type** — dropdown: WordPress / Custom / Other (default Custom). +- When **WordPress** is selected, two additional fields appear: + - **WP Username** — text input. + - **WP Application Password** — masked input with reveal toggle (same convention as the Social Accounts password field). Not required at the DB level, but the form validates both are present together when `cms_type = wordpress` is submitted (client-side/Livewire validation — DB stays nullable to avoid migration headaches if a site is later switched away from WordPress). + +Sites table view gains a **CMS Type** column. No changes to the Posts UI (the new `published_url` field is visible as a link in the Posts table's existing "actions" area or a new column — small addition, decided at plan time). + +## API + +No new endpoints. + +- `GET /api/sites` / `GET /api/sites/{id}` — `SiteResource` now also returns `cms_type`, `wp_username`, `wp_app_password` (plain text), consistent with the existing precedent that Social Accounts' API already returns plain-text passwords, since the AI agent needs credentials to act directly. +- `GET /api/posts` / `GET /api/posts/{id}` — response shape gains `published_url`. + +## Testing / Edge Cases + +- Feature test: `posts:publish` on a post with `type != 'blog'` fails with the expected message, exit 1, Post status unchanged. +- Feature test: `posts:publish` on a post whose site has `cms_type != wordpress` fails accordingly, Post status unchanged. +- Feature test: `posts:publish` on a WordPress site missing credentials fails accordingly. +- Feature test: `posts:publish` on a post with no title fails accordingly. +- Feature test: successful publish (mocked HTTP via Guzzle's mock handler / Laravel's `Http::fake` equivalent) — asserts Post updated to `status=published` with correct `published_url`, and the printed JSON/exit code are correct. +- Feature test: WP API returns an error response (e.g. 401 invalid credentials, 500) — asserts Post updated to `status=failed`, correct error message surfaced, exit 1. +- Feature test: WP API unreachable (connection exception) — asserts graceful failure, `status=failed`, exit 1. +- Livewire test: Sites form shows/hides WP fields based on `cms_type` selection, and validates the paired-fields rule. +- Migration test/manual check: existing sites default to `cms_type=custom` with null WP fields (no backfill needed beyond the column defaults). \ No newline at end of file From 88c8203931894601e18f9492c93774a3868c6451 Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sun, 19 Jul 2026 16:24:23 +0300 Subject: [PATCH 2/8] docs: add implementation plan for WordPress publishing command --- .../plans/2026-07-19-wordpress-publish.md | 997 ++++++++++++++++++ 1 file changed, 997 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-19-wordpress-publish.md 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 From 30095519e478ddb1fd5cd2dba04161a665855551 Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sun, 19 Jul 2026 16:43:13 +0300 Subject: [PATCH 3/8] feat: add cms_type and WordPress credential fields to sites --- app/Models/Site.php | 5 ++++ database/factories/SiteFactory.php | 12 +++++++++ ...9_134201_add_cms_fields_to_sites_table.php | 24 +++++++++++++++++ tests/Feature/SiteModelTest.php | 27 +++++++++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 database/migrations/2026_07_19_134201_add_cms_fields_to_sites_table.php 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/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'); +}); From 80423301c45d028e2fa1146b8dfb21828c887efe Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sun, 19 Jul 2026 16:48:03 +0300 Subject: [PATCH 4/8] feat: add published_url column to posts Co-Authored-By: Claude Sonnet 5 --- app/Models/Post.php | 1 + ...34706_add_published_url_to_posts_table.php | 22 +++++++++++++++++++ tests/Feature/PostModelTest.php | 14 ++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 database/migrations/2026_07_19_134706_add_published_url_to_posts_table.php 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/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/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(); +}); From 5501b9368610ad140a4e4674573e5a614f49ccd8 Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sun, 19 Jul 2026 16:51:15 +0300 Subject: [PATCH 5/8] feat: expose cms_type/WordPress credentials and published_url in API responses --- app/Http/Resources/PostResource.php | 1 + app/Http/Resources/SiteResource.php | 5 ++++- tests/Feature/Api/PostsApiTest.php | 8 ++++++++ tests/Feature/Api/SitesApiTest.php | 14 ++++++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) 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..a59131d 100644 --- a/app/Http/Resources/SiteResource.php +++ b/app/Http/Resources/SiteResource.php @@ -14,8 +14,11 @@ 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, ]; } -} +} \ No newline at end of file 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'); +}); From 336436bdc751f3bf9690d4001156110fad71d564 Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sun, 19 Jul 2026 16:54:38 +0300 Subject: [PATCH 6/8] feat: add posts:publish command for WordPress REST API publishing --- app/Console/Commands/PublishPost.php | 85 +++++++++++++++++ tests/Feature/PublishPostCommandTest.php | 114 +++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 app/Console/Commands/PublishPost.php create mode 100644 tests/Feature/PublishPostCommandTest.php 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'); +}); From c2ef6d9986da6eecdc6804647ebd829133b1b483 Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sun, 19 Jul 2026 16:58:42 +0300 Subject: [PATCH 7/8] feat: manage cms_type and WordPress credentials in Sites UI --- app/Livewire/Sites/Index.php | 32 ++++++++++- .../views/livewire/sites/index.blade.php | 41 +++++++++++++ tests/Feature/Livewire/SitesTest.php | 57 +++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) 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/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/Livewire/SitesTest.php b/tests/Feature/Livewire/SitesTest.php index 8055b5b..3d48b8f 100644 --- a/tests/Feature/Livewire/SitesTest.php +++ b/tests/Feature/Livewire/SitesTest.php @@ -48,3 +48,60 @@ 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); +}); From f7b71684f1c95040df356c84c34edad56f3e7f4f Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sun, 19 Jul 2026 17:05:01 +0300 Subject: [PATCH 8/8] test: cover credential-clearing and trailing-slash URL edge cases Also fixes a missing trailing newline in SiteResource.php. --- app/Http/Resources/SiteResource.php | 2 +- tests/Feature/Livewire/SitesTest.php | 14 +++++++++++++ tests/Feature/PublishPostCommandTest.php | 25 ++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/app/Http/Resources/SiteResource.php b/app/Http/Resources/SiteResource.php index a59131d..049823e 100644 --- a/app/Http/Resources/SiteResource.php +++ b/app/Http/Resources/SiteResource.php @@ -21,4 +21,4 @@ class SiteResource extends JsonResource 'updated_at' => $this->updated_at, ]; } -} \ No newline at end of file +} diff --git a/tests/Feature/Livewire/SitesTest.php b/tests/Feature/Livewire/SitesTest.php index 3d48b8f..373a18d 100644 --- a/tests/Feature/Livewire/SitesTest.php +++ b/tests/Feature/Livewire/SitesTest.php @@ -105,3 +105,17 @@ test('WordPress app password can be revealed and hidden in the table', function ->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/PublishPostCommandTest.php b/tests/Feature/PublishPostCommandTest.php index 8a20f1f..f1ed47f 100644 --- a/tests/Feature/PublishPostCommandTest.php +++ b/tests/Feature/PublishPostCommandTest.php @@ -112,3 +112,28 @@ test('it marks the post as failed when the site is unreachable', function () { 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'; + }); +});