php-agentic/tests/Feature/Jobs/ProcessContentTaskTest.php
darbs-claude 26b0f19f4c
Some checks failed
CI / PHP 8.2 (pull_request) Failing after 1s
CI / PHP 8.3 (pull_request) Failing after 1s
CI / PHP 8.4 (pull_request) Failing after 1s
CI / Assets (pull_request) Failing after 1s
test: add job tests for BatchContentGeneration and ProcessContentTask (#10)
- tests/Feature/Jobs/BatchContentGenerationTest.php
  - job configuration (timeout, priority, batch size, ShouldQueue)
  - queue assignment to ai-batch with Queue::fake()
  - tag generation (batch-generation + priority:*)
  - job chaining: ProcessContentTask dispatch per task
  - handle() empty-collection path (documented alias-mock limitation)

- tests/Feature/Jobs/ProcessContentTaskTest.php
  - job configuration (tries, backoff, timeout, ShouldQueue)
  - failed() marks task failed with exception message
  - handle() early-exit: missing prompt
  - handle() early-exit: denied entitlement
  - handle() early-exit: unavailable provider
  - handle() success without workspace (no usage recording)
  - handle() success with workspace (entitlement check + usage recording)
  - processOutput() stub behaviour (target absent/present, no crash)
  - variable interpolation: strings, arrays, unmatched placeholders, empty data
  - retry logic: re-dispatch, failed() called on unhandled exception

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 00:56:16 +00:00

812 lines
31 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
/**
* Tests for the ProcessContentTask queue job.
*
* Covers job configuration, execution paths, error handling, retry logic,
* and the stub processOutput() implementation.
* Uses Mockery to isolate the job from external dependencies.
*/
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 Illuminate\Support\Facades\Queue;
// =========================================================================
// Helpers
// =========================================================================
/**
* Build a mock ContentTask with sensible defaults.
*
* @param array<string, mixed> $overrides
*/
function mockContentTask(array $overrides = []): \Mockery\MockInterface
{
$prompt = Mockery::mock('Mod\Content\Models\ContentPrompt');
$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->id = $overrides['task_id'] ?? 1;
$task->prompt = array_key_exists('prompt', $overrides) ? $overrides['prompt'] : $prompt;
$task->workspace = $overrides['workspace'] ?? null;
$task->input_data = $overrides['input_data'] ?? [];
$task->target_type = $overrides['target_type'] ?? null;
$task->target_id = $overrides['target_id'] ?? null;
$task->target = $overrides['target'] ?? null;
$task->shouldReceive('markProcessing')->andReturnNull()->byDefault();
$task->shouldReceive('markFailed')->andReturnNull()->byDefault();
$task->shouldReceive('markCompleted')->andReturnNull()->byDefault();
return $task;
}
/**
* Build a mock AgenticResponse.
*/
function mockAgenticResponse(array $overrides = []): AgenticResponse
{
return new AgenticResponse(
content: $overrides['content'] ?? 'Generated content',
model: $overrides['model'] ?? 'claude-sonnet-4-20250514',
inputTokens: $overrides['inputTokens'] ?? 100,
outputTokens: $overrides['outputTokens'] ?? 50,
stopReason: $overrides['stopReason'] ?? 'end_turn',
durationMs: $overrides['durationMs'] ?? 1000,
raw: $overrides['raw'] ?? [],
);
}
/**
* Build a mock EntitlementResult.
*/
function mockEntitlementResult(bool $denied = false, string $message = ''): object
{
return new class($denied, $message) {
public function __construct(
private readonly bool $denied,
public readonly string $message,
) {}
public function isDenied(): bool
{
return $this->denied;
}
};
}
// =========================================================================
// Job Configuration Tests
// =========================================================================
describe('job configuration', function () {
it('retries up to 3 times', function () {
$task = mockContentTask();
$job = new ProcessContentTask($task);
expect($job->tries)->toBe(3);
});
it('backs off for 60 seconds between retries', function () {
$task = mockContentTask();
$job = new ProcessContentTask($task);
expect($job->backoff)->toBe(60);
});
it('has a 300 second timeout', function () {
$task = mockContentTask();
$job = new ProcessContentTask($task);
expect($job->timeout)->toBe(300);
});
it('dispatches to the ai queue', function () {
Queue::fake();
$task = mockContentTask();
ProcessContentTask::dispatch($task);
Queue::assertPushedOn('ai', ProcessContentTask::class);
});
it('implements ShouldQueue', function () {
$task = mockContentTask();
$job = new ProcessContentTask($task);
expect($job)->toBeInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class);
});
it('stores the task on the job', function () {
$task = mockContentTask(['task_id' => 42]);
$job = new ProcessContentTask($task);
expect($job->task->id)->toBe(42);
});
});
// =========================================================================
// Failed Handler Tests
// =========================================================================
describe('failed handler', function () {
it('marks the task as failed with the exception message', function () {
$task = mockContentTask();
$task->shouldReceive('markFailed')
->once()
->with('Something went wrong');
$job = new ProcessContentTask($task);
$job->failed(new \RuntimeException('Something went wrong'));
});
it('marks the task as failed with any throwable message', function () {
$task = mockContentTask();
$task->shouldReceive('markFailed')
->once()
->with('Database connection lost');
$job = new ProcessContentTask($task);
$job->failed(new \Exception('Database connection lost'));
});
it('uses the exception message verbatim', function () {
$task = mockContentTask();
$capturedMessage = null;
$task->shouldReceive('markFailed')
->once()
->andReturnUsing(function (string $message) use (&$capturedMessage) {
$capturedMessage = $message;
});
$job = new ProcessContentTask($task);
$job->failed(new \RuntimeException('Detailed error: code 503'));
expect($capturedMessage)->toBe('Detailed error: code 503');
});
});
// =========================================================================
// Handle Early Exit: Missing Prompt
// =========================================================================
describe('handle with missing prompt', function () {
it('marks the task failed when prompt is null', function () {
$task = mockContentTask(['prompt' => null]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markFailed')
->once()
->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);
});
it('does not call the AI provider when prompt is missing', function () {
$task = mockContentTask(['prompt' => null]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markFailed')->once();
$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);
});
});
// =========================================================================
// Handle Early Exit: Entitlement Denied
// =========================================================================
describe('handle with denied entitlement', function () {
it('marks the task failed when entitlement is denied', function () {
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
$task = mockContentTask(['workspace' => $workspace]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markFailed')
->once()
->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');
$entitlements->shouldReceive('can')
->once()
->with($workspace, 'ai.credits')
->andReturn($result);
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
it('does not invoke the AI provider when entitlement is denied', function () {
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
$task = mockContentTask(['workspace' => $workspace]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markFailed')->once();
$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);
});
it('skips entitlement check when task has no workspace', function () {
$task = mockContentTask(['workspace' => null]);
$task->shouldReceive('markProcessing')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(false);
$provider->shouldReceive('name')->andReturn('claude')->byDefault();
$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');
$task->shouldReceive('markFailed')
->once()
->with(Mockery::pattern('/is not configured/'));
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
});
// =========================================================================
// Handle Early Exit: Provider Unavailable
// =========================================================================
describe('handle with unavailable provider', function () {
it('marks the task failed when provider is not configured', function () {
$task = mockContentTask();
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markFailed')
->once()
->with("AI provider 'claude' is not configured");
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(false);
$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);
});
it('includes the provider name in the failure message', function () {
$task = mockContentTask(['prompt_model' => 'gemini']);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markFailed')
->once()
->with("AI provider 'gemini' is not configured");
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(false);
$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);
});
});
// =========================================================================
// Handle Successful Execution (without workspace)
// =========================================================================
describe('handle with successful generation (no workspace)', function () {
it('marks the task as processing then completed', function () {
$task = mockContentTask([
'workspace' => null,
'input_data' => ['name' => 'World'],
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')
->once()
->with('Generated content', Mockery::type('array'));
$response = mockAgenticResponse();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->once()
->andReturn($response);
$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);
});
it('passes interpolated user prompt to the provider', function () {
$task = mockContentTask([
'workspace' => null,
'user_template' => 'Hello {{name}}, your ID is {{id}}',
'input_data' => ['name' => 'Alice', 'id' => '42'],
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$response = mockAgenticResponse();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->once()
->with(
Mockery::any(),
'Hello Alice, your ID is 42',
Mockery::any(),
)
->andReturn($response);
$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);
});
it('passes system prompt to the provider', function () {
$task = mockContentTask([
'workspace' => null,
'system_prompt' => 'You are a content writer.',
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$response = mockAgenticResponse();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->once()
->with('You are a content writer.', Mockery::any(), Mockery::any())
->andReturn($response);
$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);
});
it('includes token and cost metadata when marking completed', function () {
$task = mockContentTask(['workspace' => null]);
$task->shouldReceive('markProcessing')->once();
$capturedMeta = null;
$task->shouldReceive('markCompleted')
->once()
->andReturnUsing(function (string $content, array $meta) use (&$capturedMeta) {
$capturedMeta = $meta;
});
$response = mockAgenticResponse([
'inputTokens' => 120,
'outputTokens' => 60,
'model' => 'claude-sonnet-4-20250514',
'durationMs' => 2500,
]);
$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);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
expect($capturedMeta)
->toHaveKey('tokens_input', 120)
->toHaveKey('tokens_output', 60)
->toHaveKey('model', 'claude-sonnet-4-20250514')
->toHaveKey('duration_ms', 2500)
->toHaveKey('estimated_cost');
});
it('does not record usage when workspace is absent', function () {
$task = mockContentTask(['workspace' => null]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')->andReturn(mockAgenticResponse());
$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);
});
});
// =========================================================================
// Handle Successful Execution (with workspace)
// =========================================================================
describe('handle with successful generation (with workspace)', function () {
it('records AI usage after successful generation', function () {
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
$task = mockContentTask(['workspace' => $workspace, 'task_id' => 7]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$response = mockAgenticResponse(['inputTokens' => 80, 'outputTokens' => 40]);
$allowedResult = mockEntitlementResult(denied: false);
$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);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$entitlements->shouldReceive('can')
->once()
->with($workspace, 'ai.credits')
->andReturn($allowedResult);
$entitlements->shouldReceive('recordUsage')
->once()
->with(
$workspace,
'ai.credits',
quantity: 1,
metadata: Mockery::type('array'),
);
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
it('includes task and prompt metadata in usage recording', function () {
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
$task = mockContentTask([
'workspace' => $workspace,
'task_id' => 99,
'prompt_id' => 5,
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$response = mockAgenticResponse();
$allowedResult = mockEntitlementResult(denied: false);
$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);
$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) {
$capturedMeta = $metadata;
});
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
expect($capturedMeta)
->toHaveKey('task_id', 99)
->toHaveKey('prompt_id', 5);
});
});
// =========================================================================
// Handle processOutput Stub Tests
// =========================================================================
describe('processOutput stub', function () {
it('completes without error when task has no target', function () {
$task = mockContentTask([
'workspace' => null,
'target_type' => null,
'target_id' => null,
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')->andReturn(mockAgenticResponse());
$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);
});
it('completes without error when task has a target but no matching model (stub behaviour)', function () {
// processOutput() is currently a stub: it logs nothing and returns
// when the target is null. This test documents the stub behaviour.
$task = mockContentTask([
'workspace' => null,
'target_type' => 'App\\Models\\Article',
'target_id' => 1,
'target' => null, // target relationship not resolved
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')->andReturn(mockAgenticResponse());
$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);
});
it('calls processOutput when both target_type and target_id are set', function () {
$target = Mockery::mock('stdClass');
$task = mockContentTask([
'workspace' => null,
'target_type' => 'App\\Models\\Article',
'target_id' => 5,
'target' => $target,
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')->andReturn(mockAgenticResponse());
$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);
});
});
// =========================================================================
// Variable Interpolation Tests (via handle())
// =========================================================================
describe('variable interpolation', function () {
it('replaces single string placeholder', function () {
$task = mockContentTask([
'workspace' => null,
'user_template' => 'Write about {{topic}}',
'input_data' => ['topic' => 'PHP testing'],
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->with(Mockery::any(), 'Write about PHP testing', Mockery::any())
->once()
->andReturn(mockAgenticResponse());
$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);
});
it('leaves unmatched placeholders unchanged', function () {
$task = mockContentTask([
'workspace' => null,
'user_template' => 'Hello {{name}}, your role is {{role}}',
'input_data' => ['name' => 'Bob'], // {{role}} has no value
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->with(Mockery::any(), 'Hello Bob, your role is {{role}}', Mockery::any())
->once()
->andReturn(mockAgenticResponse());
$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);
});
it('serialises array values as JSON in placeholders', function () {
$task = mockContentTask([
'workspace' => null,
'user_template' => 'Data: {{items}}',
'input_data' => ['items' => ['a', 'b', 'c']],
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->with(Mockery::any(), 'Data: ["a","b","c"]', Mockery::any())
->once()
->andReturn(mockAgenticResponse());
$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);
});
it('handles empty input_data without error', function () {
$task = mockContentTask([
'workspace' => null,
'user_template' => 'Static template with no variables',
'input_data' => [],
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->with(Mockery::any(), 'Static template with no variables', Mockery::any())
->once()
->andReturn(mockAgenticResponse());
$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);
});
});
// =========================================================================
// Retry Logic Tests
// =========================================================================
describe('retry logic', function () {
it('job can be re-dispatched after failure', function () {
Queue::fake();
$task = mockContentTask();
ProcessContentTask::dispatch($task);
ProcessContentTask::dispatch($task); // simulated retry
Queue::assertPushed(ProcessContentTask::class, 2);
});
it('failed() is called when an unhandled exception propagates', function () {
$task = mockContentTask(['workspace' => null]);
$task->shouldReceive('markProcessing')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->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')
->once()
->with('API timeout');
$job = new ProcessContentTask($task);
try {
$job->handle($ai, $processor, $entitlements);
} catch (\Throwable $e) {
$job->failed($e);
}
});
});