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('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