PHP 8.2+ Guzzle-based client mirroring the Python SDK surface: - Client::healthz / run / uploadFile / createToken / listTokens / revokeToken - Readonly value objects: RunRequest, RunResult, FileToken, AppToken - Exception hierarchy: ForgeException (abstract) -> ApiException -> AuthException, plus TransportException - camelCase PHP <-> snake_case wire conversion at the boundary - Streamed multipart uploads via fopen($path, 'r') - Injectable GuzzleHttp\ClientInterface (MockHandler-friendly) - HTTP timeout = subprocess timeout + 30s margin - 15 PHPUnit tests, 61 assertions, no live network - README with Laravel + WordPress integration snippets - MIT license, no Sulkta-specific assumptions
340 lines
12 KiB
PHP
340 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Clawdforge\Tests;
|
|
|
|
use Clawdforge\AppToken;
|
|
use Clawdforge\Client;
|
|
use Clawdforge\Exception\ApiException;
|
|
use Clawdforge\Exception\AuthException;
|
|
use Clawdforge\Exception\ForgeException;
|
|
use Clawdforge\Exception\TransportException;
|
|
use Clawdforge\FileToken;
|
|
use Clawdforge\RunRequest;
|
|
use Clawdforge\RunResult;
|
|
use GuzzleHttp\Client as GuzzleClient;
|
|
use GuzzleHttp\Exception\ConnectException;
|
|
use GuzzleHttp\Handler\MockHandler;
|
|
use GuzzleHttp\HandlerStack;
|
|
use GuzzleHttp\Middleware;
|
|
use GuzzleHttp\Psr7\Request;
|
|
use GuzzleHttp\Psr7\Response;
|
|
use InvalidArgumentException;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class ClientTest extends TestCase
|
|
{
|
|
private const BASE_URL = 'http://localhost:8800';
|
|
private const TOKEN = 'cf_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
|
|
|
|
/**
|
|
* @param list<Response|\Throwable> $responses
|
|
* @param array<int, array{0: \Psr\Http\Message\RequestInterface, 1: array<string, mixed>}> $history captured (request, options) pairs
|
|
*/
|
|
private function makeClient(array $responses, array &$history = []): Client
|
|
{
|
|
$mock = new MockHandler($responses);
|
|
$stack = HandlerStack::create($mock);
|
|
$stack->push(Middleware::history($history));
|
|
$guzzle = new GuzzleClient(['handler' => $stack, 'http_errors' => false]);
|
|
|
|
return new Client(self::BASE_URL, self::TOKEN, $guzzle);
|
|
}
|
|
|
|
public function testHealthzReturnsDecodedJson(): void
|
|
{
|
|
$history = [];
|
|
$client = $this->makeClient([
|
|
new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([
|
|
'ok' => true,
|
|
'claude_present' => true,
|
|
'claude_version' => '1.2.3',
|
|
])),
|
|
], $history);
|
|
|
|
$health = $client->healthz();
|
|
|
|
self::assertTrue($health['ok']);
|
|
self::assertSame('1.2.3', $health['claude_version']);
|
|
self::assertCount(1, $history);
|
|
/** @var \Psr\Http\Message\RequestInterface $req */
|
|
$req = $history[0]['request'];
|
|
self::assertSame('GET', $req->getMethod());
|
|
self::assertSame('/healthz', $req->getUri()->getPath());
|
|
self::assertSame('Bearer ' . self::TOKEN, $req->getHeaderLine('Authorization'));
|
|
}
|
|
|
|
public function testRunSuccessJsonResult(): void
|
|
{
|
|
$history = [];
|
|
$client = $this->makeClient([
|
|
new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([
|
|
'ok' => true,
|
|
'result' => ['hello' => 'world'],
|
|
'duration_ms' => 1234,
|
|
'stop_reason' => 'end_turn',
|
|
])),
|
|
], $history);
|
|
|
|
$r = $client->run(new RunRequest(prompt: 'Reply with JSON: {"hello": "world"}'));
|
|
|
|
self::assertInstanceOf(RunResult::class, $r);
|
|
self::assertTrue($r->ok);
|
|
self::assertIsArray($r->result);
|
|
self::assertSame(['hello' => 'world'], $r->result);
|
|
self::assertSame(1234, $r->durationMs);
|
|
self::assertSame('end_turn', $r->stopReason);
|
|
|
|
/** @var \Psr\Http\Message\RequestInterface $req */
|
|
$req = $history[0]['request'];
|
|
$sentBody = json_decode((string) $req->getBody(), true);
|
|
self::assertIsArray($sentBody);
|
|
self::assertSame('Reply with JSON: {"hello": "world"}', $sentBody['prompt']);
|
|
self::assertSame('sonnet', $sentBody['model']); // default
|
|
self::assertArrayNotHasKey('system', $sentBody);
|
|
self::assertArrayNotHasKey('files', $sentBody);
|
|
self::assertArrayNotHasKey('timeout_secs', $sentBody);
|
|
}
|
|
|
|
public function testRunSuccessStringResult(): void
|
|
{
|
|
$client = $this->makeClient([
|
|
new Response(200, [], (string) json_encode([
|
|
'ok' => true,
|
|
'result' => 'plain text reply',
|
|
'duration_ms' => 800,
|
|
'stop_reason' => 'end_turn',
|
|
])),
|
|
]);
|
|
|
|
$r = $client->run(new RunRequest(prompt: 'hi'));
|
|
|
|
self::assertIsString($r->result);
|
|
self::assertSame('plain text reply', $r->result);
|
|
}
|
|
|
|
public function testRunSendsExpectedBodyWithAllFields(): void
|
|
{
|
|
$history = [];
|
|
$client = $this->makeClient([
|
|
new Response(200, [], (string) json_encode([
|
|
'ok' => true, 'result' => 'x', 'duration_ms' => 1, 'stop_reason' => 'end_turn',
|
|
])),
|
|
], $history);
|
|
|
|
$client->run(new RunRequest(
|
|
prompt: 'hi',
|
|
model: 'opus',
|
|
system: 'be terse',
|
|
files: ['ff_abc'],
|
|
timeoutSecs: 42,
|
|
));
|
|
|
|
/** @var \Psr\Http\Message\RequestInterface $req */
|
|
$req = $history[0]['request'];
|
|
$sent = json_decode((string) $req->getBody(), true);
|
|
self::assertSame([
|
|
'prompt' => 'hi',
|
|
'model' => 'opus',
|
|
'system' => 'be terse',
|
|
'files' => ['ff_abc'],
|
|
'timeout_secs' => 42,
|
|
], $sent);
|
|
}
|
|
|
|
public function testRun502RaisesApiExceptionWithDecodedEnvelope(): void
|
|
{
|
|
$client = $this->makeClient([
|
|
new Response(502, [], (string) json_encode([
|
|
'ok' => false,
|
|
'error' => 'subprocess timed out',
|
|
'stderr' => '...',
|
|
'duration_ms' => 60000,
|
|
'stop_reason' => 'timeout',
|
|
])),
|
|
]);
|
|
|
|
try {
|
|
$client->run(new RunRequest(prompt: 'hi', timeoutSecs: 60));
|
|
self::fail('expected ApiException');
|
|
} catch (ApiException $e) {
|
|
self::assertSame(502, $e->statusCode);
|
|
self::assertNotNull($e->decoded);
|
|
self::assertSame('timeout', $e->decoded['stop_reason']);
|
|
self::assertStringContainsString('subprocess timed out', $e->getMessage());
|
|
self::assertInstanceOf(ForgeException::class, $e);
|
|
}
|
|
}
|
|
|
|
public function testRun401RaisesAuthException(): void
|
|
{
|
|
$client = $this->makeClient([
|
|
new Response(401, [], (string) json_encode(['detail' => 'missing bearer'])),
|
|
]);
|
|
|
|
try {
|
|
$client->run(new RunRequest(prompt: 'hi'));
|
|
self::fail('expected AuthException');
|
|
} catch (AuthException $e) {
|
|
self::assertSame(401, $e->statusCode);
|
|
self::assertInstanceOf(ApiException::class, $e);
|
|
self::assertInstanceOf(ForgeException::class, $e);
|
|
}
|
|
}
|
|
|
|
public function testRunTransportErrorWraps(): void
|
|
{
|
|
$client = $this->makeClient([
|
|
new ConnectException('Connection refused', new Request('POST', self::BASE_URL . '/run')),
|
|
]);
|
|
|
|
$this->expectException(TransportException::class);
|
|
$client->run(new RunRequest(prompt: 'hi'));
|
|
}
|
|
|
|
public function testRunRejectsEmptyPromptLocally(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
new RunRequest(prompt: '');
|
|
}
|
|
|
|
public function testUploadFileFromPath(): void
|
|
{
|
|
$history = [];
|
|
$client = $this->makeClient([
|
|
new Response(200, [], (string) json_encode([
|
|
'file_token' => 'ff_abc123',
|
|
'ttl_secs' => 3600,
|
|
'size' => 11,
|
|
])),
|
|
], $history);
|
|
|
|
$tmp = tempnam(sys_get_temp_dir(), 'cf_');
|
|
self::assertNotFalse($tmp);
|
|
file_put_contents($tmp, 'hello world');
|
|
|
|
try {
|
|
$ft = $client->uploadFile($tmp, 3600);
|
|
} finally {
|
|
@unlink($tmp);
|
|
}
|
|
|
|
self::assertInstanceOf(FileToken::class, $ft);
|
|
self::assertSame('ff_abc123', $ft->fileToken);
|
|
self::assertSame(3600, $ft->ttlSecs);
|
|
self::assertSame(11, $ft->size);
|
|
|
|
/** @var \Psr\Http\Message\RequestInterface $req */
|
|
$req = $history[0]['request'];
|
|
self::assertSame('POST', $req->getMethod());
|
|
self::assertSame('/files', $req->getUri()->getPath());
|
|
self::assertStringStartsWith('multipart/form-data', $req->getHeaderLine('Content-Type'));
|
|
// Multipart body should mention both fields by name.
|
|
$body = (string) $req->getBody();
|
|
self::assertStringContainsString('name="ttl_secs"', $body);
|
|
self::assertStringContainsString('name="file"', $body);
|
|
}
|
|
|
|
public function testCreateTokenRoundTrip(): void
|
|
{
|
|
$history = [];
|
|
$client = $this->makeClient([
|
|
new Response(200, [], (string) json_encode([
|
|
'name' => 'cauldron',
|
|
'token' => 'cf_brandnew_xxx',
|
|
'ip_cidrs' => ['172.24.0.0/16'],
|
|
])),
|
|
], $history);
|
|
|
|
$t = $client->createToken('cauldron', ['172.24.0.0/16']);
|
|
|
|
self::assertInstanceOf(AppToken::class, $t);
|
|
self::assertSame('cauldron', $t->name);
|
|
self::assertSame('cf_brandnew_xxx', $t->token);
|
|
self::assertSame(['172.24.0.0/16'], $t->ipCidrs);
|
|
|
|
/** @var \Psr\Http\Message\RequestInterface $req */
|
|
$req = $history[0]['request'];
|
|
$sent = json_decode((string) $req->getBody(), true);
|
|
self::assertSame(['name' => 'cauldron', 'ip_cidrs' => ['172.24.0.0/16']], $sent);
|
|
}
|
|
|
|
public function testListTokensNormalizesCommaJoinedCidrsAndEnabledFlag(): void
|
|
{
|
|
$client = $this->makeClient([
|
|
new Response(200, [], (string) json_encode([
|
|
'tokens' => [
|
|
[
|
|
'name' => 'cauldron',
|
|
'ip_cidrs' => '172.24.0.0/16',
|
|
'created_at' => 100,
|
|
'last_used' => 200,
|
|
'enabled' => 1,
|
|
],
|
|
[
|
|
'name' => 'petalparse',
|
|
'ip_cidrs' => '',
|
|
'created_at' => 50,
|
|
'last_used' => null,
|
|
'enabled' => 0,
|
|
],
|
|
],
|
|
])),
|
|
]);
|
|
|
|
$toks = $client->listTokens();
|
|
|
|
self::assertCount(2, $toks);
|
|
self::assertSame('cauldron', $toks[0]->name);
|
|
self::assertSame(['172.24.0.0/16'], $toks[0]->ipCidrs);
|
|
self::assertTrue($toks[0]->enabled);
|
|
self::assertNull($toks[0]->token); // never returned on list
|
|
self::assertSame(200, $toks[0]->lastUsed);
|
|
|
|
self::assertSame([], $toks[1]->ipCidrs);
|
|
self::assertFalse($toks[1]->enabled);
|
|
self::assertNull($toks[1]->lastUsed);
|
|
}
|
|
|
|
public function testRevokeTokenReturnsTrueOnOk(): void
|
|
{
|
|
$history = [];
|
|
$client = $this->makeClient([
|
|
new Response(200, [], (string) json_encode(['ok' => true])),
|
|
], $history);
|
|
|
|
self::assertTrue($client->revokeToken('cauldron'));
|
|
|
|
/** @var \Psr\Http\Message\RequestInterface $req */
|
|
$req = $history[0]['request'];
|
|
self::assertSame('DELETE', $req->getMethod());
|
|
self::assertSame('/admin/tokens/cauldron', $req->getUri()->getPath());
|
|
}
|
|
|
|
public function testRevokeTokenMissingRaises404(): void
|
|
{
|
|
$client = $this->makeClient([
|
|
new Response(404, [], (string) json_encode(['detail' => 'no such token'])),
|
|
]);
|
|
|
|
try {
|
|
$client->revokeToken('nosuch');
|
|
self::fail('expected ApiException');
|
|
} catch (ApiException $e) {
|
|
self::assertSame(404, $e->statusCode);
|
|
}
|
|
}
|
|
|
|
public function testConstructorRequiresBaseUrl(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
new Client('', 'cf_x');
|
|
}
|
|
|
|
public function testConstructorRequiresToken(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
new Client(self::BASE_URL, '');
|
|
}
|
|
}
|