diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 147632b9..1c19c6d1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -65,7 +65,19 @@ jobs:
fetch-depth: 0
- uses: shivammathur/setup-php@v2
with:
- php-version: '8.3'
+ # 8.4, not 8.3: composer.lock pins symfony/clock, symfony/string,
+ # symfony/translation, symfony/css-selector and symfony/event-dispatcher
+ # at v8.1.x, all of which require php >=8.4.1. On 8.3 the install step
+ # aborted with "Your lock file does not contain a compatible set of
+ # packages" before a single test ran, so this job has been failing at
+ # install — not at the suite — on every push since the lock was written.
+ #
+ # composer.json still declares php ^8.2. That floor is now untested and
+ # unreachable with this lock; reconciling the two means either pinning
+ # config.platform.php and resolving the tree back down, or raising the
+ # declared minimum. Tracked separately — this change only gets the
+ # suite running again.
+ php-version: '8.4'
# pcov, not the default none: --coverage silently produces nothing
# without a coverage driver installed.
coverage: pcov
@@ -80,10 +92,15 @@ jobs:
# in the default ./tests, which in this repo holds unrelated CLI
# fixtures. Without it nothing is bound and every test errors.
#
- # continue-on-error while the suite is brought up: as of 2026-08-01 it
- # runs 1302 tests, 993 passing and 303 failing — the failures are real
- # and tracked (Livewire view stubs, the missing Core\Mcp dependency
- # trait), not flakes. Drop this flag once the count reaches zero.
+ # continue-on-error while the suite is brought up: as of 2026-08-08 it
+ # runs 1311 tests, 1155 passing and 156 failing, with nothing skipped or
+ # excluded. The failures are real and tracked, not flakes — the largest
+ # remaining groups are the Core\Mcp tools (Core\Mcp\Tools\Concerns\
+ # ValidatesDependencies and Core\Mcp\Dependencies\HasDependencies live in
+ # dappcore/mcp, which is not a dependency here and collides with this
+ # package's own Core\Mcp\ PSR-4 root), the admin Livewire view stubs, and
+ # tests that reach a live Qdrant or Elasticsearch. Drop this flag once
+ # the count reaches zero.
continue-on-error: true
run: ./vendor/bin/pest --test-directory=php/tests --coverage --coverage-clover=coverage.xml
- name: Upload PHP coverage to Codecov
diff --git a/composer.json b/composer.json
index 8a490ad9..44f1e4e8 100644
--- a/composer.json
+++ b/composer.json
@@ -9,11 +9,12 @@
],
"license": "EUPL-1.2",
"require": {
- "php": "^8.2",
+ "php": "^8.4",
"dappcore/php": "*",
"spatie/laravel-activitylog": "^4.8"
},
"require-dev": {
+ "dappcore/php-content": "^0.1",
"dappcore/php-tenant": "^0.1",
"laravel/pint": "^1.18",
"livewire/livewire": "^3.0",
diff --git a/composer.lock b/composer.lock
index 354bd519..535e4ab6 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "f4bf125fdc4a309f3c89e33f8bc1e579",
+ "content-hash": "90139806273023bc049353468bc77969",
"packages": [
{
"name": "brick/math",
@@ -6380,6 +6380,70 @@
],
"time": "2025-08-20T19:15:30+00:00"
},
+ {
+ "name": "dappcore/php-content",
+ "version": "v0.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dAppCore/php-content.git",
+ "reference": "eee31983226418c3da6bded3833eae239b3ccf6f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dAppCore/php-content/zipball/eee31983226418c3da6bded3833eae239b3ccf6f",
+ "reference": "eee31983226418c3da6bded3833eae239b3ccf6f",
+ "shasum": ""
+ },
+ "require": {
+ "dappcore/php": "*",
+ "ezyang/htmlpurifier": "^4.17",
+ "php": "^8.2"
+ },
+ "replace": {
+ "core/php-content": "self.version"
+ },
+ "require-dev": {
+ "dappcore/php-tenant": "@dev",
+ "laravel/pint": "^1.18",
+ "orchestra/testbench": "^9.0|^10.0",
+ "pestphp/pest": "^3.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Core\\Mod\\Content\\Boot"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Core\\Mod\\Content\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "EUPL-1.2"
+ ],
+ "description": "Content management and headless CMS for Laravel",
+ "keywords": [
+ "cms",
+ "content",
+ "headless",
+ "laravel"
+ ],
+ "support": {
+ "issues": "https://github.com/dAppCore/php-content/issues",
+ "source": "https://github.com/dAppCore/php-content/tree/v0.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://donate.trees.org/-/NPMMSVUP?member=SWZTDDWH",
+ "type": "custom"
+ }
+ ],
+ "time": "2026-07-31T16:32:24+00:00"
+ },
{
"name": "dappcore/php-tenant",
"version": "v0.1.0",
@@ -6489,6 +6553,67 @@
},
"time": "2026-02-07T07:09:04+00:00"
},
+ {
+ "name": "ezyang/htmlpurifier",
+ "version": "v4.19.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ezyang/htmlpurifier.git",
+ "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
+ "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
+ "shasum": ""
+ },
+ "require": {
+ "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
+ },
+ "require-dev": {
+ "cerdic/css-tidy": "^1.7 || ^2.0",
+ "simpletest/simpletest": "dev-master"
+ },
+ "suggest": {
+ "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
+ "ext-bcmath": "Used for unit conversion and imagecrash protection",
+ "ext-iconv": "Converts text to and from non-UTF-8 encodings",
+ "ext-tidy": "Used for pretty-printing HTML"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "library/HTMLPurifier.composer.php"
+ ],
+ "psr-0": {
+ "HTMLPurifier": "library/"
+ },
+ "exclude-from-classmap": [
+ "/library/HTMLPurifier/Language/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-2.1-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Edward Z. Yang",
+ "email": "admin@htmlpurifier.org",
+ "homepage": "http://ezyang.com"
+ }
+ ],
+ "description": "Standards compliant HTML filter written in PHP",
+ "homepage": "http://htmlpurifier.org/",
+ "keywords": [
+ "html"
+ ],
+ "support": {
+ "issues": "https://github.com/ezyang/htmlpurifier/issues",
+ "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
+ },
+ "time": "2025-10-17T16:34:55+00:00"
+ },
{
"name": "fakerphp/faker",
"version": "v1.24.1",
@@ -10273,7 +10398,7 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
- "php": "^8.2"
+ "php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
diff --git a/php/Console/Commands/GenerateCommand.php b/php/Console/Commands/GenerateCommand.php
index 0e64e75c..7ef629ad 100644
--- a/php/Console/Commands/GenerateCommand.php
+++ b/php/Console/Commands/GenerateCommand.php
@@ -7,10 +7,12 @@
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Models\AgentPlan;
+use Core\Mod\Content\Jobs\GenerateContentJob;
+use Core\Mod\Content\Models\AIUsage;
+use Core\Mod\Content\Models\ContentBrief;
+use Core\Mod\Content\Services\AIGatewayService;
use Illuminate\Console\Command;
-use Mod\Content\Jobs\GenerateContentJob;
-use Mod\Content\Models\ContentBrief;
-use Mod\Content\Services\AIGatewayService;
+use Illuminate\Support\Str;
class GenerateCommand extends Command
{
@@ -104,7 +106,7 @@ protected function generateBrief(): int
// Create brief
$brief = ContentBrief::create([
'title' => $title,
- 'slug' => \Illuminate\Support\Str::slug($title),
+ 'slug' => Str::slug($title),
'content_type' => $this->option('type'),
'service' => $this->option('service'),
'keywords' => $this->option('keywords')
@@ -268,7 +270,7 @@ protected function generateFromPlan(): int
// Create brief from task
$brief = ContentBrief::create([
'title' => $taskName,
- 'slug' => \Illuminate\Support\Str::slug($taskName).'-'.time(),
+ 'slug' => Str::slug($taskName).'-'.time(),
'content_type' => $this->option('type'),
'service' => $this->option('service') ?? ($plan->metadata['service'] ?? null),
'target_word_count' => (int) $this->option('words'),
@@ -333,7 +335,7 @@ protected function showQueueStats(): int
$this->newLine();
$this->line(' AI Usage (This Month):');
- $usage = \Mod\Content\Models\AIUsage::thisMonth()
+ $usage = AIUsage::thisMonth()
->selectRaw('provider, SUM(input_tokens) as input, SUM(output_tokens) as output, SUM(cost_estimate) as cost')
->groupBy('provider')
->get();
diff --git a/php/Agentic/Data/CreditTransaction.php b/php/Data/CreditTransaction.php
similarity index 100%
rename from php/Agentic/Data/CreditTransaction.php
rename to php/Data/CreditTransaction.php
diff --git a/php/Agentic/Data/FleetStats.php b/php/Data/FleetStats.php
similarity index 100%
rename from php/Agentic/Data/FleetStats.php
rename to php/Data/FleetStats.php
diff --git a/php/Jobs/BatchContentGeneration.php b/php/Jobs/BatchContentGeneration.php
index 9cf14607..1765b6e3 100644
--- a/php/Jobs/BatchContentGeneration.php
+++ b/php/Jobs/BatchContentGeneration.php
@@ -4,13 +4,13 @@
namespace Core\Mod\Agentic\Jobs;
+use Core\Mod\Content\Models\ContentTask;
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 Mod\Content\Models\ContentTask;
class BatchContentGeneration implements ShouldQueue
{
diff --git a/php/Jobs/ProcessContentTask.php b/php/Jobs/ProcessContentTask.php
index 03c8abf5..879ea28c 100644
--- a/php/Jobs/ProcessContentTask.php
+++ b/php/Jobs/ProcessContentTask.php
@@ -5,13 +5,13 @@
namespace Core\Mod\Agentic\Jobs;
use Core\Mod\Agentic\Services\AgenticManager;
+use Core\Mod\Content\Models\ContentTask;
use Core\Tenant\Services\EntitlementService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
-use Mod\Content\Models\ContentTask;
use Throwable;
class ProcessContentTask implements ShouldQueue
@@ -51,7 +51,12 @@ public function handle(
$result = $entitlements->can($workspace, 'ai.credits');
if ($result->isDenied()) {
- $this->task->markFailed("Entitlement denied: {$result->message}");
+ // getMessage(), not ->message: EntitlementResult carries the text
+ // as a readonly $reason and exposes it through getMessage(). There
+ // is no $message property and no __get, so this interpolated an
+ // undefined property and every denial was recorded with an empty
+ // reason.
+ $this->task->markFailed("Entitlement denied: {$result->getMessage()}");
return;
}
@@ -111,7 +116,10 @@ public function failed(Throwable $exception): void
private function interpolateVariables(string $template, array $data): string
{
foreach ($data as $key => $value) {
- $placeholder = '{{{'.$key.'}}}';
+ // Two braces, not three. Every prompt template in the package writes
+ // {{name}}, so the three-brace placeholder never matched anything and
+ // user templates reached the provider with their variables intact.
+ $placeholder = '{{'.$key.'}}';
if (is_string($value)) {
$template = str_replace($placeholder, $value, $template);
diff --git a/php/Agentic/Livewire/BrainExplorer.php b/php/Livewire/BrainExplorer.php
similarity index 99%
rename from php/Agentic/Livewire/BrainExplorer.php
rename to php/Livewire/BrainExplorer.php
index f50f105c..b7664f3e 100644
--- a/php/Agentic/Livewire/BrainExplorer.php
+++ b/php/Livewire/BrainExplorer.php
@@ -300,6 +300,6 @@ private function normaliseMemory(array|BrainMemory $memory): array
*/
protected function viewPath(): string
{
- return __DIR__.'/../../resources/views/livewire/agentic/brain-explorer.blade.php';
+ return __DIR__.'/../resources/views/livewire/agentic/brain-explorer.blade.php';
}
}
diff --git a/php/Agentic/Livewire/CreditLedger.php b/php/Livewire/CreditLedger.php
similarity index 98%
rename from php/Agentic/Livewire/CreditLedger.php
rename to php/Livewire/CreditLedger.php
index b19b27a5..b2694736 100644
--- a/php/Agentic/Livewire/CreditLedger.php
+++ b/php/Livewire/CreditLedger.php
@@ -286,6 +286,6 @@ private function syncSelectedAgentId(): void
*/
protected function viewPath(): string
{
- return __DIR__.'/../../resources/views/livewire/agentic/credit-ledger.blade.php';
+ return __DIR__.'/../resources/views/livewire/agentic/credit-ledger.blade.php';
}
}
diff --git a/php/Agentic/Livewire/FleetOverview.php b/php/Livewire/FleetOverview.php
similarity index 99%
rename from php/Agentic/Livewire/FleetOverview.php
rename to php/Livewire/FleetOverview.php
index 6bdeda01..5d5b3e1a 100644
--- a/php/Agentic/Livewire/FleetOverview.php
+++ b/php/Livewire/FleetOverview.php
@@ -299,6 +299,6 @@ private function summariseBudget(array $budget): string
*/
protected function viewPath(): string
{
- return __DIR__.'/../../resources/views/livewire/agentic/fleet-overview.blade.php';
+ return __DIR__.'/../resources/views/livewire/agentic/fleet-overview.blade.php';
}
}
diff --git a/php/Agentic/Livewire/HubComponent.php b/php/Livewire/HubComponent.php
similarity index 95%
rename from php/Agentic/Livewire/HubComponent.php
rename to php/Livewire/HubComponent.php
index 73f823e3..7d6aef6d 100644
--- a/php/Agentic/Livewire/HubComponent.php
+++ b/php/Livewire/HubComponent.php
@@ -19,7 +19,7 @@
* {
* protected function viewPath(): string
* {
- * return __DIR__.'/../../resources/views/livewire/agentic/fleet-overview.blade.php';
+ * return __DIR__.'/../resources/views/livewire/agentic/fleet-overview.blade.php';
* }
* }
*/
diff --git a/php/Mcp/Resources/ContentResource.php b/php/Mcp/Resources/ContentResource.php
index 185cb311..19013593 100644
--- a/php/Mcp/Resources/ContentResource.php
+++ b/php/Mcp/Resources/ContentResource.php
@@ -6,11 +6,11 @@
namespace Core\Mcp\Resources;
+use Core\Mod\Content\Models\ContentItem;
use Core\Tenant\Models\Workspace;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Resource;
-use Mod\Content\Models\ContentItem;
use Symfony\Component\Yaml\Yaml;
// Not final: resolveWorkspace / resolveContentItem / listResources are
diff --git a/php/Mcp/Tools/Agent/Content/ContentBatchGenerate.php b/php/Mcp/Tools/Agent/Content/ContentBatchGenerate.php
index a1773c7b..8c427fa2 100644
--- a/php/Mcp/Tools/Agent/Content/ContentBatchGenerate.php
+++ b/php/Mcp/Tools/Agent/Content/ContentBatchGenerate.php
@@ -5,8 +5,8 @@
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
-use Mod\Content\Jobs\GenerateContentJob;
-use Mod\Content\Models\ContentBrief;
+use Core\Mod\Content\Jobs\GenerateContentJob;
+use Core\Mod\Content\Models\ContentBrief;
/**
* Queue multiple briefs for batch content generation.
diff --git a/php/Mcp/Tools/Agent/Content/ContentBriefCreate.php b/php/Mcp/Tools/Agent/Content/ContentBriefCreate.php
index e922a0bc..b545b8fe 100644
--- a/php/Mcp/Tools/Agent/Content/ContentBriefCreate.php
+++ b/php/Mcp/Tools/Agent/Content/ContentBriefCreate.php
@@ -6,9 +6,9 @@
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
+use Core\Mod\Content\Enums\BriefContentType;
+use Core\Mod\Content\Models\ContentBrief;
use Illuminate\Support\Str;
-use Mod\Content\Enums\BriefContentType;
-use Mod\Content\Models\ContentBrief;
/**
* Create a content brief for AI generation.
diff --git a/php/Mcp/Tools/Agent/Content/ContentBriefGet.php b/php/Mcp/Tools/Agent/Content/ContentBriefGet.php
index 72fd152b..cb67e8d2 100644
--- a/php/Mcp/Tools/Agent/Content/ContentBriefGet.php
+++ b/php/Mcp/Tools/Agent/Content/ContentBriefGet.php
@@ -5,8 +5,8 @@
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
-use Mod\Content\Enums\BriefContentType;
-use Mod\Content\Models\ContentBrief;
+use Core\Mod\Content\Enums\BriefContentType;
+use Core\Mod\Content\Models\ContentBrief;
/**
* Get details of a specific content brief including generated content.
diff --git a/php/Mcp/Tools/Agent/Content/ContentBriefList.php b/php/Mcp/Tools/Agent/Content/ContentBriefList.php
index 6c0f9d26..acb77724 100644
--- a/php/Mcp/Tools/Agent/Content/ContentBriefList.php
+++ b/php/Mcp/Tools/Agent/Content/ContentBriefList.php
@@ -5,8 +5,8 @@
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
-use Mod\Content\Enums\BriefContentType;
-use Mod\Content\Models\ContentBrief;
+use Core\Mod\Content\Enums\BriefContentType;
+use Core\Mod\Content\Models\ContentBrief;
/**
* List content briefs with optional status filter.
diff --git a/php/Mcp/Tools/Agent/Content/ContentFromPlan.php b/php/Mcp/Tools/Agent/Content/ContentFromPlan.php
index c1c257ba..4c5ca3db 100644
--- a/php/Mcp/Tools/Agent/Content/ContentFromPlan.php
+++ b/php/Mcp/Tools/Agent/Content/ContentFromPlan.php
@@ -6,10 +6,10 @@
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
+use Core\Mod\Content\Enums\BriefContentType;
+use Core\Mod\Content\Jobs\GenerateContentJob;
+use Core\Mod\Content\Models\ContentBrief;
use Illuminate\Support\Str;
-use Mod\Content\Enums\BriefContentType;
-use Mod\Content\Jobs\GenerateContentJob;
-use Mod\Content\Models\ContentBrief;
/**
* Create content briefs from plan tasks and queue for generation.
diff --git a/php/Mcp/Tools/Agent/Content/ContentGenerate.php b/php/Mcp/Tools/Agent/Content/ContentGenerate.php
index 3529403d..58dcf681 100644
--- a/php/Mcp/Tools/Agent/Content/ContentGenerate.php
+++ b/php/Mcp/Tools/Agent/Content/ContentGenerate.php
@@ -5,9 +5,9 @@
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
-use Mod\Content\Jobs\GenerateContentJob;
-use Mod\Content\Models\ContentBrief;
-use Mod\Content\Services\AIGatewayService;
+use Core\Mod\Content\Jobs\GenerateContentJob;
+use Core\Mod\Content\Models\ContentBrief;
+use Core\Mod\Content\Services\AIGatewayService;
/**
* Generate content for a brief using AI pipeline.
diff --git a/php/Mcp/Tools/Agent/Content/ContentStatus.php b/php/Mcp/Tools/Agent/Content/ContentStatus.php
index fa887358..b05a3e71 100644
--- a/php/Mcp/Tools/Agent/Content/ContentStatus.php
+++ b/php/Mcp/Tools/Agent/Content/ContentStatus.php
@@ -5,8 +5,8 @@
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
-use Mod\Content\Models\ContentBrief;
-use Mod\Content\Services\AIGatewayService;
+use Core\Mod\Content\Models\ContentBrief;
+use Core\Mod\Content\Services\AIGatewayService;
/**
* Get content generation pipeline status.
diff --git a/php/Mcp/Tools/Agent/Content/ContentUsageStats.php b/php/Mcp/Tools/Agent/Content/ContentUsageStats.php
index 9d6e3eea..34cbf5cc 100644
--- a/php/Mcp/Tools/Agent/Content/ContentUsageStats.php
+++ b/php/Mcp/Tools/Agent/Content/ContentUsageStats.php
@@ -5,7 +5,7 @@
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
-use Mod\Content\Models\AIUsage;
+use Core\Mod\Content\Models\AIUsage;
/**
* Get AI usage statistics for content generation.
diff --git a/php/Models/Prompt.php b/php/Models/Prompt.php
index 2c1ee42f..ca1e08b9 100644
--- a/php/Models/Prompt.php
+++ b/php/Models/Prompt.php
@@ -4,11 +4,11 @@
namespace Core\Mod\Agentic\Models;
+use Core\Mod\Content\Models\ContentTask;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
-use Mod\Content\Models\ContentTask;
class Prompt extends Model
{
diff --git a/php/Services/ContentService.php b/php/Services/ContentService.php
index af205068..7cb763f2 100644
--- a/php/Services/ContentService.php
+++ b/php/Services/ContentService.php
@@ -4,8 +4,8 @@
namespace Core\Mod\Agentic\Services;
+use Core\Mod\Content\Models\ContentItem;
use Illuminate\Support\Facades\File;
-use Mod\Content\Models\ContentItem;
use Symfony\Component\Yaml\Yaml;
class ContentService
diff --git a/php/Agentic/Services/CreditService.php b/php/Services/CreditService.php
similarity index 100%
rename from php/Agentic/Services/CreditService.php
rename to php/Services/CreditService.php
diff --git a/php/Agentic/Services/SessionService.php b/php/Services/SessionService.php
similarity index 100%
rename from php/Agentic/Services/SessionService.php
rename to php/Services/SessionService.php
diff --git a/php/tests/Feature/Agentic/Livewire/BrainExplorerTest.php b/php/tests/Feature/Agentic/Livewire/BrainExplorerTest.php
index 9319c6e8..9454813b 100644
--- a/php/tests/Feature/Agentic/Livewire/BrainExplorerTest.php
+++ b/php/tests/Feature/Agentic/Livewire/BrainExplorerTest.php
@@ -4,77 +4,103 @@
declare(strict_types=1);
+namespace Core\Mod\Agentic\Tests\Feature\Agentic\Livewire;
+
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
+use Core\Mod\Agentic\Tests\Feature\Livewire\LivewireTestCase;
+use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
+use RuntimeException;
+
+/**
+ * A classic PHPUnit class, not a Pest file: Pest binds Tests\TestCase to the
+ * whole Feature directory, and a file-level uses(LivewireTestCase::class)
+ * inside that directory is a second binding for the same file, which Pest
+ * rejects outright (TestCaseAlreadyInUse) regardless of the inheritance
+ * between the two. Class-style tests are not touched by uses()->in(), which
+ * is why the thirteen sibling files in Feature/Livewire have always run.
+ */
+class BrainExplorerTest extends LivewireTestCase
+{
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ // Forgetting a memory deletes its point from Qdrant. Unfaked, that is a
+ // real request to localhost:6334 which retries six times before failing,
+ // so the test asserted nothing about the component and just timed out
+ // against whatever was or was not listening on the developer's machine.
+ Http::fake();
-uses(\Core\Mod\Agentic\Tests\Feature\Livewire\LivewireTestCase::class);
-
-beforeEach(function (): void {
- $this->actingAsHades();
-});
-
-it('wires brain actions and flux blade controls', function (): void {
- $this->assertFluxComponentWiring(
- 'BrainExplorer',
- 'brain-explorer',
- ['ForgetKnowledge', 'ListKnowledge', 'RecallKnowledge'],
- ['livewireComponent('BrainExplorer');
- $workspace = createWorkspace();
-
- BrainMemory::query()->create([
- 'workspace_id' => $workspace->id,
- 'agent_id' => 'virgil',
- 'type' => 'decision',
- 'content' => 'Dispatch decisions are stored in the queue log.',
- 'confidence' => 0.9,
- 'tags' => ['dispatch', 'queue'],
- ]);
-
- Livewire::test($component, ['workspaceId' => $workspace->id])
- ->assertSee('Brain Explorer')
- ->assertSee('Dispatch decisions are stored in the queue log.')
- ->assertSee('virgil');
-});
-
-it('falls back to database search and forgets memories', function (): void {
- $component = $this->livewireComponent('BrainExplorer');
- $workspace = createWorkspace();
-
- $memory = BrainMemory::query()->create([
- 'workspace_id' => $workspace->id,
- 'agent_id' => 'virgil',
- 'type' => 'context',
- 'content' => 'Dispatch queue memory for local fallback search.',
- 'confidence' => 0.7,
- 'tags' => ['dispatch'],
- ]);
-
- app()->instance(BrainService::class, new class extends BrainService
+ $this->actingAsHades();
+ }
+
+ public function test_wires_brain_actions_and_flux_blade_controls(): void
+ {
+ $this->assertFluxComponentWiring(
+ 'BrainExplorer',
+ 'brain-explorer',
+ ['ForgetKnowledge', 'ListKnowledge', 'RecallKnowledge'],
+ [' $workspace->id])
- ->set('query', 'dispatch queue')
- ->call('searchMemories')
- ->assertSee('Dispatch queue memory for local fallback search.')
- ->call('forgetMemory', $memory->id)
- ->assertDontSee('Dispatch queue memory for local fallback search.');
-
- expect(BrainMemory::withTrashed()->find($memory->id)?->deleted_at)->not->toBeNull();
-});
+ $component = $this->livewireComponent('BrainExplorer');
+ $workspace = createWorkspace();
+
+ BrainMemory::query()->create([
+ 'workspace_id' => $workspace->id,
+ 'agent_id' => 'virgil',
+ 'type' => 'decision',
+ 'content' => 'Dispatch decisions are stored in the queue log.',
+ 'confidence' => 0.9,
+ 'tags' => ['dispatch', 'queue'],
+ ]);
+
+ Livewire::test($component, ['workspaceId' => $workspace->id])
+ ->assertSee('Brain Explorer')
+ ->assertSee('Dispatch decisions are stored in the queue log.')
+ ->assertSee('virgil');
+ }
+
+ public function test_falls_back_to_database_search_and_forgets_memories(): void
+ {
+ $component = $this->livewireComponent('BrainExplorer');
+ $workspace = createWorkspace();
+
+ $memory = BrainMemory::query()->create([
+ 'workspace_id' => $workspace->id,
+ 'agent_id' => 'virgil',
+ 'type' => 'context',
+ 'content' => 'Dispatch queue memory for local fallback search.',
+ 'confidence' => 0.7,
+ 'tags' => ['dispatch'],
+ ]);
+
+ app()->instance(BrainService::class, new class extends BrainService
+ {
+ public function recall(
+ string $query,
+ int $topK,
+ array $filter,
+ int $workspaceId,
+ array $keywords = [],
+ array $boostKeywords = [],
+ ): array {
+ throw new RuntimeException('Brain backend offline');
+ }
+ });
+
+ Livewire::test($component, ['workspaceId' => $workspace->id])
+ ->set('query', 'dispatch queue')
+ ->call('searchMemories')
+ ->assertSee('Dispatch queue memory for local fallback search.')
+ ->call('forgetMemory', $memory->id)
+ ->assertDontSee('Dispatch queue memory for local fallback search.');
+
+ $this->assertNotNull(BrainMemory::withTrashed()->find($memory->id)?->deleted_at);
+ }
+}
diff --git a/php/tests/Feature/Agentic/Livewire/CreditLedgerTest.php b/php/tests/Feature/Agentic/Livewire/CreditLedgerTest.php
index 427c2726..4bdc9289 100644
--- a/php/tests/Feature/Agentic/Livewire/CreditLedgerTest.php
+++ b/php/tests/Feature/Agentic/Livewire/CreditLedgerTest.php
@@ -4,87 +4,100 @@
declare(strict_types=1);
+namespace Core\Mod\Agentic\Tests\Feature\Agentic\Livewire;
+
use Core\Mod\Agentic\Actions\Credits\AwardCredits;
use Core\Mod\Agentic\Actions\Credits\GetBalance;
use Core\Mod\Agentic\Models\FleetNode;
+use Core\Mod\Agentic\Tests\Feature\Livewire\LivewireTestCase;
use Livewire\Livewire;
-use function Pest\Laravel\assertDatabaseHas;
-
-uses(\Core\Mod\Agentic\Tests\Feature\Livewire\LivewireTestCase::class);
-
-beforeEach(function (): void {
- $this->actingAsHades();
-});
-
-it('wires credit actions and flux blade controls', function (): void {
- $this->assertFluxComponentWiring(
- 'CreditLedger',
- 'credit-ledger',
- ['AwardCredits', 'GetBalance', 'GetCreditHistory'],
- ['livewireComponent('CreditLedger');
- $workspace = createWorkspace();
-
- FleetNode::query()->create([
- 'workspace_id' => $workspace->id,
- 'agent_id' => 'alpha',
- 'platform' => 'darwin',
- 'status' => FleetNode::STATUS_ONLINE,
- 'registered_at' => now(),
- 'last_heartbeat_at' => now(),
- ]);
-
- AwardCredits::run($workspace->id, 'alpha', 'manual-refund', 5, null, 'Initial award');
-
- Livewire::test($component, ['workspaceId' => $workspace->id])
- ->assertSee('Credit Ledger')
- ->assertSee('alpha')
- ->assertSee('Initial award')
- ->assertSee('5');
-});
-
-it('refunds and deducts credits through the ledger actions', function (): void {
- $component = $this->livewireComponent('CreditLedger');
- $workspace = createWorkspace();
-
- FleetNode::query()->create([
- 'workspace_id' => $workspace->id,
- 'agent_id' => 'alpha',
- 'platform' => 'darwin',
- 'status' => FleetNode::STATUS_ONLINE,
- 'registered_at' => now(),
- 'last_heartbeat_at' => now(),
- ]);
-
- Livewire::test($component, ['workspaceId' => $workspace->id])
- ->set('selectedAgentId', 'alpha')
- ->set('adjustmentAmount', 3)
- ->set('adjustmentReason', 'Manual refund')
- ->call('refundCredits')
- ->assertHasNoErrors()
- ->set('adjustmentAmount', 2)
- ->set('adjustmentReason', 'Manual deduction')
- ->call('deductCredits')
- ->assertHasNoErrors();
-
- assertDatabaseHas('credit_entries', [
- 'workspace_id' => $workspace->id,
- 'task_type' => 'manual-refund',
- 'amount' => 3,
- 'description' => 'Manual refund',
- ]);
-
- assertDatabaseHas('credit_entries', [
- 'workspace_id' => $workspace->id,
- 'task_type' => 'manual-deduction',
- 'amount' => -2,
- 'description' => 'Manual deduction',
- ]);
-
- expect(GetBalance::run($workspace->id, 'alpha')['balance'])->toBe(1);
-});
+/**
+ * Class-style for the same reason as BrainExplorerTest: a file-level
+ * uses(LivewireTestCase::class) collides with the directory-wide
+ * uses(Tests\TestCase::class) that Pest.php applies to Feature.
+ */
+class CreditLedgerTest extends LivewireTestCase
+{
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->actingAsHades();
+ }
+
+ public function test_wires_credit_actions_and_flux_blade_controls(): void
+ {
+ $this->assertFluxComponentWiring(
+ 'CreditLedger',
+ 'credit-ledger',
+ ['AwardCredits', 'GetBalance', 'GetCreditHistory'],
+ ['livewireComponent('CreditLedger');
+ $workspace = createWorkspace();
+
+ FleetNode::query()->create([
+ 'workspace_id' => $workspace->id,
+ 'agent_id' => 'alpha',
+ 'platform' => 'darwin',
+ 'status' => FleetNode::STATUS_ONLINE,
+ 'registered_at' => now(),
+ 'last_heartbeat_at' => now(),
+ ]);
+
+ AwardCredits::run($workspace->id, 'alpha', 'manual-refund', 5, null, 'Initial award');
+
+ Livewire::test($component, ['workspaceId' => $workspace->id])
+ ->assertSee('Credit Ledger')
+ ->assertSee('alpha')
+ ->assertSee('Initial award')
+ ->assertSee('5');
+ }
+
+ public function test_refunds_and_deducts_credits_through_the_ledger_actions(): void
+ {
+ $component = $this->livewireComponent('CreditLedger');
+ $workspace = createWorkspace();
+
+ FleetNode::query()->create([
+ 'workspace_id' => $workspace->id,
+ 'agent_id' => 'alpha',
+ 'platform' => 'darwin',
+ 'status' => FleetNode::STATUS_ONLINE,
+ 'registered_at' => now(),
+ 'last_heartbeat_at' => now(),
+ ]);
+
+ Livewire::test($component, ['workspaceId' => $workspace->id])
+ ->set('selectedAgentId', 'alpha')
+ ->set('adjustmentAmount', 3)
+ ->set('adjustmentReason', 'Manual refund')
+ ->call('refundCredits')
+ ->assertHasNoErrors()
+ ->set('adjustmentAmount', 2)
+ ->set('adjustmentReason', 'Manual deduction')
+ ->call('deductCredits')
+ ->assertHasNoErrors();
+
+ $this->assertDatabaseHas('credit_entries', [
+ 'workspace_id' => $workspace->id,
+ 'task_type' => 'manual-refund',
+ 'amount' => 3,
+ 'description' => 'Manual refund',
+ ]);
+
+ $this->assertDatabaseHas('credit_entries', [
+ 'workspace_id' => $workspace->id,
+ 'task_type' => 'manual-deduction',
+ 'amount' => -2,
+ 'description' => 'Manual deduction',
+ ]);
+
+ $this->assertSame(1, GetBalance::run($workspace->id, 'alpha')['balance']);
+ }
+}
diff --git a/php/tests/Feature/Agentic/Livewire/FleetOverviewTest.php b/php/tests/Feature/Agentic/Livewire/FleetOverviewTest.php
index 750e0be7..b1c51250 100644
--- a/php/tests/Feature/Agentic/Livewire/FleetOverviewTest.php
+++ b/php/tests/Feature/Agentic/Livewire/FleetOverviewTest.php
@@ -4,93 +4,106 @@
declare(strict_types=1);
+namespace Core\Mod\Agentic\Tests\Feature\Agentic\Livewire;
+
use Core\Mod\Agentic\Models\FleetNode;
+use Core\Mod\Agentic\Tests\Feature\Livewire\LivewireTestCase;
use Livewire\Livewire;
-use function Pest\Laravel\assertDatabaseHas;
-
-uses(\Core\Mod\Agentic\Tests\Feature\Livewire\LivewireTestCase::class);
-
-beforeEach(function (): void {
- $this->actingAsHades();
-});
-
-it('wires fleet actions and flux blade controls', function (): void {
- $this->assertFluxComponentWiring(
- 'FleetOverview',
- 'fleet-overview',
- ['AssignTask', 'GetFleetStats', 'ListNodes'],
- ['livewireComponent('FleetOverview');
- $workspace = createWorkspace();
-
- FleetNode::query()->create([
- 'workspace_id' => $workspace->id,
- 'agent_id' => 'alpha',
- 'platform' => 'darwin',
- 'models' => ['gpt-5.5'],
- 'status' => FleetNode::STATUS_ONLINE,
- 'registered_at' => now(),
- 'last_heartbeat_at' => now(),
- ]);
-
- FleetNode::query()->create([
- 'workspace_id' => $workspace->id,
- 'agent_id' => 'beta',
- 'platform' => 'linux',
- 'models' => ['gpt-5.4-mini'],
- 'status' => FleetNode::STATUS_BUSY,
- 'registered_at' => now(),
- 'last_heartbeat_at' => now(),
- ]);
-
- Livewire::test($component, ['workspaceId' => $workspace->id])
- ->assertSee('Fleet Overview')
- ->assertSee('Dispatch Task')
- ->assertSee('alpha')
- ->assertSee('beta');
-});
-
-it('dispatches a task to the selected node', function (): void {
- $component = $this->livewireComponent('FleetOverview');
- $workspace = createWorkspace();
-
- FleetNode::query()->create([
- 'workspace_id' => $workspace->id,
- 'agent_id' => 'alpha',
- 'platform' => 'darwin',
- 'models' => ['gpt-5.5'],
- 'status' => FleetNode::STATUS_ONLINE,
- 'registered_at' => now(),
- 'last_heartbeat_at' => now(),
- ]);
-
- Livewire::test($component, ['workspaceId' => $workspace->id])
- ->set('dispatchAgentId', 'alpha')
- ->set('dispatchRepo', 'dAppCore/core-agent')
- ->set('dispatchBranch', 'dev')
- ->set('dispatchTemplate', 'triage')
- ->set('dispatchModel', 'gpt-5.5')
- ->set('dispatchTask', 'Review the dispatch backlog and prepare the next assignment.')
- ->call('dispatchTask')
- ->assertHasNoErrors();
-
- assertDatabaseHas('fleet_tasks', [
- 'workspace_id' => $workspace->id,
- 'repo' => 'dAppCore/core-agent',
- 'branch' => 'dev',
- 'template' => 'triage',
- 'agent_model' => 'gpt-5.5',
- 'status' => 'assigned',
- ]);
-
- assertDatabaseHas('fleet_nodes', [
- 'workspace_id' => $workspace->id,
- 'agent_id' => 'alpha',
- 'status' => FleetNode::STATUS_BUSY,
- ]);
-});
+/**
+ * Class-style for the same reason as BrainExplorerTest: a file-level
+ * uses(LivewireTestCase::class) collides with the directory-wide
+ * uses(Tests\TestCase::class) that Pest.php applies to Feature.
+ */
+class FleetOverviewTest extends LivewireTestCase
+{
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->actingAsHades();
+ }
+
+ public function test_wires_fleet_actions_and_flux_blade_controls(): void
+ {
+ $this->assertFluxComponentWiring(
+ 'FleetOverview',
+ 'fleet-overview',
+ ['AssignTask', 'GetFleetStats', 'ListNodes'],
+ ['livewireComponent('FleetOverview');
+ $workspace = createWorkspace();
+
+ FleetNode::query()->create([
+ 'workspace_id' => $workspace->id,
+ 'agent_id' => 'alpha',
+ 'platform' => 'darwin',
+ 'models' => ['gpt-5.5'],
+ 'status' => FleetNode::STATUS_ONLINE,
+ 'registered_at' => now(),
+ 'last_heartbeat_at' => now(),
+ ]);
+
+ FleetNode::query()->create([
+ 'workspace_id' => $workspace->id,
+ 'agent_id' => 'beta',
+ 'platform' => 'linux',
+ 'models' => ['gpt-5.4-mini'],
+ 'status' => FleetNode::STATUS_BUSY,
+ 'registered_at' => now(),
+ 'last_heartbeat_at' => now(),
+ ]);
+
+ Livewire::test($component, ['workspaceId' => $workspace->id])
+ ->assertSee('Fleet Overview')
+ ->assertSee('Dispatch Task')
+ ->assertSee('alpha')
+ ->assertSee('beta');
+ }
+
+ public function test_dispatches_a_task_to_the_selected_node(): void
+ {
+ $component = $this->livewireComponent('FleetOverview');
+ $workspace = createWorkspace();
+
+ FleetNode::query()->create([
+ 'workspace_id' => $workspace->id,
+ 'agent_id' => 'alpha',
+ 'platform' => 'darwin',
+ 'models' => ['gpt-5.5'],
+ 'status' => FleetNode::STATUS_ONLINE,
+ 'registered_at' => now(),
+ 'last_heartbeat_at' => now(),
+ ]);
+
+ Livewire::test($component, ['workspaceId' => $workspace->id])
+ ->set('dispatchAgentId', 'alpha')
+ ->set('dispatchRepo', 'dAppCore/core-agent')
+ ->set('dispatchBranch', 'dev')
+ ->set('dispatchTemplate', 'triage')
+ ->set('dispatchModel', 'gpt-5.5')
+ ->set('dispatchTask', 'Review the dispatch backlog and prepare the next assignment.')
+ ->call('dispatchTask')
+ ->assertHasNoErrors();
+
+ $this->assertDatabaseHas('fleet_tasks', [
+ 'workspace_id' => $workspace->id,
+ 'repo' => 'dAppCore/core-agent',
+ 'branch' => 'dev',
+ 'template' => 'triage',
+ 'agent_model' => 'gpt-5.5',
+ 'status' => 'assigned',
+ ]);
+
+ $this->assertDatabaseHas('fleet_nodes', [
+ 'workspace_id' => $workspace->id,
+ 'agent_id' => 'alpha',
+ 'status' => FleetNode::STATUS_BUSY,
+ ]);
+ }
+}
diff --git a/php/tests/Feature/Agentic/Services/CreditServiceTest.php b/php/tests/Feature/Agentic/Services/CreditServiceTest.php
index f18d8960..c66e4662 100644
--- a/php/tests/Feature/Agentic/Services/CreditServiceTest.php
+++ b/php/tests/Feature/Agentic/Services/CreditServiceTest.php
@@ -9,24 +9,9 @@
use Core\Mod\Agentic\Models\FleetNode;
use Core\Mod\Agentic\Services\CreditService;
-use function Pest\Laravel\assertDatabaseHas;
-
-if (! function_exists('loadAgenticPhpClass')) {
- function loadAgenticPhpClass(string $relativePath): void
- {
- $phpRoot = dirname(__DIR__, 4);
- require_once $phpRoot.'/'.$relativePath;
- }
-}
-
-beforeEach(function (): void {
- loadAgenticPhpClass('Agentic/Data/CreditTransaction.php');
- loadAgenticPhpClass('Agentic/Services/CreditService.php');
-});
-
test('CreditService_refundAndDeduct_Good_tracks_workspace_balance_and_ledger_entries', function (): void {
$workspace = createWorkspace();
- $service = new CreditService();
+ $service = new CreditService;
$refund = $service->refund($workspace->id, 7, 'Initial workspace credit');
$deduction = $service->deduct($workspace->id, 2, 'Dispatch overrun');
@@ -41,7 +26,7 @@ function loadAgenticPhpClass(string $relativePath): void
->and($ledger)->toHaveCount(2)
->and($ledger->first()->taskType)->toBe('manual-deduction');
- assertDatabaseHas('credit_entries', [
+ test()->assertDatabaseHas('credit_entries', [
'workspace_id' => $workspace->id,
'fleet_node_id' => null,
'task_type' => 'manual-refund',
@@ -49,7 +34,7 @@ function loadAgenticPhpClass(string $relativePath): void
'balance_after' => 7,
]);
- assertDatabaseHas('credit_entries', [
+ test()->assertDatabaseHas('credit_entries', [
'workspace_id' => $workspace->id,
'fleet_node_id' => null,
'task_type' => 'manual-deduction',
@@ -60,7 +45,7 @@ function loadAgenticPhpClass(string $relativePath): void
test('CreditService_deduct_Bad_rejects_zero_amounts_and_blank_reasons', function (): void {
$workspace = createWorkspace();
- $service = new CreditService();
+ $service = new CreditService;
expect(fn () => $service->deduct($workspace->id, 0, 'No-op'))
->toThrow(InvalidArgumentException::class, 'amount must be greater than zero');
@@ -89,7 +74,7 @@ function loadAgenticPhpClass(string $relativePath): void
'description' => 'Legacy per-node award',
]);
- $service = new CreditService();
+ $service = new CreditService;
$service->refund($workspace->id, 4, 'Workspace top-up');
$balance = $service->balance($workspace->id);
diff --git a/php/tests/Feature/Agentic/Services/SessionServiceTest.php b/php/tests/Feature/Agentic/Services/SessionServiceTest.php
index 6fa21612..f2cdc12c 100644
--- a/php/tests/Feature/Agentic/Services/SessionServiceTest.php
+++ b/php/tests/Feature/Agentic/Services/SessionServiceTest.php
@@ -8,21 +8,9 @@
use Core\Mod\Agentic\Services\SessionService;
use Illuminate\Support\Facades\Event;
-if (! function_exists('loadAgenticPhpClass')) {
- function loadAgenticPhpClass(string $relativePath): void
- {
- $phpRoot = dirname(__DIR__, 4);
- require_once $phpRoot.'/'.$relativePath;
- }
-}
-
-beforeEach(function (): void {
- loadAgenticPhpClass('Agentic/Services/SessionService.php');
-});
-
test('SessionService_create_Good_creates_active_sessions_and_emits_sse_frames', function (): void {
$workspace = createWorkspace();
- $service = new SessionService();
+ $service = new SessionService;
$captured = [];
Event::listen(SessionService::SSE_EVENT, function (array $payload) use (&$captured): void {
@@ -52,7 +40,7 @@ function loadAgenticPhpClass(string $relativePath): void
test('SessionService_updateState_Bad_rejects_invalid_terminal_state_transitions', function (): void {
$workspace = createWorkspace();
- $service = new SessionService();
+ $service = new SessionService;
$session = $service->create($workspace->id, ['agent_type' => 'sonnet']);
$completed = $service->updateState($session, 'closed');
@@ -65,7 +53,7 @@ function loadAgenticPhpClass(string $relativePath): void
test('SessionService_updateState_Ugly_allows_handoff_reactivation_by_session_id', function (): void {
$workspace = createWorkspace();
- $service = new SessionService();
+ $service = new SessionService;
$session = $service->create($workspace->id, [
'agent_type' => 'haiku',
'handoff_notes' => ['summary' => 'Ready for follow-up'],
diff --git a/php/tests/Feature/ContentServiceTest.php b/php/tests/Feature/ContentServiceTest.php
index 7c4704f9..706db80f 100644
--- a/php/tests/Feature/ContentServiceTest.php
+++ b/php/tests/Feature/ContentServiceTest.php
@@ -6,6 +6,38 @@
use Core\Mod\Agentic\Services\ContentService;
use Illuminate\Support\Facades\File;
+/*
+|--------------------------------------------------------------------------
+| Content fixtures
+|--------------------------------------------------------------------------
+|
+| ContentService resolves every path it touches through base_path() plus a
+| configurable relative prefix, defaulting to app/Mod/Agentic/Resources/*.
+| That default belongs to a host application. Under Testbench base_path() is
+| the bare skeleton, so the help-article prompt template was never there and
+| five of these tests did nothing but markTestSkipped('Help article prompt
+| not found') — permanently, on every machine and in CI. The batch fixture
+| batch-001-link-getting-started was missing for the same reason, which is
+| why the three tests that read it failed rather than skipped.
+|
+| The three paths are config-driven, so the suite points them at a sandbox
+| under base_path() and writes the fixtures it needs. Nothing here depends on
+| a host application any more.
+|
+*/
+
+const CONTENT_SANDBOX = 'content-service-sandbox';
+
+function contentSandbox(string $relative = ''): string
+{
+ return base_path(rtrim(CONTENT_SANDBOX.'/'.ltrim($relative, '/'), '/'));
+}
+
+function contentTask(string $file): string
+{
+ return contentSandbox('tasks/'.$file);
+}
+
function makeAgenticResponse(string $content = '## Article Content'): AgenticResponse
{
return new AgenticResponse(
@@ -18,10 +50,50 @@ function makeAgenticResponse(string $content = '## Article Content'): AgenticRes
}
beforeEach(function () {
+ config([
+ 'mcp.content.batch_path' => CONTENT_SANDBOX.'/tasks',
+ 'mcp.content.prompt_path' => CONTENT_SANDBOX.'/prompts/content',
+ 'mcp.content.drafts_path' => CONTENT_SANDBOX.'/drafts',
+ ]);
+
+ File::deleteDirectory(contentSandbox());
+ File::ensureDirectoryExists(contentSandbox('tasks'));
+ File::ensureDirectoryExists(contentSandbox('prompts/content'));
+
+ File::put(
+ contentSandbox('prompts/content/help-article.md'),
+ "# Help Article\n\nWrite an article titled {{TITLE}} for {{SERVICE_NAME}}.\n",
+ );
+
+ File::put(contentTask('batch-001-link-getting-started.md'), <<<'MARKDOWN'
+ # Batch 001 — Host Link getting started
+
+ **Service:** Host Link
+ **Category:** Getting Started
+ **Priority:** high
+
+ ### Article 1:
+ ```yaml
+ SLUG: link-getting-started
+ TITLE: Getting started with Host Link
+ ```
+
+ ### Article 2:
+ ```yaml
+ SLUG: link-connect-a-domain
+ TITLE: Connect a domain to Host Link
+ ```
+ MARKDOWN);
+
+ // config() is read in the constructor, so the service is built after it.
$this->manager = Mockery::mock(AgenticManager::class);
$this->service = new ContentService($this->manager);
});
+afterEach(function () {
+ File::deleteDirectory(contentSandbox());
+});
+
it('lists available batches', function () {
$batches = $this->service->listBatches();
@@ -56,49 +128,17 @@ function makeAgenticResponse(string $content = '## Article Content'): AgenticRes
it('handles generation errors gracefully', function () {
$provider = Mockery::mock(AgenticProviderInterface::class);
- $provider->shouldReceive('generate')->andThrow(new \Exception('API Error'));
+ $provider->shouldReceive('generate')->andThrow(new Exception('API Error'));
$this->manager->shouldReceive('provider')->with('gemini')->andReturn($provider);
- // Create a temporary test batch file
- $testBatchPath = base_path('app/Mod/Agentic/Resources/tasks/batch-test-error.md');
- // Ensure the prompts directory exists for the test if it's looking for a template
- $promptPath = base_path('app/Mod/Agentic/Resources/prompts/content/help-article.md');
-
- // We need to ensure the help-article prompt exists, otherwise it fails before hitting the API
- if (! File::exists($promptPath)) {
- $this->markTestSkipped('Help article prompt not found');
- }
-
- File::put($testBatchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: test-slug-error\nTITLE: Test\n```");
+ File::put(contentTask('batch-test-error.md'), "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: test-slug-error\nTITLE: Test\n```");
- // Clean up potential leftover draft and state files
- $draftPath = base_path('app/Mod/Agentic/Resources/drafts/help/general/test-slug-error.md');
- $statePath = base_path('app/Mod/Agentic/Resources/tasks/batch-test-error.progress.json');
- if (File::exists($draftPath)) {
- File::delete($draftPath);
- }
- if (File::exists($statePath)) {
- File::delete($statePath);
- }
+ $results = $this->service->generateBatch('batch-test-error', 'gemini', false);
- try {
- $results = $this->service->generateBatch('batch-test-error', 'gemini', false);
-
- expect($results['failed'])->toBe(1);
- expect($results['articles']['test-slug-error']['status'])->toBe('failed');
- expect($results['articles']['test-slug-error']['error'])->toBe('API Error');
- } finally {
- if (File::exists($testBatchPath)) {
- File::delete($testBatchPath);
- }
- if (File::exists($draftPath)) {
- File::delete($draftPath);
- }
- if (File::exists($statePath)) {
- File::delete($statePath);
- }
- }
+ expect($results['failed'])->toBe(1);
+ expect($results['articles']['test-slug-error']['status'])->toBe('failed');
+ expect($results['articles']['test-slug-error']['error'])->toBe('API Error');
});
it('returns null progress when no state file exists', function () {
@@ -109,42 +149,25 @@ function makeAgenticResponse(string $content = '## Article Content'): AgenticRes
it('saves progress state after batch generation', function () {
$provider = Mockery::mock(AgenticProviderInterface::class);
- $provider->shouldReceive('generate')->andThrow(new \Exception('API Error'));
+ $provider->shouldReceive('generate')->andThrow(new Exception('API Error'));
$this->manager->shouldReceive('provider')->with('gemini')->andReturn($provider);
- $promptPath = base_path('app/Mod/Agentic/Resources/prompts/content/help-article.md');
- if (! File::exists($promptPath)) {
- $this->markTestSkipped('Help article prompt not found');
- }
-
$batchId = 'batch-test-progress';
- $batchPath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.md");
- $statePath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.progress.json");
-
- File::put($batchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: progress-slug-a\nTITLE: Test A\n```\n### Article 2:\n```yaml\nSLUG: progress-slug-b\nTITLE: Test B\n```");
-
- try {
- $this->service->generateBatch($batchId, 'gemini', false, 0);
-
- $progress = $this->service->loadBatchProgress($batchId);
-
- expect($progress)->toBeArray();
- expect($progress['batch_id'])->toBe($batchId);
- expect($progress['provider'])->toBe('gemini');
- expect($progress['articles'])->toHaveKeys(['progress-slug-a', 'progress-slug-b']);
- expect($progress['articles']['progress-slug-a']['status'])->toBe('failed');
- expect($progress['articles']['progress-slug-a']['attempts'])->toBe(1);
- expect($progress['articles']['progress-slug-a']['last_error'])->toBe('API Error');
- } finally {
- File::deleteDirectory(base_path('app/Mod/Agentic/Resources/drafts/help/general'), true);
- if (File::exists($batchPath)) {
- File::delete($batchPath);
- }
- if (File::exists($statePath)) {
- File::delete($statePath);
- }
- }
+
+ File::put(contentTask("{$batchId}.md"), "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: progress-slug-a\nTITLE: Test A\n```\n### Article 2:\n```yaml\nSLUG: progress-slug-b\nTITLE: Test B\n```");
+
+ $this->service->generateBatch($batchId, 'gemini', false, 0);
+
+ $progress = $this->service->loadBatchProgress($batchId);
+
+ expect($progress)->toBeArray();
+ expect($progress['batch_id'])->toBe($batchId);
+ expect($progress['provider'])->toBe('gemini');
+ expect($progress['articles'])->toHaveKeys(['progress-slug-a', 'progress-slug-b']);
+ expect($progress['articles']['progress-slug-a']['status'])->toBe('failed');
+ expect($progress['articles']['progress-slug-a']['attempts'])->toBe(1);
+ expect($progress['articles']['progress-slug-a']['last_error'])->toBe('API Error');
});
it('skips previously generated articles on second run', function () {
@@ -159,44 +182,21 @@ function makeAgenticResponse(string $content = '## Article Content'): AgenticRes
$this->manager->shouldReceive('provider')->with('gemini')->andReturn($provider);
- $promptPath = base_path('app/Mod/Agentic/Resources/prompts/content/help-article.md');
- if (! File::exists($promptPath)) {
- $this->markTestSkipped('Help article prompt not found');
- }
-
$batchId = 'batch-test-resume-skip';
- $batchPath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.md");
- $statePath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.progress.json");
- $draftDir = base_path('app/Mod/Agentic/Resources/drafts/help/general');
-
- File::put($batchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: resume-skip-slug-a\nTITLE: Test A\n```\n### Article 2:\n```yaml\nSLUG: resume-skip-slug-b\nTITLE: Test B\n```");
-
- try {
- // First run generates both articles
- $first = $this->service->generateBatch($batchId, 'gemini', false, 0);
- expect($first['generated'])->toBe(2);
- expect($callCount)->toBe(2);
-
- // Second run skips already-generated articles
- $second = $this->service->generateBatch($batchId, 'gemini', false, 0);
- expect($second['generated'])->toBe(0);
- expect($second['skipped'])->toBe(2);
- // Provider should not have been called again
- expect($callCount)->toBe(2);
- } finally {
- foreach (['resume-skip-slug-a', 'resume-skip-slug-b'] as $slug) {
- $draft = "{$draftDir}/{$slug}.md";
- if (File::exists($draft)) {
- File::delete($draft);
- }
- }
- if (File::exists($batchPath)) {
- File::delete($batchPath);
- }
- if (File::exists($statePath)) {
- File::delete($statePath);
- }
- }
+
+ File::put(contentTask("{$batchId}.md"), "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: resume-skip-slug-a\nTITLE: Test A\n```\n### Article 2:\n```yaml\nSLUG: resume-skip-slug-b\nTITLE: Test B\n```");
+
+ // First run generates both articles
+ $first = $this->service->generateBatch($batchId, 'gemini', false, 0);
+ expect($first['generated'])->toBe(2);
+ expect($callCount)->toBe(2);
+
+ // Second run skips already-generated articles
+ $second = $this->service->generateBatch($batchId, 'gemini', false, 0);
+ expect($second['generated'])->toBe(0);
+ expect($second['skipped'])->toBe(2);
+ // Provider should not have been called again
+ expect($callCount)->toBe(2);
});
it('resume returns error when no prior state exists', function () {
@@ -207,7 +207,6 @@ function makeAgenticResponse(string $content = '## Article Content'): AgenticRes
});
it('resume retries only failed and pending articles', function () {
- $slugs = ['resume-retry-a', 'resume-retry-b'];
$callCount = 0;
$provider = Mockery::mock(AgenticProviderInterface::class);
@@ -219,7 +218,7 @@ function makeAgenticResponse(string $content = '## Article Content'): AgenticRes
// Call 2: B on first run → succeeds
// Resume run: only A is retried (B is already generated)
if ($callCount === 1) {
- throw new \Exception('Transient Error');
+ throw new Exception('Transient Error');
}
return makeAgenticResponse('## Content');
@@ -227,45 +226,22 @@ function makeAgenticResponse(string $content = '## Article Content'): AgenticRes
$this->manager->shouldReceive('provider')->with('gemini')->andReturn($provider);
- $promptPath = base_path('app/Mod/Agentic/Resources/prompts/content/help-article.md');
- if (! File::exists($promptPath)) {
- $this->markTestSkipped('Help article prompt not found');
- }
-
$batchId = 'batch-test-resume-retry';
- $batchPath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.md");
- $statePath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.progress.json");
- $draftDir = base_path('app/Mod/Agentic/Resources/drafts/help/general');
-
- File::put($batchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: resume-retry-a\nTITLE: Retry A\n```\n### Article 2:\n```yaml\nSLUG: resume-retry-b\nTITLE: Retry B\n```");
-
- try {
- // First run: A fails, B succeeds
- $first = $this->service->generateBatch($batchId, 'gemini', false, 0);
- expect($first['failed'])->toBe(1);
- expect($first['generated'])->toBe(1);
- expect($first['articles']['resume-retry-a']['status'])->toBe('failed');
- expect($first['articles']['resume-retry-b']['status'])->toBe('generated');
-
- // Resume: only retries failed article A
- $resumed = $this->service->resumeBatch($batchId, 'gemini', 0);
- expect($resumed)->toHaveKey('resumed_from');
- expect($resumed['skipped'])->toBeGreaterThanOrEqual(1); // B is skipped
- expect($resumed['articles']['resume-retry-b']['status'])->toBe('skipped');
- } finally {
- foreach ($slugs as $slug) {
- $draft = "{$draftDir}/{$slug}.md";
- if (File::exists($draft)) {
- File::delete($draft);
- }
- }
- if (File::exists($batchPath)) {
- File::delete($batchPath);
- }
- if (File::exists($statePath)) {
- File::delete($statePath);
- }
- }
+
+ File::put(contentTask("{$batchId}.md"), "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: resume-retry-a\nTITLE: Retry A\n```\n### Article 2:\n```yaml\nSLUG: resume-retry-b\nTITLE: Retry B\n```");
+
+ // First run: A fails, B succeeds
+ $first = $this->service->generateBatch($batchId, 'gemini', false, 0);
+ expect($first['failed'])->toBe(1);
+ expect($first['generated'])->toBe(1);
+ expect($first['articles']['resume-retry-a']['status'])->toBe('failed');
+ expect($first['articles']['resume-retry-b']['status'])->toBe('generated');
+
+ // Resume: only retries failed article A
+ $resumed = $this->service->resumeBatch($batchId, 'gemini', 0);
+ expect($resumed)->toHaveKey('resumed_from');
+ expect($resumed['skipped'])->toBeGreaterThanOrEqual(1); // B is skipped
+ expect($resumed['articles']['resume-retry-b']['status'])->toBe('skipped');
});
it('retries individual failures up to maxRetries times', function () {
@@ -275,7 +251,7 @@ function makeAgenticResponse(string $content = '## Article Content'): AgenticRes
->andReturnUsing(function () use (&$callCount) {
$callCount++;
if ($callCount < 3) {
- throw new \Exception("Attempt {$callCount} failed");
+ throw new Exception("Attempt {$callCount} failed");
}
return makeAgenticResponse('## Content');
@@ -283,39 +259,19 @@ function makeAgenticResponse(string $content = '## Article Content'): AgenticRes
$this->manager->shouldReceive('provider')->with('gemini')->andReturn($provider);
- $promptPath = base_path('app/Mod/Agentic/Resources/prompts/content/help-article.md');
- if (! File::exists($promptPath)) {
- $this->markTestSkipped('Help article prompt not found');
- }
-
$batchId = 'batch-test-maxretries';
- $batchPath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.md");
- $statePath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.progress.json");
- $draftPath = base_path('app/Mod/Agentic/Resources/drafts/help/general/maxretries-slug.md');
-
- File::put($batchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: maxretries-slug\nTITLE: Retry Test\n```");
-
- try {
- // With maxRetries=2 (3 total attempts), succeeds on 3rd attempt
- $results = $this->service->generateBatch($batchId, 'gemini', false, 2);
-
- expect($results['generated'])->toBe(1);
- expect($results['failed'])->toBe(0);
- expect($results['articles']['maxretries-slug']['status'])->toBe('generated');
- expect($callCount)->toBe(3);
-
- $progress = $this->service->loadBatchProgress($batchId);
- expect($progress['articles']['maxretries-slug']['status'])->toBe('generated');
- expect($progress['articles']['maxretries-slug']['attempts'])->toBe(3);
- } finally {
- if (File::exists($batchPath)) {
- File::delete($batchPath);
- }
- if (File::exists($statePath)) {
- File::delete($statePath);
- }
- if (File::exists($draftPath)) {
- File::delete($draftPath);
- }
- }
+
+ File::put(contentTask("{$batchId}.md"), "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: maxretries-slug\nTITLE: Retry Test\n```");
+
+ // With maxRetries=2 (3 total attempts), succeeds on 3rd attempt
+ $results = $this->service->generateBatch($batchId, 'gemini', false, 2);
+
+ expect($results['generated'])->toBe(1);
+ expect($results['failed'])->toBe(0);
+ expect($results['articles']['maxretries-slug']['status'])->toBe('generated');
+ expect($callCount)->toBe(3);
+
+ $progress = $this->service->loadBatchProgress($batchId);
+ expect($progress['articles']['maxretries-slug']['status'])->toBe('generated');
+ expect($progress['articles']['maxretries-slug']['attempts'])->toBe(3);
});
diff --git a/php/tests/Feature/Jobs/BatchContentGenerationTest.php b/php/tests/Feature/Jobs/BatchContentGenerationTest.php
index 5b8bf4d4..3b912d9d 100644
--- a/php/tests/Feature/Jobs/BatchContentGenerationTest.php
+++ b/php/tests/Feature/Jobs/BatchContentGenerationTest.php
@@ -6,14 +6,18 @@
* Tests for the BatchContentGeneration queue job.
*
* Covers job configuration, queue assignment, tag generation, and dispatch behaviour.
- * The handle() integration requires ContentTask from host-uk/core and is tested
- * via queue dispatch assertions and alias mocking where the table is unavailable.
+ * ContentTask comes from dappcore/php-content; the empty-batch path runs against
+ * a real content_tasks table created for that test.
*/
use Core\Mod\Agentic\Jobs\BatchContentGeneration;
use Core\Mod\Agentic\Jobs\ProcessContentTask;
+use Core\Mod\Content\Models\ContentTask;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
+use Illuminate\Support\Facades\Schema;
// =========================================================================
// Job Configuration Tests
@@ -60,7 +64,7 @@
it('implements ShouldQueue', function () {
$job = new BatchContentGeneration;
- expect($job)->toBeInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class);
+ expect($job)->toBeInstanceOf(ShouldQueue::class);
});
});
@@ -159,7 +163,7 @@
// Simulate what handle() does when tasks are found:
// dispatch a ProcessContentTask for each task
- $mockTask = Mockery::mock('Mod\Content\Models\ContentTask');
+ $mockTask = Mockery::mock(ContentTask::class)->makePartial();
ProcessContentTask::dispatch($mockTask);
@@ -169,7 +173,7 @@
it('ProcessContentTask is dispatched to the ai queue', function () {
Queue::fake();
- $mockTask = Mockery::mock('Mod\Content\Models\ContentTask');
+ $mockTask = Mockery::mock(ContentTask::class)->makePartial();
ProcessContentTask::dispatch($mockTask);
@@ -180,9 +184,9 @@
Queue::fake();
$tasks = [
- Mockery::mock('Mod\Content\Models\ContentTask'),
- Mockery::mock('Mod\Content\Models\ContentTask'),
- Mockery::mock('Mod\Content\Models\ContentTask'),
+ Mockery::mock(ContentTask::class)->makePartial(),
+ Mockery::mock(ContentTask::class)->makePartial(),
+ Mockery::mock(ContentTask::class)->makePartial(),
];
foreach ($tasks as $task) {
@@ -199,27 +203,37 @@
describe('handle with no matching tasks', function () {
it('logs an info message when no tasks are found', function () {
+ // A real table, not an alias mock. Mockery alias mocks replace the class
+ // for the whole PHP process, which is why this needed process isolation
+ // and was skipped instead. dappcore/php-content owns the schema, but
+ // loading its migrations suite-wide would also recreate prompts with
+ // stricter columns than this package's own migration, so only the one
+ // table under test is created here.
+ Schema::create('content_tasks', function (Blueprint $table) {
+ $table->id();
+ $table->unsignedBigInteger('workspace_id');
+ $table->unsignedBigInteger('prompt_id');
+ $table->string('status')->default('pending');
+ $table->string('priority')->default('normal');
+ $table->json('input_data');
+ $table->longText('output')->nullable();
+ $table->json('metadata')->nullable();
+ $table->string('target_type')->nullable();
+ $table->unsignedBigInteger('target_id')->nullable();
+ $table->timestamp('scheduled_for')->nullable();
+ $table->timestamp('started_at')->nullable();
+ $table->timestamp('completed_at')->nullable();
+ $table->text('error_message')->nullable();
+ $table->timestamps();
+ });
+
Log::shouldReceive('info')
->once()
->with('BatchContentGeneration: No normal priority tasks to process');
- // Build an empty collection for the query result
- $emptyCollection = collect([]);
-
- $builder = Mockery::mock(\Illuminate\Database\Eloquent\Builder::class);
- $builder->shouldReceive('where')->andReturnSelf();
- $builder->shouldReceive('orWhere')->andReturnSelf();
- $builder->shouldReceive('orderBy')->andReturnSelf();
- $builder->shouldReceive('limit')->andReturnSelf();
- $builder->shouldReceive('get')->andReturn($emptyCollection);
-
- // Alias mock for the static query() call
- $taskMock = Mockery::mock('alias:Mod\Content\Models\ContentTask');
- $taskMock->shouldReceive('query')->andReturn($builder);
-
$job = new BatchContentGeneration('normal', 10);
$job->handle();
- })->skip('Alias mocking requires process isolation; covered by integration tests.');
+ });
it('does not dispatch any ProcessContentTask when collection is empty', function () {
Queue::fake();
@@ -250,8 +264,8 @@
Queue::fake();
$tasks = collect([
- Mockery::mock('Mod\Content\Models\ContentTask'),
- Mockery::mock('Mod\Content\Models\ContentTask'),
+ Mockery::mock(ContentTask::class)->makePartial(),
+ Mockery::mock(ContentTask::class)->makePartial(),
]);
// Simulate handle() dispatch loop
diff --git a/php/tests/Feature/Jobs/ProcessContentTaskTest.php b/php/tests/Feature/Jobs/ProcessContentTaskTest.php
index 9f5b94f2..497442ab 100644
--- a/php/tests/Feature/Jobs/ProcessContentTaskTest.php
+++ b/php/tests/Feature/Jobs/ProcessContentTaskTest.php
@@ -11,10 +11,16 @@
*/
use Core\Mod\Agentic\Jobs\ProcessContentTask;
+use Core\Mod\Agentic\Models\Prompt;
use Core\Mod\Agentic\Services\AgenticManager;
use Core\Mod\Agentic\Services\AgenticProviderInterface;
use Core\Mod\Agentic\Services\AgenticResponse;
+use Core\Mod\Content\Models\ContentTask;
+use Core\Tenant\Models\UsageRecord;
+use Core\Tenant\Services\EntitlementResult;
+use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Queue;
+use Mockery\MockInterface;
// =========================================================================
// Helpers
@@ -25,16 +31,16 @@
*
* @param array $overrides
*/
-function mockContentTask(array $overrides = []): \Mockery\MockInterface
+function mockContentTask(array $overrides = []): MockInterface
{
- $prompt = Mockery::mock('Mod\Content\Models\ContentPrompt');
+ $prompt = Mockery::mock(Prompt::class)->makePartial();
$prompt->model = $overrides['prompt_model'] ?? 'claude';
$prompt->user_template = $overrides['user_template'] ?? 'Hello {{name}}';
$prompt->system_prompt = $overrides['system_prompt'] ?? 'You are helpful.';
$prompt->model_config = $overrides['model_config'] ?? [];
$prompt->id = $overrides['prompt_id'] ?? 1;
- $task = Mockery::mock('Mod\Content\Models\ContentTask');
+ $task = Mockery::mock(ContentTask::class)->makePartial();
$task->id = $overrides['task_id'] ?? 1;
$task->prompt = array_key_exists('prompt', $overrides) ? $overrides['prompt'] : $prompt;
$task->workspace = $overrides['workspace'] ?? null;
@@ -67,22 +73,19 @@ function mockAgenticResponse(array $overrides = []): AgenticResponse
}
/**
- * Build a mock EntitlementResult.
+ * Build an EntitlementResult.
+ *
+ * The real class, not a look-alike: EntitlementService::can() declares
+ * EntitlementResult as its return type, so a stand-in with the same two methods
+ * fails the return check the moment the service is mocked against its real
+ * signature. The old stand-in also published a public $message, which is what
+ * let ProcessContentTask read a property EntitlementResult does not have.
*/
-function mockEntitlementResult(bool $denied = false, string $message = ''): object
+function mockEntitlementResult(bool $denied = false, string $message = ''): EntitlementResult
{
- return new class($denied, $message)
- {
- public function __construct(
- private readonly bool $denied,
- public readonly string $message,
- ) {}
-
- public function isDenied(): bool
- {
- return $this->denied;
- }
- };
+ return $denied
+ ? EntitlementResult::denied($message, featureCode: 'ai.credits')
+ : EntitlementResult::allowed(featureCode: 'ai.credits');
}
// =========================================================================
@@ -124,7 +127,7 @@ public function isDenied(): bool
$task = mockContentTask();
$job = new ProcessContentTask($task);
- expect($job)->toBeInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class);
+ expect($job)->toBeInstanceOf(ShouldQueue::class);
});
it('stores the task on the job', function () {
@@ -147,7 +150,7 @@ public function isDenied(): bool
->with('Something went wrong');
$job = new ProcessContentTask($task);
- $job->failed(new \RuntimeException('Something went wrong'));
+ $job->failed(new RuntimeException('Something went wrong'));
});
it('marks the task as failed with any throwable message', function () {
@@ -157,7 +160,7 @@ public function isDenied(): bool
->with('Database connection lost');
$job = new ProcessContentTask($task);
- $job->failed(new \Exception('Database connection lost'));
+ $job->failed(new Exception('Database connection lost'));
});
it('uses the exception message verbatim', function () {
@@ -171,7 +174,7 @@ public function isDenied(): bool
});
$job = new ProcessContentTask($task);
- $job->failed(new \RuntimeException('Detailed error: code 503'));
+ $job->failed(new RuntimeException('Detailed error: code 503'));
expect($capturedMessage)->toBe('Detailed error: code 503');
});
@@ -191,11 +194,10 @@ public function isDenied(): bool
->with('Prompt not found');
$ai = Mockery::mock(AgenticManager::class);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
it('does not call the AI provider when prompt is missing', function () {
@@ -206,11 +208,10 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldNotReceive('provider');
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
});
@@ -229,7 +230,6 @@ public function isDenied(): bool
->with('Entitlement denied: Insufficient credits');
$ai = Mockery::mock(AgenticManager::class);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$result = mockEntitlementResult(denied: true, message: 'Insufficient credits');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
@@ -239,7 +239,7 @@ public function isDenied(): bool
->andReturn($result);
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
it('does not invoke the AI provider when entitlement is denied', function () {
@@ -252,14 +252,12 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldNotReceive('provider');
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
-
$result = mockEntitlementResult(denied: true, message: 'Out of credits');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$entitlements->shouldReceive('can')->andReturn($result);
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
it('skips entitlement check when task has no workspace', function () {
@@ -273,8 +271,6 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
-
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$entitlements->shouldNotReceive('can');
@@ -283,7 +279,7 @@ public function isDenied(): bool
->with(Mockery::pattern('/is not configured/'));
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
});
@@ -305,11 +301,10 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->with('claude')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
it('includes the provider name in the failure message', function () {
@@ -325,11 +320,10 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->with('gemini')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
});
@@ -360,11 +354,10 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->with('claude')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
it('passes interpolated user prompt to the provider', function () {
@@ -393,11 +386,10 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
it('passes system prompt to the provider', function () {
@@ -421,11 +413,10 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
it('includes token and cost metadata when marking completed', function () {
@@ -453,11 +444,10 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
expect($capturedMeta)
->toHaveKey('tokens_input', 120)
@@ -479,13 +469,11 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
-
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$entitlements->shouldNotReceive('recordUsage');
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
});
@@ -511,8 +499,6 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
-
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$entitlements->shouldReceive('can')
->once()
@@ -520,15 +506,17 @@ public function isDenied(): bool
->andReturn($allowedResult);
$entitlements->shouldReceive('recordUsage')
->once()
- ->with(
- $workspace,
- 'ai.credits',
- quantity: 1,
- metadata: Mockery::type('array'),
- );
+ // Positional, matching recordUsage($workspace, $featureCode,
+ // $quantity, $user, $metadata): the call arrives with all five slots
+ // filled, so a named-argument expectation has no entry for $user and
+ // Mockery fails looking up argument 3.
+ ->with($workspace, 'ai.credits', 1, null, Mockery::type('array'))
+ // recordUsage() returns a non-nullable UsageRecord, so an
+ // expectation without andReturn() fails the return type check.
+ ->andReturn(Mockery::mock(UsageRecord::class));
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
it('includes task and prompt metadata in usage recording', function () {
@@ -551,19 +539,22 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
-
$capturedMeta = null;
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$entitlements->shouldReceive('can')->andReturn($allowedResult);
$entitlements->shouldReceive('recordUsage')
->once()
- ->andReturnUsing(function ($ws, $key, $quantity, $metadata) use (&$capturedMeta) {
+ // Five parameters, matching recordUsage(): the job passes quantity
+ // and metadata as named arguments and skips $user, so $user still
+ // arrives positionally as null between them.
+ ->andReturnUsing(function ($ws, $key, $quantity, $user, $metadata) use (&$capturedMeta) {
$capturedMeta = $metadata;
+
+ return Mockery::mock(UsageRecord::class);
});
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
expect($capturedMeta)
->toHaveKey('task_id', 99)
@@ -592,13 +583,12 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
// Should complete without exception
- expect(fn () => $job->handle($ai, $processor, $entitlements))->not->toThrow(\Exception::class);
+ expect(fn () => $job->handle($ai, $entitlements))->not->toThrow(Exception::class);
});
it('completes without error when task has a target but no matching model (stub behaviour)', function () {
@@ -620,12 +610,11 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
- expect(fn () => $job->handle($ai, $processor, $entitlements))->not->toThrow(\Exception::class);
+ expect(fn () => $job->handle($ai, $entitlements))->not->toThrow(Exception::class);
});
it('calls processOutput when both target_type and target_id are set', function () {
@@ -647,13 +636,11 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
- // ContentProcessingService is passed but the stub does not call it
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
- expect(fn () => $job->handle($ai, $processor, $entitlements))->not->toThrow(\Exception::class);
+ expect(fn () => $job->handle($ai, $entitlements))->not->toThrow(Exception::class);
});
});
@@ -681,11 +668,10 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
it('leaves unmatched placeholders unchanged', function () {
@@ -707,11 +693,10 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
it('serialises array values as JSON in placeholders', function () {
@@ -733,11 +718,10 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
it('handles empty input_data without error', function () {
@@ -759,11 +743,10 @@ public function isDenied(): bool
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
- $job->handle($ai, $processor, $entitlements);
+ $job->handle($ai, $entitlements);
});
});
@@ -790,12 +773,11 @@ public function isDenied(): bool
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
- ->andThrow(new \RuntimeException('API timeout'));
+ ->andThrow(new RuntimeException('API timeout'));
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
- $processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$task->shouldReceive('markFailed')
@@ -805,8 +787,8 @@ public function isDenied(): bool
$job = new ProcessContentTask($task);
try {
- $job->handle($ai, $processor, $entitlements);
- } catch (\Throwable $e) {
+ $job->handle($ai, $entitlements);
+ } catch (Throwable $e) {
$job->failed($e);
}
});
diff --git a/php/tests/Feature/Livewire/LivewireTestCase.php b/php/tests/Feature/Livewire/LivewireTestCase.php
index 7c8045f9..8aa4615b 100644
--- a/php/tests/Feature/Livewire/LivewireTestCase.php
+++ b/php/tests/Feature/Livewire/LivewireTestCase.php
@@ -67,16 +67,13 @@ protected function actingAsHades(): static
}
/**
- * Load a Livewire component class from the module under test.
+ * Name a Livewire component class from the module under test.
*
* Example:
* $component = $this->livewireComponent('FleetOverview');
*/
protected function livewireComponent(string $component): string
{
- $phpRoot = dirname(__DIR__, 3);
- require_once $phpRoot."/Agentic/Livewire/{$component}.php";
-
return "Core\\Mod\\Agentic\\Livewire\\{$component}";
}
@@ -96,7 +93,7 @@ protected function assertFluxComponentWiring(
array $bladeNeedles,
): void {
$phpRoot = dirname(__DIR__, 3);
- $componentSource = file_get_contents($phpRoot."/Agentic/Livewire/{$component}.php");
+ $componentSource = file_get_contents($phpRoot."/Livewire/{$component}.php");
$bladeSource = file_get_contents(
$phpRoot."/resources/views/livewire/agentic/{$bladeName}.blade.php",
);
diff --git a/php/tests/Pest.php b/php/tests/Pest.php
index 34285787..9a6539ea 100644
--- a/php/tests/Pest.php
+++ b/php/tests/Pest.php
@@ -33,12 +33,12 @@
// matched, so no TestCase was bound, no Testbench app booted, and every test
// died on a null Eloquent connection resolver.
//
-// Feature/Agentic/Livewire is excluded in phpunit.xml rather than bound here:
-// those three files each declare uses(LivewireTestCase::class) at file level,
-// and although LivewireTestCase extends this same TestCase, Pest compares the
-// bound class by name rather than by inheritance and rejects the overlap either
-// way round. Untangling that is a change to the Livewire suite, not to this
-// binding, so it is left alone and called out in phpunit.xml.
+// This binding covers Feature wholesale, so a Pest file underneath it can never
+// declare its own uses(SomeOtherTestCase::class) — Pest raises
+// TestCaseAlreadyInUse for the second binding on a file, and does so by class
+// name, so a subclass of this TestCase is rejected just the same. Tests needing
+// a richer base (the Livewire ones) are written as PHPUnit classes extending it
+// instead; class-style tests are not matched by uses()->in() at all.
uses(TestCase::class)->in(__DIR__.'/Feature', __DIR__.'/Unit', __DIR__.'/UseCase');
/*
diff --git a/php/tests/Unit/ProcessContentTaskTest.php b/php/tests/Unit/ProcessContentTaskTest.php
index 70199d33..aeda5814 100644
--- a/php/tests/Unit/ProcessContentTaskTest.php
+++ b/php/tests/Unit/ProcessContentTaskTest.php
@@ -14,8 +14,9 @@
use Core\Mod\Agentic\Services\AgenticManager;
use Core\Mod\Agentic\Services\AgenticProviderInterface;
use Core\Mod\Agentic\Services\AgenticResponse;
+use Core\Mod\Content\Models\ContentTask;
+use Core\Tenant\Services\EntitlementResult;
use Core\Tenant\Services\EntitlementService;
-use Mod\Content\Models\ContentTask;
// =========================================================================
// Helpers
@@ -28,7 +29,7 @@
*/
function makeTask(array $attributes = []): ContentTask
{
- $task = Mockery::mock(ContentTask::class);
+ $task = Mockery::mock(ContentTask::class)->makePartial();
$task->shouldReceive('markProcessing')->byDefault();
$task->shouldReceive('markCompleted')->byDefault();
$task->shouldReceive('markFailed')->byDefault();
@@ -111,9 +112,9 @@ function makeResponse(string $content = 'Generated content'): AgenticResponse
describe('handle — entitlement checks', function () {
it('marks task as failed when entitlement is denied', function () {
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
- $entitlementResult = Mockery::mock();
- $entitlementResult->shouldReceive('isDenied')->andReturn(true);
- $entitlementResult->message = 'No AI credits remaining';
+ // The real result object: EntitlementService::can() is typed to return
+ // EntitlementResult, so a bare Mockery double fails the return check.
+ $entitlementResult = EntitlementResult::denied('No AI credits remaining', featureCode: 'ai.credits');
$task = makeTask([
'prompt' => makePrompt(),
@@ -238,8 +239,7 @@ function makeResponse(string $content = 'Generated content'): AgenticResponse
it('records AI usage when workspace is present', function () {
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
- $entitlementResult = Mockery::mock();
- $entitlementResult->shouldReceive('isDenied')->andReturn(false);
+ $entitlementResult = EntitlementResult::allowed(featureCode: 'ai.credits');
$response = makeResponse();
$provider = Mockery::mock(AgenticProviderInterface::class);
@@ -276,7 +276,7 @@ function makeResponse(string $content = 'Generated content'): AgenticResponse
describe('handle — template variable interpolation', function () {
it('replaces string placeholders in user template', function () {
- $prompt = makePrompt('claude-sonnet-4-20250514', 'Write about {{{topic}}}.');
+ $prompt = makePrompt('claude-sonnet-4-20250514', 'Write about {{topic}}.');
$response = makeResponse();
$provider = Mockery::mock(AgenticProviderInterface::class);
@@ -304,7 +304,7 @@ function makeResponse(string $content = 'Generated content'): AgenticResponse
});
it('JSON-encodes array values in template', function () {
- $prompt = makePrompt('claude-sonnet-4-20250514', 'Tags: {{{tags}}}.');
+ $prompt = makePrompt('claude-sonnet-4-20250514', 'Tags: {{tags}}.');
$response = makeResponse();
$tags = ['php', 'laravel'];
@@ -333,7 +333,7 @@ function makeResponse(string $content = 'Generated content'): AgenticResponse
});
it('leaves unknown placeholders untouched', function () {
- $prompt = makePrompt('claude-sonnet-4-20250514', 'Hello {{{name}}}, see {{{unknown}}}.');
+ $prompt = makePrompt('claude-sonnet-4-20250514', 'Hello {{name}}, see {{unknown}}.');
$response = makeResponse();
$provider = Mockery::mock(AgenticProviderInterface::class);
@@ -341,7 +341,7 @@ function makeResponse(string $content = 'Generated content'): AgenticResponse
$provider->shouldReceive('generate')
->with(
$prompt->system_prompt,
- 'Hello World, see {{{unknown}}}.',
+ 'Hello World, see {{unknown}}.',
Mockery::any(),
)
->once()
diff --git a/phpunit.xml b/phpunit.xml
index e840faf4..8517a54a 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -17,14 +17,6 @@
php/tests/Feature
-
- php/tests/Feature/Agentic/Livewire
php/tests/UseCase