Merge pull request 'refactor(jobs): remove processOutput stub from ProcessContentTask' (#47) from refactor/remove-process-content-task-stub into main
Reviewed-on: #47
This commit is contained in:
commit
411d7decac
2 changed files with 382 additions and 26 deletions
|
|
@ -12,7 +12,6 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
|||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Mod\Content\Models\ContentTask;
|
||||
use Mod\Content\Services\ContentProcessingService;
|
||||
use Throwable;
|
||||
|
||||
class ProcessContentTask implements ShouldQueue
|
||||
|
|
@ -33,7 +32,6 @@ class ProcessContentTask implements ShouldQueue
|
|||
|
||||
public function handle(
|
||||
AgenticManager $ai,
|
||||
ContentProcessingService $processor,
|
||||
EntitlementService $entitlements
|
||||
): void {
|
||||
$this->task->markProcessing();
|
||||
|
|
@ -103,11 +101,6 @@ class ProcessContentTask implements ShouldQueue
|
|||
]
|
||||
);
|
||||
}
|
||||
|
||||
// If this task has a target, process the output
|
||||
if ($this->task->target_type && $this->task->target_id) {
|
||||
$this->processOutput($response->content, $processor);
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(Throwable $exception): void
|
||||
|
|
@ -115,9 +108,6 @@ class ProcessContentTask implements ShouldQueue
|
|||
$this->task->markFailed($exception->getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate template variables.
|
||||
*/
|
||||
private function interpolateVariables(string $template, array $data): string
|
||||
{
|
||||
foreach ($data as $key => $value) {
|
||||
|
|
@ -130,20 +120,4 @@ class ProcessContentTask implements ShouldQueue
|
|||
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the AI output based on target type.
|
||||
*/
|
||||
private function processOutput(string $content, ContentProcessingService $processor): void
|
||||
{
|
||||
$target = $this->task->target;
|
||||
|
||||
if (! $target) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle different target types
|
||||
// This can be extended for different content types
|
||||
// For now, just log that processing occurred
|
||||
}
|
||||
}
|
||||
|
|
|
|||
382
tests/Unit/ProcessContentTaskTest.php
Normal file
382
tests/Unit/ProcessContentTaskTest.php
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tests for the ProcessContentTask queued job.
|
||||
*
|
||||
* Covers the handle() flow: prompt validation, entitlement checks,
|
||||
* provider availability, task completion, usage recording, and
|
||||
* template variable interpolation.
|
||||
*/
|
||||
|
||||
use Core\Mod\Agentic\Jobs\ProcessContentTask;
|
||||
use Core\Mod\Agentic\Services\AgenticManager;
|
||||
use Core\Mod\Agentic\Services\AgenticProviderInterface;
|
||||
use Core\Mod\Agentic\Services\AgenticResponse;
|
||||
use Core\Tenant\Services\EntitlementService;
|
||||
use Mod\Content\Models\ContentTask;
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Build a minimal mock ContentTask with sensible defaults.
|
||||
*
|
||||
* @param array<string,mixed> $attributes Override property returns.
|
||||
*/
|
||||
function makeTask(array $attributes = []): ContentTask
|
||||
{
|
||||
$task = Mockery::mock(ContentTask::class);
|
||||
$task->shouldReceive('markProcessing')->byDefault();
|
||||
$task->shouldReceive('markCompleted')->byDefault();
|
||||
$task->shouldReceive('markFailed')->byDefault();
|
||||
|
||||
foreach ($attributes as $prop => $value) {
|
||||
$task->shouldReceive('__get')->with($prop)->andReturn($value);
|
||||
}
|
||||
|
||||
// Return null for any property not explicitly configured
|
||||
$task->shouldReceive('__get')->byDefault()->andReturn(null);
|
||||
|
||||
return $task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal mock prompt object.
|
||||
*/
|
||||
function makePrompt(string $model = 'claude-sonnet-4-20250514', string $userTemplate = 'Hello'): object
|
||||
{
|
||||
return (object) [
|
||||
'id' => 1,
|
||||
'model' => $model,
|
||||
'system_prompt' => 'You are a helpful assistant.',
|
||||
'user_template' => $userTemplate,
|
||||
'model_config' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AgenticResponse suitable for testing.
|
||||
*/
|
||||
function makeResponse(string $content = 'Generated content'): AgenticResponse
|
||||
{
|
||||
return new AgenticResponse(
|
||||
content: $content,
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
durationMs: 1200,
|
||||
stopReason: 'end_turn',
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// handle() — prompt validation
|
||||
// =========================================================================
|
||||
|
||||
describe('handle — prompt validation', function () {
|
||||
it('marks task as failed when prompt is missing', function () {
|
||||
$task = makeTask(['prompt' => null]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markFailed')->once()->with('Prompt not found');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle(
|
||||
Mockery::mock(AgenticManager::class),
|
||||
Mockery::mock(EntitlementService::class),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call AI provider when prompt is missing', function () {
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldNotReceive('provider');
|
||||
|
||||
$task = makeTask(['prompt' => null]);
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, Mockery::mock(EntitlementService::class));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// handle() — entitlement checks
|
||||
// =========================================================================
|
||||
|
||||
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';
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => makePrompt(),
|
||||
'workspace' => $workspace,
|
||||
]);
|
||||
$task->shouldReceive('markFailed')
|
||||
->once()
|
||||
->with(Mockery::on(fn ($msg) => str_contains($msg, 'Entitlement denied')));
|
||||
|
||||
$entitlements = Mockery::mock(EntitlementService::class);
|
||||
$entitlements->shouldReceive('can')
|
||||
->with($workspace, 'ai.credits')
|
||||
->andReturn($entitlementResult);
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle(Mockery::mock(AgenticManager::class), $entitlements);
|
||||
});
|
||||
|
||||
it('skips entitlement check when task has no workspace', function () {
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(false);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => makePrompt(),
|
||||
'workspace' => null,
|
||||
]);
|
||||
|
||||
$entitlements = Mockery::mock(EntitlementService::class);
|
||||
$entitlements->shouldNotReceive('can');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $entitlements);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// handle() — provider availability
|
||||
// =========================================================================
|
||||
|
||||
describe('handle — provider availability', function () {
|
||||
it('marks task as failed when provider is unavailable', function () {
|
||||
$prompt = makePrompt('gemini-2.0-flash');
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(false);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->with('gemini-2.0-flash')->andReturn($provider);
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => $prompt,
|
||||
'workspace' => null,
|
||||
]);
|
||||
$task->shouldReceive('markFailed')
|
||||
->once()
|
||||
->with("AI provider 'gemini-2.0-flash' is not configured");
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, Mockery::mock(EntitlementService::class));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// handle() — successful completion
|
||||
// =========================================================================
|
||||
|
||||
describe('handle — successful completion', function () {
|
||||
it('marks task as completed with response metadata', function () {
|
||||
$prompt = makePrompt('claude-sonnet-4-20250514', 'Write about PHP.');
|
||||
$response = makeResponse('PHP is a great language.');
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->with('claude-sonnet-4-20250514')->andReturn($provider);
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => $prompt,
|
||||
'workspace' => null,
|
||||
'input_data' => [],
|
||||
]);
|
||||
$task->shouldReceive('markCompleted')
|
||||
->once()
|
||||
->with('PHP is a great language.', Mockery::on(function ($metadata) {
|
||||
return $metadata['tokens_input'] === 100
|
||||
&& $metadata['tokens_output'] === 50
|
||||
&& $metadata['model'] === 'claude-sonnet-4-20250514'
|
||||
&& $metadata['duration_ms'] === 1200
|
||||
&& isset($metadata['estimated_cost']);
|
||||
}));
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, Mockery::mock(EntitlementService::class));
|
||||
});
|
||||
|
||||
it('does not record usage when task has no workspace', function () {
|
||||
$response = makeResponse();
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => makePrompt(),
|
||||
'workspace' => null,
|
||||
'input_data' => [],
|
||||
]);
|
||||
|
||||
$entitlements = Mockery::mock(EntitlementService::class);
|
||||
$entitlements->shouldNotReceive('recordUsage');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $entitlements);
|
||||
});
|
||||
|
||||
it('records AI usage when workspace is present', function () {
|
||||
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
|
||||
$entitlementResult = Mockery::mock();
|
||||
$entitlementResult->shouldReceive('isDenied')->andReturn(false);
|
||||
|
||||
$response = makeResponse();
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$prompt = makePrompt();
|
||||
$task = makeTask([
|
||||
'prompt' => $prompt,
|
||||
'workspace' => $workspace,
|
||||
'input_data' => [],
|
||||
'id' => 42,
|
||||
]);
|
||||
|
||||
$entitlements = Mockery::mock(EntitlementService::class);
|
||||
$entitlements->shouldReceive('can')
|
||||
->with($workspace, 'ai.credits')
|
||||
->andReturn($entitlementResult);
|
||||
$entitlements->shouldReceive('recordUsage')
|
||||
->once()
|
||||
->with($workspace, 'ai.credits', quantity: 1, metadata: Mockery::on(function ($meta) {
|
||||
return $meta['task_id'] === 42
|
||||
&& isset($meta['model'])
|
||||
&& isset($meta['estimated_cost']);
|
||||
}));
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $entitlements);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// handle() — template variable interpolation
|
||||
// =========================================================================
|
||||
|
||||
describe('handle — template variable interpolation', function () {
|
||||
it('replaces string placeholders in user template', function () {
|
||||
$prompt = makePrompt('claude-sonnet-4-20250514', 'Write about {{{topic}}}.');
|
||||
$response = makeResponse();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')
|
||||
->with(
|
||||
$prompt->system_prompt,
|
||||
'Write about Laravel.',
|
||||
Mockery::any(),
|
||||
)
|
||||
->once()
|
||||
->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => $prompt,
|
||||
'workspace' => null,
|
||||
'input_data' => ['topic' => 'Laravel'],
|
||||
]);
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, Mockery::mock(EntitlementService::class));
|
||||
});
|
||||
|
||||
it('JSON-encodes array values in template', function () {
|
||||
$prompt = makePrompt('claude-sonnet-4-20250514', 'Tags: {{{tags}}}.');
|
||||
$response = makeResponse();
|
||||
$tags = ['php', 'laravel'];
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')
|
||||
->with(
|
||||
$prompt->system_prompt,
|
||||
'Tags: '.json_encode($tags).'.',
|
||||
Mockery::any(),
|
||||
)
|
||||
->once()
|
||||
->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => $prompt,
|
||||
'workspace' => null,
|
||||
'input_data' => ['tags' => $tags],
|
||||
]);
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, Mockery::mock(EntitlementService::class));
|
||||
});
|
||||
|
||||
it('leaves unknown placeholders untouched', function () {
|
||||
$prompt = makePrompt('claude-sonnet-4-20250514', 'Hello {{{name}}}, see {{{unknown}}}.');
|
||||
$response = makeResponse();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')
|
||||
->with(
|
||||
$prompt->system_prompt,
|
||||
'Hello World, see {{{unknown}}}.',
|
||||
Mockery::any(),
|
||||
)
|
||||
->once()
|
||||
->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => $prompt,
|
||||
'workspace' => null,
|
||||
'input_data' => ['name' => 'World'],
|
||||
]);
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, Mockery::mock(EntitlementService::class));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// failed() — queue failure handler
|
||||
// =========================================================================
|
||||
|
||||
describe('failed', function () {
|
||||
it('marks task as failed with exception message', function () {
|
||||
$exception = new RuntimeException('Connection refused');
|
||||
|
||||
$task = makeTask();
|
||||
$task->shouldReceive('markFailed')->once()->with('Connection refused');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->failed($exception);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue