diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml new file mode 100644 index 0000000..e06e2b8 --- /dev/null +++ b/.github/workflows/unit_tests.yml @@ -0,0 +1,68 @@ +name: unit tests +permissions: + contents: read + pull-requests: write + +on: + push: + branches: + - main + paths: + - .pre-commit-config.yaml + - .github/workflows/code_checks.yml + - .github/workflows/docs.yml + - .github/workflows/unit_tests.yml + - '**.py' + - '**.ipynb' + - uv.lock + - pyproject.toml + - '**.rst' + - '**.md' + pull_request: + branches: + - main + paths: + - .pre-commit-config.yaml + - .github/workflows/code_checks.yml + - .github/workflows/docs.yml + - .github/workflows/unit_tests.yml + - '**.py' + - '**.ipynb' + - uv.lock + - pyproject.toml + - '**.rst' + - '**.md' + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5.0.0 + + - name: Install uv + uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 + with: + # Install a specific version of uv. + version: "0.9.7" + enable-cache: true + + - name: "Set up Python" + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c + with: + python-version-file: ".python-version" + + - name: Install the project + run: uv sync --all-extras --dev + + - name: Install dependencies and check code + run: | + uv run pytest -m "not integration_test" --cov src/aieng_platform_onboard --cov-report=xml tests + + # Uncomment this once this repo is configured on Codecov + - name: Upload coverage to Codecov + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: VectorInstitute/aieng-platform + fail_ci_if_error: true + verbose: true diff --git a/.gitignore b/.gitignore index 6f46109..2396697 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ terraform.tfvars .coder keys/ +.coverage diff --git a/src/aieng_platform_onboard/cli.py b/src/aieng_platform_onboard/cli.py index b7a0b7c..1bc5398 100644 --- a/src/aieng_platform_onboard/cli.py +++ b/src/aieng_platform_onboard/cli.py @@ -11,6 +11,7 @@ import argparse import subprocess import sys +from importlib.metadata import version from pathlib import Path from typing import Any @@ -34,6 +35,21 @@ ) +def get_version() -> str: + """ + Get the installed version of the package. + + Returns + ------- + str + Version string from package metadata. + """ + try: + return version("aieng-platform-onboard") + except Exception: + return "unknown" + + def run_integration_test(test_script: Path) -> tuple[bool, str]: """ Execute integration test script to verify API keys. @@ -455,6 +471,12 @@ def main() -> int: # noqa: PLR0911 description="Bootcamp participant onboarding script", formatter_class=argparse.RawDescriptionHelpFormatter, ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {get_version()}", + help="Show version number and exit", + ) parser.add_argument( "--bootcamp-name", type=str, diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..bbb0417 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for aieng_platform_onboard package.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ebc34b7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,202 @@ +"""Pytest configuration and fixtures for aieng_platform_onboard tests.""" + +from datetime import datetime, timezone +from typing import Any +from unittest.mock import Mock + +import pytest +from google.cloud import firestore + + +@pytest.fixture +def mock_firestore_client() -> Mock: + """ + Create a mock Firestore client for testing. + + Returns + ------- + Mock + Mock Firestore client instance. + """ + return Mock(spec=firestore.Client) + + +@pytest.fixture +def mock_firestore_document() -> Mock: + """ + Create a mock Firestore document for testing. + + Returns + ------- + Mock + Mock Firestore document instance. + """ + mock_doc = Mock() + mock_doc.exists = True + mock_doc.id = "test-handle" + mock_doc.to_dict.return_value = { + "github_handle": "test-handle", + "team_name": "test-team", + "onboarded": False, + "email": "test@example.com", + } + return mock_doc + + +@pytest.fixture +def sample_participant_data() -> dict[str, Any]: + """ + Sample participant data for testing. + + Returns + ------- + dict[str, Any] + Sample participant data dictionary. + """ + return { + "github_handle": "test-user", + "team_name": "test-team", + "onboarded": False, + "email": "test@example.com", + "created_at": datetime.now(timezone.utc), + } + + +@pytest.fixture +def sample_team_data() -> dict[str, Any]: + """ + Sample team data for testing. + + Returns + ------- + dict[str, Any] + Sample team data dictionary. + """ + return { + "team_name": "test-team", + "openai_api_key": "test-openai-key", + "langfuse_secret_key": "test-langfuse-secret", + "langfuse_public_key": "test-langfuse-public", + "langfuse_url": "https://test-langfuse.example.com", + "web_search_api_key": "test-search-key", + "participants": ["test-user", "test-user-2"], + } + + +@pytest.fixture +def sample_global_keys() -> dict[str, Any]: + """ + Sample global keys for testing. + + Returns + ------- + dict[str, Any] + Sample global keys dictionary. + """ + return { + "EMBEDDING_BASE_URL": "https://embedding.example.com", + "EMBEDDING_API_KEY": "test-embedding-key", + "WEAVIATE_HTTP_HOST": "weaviate.example.com", + "WEAVIATE_GRPC_HOST": "weaviate-grpc.example.com", + "WEAVIATE_API_KEY": "test-weaviate-key", + "WEAVIATE_HTTP_PORT": "443", + "WEAVIATE_GRPC_PORT": "50051", + "WEAVIATE_HTTP_SECURE": "true", + "WEAVIATE_GRPC_SECURE": "true", + } + + +@pytest.fixture +def mock_requests_post(monkeypatch: pytest.MonkeyPatch) -> Mock: + """ + Mock requests.post for testing HTTP requests. + + Parameters + ---------- + monkeypatch : pytest.MonkeyPatch + Pytest monkeypatch fixture. + + Returns + ------- + Mock + Mock requests.post function. + """ + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"token": "test-token"} + + mock_post = Mock(return_value=mock_response) + monkeypatch.setattr("requests.post", mock_post) + return mock_post + + +@pytest.fixture +def mock_google_auth(monkeypatch: pytest.MonkeyPatch) -> tuple[Mock, str]: + """ + Mock google.auth.default for testing authentication. + + Parameters + ---------- + monkeypatch : pytest.MonkeyPatch + Pytest monkeypatch fixture. + + Returns + ------- + tuple[Mock, str] + Tuple of (mock_credentials, project_id). + """ + mock_credentials = Mock() + mock_credentials.service_account_email = "test@example.iam.gserviceaccount.com" + mock_credentials.signer = Mock() + + def mock_default() -> tuple[Mock, str]: + return mock_credentials, "test-project" + + monkeypatch.setattr("google.auth.default", mock_default) + return mock_credentials, "test-project" + + +@pytest.fixture +def mock_subprocess_run(monkeypatch: pytest.MonkeyPatch) -> Mock: + """ + Mock subprocess.run for testing command execution. + + Parameters + ---------- + monkeypatch : pytest.MonkeyPatch + Pytest monkeypatch fixture. + + Returns + ------- + Mock + Mock subprocess.run function. + """ + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "test output" + mock_result.stderr = "" + + mock_run = Mock(return_value=mock_result) + monkeypatch.setattr("subprocess.run", mock_run) + return mock_run + + +@pytest.fixture +def mock_console(monkeypatch: pytest.MonkeyPatch) -> Mock: + """ + Mock Rich console for testing output. + + Parameters + ---------- + monkeypatch : pytest.MonkeyPatch + Pytest monkeypatch fixture. + + Returns + ------- + Mock + Mock console instance. + """ + mock_console_instance = Mock() + monkeypatch.setattr("aieng_platform_onboard.utils.console", mock_console_instance) + monkeypatch.setattr("aieng_platform_onboard.cli.console", mock_console_instance) + return mock_console_instance diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..0ca30a7 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,579 @@ +"""Unit tests for aieng_platform_onboard.cli module.""" + +import subprocess +from pathlib import Path +from typing import Any +from unittest.mock import Mock + +import pytest + +from aieng_platform_onboard.cli import ( + display_onboarding_status_report, + get_version, + main, + run_integration_test, +) + + +class TestGetVersion: + """Tests for get_version function.""" + + def test_get_version_success(self) -> None: + """Test successful version retrieval.""" + version = get_version() + # Should return a valid version string (not 'unknown') + assert version != "unknown" + assert len(version) > 0 + + def test_get_version_not_installed(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test version retrieval when package is not installed.""" + + def mock_version_error(package: str) -> None: + raise Exception("Package not found") + + monkeypatch.setattr("aieng_platform_onboard.cli.version", mock_version_error) + + version = get_version() + assert version == "unknown" + + +class TestRunIntegrationTest: + """Tests for run_integration_test function.""" + + def test_run_integration_test_success( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test successful integration test execution.""" + test_script = tmp_path / "test.py" + test_script.write_text("print('test')") + + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "test passed" + mock_result.stderr = "" + + mock_run = Mock(return_value=mock_result) + monkeypatch.setattr("subprocess.run", mock_run) + + success, output = run_integration_test(test_script) + + assert success is True + assert "test passed" in output + + def test_run_integration_test_failure( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test integration test execution failure.""" + test_script = tmp_path / "test.py" + test_script.write_text("import sys; sys.exit(1)") + + mock_result = Mock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "test failed" + + mock_run = Mock(return_value=mock_result) + monkeypatch.setattr("subprocess.run", mock_run) + + success, output = run_integration_test(test_script) + + assert success is False + assert "test failed" in output + + def test_run_integration_test_timeout( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test integration test timeout.""" + test_script = tmp_path / "test.py" + test_script.write_text("while True: pass") + + def mock_run(*args: Any, **kwargs: Any) -> None: + raise subprocess.TimeoutExpired(cmd="pytest", timeout=60) + + monkeypatch.setattr("subprocess.run", mock_run) + + success, output = run_integration_test(test_script) + + assert success is False + assert "timed out" in output + + +class TestDisplayOnboardingStatusReport: + """Tests for display_onboarding_status_report function.""" + + def test_display_status_report_success( + self, monkeypatch: pytest.MonkeyPatch, mock_console: Mock + ) -> None: + """Test successful display of onboarding status report.""" + # Mock initialize_firestore_admin + mock_db = Mock() + monkeypatch.setattr( + "aieng_platform_onboard.cli.initialize_firestore_admin", + lambda **kwargs: mock_db, + ) + + # Mock get_all_participants_with_status + mock_participants = [ + { + "github_handle": "user1", + "team_name": "team-a", + "onboarded": True, + "onboarded_at": None, + }, + { + "github_handle": "user2", + "team_name": "team-a", + "onboarded": False, + "onboarded_at": None, + }, + { + "github_handle": "user3", + "team_name": "team-b", + "onboarded": True, + "onboarded_at": None, + }, + ] + monkeypatch.setattr( + "aieng_platform_onboard.cli.get_all_participants_with_status", + lambda db: mock_participants, + ) + + exit_code = display_onboarding_status_report("test-project") + + assert exit_code == 0 + # Verify console.print was called (output was displayed) + assert mock_console.print.call_count > 0 + + def test_display_status_report_no_participants( + self, monkeypatch: pytest.MonkeyPatch, mock_console: Mock + ) -> None: + """Test status report display when no participants exist.""" + mock_db = Mock() + monkeypatch.setattr( + "aieng_platform_onboard.cli.initialize_firestore_admin", + lambda **kwargs: mock_db, + ) + + monkeypatch.setattr( + "aieng_platform_onboard.cli.get_all_participants_with_status", + lambda db: [], + ) + + exit_code = display_onboarding_status_report("test-project") + + assert exit_code == 0 + + def test_display_status_report_firestore_error( + self, monkeypatch: pytest.MonkeyPatch, mock_console: Mock + ) -> None: + """Test status report display with Firestore connection error.""" + + def mock_init_error(**kwargs: Any) -> None: + raise Exception("Connection failed") + + monkeypatch.setattr( + "aieng_platform_onboard.cli.initialize_firestore_admin", mock_init_error + ) + + exit_code = display_onboarding_status_report("test-project") + + assert exit_code == 1 + + def test_display_status_report_fetch_error( + self, monkeypatch: pytest.MonkeyPatch, mock_console: Mock + ) -> None: + """Test status report display with data fetch error.""" + mock_db = Mock() + monkeypatch.setattr( + "aieng_platform_onboard.cli.initialize_firestore_admin", + lambda **kwargs: mock_db, + ) + + def mock_fetch_error(db: Any) -> None: + raise Exception("Failed to fetch participants") + + monkeypatch.setattr( + "aieng_platform_onboard.cli.get_all_participants_with_status", + mock_fetch_error, + ) + + exit_code = display_onboarding_status_report("test-project") + + assert exit_code == 1 + + +class TestMain: + """Tests for main function.""" + + def test_main_version_flag( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture + ) -> None: + """Test main with --version flag.""" + monkeypatch.setattr("sys.argv", ["onboard", "--version"]) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + # Should contain "onboard" and a version number + assert "onboard" in captured.out + assert len(captured.out.strip()) > 0 + + def test_main_admin_status_report( + self, monkeypatch: pytest.MonkeyPatch, mock_console: Mock + ) -> None: + """Test main with --admin-status-report flag.""" + monkeypatch.setattr( + "sys.argv", ["onboard", "--admin-status-report", "--gcp-project", "test"] + ) + + # Mock the display function + mock_display = Mock(return_value=0) + monkeypatch.setattr( + "aieng_platform_onboard.cli.display_onboarding_status_report", + mock_display, + ) + + exit_code = main() + + assert exit_code == 0 + mock_display.assert_called_once_with("test") + + def test_main_missing_required_args( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture + ) -> None: + """Test main with missing required arguments.""" + monkeypatch.setattr("sys.argv", ["onboard"]) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 2 + captured = capsys.readouterr() + assert "--bootcamp-name is required" in captured.err + + def test_main_check_already_onboarded( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_console: Mock, + ) -> None: + """Test main when participant is already onboarded.""" + # Create a complete .env file + env_file = tmp_path / ".env" + env_content = """ +OPENAI_API_KEY="test-key" +EMBEDDING_BASE_URL="https://example.com" +EMBEDDING_API_KEY="test-key" +LANGFUSE_SECRET_KEY="test-key" +LANGFUSE_PUBLIC_KEY="test-key" +LANGFUSE_HOST="https://example.com" +WEB_SEARCH_API_KEY="test-key" +WEAVIATE_HTTP_HOST="example.com" +WEAVIATE_GRPC_HOST="example.com" +WEAVIATE_API_KEY="test-key" +""" + env_file.write_text(env_content) + + test_script = tmp_path / "test.py" + test_script.write_text("print('test')") + + monkeypatch.setattr( + "sys.argv", + [ + "onboard", + "--bootcamp-name", + "test", + "--test-script", + str(test_script), + "--output-dir", + str(tmp_path), + ], + ) + + exit_code = main() + + assert exit_code == 0 + + def test_main_successful_onboarding( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_console: Mock, + ) -> None: + """Test successful participant onboarding flow.""" + test_script = tmp_path / "test.py" + test_script.write_text("print('test')") + + monkeypatch.setattr( + "sys.argv", + [ + "onboard", + "--bootcamp-name", + "test", + "--test-script", + str(test_script), + "--output-dir", + str(tmp_path), + "--firebase-api-key", + "test-key", + ], + ) + + # Mock environment + monkeypatch.setenv("GITHUB_USER", "test-user") + + # Mock fetch_token_from_service + monkeypatch.setattr( + "aieng_platform_onboard.cli.fetch_token_from_service", + lambda user: (True, "test-token", None), + ) + + # Mock initialize_firestore_with_token + mock_db = Mock() + monkeypatch.setattr( + "aieng_platform_onboard.cli.initialize_firestore_with_token", + lambda *args, **kwargs: mock_db, + ) + + # Mock check_onboarded_status + monkeypatch.setattr( + "aieng_platform_onboard.cli.check_onboarded_status", + lambda db, user: (True, False), + ) + + # Mock get_participant_data + monkeypatch.setattr( + "aieng_platform_onboard.cli.get_participant_data", + lambda db, user: { + "github_handle": "test-user", + "team_name": "test-team", + "onboarded": False, + }, + ) + + # Mock get_team_data + monkeypatch.setattr( + "aieng_platform_onboard.cli.get_team_data", + lambda db, team: { + "team_name": "test-team", + "openai_api_key": "test-key", + "langfuse_secret_key": "test-secret", + "langfuse_public_key": "test-public", + "langfuse_url": "https://test.example.com", + "web_search_api_key": "test-search", + }, + ) + + # Mock get_global_keys + monkeypatch.setattr( + "aieng_platform_onboard.cli.get_global_keys", + lambda db: { + "EMBEDDING_BASE_URL": "https://embedding.example.com", + "EMBEDDING_API_KEY": "test-embedding", + "WEAVIATE_HTTP_HOST": "weaviate.example.com", + "WEAVIATE_GRPC_HOST": "weaviate-grpc.example.com", + "WEAVIATE_API_KEY": "test-weaviate", + "WEAVIATE_HTTP_PORT": "443", + "WEAVIATE_GRPC_PORT": "50051", + "WEAVIATE_HTTP_SECURE": "true", + "WEAVIATE_GRPC_SECURE": "true", + }, + ) + + # Mock subprocess.run for integration test + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "test passed" + mock_result.stderr = "" + monkeypatch.setattr("subprocess.run", lambda *args, **kwargs: mock_result) + + # Mock update_onboarded_status + monkeypatch.setattr( + "aieng_platform_onboard.cli.update_onboarded_status", + lambda db, user: (True, None), + ) + + exit_code = main() + + assert exit_code == 0 + # Verify .env file was created + env_file = tmp_path / ".env" + assert env_file.exists() + + def test_main_authentication_failure( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_console: Mock, + ) -> None: + """Test main with authentication failure.""" + test_script = tmp_path / "test.py" + test_script.write_text("print('test')") + + monkeypatch.setattr( + "sys.argv", + [ + "onboard", + "--bootcamp-name", + "test", + "--test-script", + str(test_script), + "--output-dir", + str(tmp_path), + "--firebase-api-key", + "test-key", + ], + ) + + monkeypatch.setenv("GITHUB_USER", "test-user") + + # Mock failed authentication + monkeypatch.setattr( + "aieng_platform_onboard.cli.fetch_token_from_service", + lambda user: (False, None, "Authentication failed"), + ) + + exit_code = main() + + assert exit_code == 1 + + def test_main_participant_not_found( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_console: Mock, + ) -> None: + """Test main when participant is not found in Firestore.""" + test_script = tmp_path / "test.py" + test_script.write_text("print('test')") + + monkeypatch.setattr( + "sys.argv", + [ + "onboard", + "--bootcamp-name", + "test", + "--test-script", + str(test_script), + "--output-dir", + str(tmp_path), + "--firebase-api-key", + "test-key", + ], + ) + + monkeypatch.setenv("GITHUB_USER", "test-user") + + monkeypatch.setattr( + "aieng_platform_onboard.cli.fetch_token_from_service", + lambda user: (True, "test-token", None), + ) + + mock_db = Mock() + monkeypatch.setattr( + "aieng_platform_onboard.cli.initialize_firestore_with_token", + lambda *args, **kwargs: mock_db, + ) + + monkeypatch.setattr( + "aieng_platform_onboard.cli.check_onboarded_status", + lambda db, user: (True, False), + ) + + # Return None for participant not found + monkeypatch.setattr( + "aieng_platform_onboard.cli.get_participant_data", + lambda db, user: None, + ) + + exit_code = main() + + assert exit_code == 1 + + def test_main_skip_test_flag( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_console: Mock, + ) -> None: + """Test main with --skip-test flag.""" + test_script = tmp_path / "test.py" + test_script.write_text("print('test')") + + monkeypatch.setattr( + "sys.argv", + [ + "onboard", + "--bootcamp-name", + "test", + "--test-script", + str(test_script), + "--skip-test", + "--firebase-api-key", + "test-key", + ], + ) + + monkeypatch.setenv("GITHUB_USER", "test-user") + + monkeypatch.setattr( + "aieng_platform_onboard.cli.fetch_token_from_service", + lambda user: (True, "test-token", None), + ) + + mock_db = Mock() + monkeypatch.setattr( + "aieng_platform_onboard.cli.initialize_firestore_with_token", + lambda *args, **kwargs: mock_db, + ) + + monkeypatch.setattr( + "aieng_platform_onboard.cli.check_onboarded_status", + lambda db, user: (True, False), + ) + + monkeypatch.setattr( + "aieng_platform_onboard.cli.get_participant_data", + lambda db, user: { + "github_handle": "test-user", + "team_name": "test-team", + }, + ) + + monkeypatch.setattr( + "aieng_platform_onboard.cli.get_team_data", + lambda db, team: { + "team_name": "test-team", + "openai_api_key": "test-key", + "langfuse_secret_key": "test-secret", + "langfuse_public_key": "test-public", + "langfuse_url": "https://test.example.com", + "web_search_api_key": "test-search", + }, + ) + + monkeypatch.setattr( + "aieng_platform_onboard.cli.get_global_keys", + lambda db: { + "EMBEDDING_BASE_URL": "https://embedding.example.com", + "EMBEDDING_API_KEY": "test-embedding", + "WEAVIATE_HTTP_HOST": "weaviate.example.com", + "WEAVIATE_GRPC_HOST": "weaviate-grpc.example.com", + "WEAVIATE_API_KEY": "test-weaviate", + "WEAVIATE_HTTP_PORT": "443", + "WEAVIATE_GRPC_PORT": "50051", + "WEAVIATE_HTTP_SECURE": "true", + "WEAVIATE_GRPC_SECURE": "true", + }, + ) + + monkeypatch.setattr( + "aieng_platform_onboard.cli.update_onboarded_status", + lambda db, user: (True, None), + ) + + exit_code = main() + + assert exit_code == 0 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..ded8499 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,570 @@ +"""Unit tests for aieng_platform_onboard.utils module.""" + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from unittest.mock import Mock + +import pytest +from google.cloud import firestore + +from aieng_platform_onboard.utils import ( + check_onboarded_status, + create_env_file, + exchange_custom_token_for_id_token, + fetch_token_from_service, + get_all_participants_with_status, + get_github_user, + get_global_keys, + get_participant_data, + get_team_data, + initialize_firestore_admin, + initialize_firestore_with_token, + update_onboarded_status, + validate_env_file, +) + + +class TestGetGithubUser: + """Tests for get_github_user function.""" + + def test_get_github_user_from_github_user_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test getting GitHub user from GITHUB_USER environment variable.""" + monkeypatch.setenv("GITHUB_USER", "test-user") + assert get_github_user() == "test-user" + + def test_get_github_user_from_gh_user_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test getting GitHub user from GH_USER environment variable.""" + monkeypatch.delenv("GITHUB_USER", raising=False) + monkeypatch.setenv("GH_USER", "test-user-gh") + assert get_github_user() == "test-user-gh" + + def test_get_github_user_from_user_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test getting GitHub user from USER environment variable.""" + monkeypatch.delenv("GITHUB_USER", raising=False) + monkeypatch.delenv("GH_USER", raising=False) + monkeypatch.setenv("USER", "test-user-system") + assert get_github_user() == "test-user-system" + + def test_get_github_user_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test getting GitHub user when no env variable is set.""" + monkeypatch.delenv("GITHUB_USER", raising=False) + monkeypatch.delenv("GH_USER", raising=False) + monkeypatch.delenv("USER", raising=False) + assert get_github_user() is None + + +class TestFetchTokenFromService: + """Tests for fetch_token_from_service function.""" + + def test_fetch_token_success_with_service_account( + self, + monkeypatch: pytest.MonkeyPatch, + mock_requests_post: Mock, + ) -> None: + """Test successful token fetch with service account credentials.""" + monkeypatch.setenv("TOKEN_SERVICE_URL", "https://token-service.example.com") + + # Mock google.auth.default to return service account credentials + mock_credentials = Mock() + mock_credentials.service_account_email = "test@example.iam.gserviceaccount.com" + mock_credentials.signer = Mock() + + monkeypatch.setattr( + "google.auth.default", lambda: (mock_credentials, "test-project") + ) + + # Mock JWT encoding + monkeypatch.setattr( + "google.auth.jwt.encode", lambda signer, payload: "test-id-token" + ) + + mock_requests_post.return_value.status_code = 200 + mock_requests_post.return_value.json.return_value = {"token": "test-token"} + mock_requests_post.return_value.content = b'{"token": "test-token"}' + + success, token, error = fetch_token_from_service("test-user") + + assert success is True + assert token == "test-token" + assert error is None + mock_requests_post.assert_called_once() + + def test_fetch_token_no_service_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test token fetch failure when service URL is not configured.""" + monkeypatch.delenv("TOKEN_SERVICE_URL", raising=False) + + success, token, error = fetch_token_from_service("test-user") + + assert success is False + assert token is None + assert "Token service URL not found" in str(error) + + def test_fetch_token_service_error( + self, + monkeypatch: pytest.MonkeyPatch, + mock_requests_post: Mock, + ) -> None: + """Test token fetch with service error response.""" + monkeypatch.setenv("TOKEN_SERVICE_URL", "https://token-service.example.com") + + # Mock google.auth.default + mock_credentials = Mock() + mock_credentials.service_account_email = "test@example.iam.gserviceaccount.com" + mock_credentials.signer = Mock() + monkeypatch.setattr( + "google.auth.default", lambda: (mock_credentials, "test-project") + ) + monkeypatch.setattr( + "google.auth.jwt.encode", lambda signer, payload: "test-id-token" + ) + + mock_requests_post.return_value.status_code = 404 + mock_requests_post.return_value.json.return_value = {"message": "Not found"} + mock_requests_post.return_value.content = b'{"message": "Not found"}' + + success, token, error = fetch_token_from_service("test-user") + + assert success is False + assert token is None + assert "Token service error" in str(error) + + +class TestExchangeCustomTokenForIdToken: + """Tests for exchange_custom_token_for_id_token function.""" + + def test_exchange_token_success(self, mock_requests_post: Mock) -> None: + """Test successful token exchange.""" + mock_requests_post.return_value.status_code = 200 + mock_requests_post.return_value.json.return_value = {"idToken": "test-id-token"} + + success, id_token, error = exchange_custom_token_for_id_token( + "custom-token", "test-api-key" + ) + + assert success is True + assert id_token == "test-id-token" + assert error is None + + def test_exchange_token_firebase_error(self, mock_requests_post: Mock) -> None: + """Test token exchange with Firebase error.""" + mock_requests_post.return_value.status_code = 400 + mock_requests_post.return_value.json.return_value = { + "error": {"message": "Invalid token"} + } + + success, id_token, error = exchange_custom_token_for_id_token( + "invalid-token", "test-api-key" + ) + + assert success is False + assert id_token is None + assert "Invalid token" in str(error) + + +class TestInitializeFirestoreWithToken: + """Tests for initialize_firestore_with_token function.""" + + def test_initialize_firestore_success( + self, monkeypatch: pytest.MonkeyPatch, mock_console: Mock + ) -> None: + """Test successful Firestore initialization with token.""" + monkeypatch.setenv("FIREBASE_WEB_API_KEY", "test-api-key") + + # Mock exchange function + def mock_exchange( + token: str, api_key: str + ) -> tuple[bool, str | None, str | None]: + return True, "test-id-token", None + + monkeypatch.setattr( + "aieng_platform_onboard.utils.exchange_custom_token_for_id_token", + mock_exchange, + ) + + # Mock Firestore client + mock_client = Mock(spec=firestore.Client) + monkeypatch.setattr( + "google.cloud.firestore.Client", lambda **kwargs: mock_client + ) + + # Mock oauth2_credentials.Credentials + monkeypatch.setattr( + "google.oauth2.credentials.Credentials", + lambda token: Mock(), + ) + + client = initialize_firestore_with_token( + "custom-token", "test-project", "test-db", "test-api-key" + ) + + assert client is not None + + def test_initialize_firestore_no_api_key( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test Firestore initialization failure without API key.""" + monkeypatch.delenv("FIREBASE_WEB_API_KEY", raising=False) + + with pytest.raises(Exception, match="Firebase Web API key required"): + initialize_firestore_with_token("custom-token", "test-project", "test-db") + + +class TestInitializeFirestoreAdmin: + """Tests for initialize_firestore_admin function.""" + + def test_initialize_firestore_admin_success( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test successful admin Firestore initialization.""" + mock_client = Mock(spec=firestore.Client) + monkeypatch.setattr( + "google.cloud.firestore.Client", lambda **kwargs: mock_client + ) + + client = initialize_firestore_admin() + + assert client is not None + + def test_initialize_firestore_admin_failure( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test admin Firestore initialization failure.""" + + def mock_client_error(**kwargs: Any) -> None: + raise Exception("Connection failed") + + monkeypatch.setattr("google.cloud.firestore.Client", mock_client_error) + + with pytest.raises( + Exception, match="Failed to initialize Firestore admin client" + ): + initialize_firestore_admin() + + +class TestGetParticipantData: + """Tests for get_participant_data function.""" + + def test_get_participant_data_success( + self, mock_firestore_client: Mock, sample_participant_data: dict[str, Any] + ) -> None: + """Test successful participant data retrieval.""" + mock_doc = Mock() + mock_doc.exists = True + mock_doc.to_dict.return_value = sample_participant_data + + mock_ref = Mock() + mock_ref.get.return_value = mock_doc + + mock_collection = Mock() + mock_collection.document.return_value = mock_ref + + mock_firestore_client.collection.return_value = mock_collection + + result = get_participant_data(mock_firestore_client, "test-user") + + assert result == sample_participant_data + mock_firestore_client.collection.assert_called_once_with("participants") + + def test_get_participant_data_not_found(self, mock_firestore_client: Mock) -> None: + """Test participant data retrieval when participant not found.""" + mock_doc = Mock() + mock_doc.exists = False + + mock_ref = Mock() + mock_ref.get.return_value = mock_doc + + mock_collection = Mock() + mock_collection.document.return_value = mock_ref + + mock_firestore_client.collection.return_value = mock_collection + + result = get_participant_data(mock_firestore_client, "nonexistent-user") + + assert result is None + + +class TestGetTeamData: + """Tests for get_team_data function.""" + + def test_get_team_data_success( + self, mock_firestore_client: Mock, sample_team_data: dict[str, Any] + ) -> None: + """Test successful team data retrieval.""" + mock_doc = Mock() + mock_doc.exists = True + mock_doc.to_dict.return_value = sample_team_data + + mock_ref = Mock() + mock_ref.get.return_value = mock_doc + + mock_collection = Mock() + mock_collection.document.return_value = mock_ref + + mock_firestore_client.collection.return_value = mock_collection + + result = get_team_data(mock_firestore_client, "test-team") + + assert result == sample_team_data + + def test_get_team_data_not_found(self, mock_firestore_client: Mock) -> None: + """Test team data retrieval when team not found.""" + mock_doc = Mock() + mock_doc.exists = False + + mock_ref = Mock() + mock_ref.get.return_value = mock_doc + + mock_collection = Mock() + mock_collection.document.return_value = mock_ref + + mock_firestore_client.collection.return_value = mock_collection + + result = get_team_data(mock_firestore_client, "nonexistent-team") + + assert result is None + + +class TestGetGlobalKeys: + """Tests for get_global_keys function.""" + + def test_get_global_keys_success( + self, mock_firestore_client: Mock, sample_global_keys: dict[str, Any] + ) -> None: + """Test successful global keys retrieval.""" + mock_doc = Mock() + mock_doc.exists = True + mock_doc.to_dict.return_value = sample_global_keys + + mock_ref = Mock() + mock_ref.get.return_value = mock_doc + + mock_collection = Mock() + mock_collection.document.return_value = mock_ref + + mock_firestore_client.collection.return_value = mock_collection + + result = get_global_keys(mock_firestore_client) + + assert result == sample_global_keys + + +class TestGetAllParticipantsWithStatus: + """Tests for get_all_participants_with_status function.""" + + def test_get_all_participants_success(self, mock_firestore_client: Mock) -> None: + """Test successful retrieval of all participants with status.""" + mock_doc1 = Mock() + mock_doc1.id = "user1" + mock_doc1.to_dict.return_value = { + "team_name": "team-a", + "onboarded": True, + "onboarded_at": datetime.now(timezone.utc), + } + + mock_doc2 = Mock() + mock_doc2.id = "user2" + mock_doc2.to_dict.return_value = { + "team_name": "team-b", + "onboarded": False, + } + + mock_collection = Mock() + mock_collection.stream.return_value = [mock_doc1, mock_doc2] + + mock_firestore_client.collection.return_value = mock_collection + + result = get_all_participants_with_status(mock_firestore_client) + + assert len(result) == 2 + assert result[0]["github_handle"] == "user1" + assert result[0]["onboarded"] is True + assert result[1]["github_handle"] == "user2" + assert result[1]["onboarded"] is False + + def test_get_all_participants_empty(self, mock_firestore_client: Mock) -> None: + """Test retrieval when no participants exist.""" + mock_collection = Mock() + mock_collection.stream.return_value = [] + + mock_firestore_client.collection.return_value = mock_collection + + result = get_all_participants_with_status(mock_firestore_client) + + assert result == [] + + +class TestCreateEnvFile: + """Tests for create_env_file function.""" + + def test_create_env_file_success( + self, + tmp_path: Path, + sample_team_data: dict[str, Any], + sample_global_keys: dict[str, Any], + ) -> None: + """Test successful .env file creation.""" + env_path = tmp_path / ".env" + + result = create_env_file(env_path, sample_team_data, sample_global_keys) + + assert result is True + assert env_path.exists() + + content = env_path.read_text() + assert "OPENAI_API_KEY" in content + assert "test-openai-key" in content + assert "LANGFUSE_SECRET_KEY" in content + assert "WEAVIATE_HTTP_HOST" in content + + def test_create_env_file_overwrite( + self, + tmp_path: Path, + sample_team_data: dict[str, Any], + sample_global_keys: dict[str, Any], + ) -> None: + """Test .env file creation overwrites existing file.""" + env_path = tmp_path / ".env" + env_path.write_text("old content") + + result = create_env_file(env_path, sample_team_data, sample_global_keys) + + assert result is True + content = env_path.read_text() + assert "old content" not in content + assert "OPENAI_API_KEY" in content + + +class TestValidateEnvFile: + """Tests for validate_env_file function.""" + + def test_validate_env_file_complete(self, tmp_path: Path) -> None: + """Test validation of complete .env file.""" + env_path = tmp_path / ".env" + content = """ +OPENAI_API_KEY="test-key" +EMBEDDING_BASE_URL="https://example.com" +EMBEDDING_API_KEY="test-key" +LANGFUSE_SECRET_KEY="test-key" +LANGFUSE_PUBLIC_KEY="test-key" +LANGFUSE_HOST="https://example.com" +WEB_SEARCH_API_KEY="test-key" +WEAVIATE_HTTP_HOST="example.com" +WEAVIATE_GRPC_HOST="example.com" +WEAVIATE_API_KEY="test-key" +""" + env_path.write_text(content) + + is_complete, missing = validate_env_file(env_path) + + assert is_complete is True + assert missing == [] + + def test_validate_env_file_missing_keys(self, tmp_path: Path) -> None: + """Test validation of incomplete .env file.""" + env_path = tmp_path / ".env" + content = """ +OPENAI_API_KEY="test-key" +EMBEDDING_BASE_URL="" +""" + env_path.write_text(content) + + is_complete, missing = validate_env_file(env_path) + + assert is_complete is False + assert "EMBEDDING_BASE_URL" in missing + assert "EMBEDDING_API_KEY" in missing + + def test_validate_env_file_not_exists(self, tmp_path: Path) -> None: + """Test validation when file doesn't exist.""" + env_path = tmp_path / "nonexistent.env" + + is_complete, missing = validate_env_file(env_path) + + assert is_complete is False + assert "File does not exist" in missing + + +class TestCheckOnboardedStatus: + """Tests for check_onboarded_status function.""" + + def test_check_onboarded_status_true(self, mock_firestore_client: Mock) -> None: + """Test checking onboarded status when participant is onboarded.""" + mock_doc = Mock() + mock_doc.exists = True + mock_doc.to_dict.return_value = {"onboarded": True} + + mock_ref = Mock() + mock_ref.get.return_value = mock_doc + + mock_collection = Mock() + mock_collection.document.return_value = mock_ref + + mock_firestore_client.collection.return_value = mock_collection + + success, is_onboarded = check_onboarded_status( + mock_firestore_client, "test-user" + ) + + assert success is True + assert is_onboarded is True + + def test_check_onboarded_status_false(self, mock_firestore_client: Mock) -> None: + """Test checking onboarded status when participant is not onboarded.""" + mock_doc = Mock() + mock_doc.exists = True + mock_doc.to_dict.return_value = {"onboarded": False} + + mock_ref = Mock() + mock_ref.get.return_value = mock_doc + + mock_collection = Mock() + mock_collection.document.return_value = mock_ref + + mock_firestore_client.collection.return_value = mock_collection + + success, is_onboarded = check_onboarded_status( + mock_firestore_client, "test-user" + ) + + assert success is True + assert is_onboarded is False + + +class TestUpdateOnboardedStatus: + """Tests for update_onboarded_status function.""" + + def test_update_onboarded_status_success(self, mock_firestore_client: Mock) -> None: + """Test successful update of onboarded status.""" + mock_ref = Mock() + mock_collection = Mock() + mock_collection.document.return_value = mock_ref + + mock_firestore_client.collection.return_value = mock_collection + + success, error = update_onboarded_status(mock_firestore_client, "test-user") + + assert success is True + assert error is None + mock_ref.update.assert_called_once() + + def test_update_onboarded_status_failure(self, mock_firestore_client: Mock) -> None: + """Test failed update of onboarded status.""" + mock_ref = Mock() + mock_ref.update.side_effect = Exception("Update failed") + + mock_collection = Mock() + mock_collection.document.return_value = mock_ref + + mock_firestore_client.collection.return_value = mock_collection + + success, error = update_onboarded_status(mock_firestore_client, "test-user") + + assert success is False + assert "Update failed" in str(error)