import json import uuid from pathlib import Path from unittest.mock import MagicMock, patch import pytest import yaml from arcade_core.usage import UsageIdentity @pytest.fixture def temp_config_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Setup temporary config directory for testing.""" config_dir = tmp_path / ".arcade" config_dir.mkdir() credentials_file = config_dir / "credentials.yaml" monkeypatch.setattr("arcade_core.usage.identity.ARCADE_CONFIG_PATH", str(config_dir)) monkeypatch.setattr("arcade_core.usage.identity.CREDENTIALS_FILE_PATH", str(credentials_file)) return config_dir @pytest.fixture def identity(temp_config_path: Path) -> UsageIdentity: """Create a UsageIdentity instance with temp config path.""" # NOTE: Although temp_config_path is directly used, it's required to ensure that # this fixture depends on the temp_config_path fixture to apply the monkeypatch # before creating the UsageIdentity instance return UsageIdentity() class TestLoadOrCreate: """Tests for load_or_create() method.""" def test_creates_new_file_when_not_exists( self, identity: UsageIdentity, temp_config_path: Path ) -> None: """Test that load_or_create creates a new usage.json file when it doesn't exist.""" data = identity.load_or_create() assert "anon_id" in data assert data["linked_principal_id"] is None assert uuid.UUID(data["anon_id"]) # Validate UUID format # Verify file was created usage_file = temp_config_path / "usage.json" assert usage_file.exists() def test_loads_existing_file(self, identity: UsageIdentity, temp_config_path: Path) -> None: """Test that load_or_create loads existing usage.json file.""" existing_data = {"anon_id": str(uuid.uuid4()), "linked_principal_id": "user-123"} usage_file = temp_config_path / "usage.json" usage_file.write_text(json.dumps(existing_data), encoding="utf-8") data = identity.load_or_create() assert data["anon_id"] == existing_data["anon_id"] assert data["linked_principal_id"] == existing_data["linked_principal_id"] def test_caches_data_after_first_load(self, identity: UsageIdentity) -> None: """Test that load_or_create caches data after first load.""" first_data = identity.load_or_create() second_data = identity.load_or_create() # Should return the same object b/c it's cached assert first_data is second_data def test_creates_new_data_on_corrupted_json( self, identity: UsageIdentity, temp_config_path: Path ) -> None: """Test that load_or_create creates new data if JSON is corrupted.""" usage_file = temp_config_path / "usage.json" usage_file.write_text("{ invalid json }", encoding="utf-8") data = identity.load_or_create() assert "anon_id" in data assert data["linked_principal_id"] is None def test_creates_new_data_on_missing_anon_id( self, identity: UsageIdentity, temp_config_path: Path ) -> None: """Test that load_or_create creates new data if anon_id is missing.""" usage_file = temp_config_path / "usage.json" usage_file.write_text(json.dumps({"some_other_key": "value"}), encoding="utf-8") data = identity.load_or_create() assert "anon_id" in data assert data["linked_principal_id"] is None def test_creates_new_data_on_non_dict_json( self, identity: UsageIdentity, temp_config_path: Path ) -> None: """Test that load_or_create creates new data if JSON is not a dict.""" usage_file = temp_config_path / "usage.json" usage_file.write_text(json.dumps(["not", "a", "dict"]), encoding="utf-8") data = identity.load_or_create() assert "anon_id" in data assert isinstance(data, dict) class TestWriteAtomic: """Tests for _write_atomic() method.""" def test_writes_data_to_file(self, identity: UsageIdentity, temp_config_path: Path) -> None: """Test that _write_atomic writes data to usage.json.""" test_data = {"anon_id": str(uuid.uuid4()), "linked_principal_id": "user-456"} identity._write_atomic(test_data) usage_file = temp_config_path / "usage.json" assert usage_file.exists() with usage_file.open() as f: loaded_data = json.load(f) assert loaded_data == test_data def test_atomic_write_cleans_up_on_failure( self, identity: UsageIdentity, temp_config_path: Path ) -> None: """Test that _write_atomic cleans up temp file on failure.""" with ( patch( "tempfile.mkstemp", return_value=(999, str(temp_config_path / ".usage_temp.tmp")) ), patch("os.fdopen", side_effect=Exception("Write failed")), ): with pytest.raises(Exception, match="Write failed"): identity._write_atomic({"anon_id": "test"}) # Verify no temp files are left behind temp_files = list(temp_config_path.glob(".usage_*.tmp")) assert len(temp_files) == 0 class TestGetDistinctId: """Tests for get_distinct_id() method.""" def test_returns_linked_principal_id_when_persisted( self, identity: UsageIdentity, temp_config_path: Path ) -> None: """Test that get_distinct_id returns persisted linked_principal_id.""" usage_file = temp_config_path / "usage.json" usage_file.write_text( json.dumps({"anon_id": str(uuid.uuid4()), "linked_principal_id": "persisted-user-123"}), encoding="utf-8", ) distinct_id = identity.get_distinct_id() assert distinct_id == "persisted-user-123" @patch("arcade_core.usage.identity.UsageIdentity.get_principal_id") def test_returns_principal_id_from_api_when_not_persisted( self, mock_get_principal: MagicMock, identity: UsageIdentity ) -> None: """Test that get_distinct_id fetches principal_id from API when not persisted.""" mock_get_principal.return_value = "api-user-456" distinct_id = identity.get_distinct_id() assert distinct_id == "api-user-456" mock_get_principal.assert_called_once() @patch("arcade_core.usage.identity.UsageIdentity.get_principal_id") def test_returns_anon_id_when_not_authenticated( self, mock_get_principal: MagicMock, identity: UsageIdentity ) -> None: """Test that get_distinct_id returns anon_id when not authenticated.""" mock_get_principal.return_value = None distinct_id = identity.get_distinct_id() data = identity.load_or_create() assert distinct_id == data["anon_id"] class TestGetPrincipalId: """Tests for get_principal_id() method.""" def test_returns_none_when_credentials_file_not_exists(self, identity: UsageIdentity) -> None: """Test that get_principal_id returns None when credentials file doesn't exist.""" principal_id = identity.get_principal_id() assert principal_id is None @patch("httpx.get") def test_returns_principal_id_on_successful_api_call( self, mock_get: MagicMock, identity: UsageIdentity, temp_config_path: Path ) -> None: """Test that get_principal_id returns principal_id from API.""" # Create credentials file credentials_file = temp_config_path / "credentials.yaml" credentials_file.write_text(yaml.dump({"cloud": {"api": {"key": "test-api-key"}}}), encoding="utf-8") # Mock API response mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"data": {"principal_id": "api-principal-123"}} mock_get.return_value = mock_response principal_id = identity.get_principal_id() assert principal_id == "api-principal-123" mock_get.assert_called_once_with( "https://cloud.arcade.dev/api/v1/auth/validate", headers={"accept": "application/json", "Authorization": "Bearer test-api-key"}, timeout=2.0, ) @patch("httpx.get") def test_returns_none_on_api_failure( self, mock_get: MagicMock, identity: UsageIdentity, temp_config_path: Path ) -> None: """Test that get_principal_id returns None on API failure.""" credentials_file = temp_config_path / "credentials.yaml" credentials_file.write_text(yaml.dump({"cloud": {"api": {"key": "test-api-key"}}}), encoding="utf-8") mock_get.side_effect = Exception("Network error") principal_id = identity.get_principal_id() assert principal_id is None def test_returns_none_when_api_key_missing( self, identity: UsageIdentity, temp_config_path: Path ) -> None: """Test that get_principal_id returns None when API key is missing.""" credentials_file = temp_config_path / "credentials.yaml" credentials_file.write_text(yaml.dump({"cloud": {}}), encoding="utf-8") principal_id = identity.get_principal_id() assert principal_id is None @patch("httpx.get") def test_returns_none_on_non_200_status( self, mock_get: MagicMock, identity: UsageIdentity, temp_config_path: Path ) -> None: """Test that get_principal_id returns None on non-200 status code.""" credentials_file = temp_config_path / "credentials.yaml" credentials_file.write_text(yaml.dump({"cloud": {"api": {"key": "test-api-key"}}}), encoding="utf-8") mock_response = MagicMock() mock_response.status_code = 401 mock_get.return_value = mock_response principal_id = identity.get_principal_id() assert principal_id is None @patch("httpx.get") def test_returns_account_id_from_oauth_whoami( self, mock_get: MagicMock, identity: UsageIdentity, temp_config_path: Path ) -> None: """Test that get_principal_id returns account_id using OAuth access token.""" credentials_file = temp_config_path / "credentials.yaml" credentials_file.write_text( yaml.dump({"cloud": {"auth": {"access_token": "oauth-token", "refresh_token": "x"}}}), encoding="utf-8", ) mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"data": {"account_id": "acct-123"}} mock_get.return_value = mock_response principal_id = identity.get_principal_id() assert principal_id == "acct-123" mock_get.assert_called_once_with( "https://cloud.arcade.dev/api/v1/auth/whoami", headers={"accept": "application/json", "Authorization": "Bearer oauth-token"}, timeout=2.0, ) class TestShouldAlias: """Tests for should_alias() method.""" @patch("arcade_core.usage.identity.UsageIdentity.get_principal_id") def test_returns_true_when_authenticated_but_not_linked( self, mock_get_principal: MagicMock, identity: UsageIdentity ) -> None: """Test that should_alias returns True when user is authenticated but not yet aliased.""" mock_get_principal.return_value = "new-principal-id" should_alias = identity.should_alias() assert should_alias is True @patch("arcade_core.usage.identity.UsageIdentity.get_principal_id") def test_returns_false_when_already_linked( self, mock_get_principal: MagicMock, identity: UsageIdentity, temp_config_path: Path ) -> None: """Test that should_alias returns False when principal_id already linked.""" principal_id = "already-linked-123" mock_get_principal.return_value = principal_id usage_file = temp_config_path / "usage.json" usage_file.write_text( json.dumps({"anon_id": str(uuid.uuid4()), "linked_principal_id": principal_id}), encoding="utf-8", ) should_alias = identity.should_alias() assert should_alias is False @patch("arcade_core.usage.identity.UsageIdentity.get_principal_id") def test_returns_false_when_not_authenticated( self, mock_get_principal: MagicMock, identity: UsageIdentity ) -> None: """Test that should_alias returns False when not authenticated.""" mock_get_principal.return_value = None should_alias = identity.should_alias() assert should_alias is False class TestResetToAnonymous: """Tests for reset_to_anonymous() method.""" def test_generates_new_anon_id(self, identity: UsageIdentity) -> None: """Test that reset_to_anonymous generates a new anon_id.""" original_data = identity.load_or_create() original_anon_id = original_data["anon_id"] identity.reset_to_anonymous() new_data = identity.load_or_create() assert new_data["anon_id"] != original_anon_id assert uuid.UUID(new_data["anon_id"]) # Validates UUID format def test_clears_linked_principal_id(self, identity: UsageIdentity) -> None: """Test that reset_to_anonymous clears linked_principal_id.""" identity.set_linked_principal_id("user-to-clear") identity.reset_to_anonymous() data = identity.load_or_create() assert data["linked_principal_id"] is None def test_persists_to_file(self, identity: UsageIdentity, temp_config_path: Path) -> None: """Test that reset_to_anonymous persists changes to file.""" identity.reset_to_anonymous() usage_file = temp_config_path / "usage.json" assert usage_file.exists() with usage_file.open() as f: file_data = json.load(f) assert "anon_id" in file_data assert file_data["linked_principal_id"] is None class TestSetLinkedPrincipalId: """Tests for set_linked_principal_id() method.""" def test_updates_linked_principal_id(self, identity: UsageIdentity) -> None: """Test that set_linked_principal_id updates the linked_principal_id.""" identity.load_or_create() # Initialize identity.set_linked_principal_id("new-user-789") data = identity.load_or_create() assert data["linked_principal_id"] == "new-user-789" def test_persists_to_file(self, identity: UsageIdentity, temp_config_path: Path) -> None: """Test that set_linked_principal_id persists changes to file.""" identity.set_linked_principal_id("persisted-user-999") usage_file = temp_config_path / "usage.json" with usage_file.open() as f: file_data = json.load(f) assert file_data["linked_principal_id"] == "persisted-user-999" def test_updates_cache(self, identity: UsageIdentity) -> None: """Test that set_linked_principal_id updates the cached data.""" identity.load_or_create() identity.set_linked_principal_id("cached-user-111") # Access _data directly to verify cache updated assert identity._data is not None assert identity._data["linked_principal_id"] == "cached-user-111" class TestAnonIdProperty: """Tests for anon_id property.""" def test_returns_anon_id(self, identity: UsageIdentity) -> None: """Test that anon_id property returns the anon_id.""" data = identity.load_or_create() assert identity.anon_id == data["anon_id"] def test_returns_valid_uuid(self, identity: UsageIdentity) -> None: """Test that anon_id property returns a valid UUID.""" anon_id = identity.anon_id assert uuid.UUID(anon_id) # Validate UUID format