HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
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');
});