add(Duration::fromSeconds(3600))); $oauthToken = new OAuthToken($accessToken, null, TokenType::BEARER, null); $stored = StoredOAuthToken::create('user-123', 'spotify', $oauthToken); expect($stored->id)->toBeNull(); // Not persisted yet expect($stored->userId)->toBe('user-123'); expect($stored->provider)->toBe('spotify'); expect($stored->token)->toBe($oauthToken); }); it('sets timestamps on creation', function () { $accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600))); $oauthToken = new OAuthToken($accessToken, null, TokenType::BEARER, null); $beforeCreate = Timestamp::now(); $stored = StoredOAuthToken::create('user-123', 'spotify', $oauthToken); $afterCreate = Timestamp::now(); expect($stored->createdAt->toTimestamp())->toBeGreaterThanOrEqual($beforeCreate->toTimestamp()); expect($stored->createdAt->toTimestamp())->toBeLessThanOrEqual($afterCreate->toTimestamp()); expect($stored->updatedAt->toTimestamp())->toBe($stored->createdAt->toTimestamp()); }); it('creates from database row', function () { $now = Timestamp::now(); $row = [ 'id' => 1, 'user_id' => 'user-456', 'provider' => 'github', 'access_token' => 'access_1234567890', 'refresh_token' => 'refresh_1234567890', 'token_type' => 'Bearer', 'expires_at' => $now->add(Duration::fromSeconds(3600))->format('Y-m-d H:i:s'), 'scope' => 'read write', 'created_at' => $now->format('Y-m-d H:i:s'), 'updated_at' => $now->format('Y-m-d H:i:s'), ]; $stored = StoredOAuthToken::fromArray($row); expect($stored->id)->toBe(1); expect($stored->userId)->toBe('user-456'); expect($stored->provider)->toBe('github'); expect($stored->token->accessToken->toString())->toBe('access_1234567890'); expect($stored->token->refreshToken->toString())->toBe('refresh_1234567890'); }); it('updates token immutably', function () { $originalAccessToken = AccessToken::create('original_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600))); $originalToken = new OAuthToken($originalAccessToken, null, TokenType::BEARER, null); $originalStored = StoredOAuthToken::create('user-123', 'spotify', $originalToken); $newAccessToken = AccessToken::create('new_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(7200))); $newToken = new OAuthToken($newAccessToken, null, TokenType::BEARER, null); $updatedStored = $originalStored->withRefreshedToken($newToken); // Original unchanged expect($originalStored->token->accessToken->toString())->toBe('original_access_1234567890'); expect($originalStored->userId)->toBe('user-123'); // New instance updated expect($updatedStored->token->accessToken->toString())->toBe('new_access_1234567890'); expect($updatedStored->userId)->toBe('user-123'); expect($updatedStored->provider)->toBe('spotify'); }); it('updates updatedAt timestamp on token refresh', function () { $originalAccessToken = AccessToken::create('original_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600))); $originalToken = new OAuthToken($originalAccessToken, null, TokenType::BEARER, null); $originalStored = StoredOAuthToken::create('user-123', 'spotify', $originalToken); // Wait a full second to ensure different timestamp sleep(1); $newAccessToken = AccessToken::create('new_1234567890', Timestamp::now()->add(Duration::fromSeconds(7200))); $newToken = new OAuthToken($newAccessToken, null, TokenType::BEARER, null); $updatedStored = $originalStored->withRefreshedToken($newToken); expect($updatedStored->updatedAt->toTimestamp()) ->toBeGreaterThanOrEqual($originalStored->updatedAt->toTimestamp() + 1); expect($updatedStored->createdAt->toTimestamp()) ->toBe($originalStored->createdAt->toTimestamp()); }); it('preserves id during token refresh', function () { $accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600))); $oauthToken = new OAuthToken($accessToken, null, TokenType::BEARER, null); $stored = new StoredOAuthToken( id: 42, userId: 'user-123', provider: 'spotify', token: $oauthToken, createdAt: Timestamp::now(), updatedAt: Timestamp::now() ); $newAccessToken = AccessToken::create('new_1234567890', Timestamp::now()->add(Duration::fromSeconds(7200))); $newToken = new OAuthToken($newAccessToken, null, TokenType::BEARER, null); $updated = $stored->withRefreshedToken($newToken); expect($updated->id)->toBe(42); }); it('detects expired token', function () { $expiredAccessToken = AccessToken::create('expired_1234567890', Timestamp::now()->subtract(Duration::fromSeconds(100))); $expiredToken = new OAuthToken($expiredAccessToken, null, TokenType::BEARER, null); $stored = StoredOAuthToken::create('user-123', 'spotify', $expiredToken); expect($stored->isExpired())->toBeTrue(); }); it('detects valid non-expired token', function () { $validAccessToken = AccessToken::create('valid_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600))); $validToken = new OAuthToken($validAccessToken, null, TokenType::BEARER, null); $stored = StoredOAuthToken::create('user-123', 'spotify', $validToken); expect($stored->isExpired())->toBeFalse(); }); it('checks if token can refresh', function () { $accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600))); $refreshToken = RefreshToken::create('refresh_1234567890'); $oauthToken = new OAuthToken($accessToken, $refreshToken, TokenType::BEARER, null); $stored = StoredOAuthToken::create('user-123', 'spotify', $oauthToken); expect($stored->canRefresh())->toBeTrue(); }); it('checks if token cannot refresh without refresh token', function () { $accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600))); $oauthToken = new OAuthToken($accessToken, null, TokenType::BEARER, null); $stored = StoredOAuthToken::create('user-123', 'spotify', $oauthToken); expect($stored->canRefresh())->toBeFalse(); }); it('converts to array for database storage', function () { $accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600))); $refreshToken = RefreshToken::create('refresh_1234567890'); $oauthToken = new OAuthToken($accessToken, $refreshToken, TokenType::BEARER, null); $stored = new StoredOAuthToken( id: 1, userId: 'user-123', provider: 'spotify', token: $oauthToken, createdAt: Timestamp::now(), updatedAt: Timestamp::now() ); $array = $stored->toArray(); expect($array['id'])->toBe(1); expect($array['user_id'])->toBe('user-123'); expect($array['provider'])->toBe('spotify'); expect($array['access_token'])->toBe('access_1234567890'); expect($array['refresh_token'])->toBe('refresh_1234567890'); expect($array['token_type'])->toBe('Bearer'); expect($array)->toHaveKey('expires_at'); expect($array)->toHaveKey('created_at'); expect($array)->toHaveKey('updated_at'); }); it('handles null id in array conversion', function () { $accessToken = AccessToken::create('access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600))); $oauthToken = new OAuthToken($accessToken, null, TokenType::BEARER, null); $stored = StoredOAuthToken::create('user-123', 'spotify', $oauthToken); $array = $stored->toArray(); expect(array_key_exists('id', $array))->toBeFalse(); }); });