dir = sys_get_temp_dir() . '/weerscherm-netatmo-' . uniqid(); $this->store = new TokenStore($this->dir . '/netatmo-token.json'); $this->http = new FakeHttp(); } protected function tearDown(): void { array_map('unlink', glob($this->dir . '/*') ?: []); @rmdir($this->dir); } private function client(): NetatmoClient { return new NetatmoClient($this->http, $this->store, 'cid', 'csecret', static fn(): int => self::NOW); } private function stationsBody(): array { return json_decode((string) file_get_contents(__DIR__ . '/fixtures/getstationsdata.json'), true); } public function testValidTokenIsUsedWithoutRefresh(): void { $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]); $this->http->queue(200, $this->stationsBody()); $body = $this->client()->getStationsData(); self::assertSame('70:ee:50:00:00:01', $body['devices'][0]['_id']); self::assertCount(1, $this->http->requests); self::assertSame('https://api.netatmo.com/api/getstationsdata', $this->http->requests[0]['url']); self::assertContains('Authorization: Bearer A1', $this->http->requests[0]['headers']); } public function testExpiringTokenIsRefreshedAndRotatedRefreshTokenStored(): void { $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 200]); $this->http ->queue(200, ['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_in' => 10800]) ->queue(200, $this->stationsBody()); $this->client()->getStationsData(); [$post, $get] = $this->http->requests; self::assertSame('POST', $post['method']); self::assertSame('https://api.netatmo.com/oauth2/token', $post['url']); self::assertSame(['grant_type' => 'refresh_token', 'refresh_token' => 'R1', 'client_id' => 'cid', 'client_secret' => 'csecret'], $post['params']); self::assertContains('Authorization: Bearer A2', $get['headers']); self::assertSame(['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_at' => self::NOW + 10800], $this->store->read()); } public function testFreshlyInitialisedStoreWithoutAccessTokenRefreshesFirst(): void { $this->store->write(['access_token' => '', 'refresh_token' => 'R1', 'expires_at' => 0]); $this->http ->queue(200, ['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_in' => 10800]) ->queue(200, $this->stationsBody()); $this->client()->getStationsData(); self::assertCount(1, $this->http->requestsOf('POST')); } public function testUnauthorizedResponseTriggersOneRefreshAndRetry(): void { $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]); $this->http ->queue(403, ['error' => ['code' => 3, 'message' => 'Access token expired']]) ->queue(200, ['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_in' => 10800]) ->queue(200, $this->stationsBody()); $this->client()->getStationsData(); self::assertSame(['GET', 'POST', 'GET'], array_column($this->http->requests, 'method')); self::assertContains('Authorization: Bearer A2', $this->http->requests[2]['headers']); } public function testSecondUnauthorizedResponseIsAuthError(): void { $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]); $this->http ->queue(401, '') ->queue(200, ['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_in' => 10800]) ->queue(401, ''); $this->expectException(NetatmoAuthException::class); $this->client()->getStationsData(); } public function testInvalidGrantIsAuthError(): void { $this->store->write(['access_token' => '', 'refresh_token' => 'R-oud', 'expires_at' => 0]); $this->http->queue(400, ['error' => 'invalid_grant']); $this->expectException(NetatmoAuthException::class); $this->client()->getStationsData(); } public function testMissingTokenFileIsAuthError(): void { $this->expectException(NetatmoAuthException::class); $this->client()->getStationsData(); } public function testServerErrorIsUpstreamError(): void { $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]); $this->http->queue(500, 'kapot'); $this->expectException(UpstreamException::class); $this->client()->getStationsData(); } public function testRefreshServerErrorIsUpstreamNotAuth(): void { $this->store->write(['access_token' => '', 'refresh_token' => 'R1', 'expires_at' => 0]); $this->http->queue(503, ''); $this->expectException(UpstreamException::class); $this->expectExceptionMessage('HTTP 503'); $this->client()->getStationsData(); } public function testConcurrentClientsShareOneRefresh(): void { $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 100]); $this->http ->queue(200, ['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_in' => 10800]) ->queue(200, $this->stationsBody()) ->queue(200, $this->stationsBody()); $first = $this->client(); $second = $this->client(); $first->getStationsData(); $second->getStationsData(); self::assertCount(1, $this->http->requestsOf('POST'), 'de tweede client leest het verse token en ververst niet'); self::assertSame('R2', $this->store->read()['refresh_token']); } public function testRejectedTokenAlreadyReplacedByOtherProcessIsReusedWithoutRefresh(): void { $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]); $this->http->queue(403, '')->queue(200, $this->stationsBody()); // Nabootsen dat een ander proces al ververst heeft zodra ons token geweigerd wordt. $http = new class($this->http, $this->store) extends FakeHttpDecorator { public function get(string $url, array $query = [], array $headers = []): \Weerscherm\HttpResponse { $res = parent::get($url, $query, $headers); if ($res->status === 403) { $this->store->write(['access_token' => 'A9', 'refresh_token' => 'R9', 'expires_at' => 1_800_000_000 + 3600]); } return $res; } }; $client = new NetatmoClient($http, $this->store, 'cid', 'csecret', static fn(): int => self::NOW); $client->getStationsData(); self::assertSame([], $this->http->requestsOf('POST'), 'geen eigen refresh nodig'); self::assertContains('Authorization: Bearer A9', $this->http->requests[1]['headers']); } public function testGetMeasureQuery(): void { $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]); $this->http->queue(200, json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure.json'), true)); $body = $this->client()->getMeasure('dev', 'mod', 1000, 2000); self::assertCount(11, $body); self::assertSame('https://api.netatmo.com/api/getmeasure', $this->http->requests[0]['url']); self::assertSame([ 'device_id' => 'dev', 'module_id' => 'mod', 'scale' => '1day', 'type' => 'sum_rain', 'date_begin' => 1000, 'date_end' => 2000, 'optimize' => 'false', 'real_time' => 'false', ], $this->http->requests[0]['params']); } public function testGetPressureHistoryQuery(): void { $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]); $this->http->queue(200, json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)); $body = $this->client()->getPressureHistory('dev', 1000, 2000); self::assertCount(49, $body); self::assertSame([ 'device_id' => 'dev', 'scale' => '30min', 'type' => 'Pressure', 'date_begin' => 1000, 'date_end' => 2000, 'optimize' => 'false', 'real_time' => 'false', ], $this->http->requests[0]['params']); } }