docs: add implementation plan for WordPress publishing command
This commit is contained in:
@@ -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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('sites', function (Blueprint $table) {
|
||||||
|
$table->string('cms_type')->default('custom')->after('description');
|
||||||
|
$table->string('wp_username')->nullable()->after('cms_type');
|
||||||
|
$table->string('wp_app_password')->nullable()->after('wp_username');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('sites', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['cms_type', 'wp_username', 'wp_app_password']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update the `Site` model**
|
||||||
|
|
||||||
|
Replace `app/Models/Site.php` contents with:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update `SiteFactory`**
|
||||||
|
|
||||||
|
Replace `database/factories/SiteFactory.php` contents with:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Site;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class SiteFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Site::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('posts', function (Blueprint $table) {
|
||||||
|
$table->string('published_url')->nullable()->after('scheduled_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('posts', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('published_url');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class SiteResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $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": "<published_url>"}` to stdout and exits 0, having set `Post.status = 'published'` and `Post.published_url`. On failure prints `{"success": false, "error": "<message>"}` and exits 1.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `tests/Feature/PublishPostCommandTest.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\Site;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
test('it fails when the post is not found', function () {
|
||||||
|
$this->artisan('posts:publish', ['post' => 999])
|
||||||
|
->assertExitCode(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it fails when the post type is not blog', function () {
|
||||||
|
$site = Site::factory()->wordpress()->create();
|
||||||
|
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'twitter', 'title' => 'Hi']);
|
||||||
|
|
||||||
|
$this->artisan('posts:publish', ['post' => $post->id])
|
||||||
|
->expectsOutputToContain("expected 'blog'")
|
||||||
|
->assertExitCode(1);
|
||||||
|
|
||||||
|
expect($post->fresh()->status)->toBe($post->status);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it fails when the post has no title', function () {
|
||||||
|
$site = Site::factory()->wordpress()->create();
|
||||||
|
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => null]);
|
||||||
|
|
||||||
|
$this->artisan('posts:publish', ['post' => $post->id])
|
||||||
|
->expectsOutputToContain('no title')
|
||||||
|
->assertExitCode(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it fails when the site is not configured as WordPress', function () {
|
||||||
|
$site = Site::factory()->create(['cms_type' => 'custom']);
|
||||||
|
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi']);
|
||||||
|
|
||||||
|
$this->artisan('posts:publish', ['post' => $post->id])
|
||||||
|
->expectsOutputToContain('not configured as WordPress')
|
||||||
|
->assertExitCode(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it fails when the WordPress site is missing credentials', function () {
|
||||||
|
$site = Site::factory()->create(['cms_type' => 'wordpress', 'wp_username' => null, 'wp_app_password' => null]);
|
||||||
|
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi']);
|
||||||
|
|
||||||
|
$this->artisan('posts:publish', ['post' => $post->id])
|
||||||
|
->expectsOutputToContain('missing WordPress credentials')
|
||||||
|
->assertExitCode(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it publishes successfully and updates the post', function () {
|
||||||
|
Http::fake([
|
||||||
|
'*/wp-json/wp/v2/posts' => Http::response([
|
||||||
|
'id' => 42,
|
||||||
|
'link' => 'https://wp.example.com/2026/07/hello-world/',
|
||||||
|
], 201),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$site = Site::factory()->wordpress()->create(['url' => 'https://wp.example.com']);
|
||||||
|
$post = Post::factory()->create([
|
||||||
|
'site_id' => $site->id,
|
||||||
|
'type' => 'blog',
|
||||||
|
'title' => 'Hello World',
|
||||||
|
'body' => 'Post content',
|
||||||
|
'status' => 'scheduled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('posts:publish', ['post' => $post->id])
|
||||||
|
->expectsOutputToContain('"success":true')
|
||||||
|
->assertExitCode(0);
|
||||||
|
|
||||||
|
expect($post->fresh())
|
||||||
|
->status->toBe('published')
|
||||||
|
->published_url->toBe('https://wp.example.com/2026/07/hello-world/');
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) use ($site) {
|
||||||
|
return $request->url() === "{$site->url}/wp-json/wp/v2/posts"
|
||||||
|
&& $request['title'] === 'Hello World'
|
||||||
|
&& $request['content'] === 'Post content'
|
||||||
|
&& $request['status'] === 'publish';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it marks the post as failed when WordPress returns an error', function () {
|
||||||
|
Http::fake([
|
||||||
|
'*/wp-json/wp/v2/posts' => Http::response([
|
||||||
|
'code' => 'rest_cannot_create',
|
||||||
|
'message' => 'Sorry, you are not allowed to create posts.',
|
||||||
|
], 401),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$site = Site::factory()->wordpress()->create();
|
||||||
|
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'draft']);
|
||||||
|
|
||||||
|
$this->artisan('posts:publish', ['post' => $post->id])
|
||||||
|
->expectsOutputToContain('not allowed to create posts')
|
||||||
|
->assertExitCode(1);
|
||||||
|
|
||||||
|
expect($post->fresh()->status)->toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it marks the post as failed when the site is unreachable', function () {
|
||||||
|
Http::fake(function () {
|
||||||
|
throw new \Illuminate\Http\Client\ConnectionException('Could not connect');
|
||||||
|
});
|
||||||
|
|
||||||
|
$site = Site::factory()->wordpress()->create();
|
||||||
|
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'draft']);
|
||||||
|
|
||||||
|
$this->artisan('posts:publish', ['post' => $post->id])
|
||||||
|
->expectsOutputToContain('could not connect')
|
||||||
|
->assertExitCode(1);
|
||||||
|
|
||||||
|
expect($post->fresh()->status)->toBe('failed');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Post;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class PublishPost extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'posts:publish {post}';
|
||||||
|
|
||||||
|
protected $description = 'Publish an existing blog post to its site\'s WordPress REST API';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$post = Post::with('site')->find($this->argument('post'));
|
||||||
|
|
||||||
|
if (! $post) {
|
||||||
|
return $this->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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Sites;
|
||||||
|
|
||||||
|
use App\Models\Site;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('components.layouts.app')]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public bool $showForm = false;
|
||||||
|
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h1 class="text-xl font-semibold">Sites</h1>
|
||||||
|
<button wire:click="create" class="bg-blue-600 text-white px-4 py-2 rounded">Add Site</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="w-full bg-white border border-gray-200 rounded">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left border-b border-gray-200">
|
||||||
|
<th class="p-3">Name</th>
|
||||||
|
<th class="p-3">URL</th>
|
||||||
|
<th class="p-3">Description</th>
|
||||||
|
<th class="p-3">CMS Type</th>
|
||||||
|
<th class="p-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($sites as $site)
|
||||||
|
<tr wire:key="site-{{ $site->id }}" class="border-b border-gray-100">
|
||||||
|
<td class="p-3">{{ $site->name }}</td>
|
||||||
|
<td class="p-3"><a href="{{ $site->url }}" target="_blank" class="text-blue-600">{{ $site->url }}</a></td>
|
||||||
|
<td class="p-3">{{ $site->description }}</td>
|
||||||
|
<td class="p-3">
|
||||||
|
{{ ucfirst($site->cms_type) }}
|
||||||
|
@if ($site->cms_type === 'wordpress')
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
{{ $site->wp_username }} /
|
||||||
|
@if ($revealedId === $site->id)
|
||||||
|
{{ $site->wp_app_password }}
|
||||||
|
@else
|
||||||
|
••••••••
|
||||||
|
@endif
|
||||||
|
<button wire:click="toggleReveal({{ $site->id }})" class="text-xs text-blue-600 ml-1">
|
||||||
|
{{ $revealedId === $site->id ? 'Hide' : 'Show' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-right space-x-2">
|
||||||
|
<button wire:click="edit({{ $site->id }})" class="text-blue-600">Edit</button>
|
||||||
|
<button wire:click="delete({{ $site->id }})" wire:confirm="Delete this site?" class="text-red-600">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
@if ($showForm)
|
||||||
|
<div class="fixed inset-0 bg-black/30 flex items-center justify-center">
|
||||||
|
<div class="bg-white p-6 rounded w-full max-w-md space-y-4">
|
||||||
|
<h2 class="text-lg font-semibold">{{ $editingId ? 'Edit Site' : 'Add Site' }}</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Name</label>
|
||||||
|
<input type="text" wire:model="name" class="w-full border rounded p-2">
|
||||||
|
@error('name') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">URL</label>
|
||||||
|
<input type="text" wire:model="url" class="w-full border rounded p-2">
|
||||||
|
@error('url') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Description</label>
|
||||||
|
<textarea wire:model="description" class="w-full border rounded p-2"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">CMS Type</label>
|
||||||
|
<select wire:model.live="cmsType" class="w-full border rounded p-2">
|
||||||
|
@foreach ($cmsTypes as $type)
|
||||||
|
<option value="{{ $type }}">{{ ucfirst($type) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('cmsType') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($cmsType === 'wordpress')
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">WP Username</label>
|
||||||
|
<input type="text" wire:model="wpUsername" class="w-full border rounded p-2">
|
||||||
|
@error('wpUsername') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">WP Application Password</label>
|
||||||
|
<input type="text" wire:model="wpAppPassword" class="w-full border rounded p-2">
|
||||||
|
@error('wpAppPassword') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button wire:click="$set('showForm', false)" class="px-4 py-2">Cancel</button>
|
||||||
|
<button wire:click="save" class="bg-blue-600 text-white px-4 py-2 rounded">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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 <post_id_from_above>
|
||||||
|
```
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user