File: /home/xedaptot/ai.naniguide.com/tests/Feature/AIHubControllerTest.php
<?php
declare(strict_types=1);
/**
* Feature tests for POST /api/v1/hub-ai/handle.
*
* Hub passthrough — proxies a pre-composed prompt through the locally
* installed Claude Code CLI binary. Engine = hardcoded; config from ENV
* (HUB_AI_CLAUDE_CLI_PATH / _MODEL / _TIMEOUT). No task pipeline, no
* observability writes.
*
* Post-2026-05-13 rearchitecture:
* - Wire body collapses to a single `{context: required|string}` field.
* - Engine boundary is `ask(AIContext)`. Hub never inspects internal
* structure (system/user/messages); it's the consumer's job to
* pre-compose the string via `AIContext::toString()`.
*
* Plugin `acelle/ai` must be installed (Hub uses its `ClaudeCLIEngine`).
* Tests intercept at the `ClaudeCLIClient` (subprocess driver) layer.
*/
use Acelle\Ai\AIHandler\Data\AIContext;
use Acelle\Ai\AIHandler\Data\AIEngineResponseData;
use Acelle\Ai\AIHandler\Data\AIEngineUsageData;
use Acelle\Ai\AIHandler\Exception\AIEngineException;
use Acelle\Ai\AIHandler\Exception\AIEngineTimeoutException;
use Acelle\Ai\Plugins\ClaudeCLI\Services\ClaudeCLIClient;
use App\Model\User;
uses(\Tests\TestCase::class);
const HUB_AI_URL = '/api/v1/hub-ai/handle';
function makeHubUserToken(): array
{
$user = new User();
$user->email = 'test-hub-ai-' . uniqid() . '@example.test';
$user->first_name = 'Hub';
$user->last_name = 'Tester';
$user->password = bcrypt('test-' . uniqid());
$user->save();
return [$user, $user->fresh()->api_token];
}
afterEach(function () {
foreach (User::where('email', 'like', 'test-hub-ai-%@example.test')->get() as $u) {
$u->delete();
}
});
/**
* Bind a fake ClaudeCLIClient subclass via the container. The real
* engine still gets constructed (`new ClaudeCLIEngine(...)` in the
* controller) but pulls the fake from `app(ClaudeCLIClient::class)`
* when no explicit client is passed.
*
* The fake records the engine's per-request `$engineConfig` array
* (which carries the ENV-derived binary_path / model / timeout the
* controller fed into the engine constructor), so tests can assert
* the Hub forwards the right scalars.
*/
function bindFakeHubClient(\Closure $askFn): ClaudeCLIClient
{
$fake = new class($askFn) extends ClaudeCLIClient {
public ?AIContext $lastContext = null;
public ?array $lastConfig = null;
public function __construct(private \Closure $askFn) {}
public function ask(AIContext $context, array $engineConfig): AIEngineResponseData
{
$this->lastContext = $context;
$this->lastConfig = $engineConfig;
return ($this->askFn)($context, $engineConfig);
}
};
app()->instance(ClaudeCLIClient::class, $fake);
return $fake;
}
it('returns 401 without bearer token', function () {
$r = $this->postJson(HUB_AI_URL, ['context' => 'hi']);
expect($r->status())->toBe(401);
});
it('returns 401 for an invalid bearer token', function () {
$r = $this->withHeaders(['Authorization' => 'Bearer invalid'])
->postJson(HUB_AI_URL, ['context' => 'hi']);
expect($r->status())->toBe(401);
});
it('returns 422 when `context` field is missing', function () {
[, $token] = makeHubUserToken();
$r = $this->withHeaders(['Authorization' => "Bearer $token"])
->postJson(HUB_AI_URL, []);
expect($r->status())->toBe(422);
});
it('returns 422 when `context` is not a string', function () {
[, $token] = makeHubUserToken();
$r = $this->withHeaders(['Authorization' => "Bearer $token"])
->postJson(HUB_AI_URL, ['context' => ['nested' => 'array']]);
expect($r->status())->toBe(422);
});
it('returns 200 with the canonical 4-key body and forwards the context string verbatim to the engine', function () {
[, $token] = makeHubUserToken();
$fake = bindFakeHubClient(fn ($_ctx, $_cfg) => new AIEngineResponseData(
content: 'pong',
engine: 'claude_cli',
model: 'sonnet',
usage: new AIEngineUsageData(inputTokens: 5, outputTokens: 1, totalTokens: 6),
finishReason: 'stop',
));
$contextString = "You are concise.\n\nSay hi.";
$r = $this->withHeaders(['Authorization' => "Bearer $token"])
->postJson(HUB_AI_URL, ['context' => $contextString]);
expect($r->status())->toBe(200);
// Lock the exact wire shape — 4 keys, no `ok` field (HTTP 200 IS success).
// A rename here breaks every consumer parser; the test is the schema lock.
expect(array_keys($r->json()))->toEqualCanonicalizing(['content', 'model', 'finish_reason', 'usage']);
expect($r->json('content'))->toBe('pong');
expect($r->json('model'))->toBe('sonnet');
expect($r->json('finish_reason'))->toBe('stop');
// Usage uses Anthropic-style key names (matches AIEngineUsageData::toArray()).
// Lock all three so a future schema drift can't half-emit (e.g. forget
// `input_tokens` and only ship `total_tokens`) without failing the test.
expect($r->json('usage'))->toEqual([
'input_tokens' => 5,
'output_tokens' => 1,
'total_tokens' => 6,
]);
// Hub treats `context` opaquely — round-trips into AIContext.toString()
// unchanged (Hub-wire round-trip contract).
expect($fake->lastContext)->not->toBeNull();
expect($fake->lastContext->toString())->toBe($contextString);
});
it('forwards HUB_AI_* ENV vars into the engine config (binary_path / model / timeout)', function () {
[, $token] = makeHubUserToken();
putenv('HUB_AI_CLAUDE_CLI_PATH=/custom/bin/claude');
putenv('HUB_AI_CLAUDE_CLI_MODEL=haiku');
putenv('HUB_AI_CLAUDE_CLI_TIMEOUT=42');
$fake = bindFakeHubClient(fn ($_ctx, $_cfg) => new AIEngineResponseData(
content: 'ok',
engine: 'claude_cli',
model: 'haiku',
finishReason: 'stop',
));
$this->withHeaders(['Authorization' => "Bearer $token"])
->postJson(HUB_AI_URL, ['context' => 'hello'])
->assertStatus(200);
expect($fake->lastConfig['base_url'])->toBe('/custom/bin/claude');
expect($fake->lastConfig['model'])->toBe('haiku');
expect($fake->lastConfig['timeout'])->toBe(42);
putenv('HUB_AI_CLAUDE_CLI_PATH');
putenv('HUB_AI_CLAUDE_CLI_MODEL');
putenv('HUB_AI_CLAUDE_CLI_TIMEOUT');
});
it('returns 504 when the CLI times out', function () {
[, $token] = makeHubUserToken();
bindFakeHubClient(function () {
throw new AIEngineTimeoutException('CLI exceeded timeout');
});
$r = $this->withHeaders(['Authorization' => "Bearer $token"])
->postJson(HUB_AI_URL, ['context' => 'hi']);
expect($r->status())->toBe(504);
expect($r->json('code'))->toBe('timeout');
});
it('returns 502 when the CLI raises a generic engine error', function () {
[, $token] = makeHubUserToken();
bindFakeHubClient(function () {
throw new AIEngineException('binary not found', engine: 'claude_cli');
});
$r = $this->withHeaders(['Authorization' => "Bearer $token"])
->postJson(HUB_AI_URL, ['context' => 'hi']);
expect($r->status())->toBe(502);
expect($r->json('code'))->toBe('engine_error');
});
it('Hub is a pure string proxy — ignores extra body fields silently', function () {
[, $token] = makeHubUserToken();
$fake = bindFakeHubClient(fn ($_ctx, $_cfg) => new AIEngineResponseData(
content: 'ok',
engine: 'claude_cli',
model: 'sonnet',
finishReason: 'stop',
));
// Send legacy-shaped body — only `context` is read; other fields ignored.
$this->withHeaders(['Authorization' => "Bearer $token"])
->postJson(HUB_AI_URL, [
'context' => 'real prompt',
'system_prompt' => 'IGNORED',
'user_prompt' => 'IGNORED',
'messages' => [['role' => 'user', 'content' => 'IGNORED']],
'model' => 'IGNORED',
'temperature' => 0.0,
'max_tokens' => 1,
])
->assertStatus(200);
expect($fake->lastContext->toString())->toBe('real prompt');
});