DX audit and fix (PHP) #9

Merged
Snider merged 32 commits from agent/dx-audit-and-fix--laravel-php-package into dev 2026-03-24 11:36:33 +00:00
66 changed files with 546 additions and 169 deletions

66
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,66 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest
container:
image: lthn/build:php-${{ matrix.php }}
strategy:
fail-fast: true
matrix:
php: ["8.3", "8.4"]
steps:
- uses: actions/checkout@v4
- name: Clone sister packages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for pkg in php-framework php-tenant; do
echo "Cloning $pkg into ../$pkg"
git clone --depth 1 \
"https://x-access-token:${GITHUB_TOKEN}@forge.lthn.ai/core/${pkg}.git" \
../$pkg
done
ls -la ../php-framework/composer.json ../php-tenant/composer.json
- name: Configure path repositories
run: |
composer config repositories.core path ../php-framework --no-interaction
composer config repositories.tenant path ../php-tenant --no-interaction
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Run Pint
run: |
if [ -f vendor/bin/pint ]; then
vendor/bin/pint --test
else
echo "Pint not installed, skipping"
fi
- name: Run unit tests
run: |
if [ -f vendor/bin/pest ]; then
if [ -d tests/Unit ] || [ -d tests/unit ]; then
vendor/bin/pest tests/Unit --ci
elif [ -d src/Tests/Unit ]; then
vendor/bin/pest src/Tests/Unit --ci
else
echo "No unit test directory found, skipping"
fi
elif [ -f vendor/bin/phpunit ]; then
vendor/bin/phpunit --testsuite=Unit
else
echo "No test runner found, skipping"
fi

View file

@ -0,0 +1,38 @@
name: Publish Composer Package
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create package archive
run: |
apt-get update && apt-get install -y zip
zip -r package.zip . \
-x ".forgejo/*" \
-x ".git/*" \
-x "tests/*" \
-x "docker/*" \
-x "*.yaml" \
-x "infection.json5" \
-x "phpstan.neon" \
-x "phpunit.xml" \
-x "psalm.xml" \
-x "rector.php" \
-x "TODO.md" \
-x "ROADMAP.md" \
-x "CONTRIBUTING.md" \
-x "package.json" \
-x "package-lock.json"
- name: Publish to Forgejo Composer registry
run: |
curl --fail --user "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file package.zip \
"https://forge.lthn.ai/api/packages/core/composer?version=${FORGEJO_REF_NAME#v}"

2
.gitignore vendored
View file

@ -22,3 +22,5 @@ yarn-error.log
/.nova
/.vscode
/.zed
.core/
.idea/

View file

@ -8,11 +8,11 @@ use Core\Events\ApiRoutesRegistering;
use Core\Events\ConsoleBooting;
use Core\Events\McpToolsRegistering;
use Core\Events\WebRoutesRegistering;
use Core\Mod\Content\Services\HtmlSanitiser;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Core\Mod\Content\Services\HtmlSanitiser;
use RuntimeException;
/**
@ -149,8 +149,8 @@ class Boot extends ServiceProvider
$event->livewire('content.admin.webhook-manager', View\Modal\Admin\WebhookManager::class);
$event->livewire('content.admin.content-search', View\Modal\Admin\ContentSearch::class);
if (file_exists(__DIR__.'/Routes/web.php')) {
$event->routes(fn () => require __DIR__.'/Routes/web.php');
if (file_exists(__DIR__.'/routes/web.php')) {
$event->routes(fn () => require __DIR__.'/routes/web.php');
}
}
@ -159,8 +159,8 @@ class Boot extends ServiceProvider
*/
public function onApiRoutes(ApiRoutesRegistering $event): void
{
if (file_exists(__DIR__.'/Routes/api.php')) {
$event->routes(fn () => require __DIR__.'/Routes/api.php');
if (file_exists(__DIR__.'/routes/api.php')) {
$event->routes(fn () => require __DIR__.'/routes/api.php');
}
}

102
CLAUDE.md
View file

@ -4,9 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Package Overview
This is `host-uk/core-content`, a Laravel package providing headless CMS functionality for the Core PHP Framework. It handles content management, AI generation, revisions, webhooks, and search.
This is `lthn/php-content`, a Laravel package providing headless CMS functionality for the Core PHP Framework. It handles content management, AI generation, revisions, webhooks, and search.
**Namespace:** `Core\Mod\Content\`
**Entry point:** `Boot.php` (extends `ServiceProvider`, uses event-driven registration)
**Dependencies:** `lthn/php` (core framework), `ezyang/htmlpurifier` (required, security-critical)
**Optional:** `core-tenant` (workspaces/users), `core-agentic` (AI services), `core-mcp` (MCP tool registration)
## Commands
@ -19,9 +22,9 @@ composer run test # Pest tests
## Architecture
### Boot.php (Service Provider)
### Event-Driven Boot
The entry point extending `ServiceProvider` with event-driven registration:
`Boot.php` registers lazily via event listeners — routes, commands, views, and MCP tools are only loaded when their events fire:
```php
public static array $listens = [
@ -32,66 +35,73 @@ public static array $listens = [
];
```
### Package Structure
### AI Generation Pipeline (Two-Stage)
```
Boot.php # Service provider + event listeners
config.php # Package configuration
Models/ # Eloquent: ContentItem, ContentBrief, ContentRevision, etc.
Services/ # Business logic: ContentSearchService, ContentRender, etc.
Controllers/Api/ # REST API controllers
Mcp/Handlers/ # MCP tools for AI agent integration
Jobs/ # Queue jobs: GenerateContentJob, ProcessContentWebhook
View/Modal/ # Livewire components (Web/ and Admin/)
View/Blade/ # Blade templates
Migrations/ # Database migrations
routes/ # web.php, api.php, console.php
```
`AIGatewayService` orchestrates a two-stage content generation pipeline:
1. **Stage 1 (Gemini):** Fast, cost-effective draft generation
2. **Stage 2 (Claude):** Quality refinement and brand voice alignment
### Key Models
Brief workflow: `pending``queued``generating``review``published`
| Model | Purpose |
|-------|---------|
| `ContentItem` | Published content with revisions |
| `ContentBrief` | Content generation requests/queue |
| `ContentRevision` | Version history for content items |
| `ContentMedia` | Attached media files |
| `ContentTaxonomy` | Categories and tags |
| `ContentWebhookEndpoint` | External webhook configurations |
### Dual API Authentication
### API Routes (routes/api.php)
All `/api/content/*` endpoints are registered twice in `routes/api.php`:
- Session auth (`middleware('auth')`)
- API key auth (`middleware(['api.auth', 'api.scope.enforce'])`) — uses `Authorization: Bearer hk_xxx`
- `/api/content/briefs` - Content brief CRUD
- `/api/content/generate/*` - AI generation (rate limited)
- `/api/content/media` - Media management
- `/api/content/search` - Full-text search
- `/api/content/webhooks/{endpoint}` - External webhooks (no auth, signature verified)
Webhook endpoints are public (no auth, signature-verified via HMAC).
### Livewire Component Paths
Livewire components live in `View/Modal/` (not the typical `Livewire/` directory):
- `View/Modal/Web/` — public-facing (Blog, Post, Help, HelpArticle, Preview)
- `View/Modal/Admin/` — admin (WebhookManager, ContentSearch)
Blade templates are in `View/Blade/`, registered with the `content` view namespace.
### Search Backends
`ContentSearchService` supports three backends via `CONTENT_SEARCH_BACKEND`:
- `database` (default) — LIKE queries with relevance scoring (title > slug > excerpt > content)
- `scout_database` — Laravel Scout with database driver
- `meilisearch` — Laravel Scout with Meilisearch
## Conventions
- UK English (colour, organisation, centre)
- UK English (colour, organisation, centre, behaviour)
- `declare(strict_types=1);` in all PHP files
- Type hints on all parameters and return types
- Final classes by default unless inheritance is intended
- Pest for testing (not PHPUnit syntax)
- Livewire + Flux Pro for UI components
- Livewire + Flux Pro for UI components (not vanilla Alpine)
- Font Awesome Pro for icons (not Heroicons)
## Rate Limiters
### Naming
Defined in `Boot::configureRateLimiting()`:
- `content-generate` - AI generation (10/min authenticated)
- `content-briefs` - Brief creation (30/min)
- `content-webhooks` - Incoming webhooks (60/min per endpoint)
- `content-search` - Search queries (configurable, default 60/min)
| Type | Convention | Example |
|------|------------|---------|
| Model | Singular PascalCase | `ContentItem` |
| Table | Plural snake_case | `content_items` |
| Controller | `{Model}Controller` | `ContentBriefController` |
| Livewire Page | `{Feature}Page` | `ProductListPage` |
| Livewire Modal | `{Feature}Modal` | `EditProductModal` |
### Don'ts
- Don't create controllers for Livewire pages
- Don't use Heroicons (use Font Awesome Pro)
- Don't use vanilla Alpine components (use Flux Pro)
- Don't use American English spellings
## Configuration (config.php)
Key settings exposed via environment:
- `CONTENT_GENERATION_TIMEOUT` - AI generation timeout
- `CONTENT_MAX_REVISIONS` - Revision limit per item
- `CONTENT_SEARCH_BACKEND` - Search driver (database, scout_database, meilisearch)
- `CONTENT_CACHE_TTL` - Content cache duration
Key environment variables:
- `CONTENT_GENERATION_TIMEOUT` — AI generation timeout (default 300s)
- `CONTENT_MAX_REVISIONS` — Revision limit per item (default 50)
- `CONTENT_SEARCH_BACKEND` — Search driver (database, scout_database, meilisearch)
- `CONTENT_CACHE_TTL` — Content cache duration (default 3600s)
- `CONTENT_SEARCH_RATE_LIMIT` — Search rate limit per minute (default 60)
## License
EUPL-1.2 (European Union Public Licence)
EUPL-1.2 (European Union Public Licence)

View file

@ -4,13 +4,13 @@ declare(strict_types=1);
namespace Core\Mod\Content\Console\Commands;
use Carbon\Carbon;
use Core\Mod\Content\Enums\ContentType;
use Core\Mod\Content\Models\ContentAuthor;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentMedia;
use Core\Mod\Content\Models\ContentTaxonomy;
use Core\Tenant\Models\Workspace;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Core\Mod\Content\Console\Commands;
use Core\Mod\Content\Services\WebhookRetryService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Core\Mod\Content\Services\WebhookRetryService;
/**
* ProcessPendingWebhooks

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Content\Console\Commands;
use Illuminate\Console\Command;
use Core\Mod\Content\Models\ContentRevision;
use Illuminate\Console\Command;
/**
* Prune old content revisions based on retention policy.

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Core\Mod\Content\Console\Commands;
use Core\Mod\Content\Models\ContentItem;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Core\Mod\Content\Models\ContentItem;
/**
* PublishScheduledContent

View file

@ -4,13 +4,13 @@ declare(strict_types=1);
namespace Core\Mod\Content\Controllers\Api;
use Core\Front\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Core\Api\Concerns\HasApiResponses;
use Core\Api\Concerns\ResolvesWorkspace;
use Core\Front\Controller;
use Core\Mod\Content\Models\ContentBrief;
use Core\Mod\Content\Resources\ContentBriefResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Content Brief API Controller

View file

@ -4,14 +4,14 @@ declare(strict_types=1);
namespace Core\Mod\Content\Controllers\Api;
use Core\Front\Controller;
use Core\Api\Concerns\HasApiResponses;
use Core\Api\Concerns\ResolvesWorkspace;
use Core\Front\Controller;
use Core\Mod\Content\Models\ContentMedia;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Core\Mod\Content\Models\ContentMedia;
/**
* Content Media API Controller

View file

@ -4,14 +4,14 @@ declare(strict_types=1);
namespace Core\Mod\Content\Controllers\Api;
use Core\Front\Controller;
use Core\Api\Concerns\HasApiResponses;
use Core\Api\Concerns\ResolvesWorkspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Core\Front\Controller;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentRevision;
use Core\Mod\Content\Resources\ContentRevisionResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Content Revision API Controller

View file

@ -4,12 +4,12 @@ declare(strict_types=1);
namespace Core\Mod\Content\Controllers\Api;
use Core\Front\Controller;
use Core\Api\Concerns\HasApiResponses;
use Core\Api\Concerns\ResolvesWorkspace;
use Core\Front\Controller;
use Core\Mod\Content\Services\ContentSearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Core\Mod\Content\Services\ContentSearchService;
/**
* Content Search API Controller

View file

@ -4,14 +4,13 @@ declare(strict_types=1);
namespace Core\Mod\Content\Controllers\Api;
use Core\Mod\Content\Jobs\ProcessContentWebhook;
use Core\Mod\Content\Models\ContentWebhookEndpoint;
use Core\Mod\Content\Services\WebhookDeliveryLogger;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Log;
use Core\Mod\Content\Jobs\ProcessContentWebhook;
use Core\Mod\Content\Models\ContentWebhookEndpoint;
use Core\Mod\Content\Models\ContentWebhookLog;
use Core\Mod\Content\Services\WebhookDeliveryLogger;
/**
* Controller for receiving external content webhooks.
@ -29,8 +28,7 @@ class ContentWebhookController extends Controller
{
public function __construct(
protected WebhookDeliveryLogger $deliveryLogger
) {
}
) {}
/**
* Receive a webhook from an external source.
@ -295,5 +293,4 @@ class ContentWebhookController extends Controller
return 'wordpress.post_updated';
}
}

View file

@ -4,16 +4,16 @@ declare(strict_types=1);
namespace Core\Mod\Content\Controllers\Api;
use Core\Front\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Core\Api\Concerns\HasApiResponses;
use Core\Api\Concerns\ResolvesWorkspace;
use Core\Front\Controller;
use Core\Mod\Content\Jobs\GenerateContentJob;
use Core\Mod\Content\Models\AIUsage;
use Core\Mod\Content\Models\ContentBrief;
use Core\Mod\Content\Resources\ContentBriefResource;
use Core\Mod\Content\Services\AIGatewayService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Content Generation API Controller

View file

@ -4,11 +4,11 @@ declare(strict_types=1);
namespace Core\Mod\Content\Controllers;
use Core\Mod\Content\Models\ContentItem;
use Core\Tenant\Models\Workspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Core\Mod\Content\Models\ContentItem;
/**
* ContentPreviewController - Preview draft content before publishing.

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Content\Database\Factories;
use Core\Mod\Content\Models\ContentBrief;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
class ContentBriefFactory extends Factory
{
protected $model = ContentBrief::class;
public function definition(): array
{
return [
'workspace_id' => Workspace::factory(),
'title' => $this->faker->sentence(),
'description' => $this->faker->paragraph(),
'status' => ContentBrief::STATUS_PENDING,
];
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Content\Database\Factories;
use Core\Mod\Content\Enums\ContentType;
use Core\Mod\Content\Models\ContentItem;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
class ContentItemFactory extends Factory
{
protected $model = ContentItem::class;
public function definition(): array
{
return [
'workspace_id' => Workspace::factory(),
'content_type' => ContentType::NATIVE->value,
'type' => 'post',
'status' => 'publish',
'slug' => $this->faker->slug(),
'title' => $this->faker->sentence(),
'excerpt' => $this->faker->paragraph(),
'content_html' => '<p>'.$this->faker->paragraphs(3, true).'</p>',
];
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Content\Database\Factories;
use Core\Mod\Content\Models\ContentTaxonomy;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
class ContentTaxonomyFactory extends Factory
{
protected $model = ContentTaxonomy::class;
public function definition(): array
{
return [
'workspace_id' => Workspace::factory(),
'type' => $this->faker->randomElement(['category', 'tag']),
'name' => $this->faker->word(),
'slug' => $this->faker->slug(),
'count' => $this->faker->numberBetween(0, 100),
];
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Content\Database\Factories;
use Core\Mod\Content\Models\ContentWebhookEndpoint;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
class ContentWebhookEndpointFactory extends Factory
{
protected $model = ContentWebhookEndpoint::class;
public function definition(): array
{
return [
'workspace_id' => Workspace::factory(),
'name' => $this->faker->company(),
'secret' => $this->faker->sha256(),
'require_signature' => true,
'allowed_types' => [],
'is_enabled' => true,
'failure_count' => 0,
];
}
public function circuitBroken(): static
{
return $this->state([
'failure_count' => ContentWebhookEndpoint::MAX_FAILURES,
'is_enabled' => false,
]);
}
public function disabled(): static
{
return $this->state([
'is_enabled' => false,
]);
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Content\Database\Factories;
use Core\Mod\Content\Models\ContentWebhookEndpoint;
use Core\Mod\Content\Models\ContentWebhookLog;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
class ContentWebhookLogFactory extends Factory
{
protected $model = ContentWebhookLog::class;
public function definition(): array
{
return [
'workspace_id' => Workspace::factory(),
'endpoint_id' => ContentWebhookEndpoint::factory(),
'event_type' => $this->faker->randomElement([
'wordpress.post_created',
'wordpress.post_updated',
'cms.content_created',
]),
'payload' => ['title' => $this->faker->sentence()],
'status' => 'pending',
'source_ip' => $this->faker->ipv4(),
];
}
}

View file

@ -4,14 +4,14 @@ declare(strict_types=1);
namespace Core\Mod\Content\Jobs;
use Core\Mod\Content\Models\ContentBrief;
use Core\Mod\Content\Services\AIGatewayService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Core\Mod\Content\Models\ContentBrief;
use Core\Mod\Content\Services\AIGatewayService;
/**
* GenerateContentJob

View file

@ -4,18 +4,18 @@ declare(strict_types=1);
namespace Core\Mod\Content\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Core\Mod\Content\Enums\ContentType;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentMedia;
use Core\Mod\Content\Models\ContentTaxonomy;
use Core\Mod\Content\Models\ContentWebhookEndpoint;
use Core\Mod\Content\Models\ContentWebhookLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* Process incoming content webhooks.

View file

@ -7,14 +7,14 @@ namespace Core\Mod\Content\Mcp\Handlers;
use Carbon\Carbon;
use Core\Front\Mcp\Contracts\McpToolHandler;
use Core\Front\Mcp\McpContext;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Services\EntitlementService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Core\Mod\Content\Enums\ContentType;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentRevision;
use Core\Mod\Content\Models\ContentTaxonomy;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Services\EntitlementService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
/**
* MCP tool handler for creating content items.

View file

@ -6,10 +6,10 @@ namespace Core\Mod\Content\Mcp\Handlers;
use Core\Front\Mcp\Contracts\McpToolHandler;
use Core\Front\Mcp\McpContext;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Auth;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentRevision;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Auth;
/**
* MCP tool handler for deleting content items.

View file

@ -6,9 +6,9 @@ namespace Core\Mod\Content\Mcp\Handlers;
use Core\Front\Mcp\Contracts\McpToolHandler;
use Core\Front\Mcp\McpContext;
use Core\Mod\Content\Models\ContentItem;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Str;
use Core\Mod\Content\Models\ContentItem;
/**
* MCP tool handler for listing content items.

View file

@ -6,8 +6,8 @@ namespace Core\Mod\Content\Mcp\Handlers;
use Core\Front\Mcp\Contracts\McpToolHandler;
use Core\Front\Mcp\McpContext;
use Core\Tenant\Models\Workspace;
use Core\Mod\Content\Models\ContentItem;
use Core\Tenant\Models\Workspace;
/**
* MCP tool handler for reading content items.

View file

@ -6,9 +6,9 @@ namespace Core\Mod\Content\Mcp\Handlers;
use Core\Front\Mcp\Contracts\McpToolHandler;
use Core\Front\Mcp\McpContext;
use Core\Mod\Content\Services\ContentSearchService;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Str;
use Core\Mod\Content\Services\ContentSearchService;
/**
* MCP tool handler for searching content.

View file

@ -6,8 +6,8 @@ namespace Core\Mod\Content\Mcp\Handlers;
use Core\Front\Mcp\Contracts\McpToolHandler;
use Core\Front\Mcp\McpContext;
use Core\Tenant\Models\Workspace;
use Core\Mod\Content\Models\ContentTaxonomy;
use Core\Tenant\Models\Workspace;
/**
* MCP tool handler for listing content taxonomies.

View file

@ -7,12 +7,12 @@ namespace Core\Mod\Content\Mcp\Handlers;
use Carbon\Carbon;
use Core\Front\Mcp\Contracts\McpToolHandler;
use Core\Front\Mcp\McpContext;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentRevision;
use Core\Mod\Content\Models\ContentTaxonomy;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
/**
* MCP tool handler for updating content items.

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Core\Mod\Content\Middleware;
use Closure;
use Core\Mod\Content\Services\ContentRender;
use Core\Tenant\Models\Workspace;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

View file

@ -4,11 +4,11 @@ declare(strict_types=1);
namespace Core\Mod\Content\Models;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Core\Tenant\Models\Workspace;
/**
* AIUsage Model

View file

@ -4,12 +4,12 @@ declare(strict_types=1);
namespace Core\Mod\Content\Models;
use Core\Mod\Content\Enums\BriefContentType;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Core\Mod\Content\Enums\BriefContentType;
use Core\Tenant\Models\Workspace;
/**
* ContentBrief Model

View file

@ -4,9 +4,12 @@ declare(strict_types=1);
namespace Core\Mod\Content\Models;
use Core\Mod\Content\Enums\ContentType;
use Core\Mod\Content\Observers\ContentItemObserver;
use Core\Mod\Content\Services\HtmlSanitiser;
use Core\Seo\HasSeoMetadata;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Seo\HasSeoMetadata;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -14,9 +17,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Core\Mod\Content\Enums\ContentType;
use Core\Mod\Content\Observers\ContentItemObserver;
use Core\Mod\Content\Services\HtmlSanitiser;
#[ObservedBy([ContentItemObserver::class])]
class ContentItem extends Model

View file

@ -377,14 +377,14 @@ class ContentWebhookEndpoint extends Model
*/
public function getStatusColorAttribute(): string
{
if (! $this->is_enabled) {
return 'zinc';
}
if ($this->isCircuitBroken()) {
return 'red';
}
if (! $this->is_enabled) {
return 'zinc';
}
if ($this->failure_count > 0) {
return 'yellow';
}
@ -397,14 +397,14 @@ class ContentWebhookEndpoint extends Model
*/
public function getStatusLabelAttribute(): string
{
if (! $this->is_enabled) {
return 'Disabled';
}
if ($this->isCircuitBroken()) {
return 'Circuit Open';
}
if (! $this->is_enabled) {
return 'Disabled';
}
if ($this->failure_count > 0) {
return "Active ({$this->failure_count} failures)";
}

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Core\Mod\Content\Observers;
use Illuminate\Support\Facades\Log;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Services\CdnPurgeService;
use Illuminate\Support\Facades\Log;
/**
* Content Item Observer - handles CDN cache purging on content changes.

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Content\Services;
use Illuminate\Support\Facades\Log;
use Core\Mod\Content\Models\ContentItem;
use Illuminate\Support\Facades\Log;
use Plug\Cdn\CdnManager;
use Plug\Response;

View file

@ -5,12 +5,12 @@ declare(strict_types=1);
namespace Core\Mod\Content\Services;
use Core\Front\Controller;
use Core\Mod\Content\Models\ContentItem;
use Core\Tenant\Models\Workspace;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Core\Mod\Content\Models\ContentItem;
use Core\Tenant\Models\Workspace;
/**
* ContentRender - Public workspace frontend renderer.

View file

@ -5,12 +5,12 @@ declare(strict_types=1);
namespace Core\Mod\Content\Services;
use Carbon\Carbon;
use Core\Mod\Content\Models\ContentItem;
use Core\Tenant\Models\Workspace;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Core\Mod\Content\Models\ContentItem;
/**
* Content Search Service

View file

@ -97,13 +97,8 @@ class HtmlSanitiser
$config->set('HTML.Nofollow', true);
$config->set('HTML.TargetNoopener', true);
// Disable cache in development, enable via config in production
$cacheDir = config('content.purifier_cache_dir');
if ($cacheDir && is_dir($cacheDir) && is_writable($cacheDir)) {
$config->set('Cache.SerializerPath', $cacheDir);
} else {
$config->set('Cache.DefinitionImpl', null);
}
// Allow id attributes (disabled by default in HTMLPurifier)
$config->set('Attr.EnableID', true);
// Safe URI schemes only
$config->set('URI.AllowedSchemes', [
@ -117,6 +112,22 @@ class HtmlSanitiser
$config->set('URI.DisableExternalResources', false);
$config->set('URI.DisableResources', false);
// Disable cache and set definition ID for custom HTML definitions
$config->set('Cache.DefinitionImpl', null);
$config->set('HTML.DefinitionID', 'core-content-html5');
$config->set('HTML.DefinitionRev', 1);
// Register HTML5 elements that HTMLPurifier doesn't know about
// NOTE: maybeGetRawHTMLDefinition() finalizes the config — all
// $config->set() calls must come before this point.
if ($def = $config->maybeGetRawHTMLDefinition()) {
$def->addElement('section', 'Block', 'Flow', 'Common');
$def->addElement('article', 'Block', 'Flow', 'Common');
$def->addElement('figure', 'Block', 'Flow', 'Common');
$def->addElement('figcaption', 'Inline', 'Flow', 'Common');
$def->addElement('mark', 'Inline', 'Inline', 'Common');
}
$this->purifier = new HTMLPurifier($config);
}

View file

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Core\Mod\Content\Services;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Core\Mod\Content\Models\ContentWebhookEndpoint;
use Core\Mod\Content\Models\ContentWebhookLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* WebhookDeliveryLogger
@ -43,7 +43,6 @@ class WebhookDeliveryLogger
* @param int $durationMs Processing duration in milliseconds
* @param int|null $responseCode HTTP response code if applicable
* @param string|null $responseBody Response body if applicable
* @return void
*/
public function logSuccess(
ContentWebhookLog $webhookLog,
@ -74,7 +73,6 @@ class WebhookDeliveryLogger
* @param int $durationMs Processing duration in milliseconds
* @param int|null $responseCode HTTP response code if applicable
* @param string|null $responseBody Response body if applicable
* @return void
*/
public function logFailure(
ContentWebhookLog $webhookLog,
@ -128,7 +126,7 @@ class WebhookDeliveryLogger
'source_ip' => $request->ip(),
'signature_verified' => false,
'signature_failure_reason' => $failureReason,
'error_message' => 'Signature verification failed: ' . $failureReason,
'error_message' => 'Signature verification failed: '.$failureReason,
'processed_at' => now(),
]);
@ -149,7 +147,6 @@ class WebhookDeliveryLogger
*
* @param ContentWebhookLog $webhookLog The webhook log entry
* @param string $verificationMethod How the signature was verified (e.g., 'current_secret', 'grace_period')
* @return void
*/
public function logSignatureSuccess(
ContentWebhookLog $webhookLog,
@ -172,7 +169,6 @@ class WebhookDeliveryLogger
*
* @param Request $request The incoming request
* @param ContentWebhookEndpoint $endpoint The webhook endpoint
* @return void
*/
public function logSignatureNotRequired(
Request $request,
@ -195,7 +191,6 @@ class WebhookDeliveryLogger
* @param array $payload The parsed payload
* @param string $eventType The determined event type
* @param array $verificationResult The signature verification result
* @return ContentWebhookLog
*/
public function createDeliveryLog(
Request $request,
@ -225,7 +220,6 @@ class WebhookDeliveryLogger
* @param ContentWebhookLog $webhookLog The webhook log entry
* @param int $durationMs Processing duration in milliseconds
* @param array|null $result Processing result details
* @return void
*/
public function recordProcessingMetrics(
ContentWebhookLog $webhookLog,
@ -341,7 +335,6 @@ class WebhookDeliveryLogger
* Extract content ID from payload.
*
* @param array $data The webhook payload
* @return int|null
*/
protected function extractContentId(array $data): ?int
{
@ -364,7 +357,6 @@ class WebhookDeliveryLogger
* Extract content type from payload.
*
* @param array $data The webhook payload
* @return string|null
*/
protected function extractContentType(array $data): ?string
{

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Core\Mod\Content\Services;
use Core\Mod\Content\Models\ContentWebhookLog;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log;
use Core\Mod\Content\Models\ContentWebhookLog;
/**
* WebhookRetryService

View file

@ -4,14 +4,14 @@ declare(strict_types=1);
namespace Core\Mod\Content\View\Modal\Admin;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentTaxonomy;
use Core\Mod\Content\Services\ContentSearchService;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentTaxonomy;
use Core\Mod\Content\Services\ContentSearchService;
/**
* Content Search Livewire Component

View file

@ -4,13 +4,13 @@ declare(strict_types=1);
namespace Core\Mod\Content\View\Modal\Admin;
use Core\Mod\Content\Models\ContentWebhookEndpoint;
use Core\Mod\Content\Models\ContentWebhookLog;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use Core\Mod\Content\Models\ContentWebhookEndpoint;
use Core\Mod\Content\Models\ContentWebhookLog;
/**
* Livewire component for managing content webhook endpoints.

View file

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Core\Mod\Content\View\Modal\Web;
use Livewire\Component;
use Core\Mod\Content\Services\ContentRender;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Services\WorkspaceService;
use Livewire\Component;
class Blog extends Component
{

View file

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Core\Mod\Content\View\Modal\Web;
use Livewire\Component;
use Core\Mod\Content\Models\ContentItem;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Services\WorkspaceService;
use Livewire\Component;
class Help extends Component
{

View file

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Core\Mod\Content\View\Modal\Web;
use Livewire\Component;
use Core\Mod\Content\Services\ContentRender;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Services\WorkspaceService;
use Livewire\Component;
class HelpArticle extends Component
{

View file

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Core\Mod\Content\View\Modal\Web;
use Livewire\Component;
use Core\Mod\Content\Services\ContentRender;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Services\WorkspaceService;
use Livewire\Component;
class Post extends Component
{

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Core\Mod\Content\View\Modal\Web;
use Core\Mod\Content\Models\ContentItem;
use Core\Tenant\Models\Workspace;
use Livewire\Component;
use Core\Mod\Content\Models\ContentItem;
/**
* Preview - Render draft/unpublished content with preview token.

View file

@ -1,17 +1,23 @@
{
"name": "host-uk/core-content",
"name": "lthn/php-content",
"description": "Content management and headless CMS for Laravel",
"keywords": ["laravel", "content", "cms", "headless"],
"keywords": [
"laravel",
"content",
"cms",
"headless"
],
"license": "EUPL-1.2",
"require": {
"php": "^8.2",
"host-uk/core": "dev-main",
"lthn/php": "*",
"ezyang/htmlpurifier": "^4.17"
},
"require-dev": {
"laravel/pint": "^1.18",
"orchestra/testbench": "^9.0|^10.0",
"pestphp/pest": "^3.0"
"pestphp/pest": "^3.0",
"core/php-tenant": "@dev"
},
"autoload": {
"psr-4": {
@ -20,7 +26,8 @@
},
"autoload-dev": {
"psr-4": {
"Core\\Mod\\Content\\Tests\\": "Tests/"
"Core\\Mod\\Content\\Tests\\": "Tests/",
"Tests\\": "tests/"
}
},
"extra": {
@ -41,5 +48,15 @@
}
},
"minimum-stability": "dev",
"prefer-stable": true
"prefer-stable": true,
"repositories": [
{
"name": "core",
"type": "path",
"url": "../php-framework"
}
],
"replace": {
"core/php-content": "self.version"
}
}

39
phpunit.xml Normal file
View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="random"
requireCoverageMetadata="false"
beStrictAboutCoverageMetadata="false"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_DEBUG" value="true"/>
<env name="APP_KEY" value="base64:Kx0qLJZJAQcDSFE2gMpuOlwrJcC6kXHM0j0KJdMGqzQ="/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>

View file

@ -9,7 +9,6 @@ declare(strict_types=1);
* Supports both session auth and API key auth.
*/
use Illuminate\Support\Facades\Route;
use Core\Mod\Content\Controllers\Api\ContentBriefController;
use Core\Mod\Content\Controllers\Api\ContentMediaController;
use Core\Mod\Content\Controllers\Api\ContentRevisionController;
@ -17,6 +16,7 @@ use Core\Mod\Content\Controllers\Api\ContentSearchController;
use Core\Mod\Content\Controllers\Api\ContentWebhookController;
use Core\Mod\Content\Controllers\Api\GenerationController;
use Core\Mod\Content\Controllers\ContentPreviewController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------

View file

@ -1,3 +1,5 @@
<?php
declare(strict_types=1);
// Console commands are registered via Core modules

View file

@ -2,12 +2,12 @@
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
use Core\Mod\Content\View\Modal\Web\Blog;
use Core\Mod\Content\View\Modal\Web\Help;
use Core\Mod\Content\View\Modal\Web\HelpArticle;
use Core\Mod\Content\View\Modal\Web\Post;
use Core\Mod\Content\View\Modal\Web\Preview;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Core\Mod\Content\Models\ContentAuthor;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentMedia;

View file

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Core\Mod\Content\Tests\Feature;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Mod\Content\Enums\ContentType;
use Core\Mod\Content\Models\ContentItem;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Core\Mod\Content\Services\ContentRender;
use Core\Tenant\Models\Workspace;
use Illuminate\Http\Request;

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Core\Mod\Content\Models\ContentAuthor;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentMedia;

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentRevision;

View file

@ -8,7 +8,6 @@ use Core\Mod\Content\Models\ContentWebhookEndpoint;
use Core\Mod\Content\Models\ContentWebhookLog;
use Core\Tenant\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@ -74,7 +73,7 @@ class WebhookSignatureVerificationTest extends TestCase
public function it_accepts_github_style_signature(): void
{
$payload = json_encode(['ID' => 456, 'post_title' => 'GitHub Style']);
$signature = 'sha256=' . hash_hmac('sha256', $payload, 'test-webhook-secret-key');
$signature = 'sha256='.hash_hmac('sha256', $payload, 'test-webhook-secret-key');
$response = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
@ -525,7 +524,7 @@ class WebhookSignatureVerificationTest extends TestCase
$response2 = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
json_decode($payload, true),
['X-Signature' => 'a' . substr($validSignature, 1), 'X-Event-Type' => 'post.updated']
['X-Signature' => 'a'.substr($validSignature, 1), 'X-Event-Type' => 'post.updated']
);
$response2->assertStatus(401);

5
tests/Pest.php Normal file
View file

@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
uses(Tests\TestCase::class)->in('Feature', 'Unit');

View file

@ -1,10 +1,36 @@
<?php
declare(strict_types=1);
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
use RefreshDatabase;
protected function getPackageProviders($app): array
{
return [
\Core\Tenant\Boot::class,
\Core\Mod\Content\Boot::class,
];
}
protected function getEnvironmentSetUp($app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
// Stub external dependencies not available in test environment
if (! class_exists(\Plug\Cdn\CdnManager::class)) {
$app->bind(\Core\Mod\Content\Services\CdnPurgeService::class, fn () => \Mockery::mock(\Core\Mod\Content\Services\CdnPurgeService::class)->shouldIgnoreMissing());
}
}
}

View file

@ -4,12 +4,12 @@ declare(strict_types=1);
namespace Core\Mod\Content\Tests\Unit;
use Core\Tenant\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Core\Mod\Content\Enums\ContentType;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentTaxonomy;
use Core\Mod\Content\Services\ContentSearchService;
use Core\Tenant\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**

View file

@ -10,6 +10,18 @@ use Tests\TestCase;
class ContentWebhookEndpointTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Register the webhook route if not already defined (routes may not
// load in Orchestra Testbench without the full Core event pipeline)
if (! \Illuminate\Support\Facades\Route::has('api.content.webhooks.receive')) {
\Illuminate\Support\Facades\Route::get('/api/content/webhooks/{endpoint}', fn () => null)
->name('api.content.webhooks.receive');
}
}
#[Test]
public function it_generates_uuid_on_creation(): void
{
@ -73,7 +85,8 @@ class ContentWebhookEndpointTest extends TestCase
#[Test]
public function it_rejects_webhook_without_secret_when_signature_required(): void
{
$endpoint = ContentWebhookEndpoint::factory()->create([
// Use make() to bypass the creating event which auto-generates secrets
$endpoint = ContentWebhookEndpoint::factory()->make([
'secret' => null,
'require_signature' => true,
]);
@ -88,7 +101,8 @@ class ContentWebhookEndpointTest extends TestCase
#[Test]
public function it_allows_webhook_without_secret_when_signature_not_required(): void
{
$endpoint = ContentWebhookEndpoint::factory()->create([
// Use make() to bypass the creating event which auto-generates secrets
$endpoint = ContentWebhookEndpoint::factory()->make([
'secret' => null,
'require_signature' => false,
]);

View file

@ -4,13 +4,13 @@ declare(strict_types=1);
namespace Core\Mod\Content\Tests\Unit;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Queue;
use Core\Mod\Content\Enums\ContentType;
use Core\Mod\Content\Jobs\ProcessContentWebhook;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentWebhookEndpoint;
use Core\Mod\Content\Models\ContentWebhookLog;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

View file

@ -31,7 +31,7 @@ class WebhookDeliveryLoggerTest extends TestCase
{
parent::setUp();
$this->logger = new WebhookDeliveryLogger();
$this->logger = new WebhookDeliveryLogger;
$this->workspace = Workspace::factory()->create();
$this->endpoint = ContentWebhookEndpoint::factory()->create([
'workspace_id' => $this->workspace->id,