From 1d0cbe720f1e503d34a1400856335792f7f6183e Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 8 Aug 2026 09:38:41 +0100 Subject: [PATCH 1/3] fix(php): move the Livewire admin screens to where PSR-4 can find them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit php/Agentic/ held eight files whose namespaces resolve one directory higher: Core\Mod\Agentic\ maps to php/, so Core\Mod\Agentic\Livewire\ HubComponent had to live at php/Livewire/HubComponent.php and instead sat at php/Agentic/Livewire/HubComponent.php. Composer never loaded any of them. The three component classes extend HubComponent, so every one of them was fatal on load, and BrainExplorer, CreditLedger and FleetOverview have never been reachable in a running application. The tests hid it. LivewireTestCase::livewireComponent() require_once'd the component file by path, and the two service tests carried their own loadAgenticPhpClass() doing the same, so the classes were pulled in by hand and the missing autoloading never showed. Both workarounds go; the files are moved to php/Livewire, php/Data and php/Services, which is where their declared namespaces have always pointed. Their view paths moved with them: the components resolve Blade through __DIR__.'/../../resources/views/...', which was correct from php/Agentic/ Livewire and is one level too deep from php/Livewire. The three Feature/Agentic/Livewire tests were excluded in phpunit.xml rather than fixed. Pest rejects a second uses() binding on a file no matter the inheritance, so a file-level uses(LivewireTestCase::class) under a directory-wide uses(TestCase::class) always throws TestCaseAlreadyInUse. Class-style tests are not matched by uses()->in() at all, which is why the thirteen sibling files in Feature/Livewire have always run — these three are now written the same way. BrainExplorerTest also fakes HTTP, because forgetting a memory posts a delete to Qdrant and was reaching localhost:6334 for real. Feature/Agentic: 15 passed, was 9 uncollectable plus 2 failing. Co-Authored-By: Virgil --- php/{Agentic => }/Data/CreditTransaction.php | 0 php/{Agentic => }/Data/FleetStats.php | 0 php/{Agentic => }/Livewire/BrainExplorer.php | 2 +- php/{Agentic => }/Livewire/CreditLedger.php | 2 +- php/{Agentic => }/Livewire/FleetOverview.php | 2 +- php/{Agentic => }/Livewire/HubComponent.php | 2 +- php/{Agentic => }/Services/CreditService.php | 0 php/{Agentic => }/Services/SessionService.php | 0 .../Agentic/Livewire/BrainExplorerTest.php | 164 ++++++++------- .../Agentic/Livewire/CreditLedgerTest.php | 171 ++++++++-------- .../Agentic/Livewire/FleetOverviewTest.php | 187 ++++++++++-------- .../Agentic/Services/CreditServiceTest.php | 25 +-- .../Agentic/Services/SessionServiceTest.php | 18 +- .../Feature/Livewire/LivewireTestCase.php | 7 +- php/tests/Pest.php | 12 +- phpunit.xml | 8 - 16 files changed, 307 insertions(+), 293 deletions(-) rename php/{Agentic => }/Data/CreditTransaction.php (100%) rename php/{Agentic => }/Data/FleetStats.php (100%) rename php/{Agentic => }/Livewire/BrainExplorer.php (99%) rename php/{Agentic => }/Livewire/CreditLedger.php (98%) rename php/{Agentic => }/Livewire/FleetOverview.php (99%) rename php/{Agentic => }/Livewire/HubComponent.php (95%) rename php/{Agentic => }/Services/CreditService.php (100%) rename php/{Agentic => }/Services/SessionService.php (100%) 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/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/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/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/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 From 7f104fcf127a323f32bd7cc3d97eecb6841a536f Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 8 Aug 2026 09:38:59 +0100 Subject: [PATCH 2/3] fix(php): take the Content module from the package that defines it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty production files imported Mod\Content\* — ContentTask, ContentBrief, AIGatewayService, GenerateContentJob and the rest. No such namespace exists. The real one is Core\Mod\Content\, shipped by dappcore/php-content, which was not a dependency of this package at all. So BatchContentGeneration:: handle() would fatal on "Class not found" the moment a worker picked it up, and the MCP content tools with it. dappcore/php-content joins require-dev, matching how dappcore/php-tenant is already carried here: it is a sibling module a host application supplies. Its only new transitive dependency is ezyang/htmlpurifier. Wiring the real classes in exposed three things the phantom ones had been hiding: interpolateVariables() built its placeholder as '{{{'.$key.'}}}' — three braces. Every template in the package writes {{name}}, and Prompt:: interpolate() builds the same placeholder correctly as "{{{$key}}}", which is $key between two brace pairs, not three. The concatenated form was a mis-transcription of it, so no variable has ever been substituted and user templates reached the provider verbatim. An entitlement denial recorded "Entitlement denied: " and nothing else. EntitlementResult keeps its text in a readonly $reason behind getMessage() and has no $message property and no __get, so $result->message was always an undefined property read. The tests were written against the phantoms and had to be corrected, not merely re-pointed: mocks of a non-existent class accept anything, so they asserted nothing. ContentTask and Prompt doubles are makePartial() so real Eloquent attribute handling runs; EntitlementResult and UsageRecord are the real classes, since can() and recordUsage() are typed and reject look-alikes; recordUsage()'s expectation is positional because a named-argument expectation leaves $user unmatched. ProcessContentTaskTest also passed a ContentProcessingService as handle()'s second argument — a signature this job has never had — and Unit/ProcessContentTaskTest asserted the three-brace placeholder. BatchContentGenerationTest's remaining skip goes too. It alias-mocked ContentTask to fake the static query(), which replaces the class for the whole PHP process and is why it needed isolation; it now runs against a real content_tasks table created for that test. Only that table, because php-content's migration also recreates prompts with columns stricter than this package's own. Feature/Jobs + Unit/ProcessContentTask: 70 passed, 3 failed, was 43 passed, 27 failed. The 3 are pre-existing and reach a live Qdrant/Elasticsearch. Co-Authored-By: Virgil --- composer.json | 1 + composer.lock | 127 +++++++++++++++- php/Console/Commands/GenerateCommand.php | 14 +- php/Jobs/BatchContentGeneration.php | 2 +- php/Jobs/ProcessContentTask.php | 14 +- php/Mcp/Resources/ContentResource.php | 2 +- .../Agent/Content/ContentBatchGenerate.php | 4 +- .../Agent/Content/ContentBriefCreate.php | 4 +- .../Tools/Agent/Content/ContentBriefGet.php | 4 +- .../Tools/Agent/Content/ContentBriefList.php | 4 +- .../Tools/Agent/Content/ContentFromPlan.php | 6 +- .../Tools/Agent/Content/ContentGenerate.php | 6 +- php/Mcp/Tools/Agent/Content/ContentStatus.php | 4 +- .../Tools/Agent/Content/ContentUsageStats.php | 2 +- php/Models/Prompt.php | 2 +- php/Services/ContentService.php | 2 +- .../Jobs/BatchContentGenerationTest.php | 64 +++++--- .../Feature/Jobs/ProcessContentTaskTest.php | 142 ++++++++---------- php/tests/Unit/ProcessContentTaskTest.php | 22 +-- 19 files changed, 279 insertions(+), 147 deletions(-) diff --git a/composer.json b/composer.json index 8a490ad9..7264522b 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "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..c09034dc 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": "81323bc828ae947949afbe268a536eee", "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", 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/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/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/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/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() From 496cf3694df54594e595f173c375ff0224157e42 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 8 Aug 2026 09:39:12 +0100 Subject: [PATCH 3/3] test(php): give the content suite fixtures instead of a permanent skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five tests in ContentServiceTest did nothing but markTestSkipped('Help article prompt not found'), on every machine and in CI. The prompt was never going to be there: ContentService resolves its paths as base_path() plus a relative prefix defaulting to app/Mod/Agentic/Resources/*, which belongs to a host application, and under Testbench base_path() is the bare skeleton. Three more tests in the same file failed rather than skipped, for the same reason — they read a batch-001-link-getting-started fixture that only a host app would have. All three prefixes are config-driven, so the suite now points them at a sandbox under base_path() and writes the prompt template and batch spec it needs, cleaning up afterwards. The per-test finally blocks go with it, since afterEach removes the whole tree. ContentServiceTest: 10 passed, was 2 passed, 3 failed, 5 skipped. The CI comment is brought back in line with the suite: 1311 tests, 1155 passing, 156 failing, nothing skipped or excluded. It named the missing Core\Mcp dependency trait, so that is now described accurately — ValidatesDependencies and HasDependencies belong to dappcore/mcp, which claims the same Core\Mcp\ PSR-4 root this package also maps onto php/Mcp, so adding it is a namespace collision to resolve, not a plain install. Co-Authored-By: Virgil --- .github/workflows/ci.yml | 13 +- php/tests/Feature/ContentServiceTest.php | 320 ++++++++++------------- 2 files changed, 147 insertions(+), 186 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 147632b9..debff831 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,10 +80,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/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); });