createClient(function (string $method, string $url) use (&$requested) { $requested = $url; return new MockResponse('{"ist": 0, "list": []}', ['response_headers' => ['content-type' => 'application/json']]); }); $client->getTimeAccount('theo@example.com', 2026, $month); $this->assertSame('https://saas.example'.$expectedPath, $requested); } public static function provideMonths(): iterable { yield 'January is index 0' => [1, '/api/v2/time/account/secret%2Fkey/theo%40example.com/2026/0']; yield 'March is index 2' => [3, '/api/v2/time/account/secret%2Fkey/theo%40example.com/2026/2']; yield 'December is index 11' => [12, '/api/v2/time/account/secret%2Fkey/theo%40example.com/2026/11']; } /** * Both the key and the address sit in the path, so both have to survive a slash or an * "@" without changing the route. */ public function testItEscapesTheKeyAndTheAddress(): void { $requested = null; $client = $this->createClient(function (string $method, string $url) use (&$requested) { $requested = $url; return new MockResponse('{"ist": 0, "list": []}', ['response_headers' => ['content-type' => 'application/json']]); }); $client->getTimeAccount('a b+c@example.com', 2026, 3); $this->assertStringContainsString('/secret%2Fkey/', $requested); $this->assertStringContainsString('/a%20b%2Bc%40example.com/', $requested); } public function testItReturnsTheDecodedPayload(): void { $client = $this->createClient(fn () => new MockResponse( '{"ist": 480, "employee": "e-1", "list": []}', ['response_headers' => ['content-type' => 'application/json']], )); $this->assertSame( ['ist' => 480, 'employee' => 'e-1', 'list' => []], $client->getTimeAccount('theo@example.com', 2026, 9), ); } public function testAnErrorStatusBecomesAnApiClientException(): void { $client = $this->createClient(fn () => new MockResponse('nope', ['http_code' => 500])); $this->expectException(ApiClientException::class); $this->expectExceptionMessageMatches('/theo@example.com \(2026-03\)/'); $client->getTimeAccount('theo@example.com', 2026, 3); } public function testUnreadableJsonBecomesAnApiClientException(): void { $client = $this->createClient(fn () => new MockResponse( 'not json', ['response_headers' => ['content-type' => 'application/json']], )); $this->expectException(ApiClientException::class); $client->getTimeAccount('theo@example.com', 2026, 3); } public function testItRefusesAMonthThatIsNotACalendarMonth(): void { $client = $this->createClient(fn () => new MockResponse('{}')); $this->expectException(ApiClientException::class); $this->expectExceptionMessage('0 is not a calendar month.'); $client->getTimeAccount('theo@example.com', 2026, 0); } public function testAnUnconfiguredBaseUrlFailsAtCallTimeNotAtBoot(): void { // The command has to stay usable - "--help" included - on a machine that has no // credentials, so this cannot be a container-time failure. $client = new ApiClient( new MockHttpClient(fn () => new MockResponse('{}')), new NullLogger(), ['base_url' => '', 'api_key' => 'k'], ); $this->expectException(ApiClientException::class); $this->expectExceptionMessage('APP_SAAS_BASE_URL is not configured.'); $client->getTimeAccount('theo@example.com', 2026, 3); } private function createClient(callable $handler): ApiClient { return new ApiClient( new MockHttpClient($handler), new NullLogger(), ['base_url' => 'https://saas.example', 'api_key' => 'secret/key'], ); } }