clawdforge/clients/php/tests/ClientTest.php
Kayos 1cff9b89d2 clients/php: initial PHP SDK for clawdforge
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
2026-04-28 22:41:02 -07:00

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, '');
}
}