$responses * @param array}> $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, ''); } }