diff --git a/.gitignore b/.gitignore index 7dc57dc7..4099784f 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,15 @@ build/ .pytest_cache/ _version.py +# Test coverage and reports +htmlcov/ +.coverage +coverage.xml +test-report.html +test-results.xml +*.cover +.hypothesis/ + # Virtual environments .venv/ venv/ diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..5a420cba --- /dev/null +++ b/tests/README.md @@ -0,0 +1,27 @@ +# Microsoft Agent 365 SDK Tests + +Unit and integration tests for the Microsoft Agent 365-Python SDK. This test suite ensures reliability, maintainability, and quality across all modules including runtime, tooling, notifications, and observability extensions. + +## Usage + +For detailed instructions on running tests and generating coverage reports, see: + +- **[Test Plan](TEST_PLAN.md)** - Comprehensive testing strategy and implementation roadmap +- **[Running Tests](RUNNING_TESTS.md)** - Complete guide for installation, running tests, generating coverage reports, and troubleshooting + +## Support + +For issues, questions, or feedback: + +- File issues in the [GitHub Issues](https://github.com/microsoft/Agent365-python/issues) section +- See the [main documentation](../README.md) for more information + +## Trademarks + +*Microsoft, Windows, Microsoft Azure and/or other Microsoft products and services referenced in the documentation may be either trademarks or registered trademarks of Microsoft in the United States and/or other countries. The licenses for this project do not grant you rights to use any Microsoft names, logos, or trademarks. Microsoft's general trademark guidelines can be found at http://go.microsoft.com/fwlink/?LinkID=254653.* + +## License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the MIT License - see the [LICENSE](../LICENSE.md) file for details. diff --git a/tests/RUNNING_TESTS.md b/tests/RUNNING_TESTS.md new file mode 100644 index 00000000..9ded70f4 --- /dev/null +++ b/tests/RUNNING_TESTS.md @@ -0,0 +1,159 @@ +# Running Unit Tests for Agent365-Python SDK + +This guide covers setting up and running tests. + +--- + +## Prerequisites + +### 1. Create Virtual Environment + +```powershell +# Create virtual environment using uv +uv venv + +# Activate the virtual environment +.\.venv\Scripts\Activate.ps1 +``` + +### 2. Configure Python Environment + +1. Press `Ctrl+Shift+P` +2. Type "Python: Select Interpreter" +3. Choose the `.venv` interpreter from the list + +### 3. Install Dependencies + +```powershell +# Test framework and reporting +uv pip install pytest pytest-asyncio pytest-mock pytest-cov pytest-html wrapt + +# SDK core libraries +uv pip install -e libraries/microsoft-agents-a365-runtime -e libraries/microsoft-agents-a365-notifications -e libraries/microsoft-agents-a365-observability-core -e libraries/microsoft-agents-a365-tooling + +# Framework extension libraries +uv pip install -e libraries/microsoft-agents-a365-observability-extensions-langchain -e libraries/microsoft-agents-a365-observability-extensions-openai -e libraries/microsoft-agents-a365-observability-extensions-semantickernel -e libraries/microsoft-agents-a365-observability-extensions-agentframework -e libraries/microsoft-agents-a365-tooling-extensions-agentframework -e libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry -e libraries/microsoft-agents-a365-tooling-extensions-openai -e libraries/microsoft-agents-a365-tooling-extensions-semantickernel +``` + +--- + +## Test Structure + +> **Note:** This structure will be updated as new tests are added. + +```plaintext +tests/ +├── runtime/ # Runtime tests +├── observability/ # Observability tests +├── tooling/ # Tooling tests +└── notifications/ # Notifications tests +``` + +--- + +## Running Tests in VS Code (Optional) + +### Test Explorer + +1. Click the beaker icon in the Activity Bar or press `Ctrl+Shift+P` → "Test: Focus on Test Explorer View" +2. Click the play button to run tests (all/folder/file/individual) +3. Right-click → "Debug Test" to debug with breakpoints + +### Command Palette + +- `Test: Run All Tests` +- `Test: Run Tests in Current File` +- `Test: Debug Tests in Current File` + +--- + +## Running Tests from Command Line + +```powershell +# Run all tests +python -m pytest tests/ + +# Run specific module/file +python -m pytest tests/runtime/ +python -m pytest tests/runtime/test_environment_utils.py + +# Run with options +python -m pytest tests/ -v # Verbose +python -m pytest tests/ -x # Stop on first failure +python -m pytest tests/ -k "environment" # Pattern matching +python -m pytest --lf # Re-run failed tests +``` + +--- + +## Generating Reports + +### HTML Reports + +```powershell +# Coverage report +python -m pytest tests/ --cov=libraries --cov-report=html -v + +# View reports +start htmlcov\index.html + +# Test report (requires: uv pip install pytest-html) +python -m pytest tests/ --html=test-report.html --self-contained-html + +# View reports +start test-report.html + +# Combined (requires: uv pip install pytest-html) +python -m pytest tests/ --cov=libraries --cov-report=html --html=test-report.html --self-contained-html -v + +# View reports +start htmlcov\index.html +``` + +### CI/CD Reports + +```powershell +# XML reports for CI/CD pipelines +python -m pytest tests/ --cov=libraries --cov-report=xml --junitxml=test-results.xml + +# View reports +start test-results.xml +start coverage.xml +``` + +--- + +## Troubleshooting + +### Common Issues + +| Issue | Solution | +|-------|----------| +| **Test loading failed** | Clean pyproject.toml, reinstall packages, restart VS Code | +| **ImportError: No module named 'pytest'** | `uv pip install pytest pytest-asyncio pytest-mock` | +| **ImportError: No module named 'microsoft_agents_a365'** | `uv pip install -e .` | +| **Tests not discovered** | Refresh Test Explorer or restart VS Code | + +### Fix Steps + +If tests fail to discover or import errors occur: + +**1. Clean pyproject.toml** + +```powershell +$content = Get-Content "pyproject.toml" -Raw +$fixed = $content -replace "`r`n", "`n" +$fixed | Set-Content "pyproject.toml" -NoNewline +``` + +**2. Reinstall packages** + +```powershell +uv pip install -e . +``` + +**3. Restart VS Code** + +- Close completely and reopen +- Wait for Python extension to reload +- Refresh Test Explorer diff --git a/tests/TEST_PLAN.md b/tests/TEST_PLAN.md new file mode 100644 index 00000000..4d0c6e6c --- /dev/null +++ b/tests/TEST_PLAN.md @@ -0,0 +1,146 @@ +# Test Plan for Agent365-Python SDK + +> **Note:** This plan is under active development. Keep updating as testing progresses. + +**Version:** 1.0 +**Date:** November 24, 2025 +**Status:** Draft + +--- + +## Overview + +### Current State +- ✅ Unit tests exist for `observability` and `runtime` modules +- ❌ Missing tests for `tooling` and `notifications` modules +- ❌ No integration tests or CI/CD automation + +### Goals +- Achieve **80%+ code coverage** across all modules +- Implement integration tests for cross-module functionality +- Integrate testing into CI/CD pipeline with coverage enforcement + +--- + +## Testing Strategy + +**Framework:** `pytest` +**Coverage:** `pytest-cov` +**Mocking:** `unittest.mock` +**Async:** `pytest-asyncio` + +**Test Pattern:** AAA (Arrange → Act → Assert) +**Naming Convention:** `test___` + +--- + +## Implementation Roadmap + +| Phase | Deliverables | Priority | +|-------|-------------|----------| +| 1.1 | Runtime unit tests | ✅ Complete | +| 1.2 | Tooling unit tests | HIGH | +| 1.3 | Notifications unit tests | HIGH | +| 1.4 | Expand observability tests | MEDIUM | +| 1.5 | Tooling extension tests | LOW | +| 2 | Integration tests | MEDIUM | +| 3 | CI/CD automation | HIGH | + +--- + +## Phase 1: Unit Tests + +### 1.1 Runtime Module +**Priority:** HIGH + +| Module | Test File | Status | +|--------|-----------|--------| +| `power_platform_api_discovery.py` | `test_power_platform_api_discovery.py` | ✅ Complete | +| `utility.py` | `test_utility.py` | ✅ Complete | +| `environment_utils.py` | `test_environment_utils.py` | ✅ Complete | +| `version_utils.py` | `test_version_utils.py` | ✅ Complete | + +--- + +### 1.2 Tooling Module +**Priority:** HIGH + +| Module | Test File | Status | +|--------|-----------|--------| +| `utils/utility.py` | `test_utility.py` | ❌ Missing | +| `models/mcp_server_config.py` | `test_mcp_server_config.py` | ❌ Missing | +| `services/mcp_tool_server_configuration_service.py` | `test_mcp_tool_server_configuration_service.py` | ❌ Missing | + +--- + +### 1.3 Notifications Module +**Priority:** HIGH + +| Module | Test File | Status | +|--------|-----------|--------| +| `models/agent_lifecycle_event.py` | `test_agent_lifecycle_event.py` | ❌ Missing | +| `models/agent_notification_activity.py` | `test_agent_notification_activity.py` | ❌ Missing | +| `models/email_reference.py` | `test_email_reference.py` | ❌ Missing | +| `agent_notification.py` | `test_agent_notification.py` | ❌ Missing | + +--- + +### 1.4 Observability Extensions +**Priority:** MEDIUM + +| Extension | Status | +|-----------|--------| +| `agentframework` | ✅ Expand existing | +| `langchain` | ✅ Expand existing | +| `openai` | ✅ Expand existing | +| `semantickernel` | ✅ Expand existing | + +--- + +### 1.5 Tooling Extensions +**Priority:** LOW + +| Extension | Status | +|-----------|--------| +| Agent Framework | ❌ Missing | +| Azure AI Foundry | ❌ Missing | +| OpenAI | ❌ Missing | +| Semantic Kernel | ❌ Missing | + +--- + +## Phase 2: Integration Tests + +**Priority:** MEDIUM + +| Integration | Status | +|-------------|--------| +| Runtime + Observability | ❌ Missing | +| Tooling + Runtime | ❌ Missing | +| Notifications + Runtime | ❌ Missing | +| Agent Framework full flow | ❌ Missing | +| LangChain full flow | ❌ Missing | + +--- + +## Phase 3: CI/CD Integration + +**Priority:** HIGH + +| Component | Status | +|-----------|--------| +| GitHub Actions workflow | ❌ Missing | +| Python matrix (3.9-3.12) | ❌ Missing | +| Coverage enforcement (80%+) | ❌ Missing | +| Codecov integration | ❌ Missing | +| PR blocking on failures | ❌ Missing | + +--- + +## Success Criteria + +- ✅ 80%+ code coverage for all modules +- ✅ All tests pass independently +- ✅ Full suite completes in < 30 seconds (unit) / < 5 minutes (full) +- ✅ Automated test execution on all PRs +- ✅ Coverage reports visible and enforced diff --git a/tests/runtime/test_environment_utils.py b/tests/runtime/test_environment_utils.py new file mode 100644 index 00000000..7660b173 --- /dev/null +++ b/tests/runtime/test_environment_utils.py @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for environment_utils module.""" + +import pytest + +from microsoft_agents_a365.runtime.environment_utils import ( + PROD_OBSERVABILITY_SCOPE, + get_observability_authentication_scope, + is_development_environment, +) + + +def test_get_observability_authentication_scope(): + """Test get_observability_authentication_scope returns production scope.""" + result = get_observability_authentication_scope() + assert result == [PROD_OBSERVABILITY_SCOPE] + + +@pytest.mark.parametrize( + "env_value,expected", + [ + (None, False), + ("Development", True), + ("production", False), + ("staging", False), + ], +) +def test_is_development_environment(monkeypatch, env_value, expected): + """Test is_development_environment returns correct value based on PYTHON_ENVIRONMENT.""" + if env_value is None: + monkeypatch.delenv("PYTHON_ENVIRONMENT", raising=False) + else: + monkeypatch.setenv("PYTHON_ENVIRONMENT", env_value) + + result = is_development_environment() + assert result == expected diff --git a/tests/runtime/test_power_platform_api_discovery.py b/tests/runtime/test_power_platform_api_discovery.py index 2a16c66c..4189d44c 100644 --- a/tests/runtime/test_power_platform_api_discovery.py +++ b/tests/runtime/test_power_platform_api_discovery.py @@ -1,159 +1,166 @@ -# Copyright (c) Microsoft. All rights reserved. - -import unittest - -from microsoft_agents_a365.runtime.power_platform_api_discovery import PowerPlatformApiDiscovery - - -class TestPowerPlatformApiDiscovery(unittest.TestCase): - def test_host_suffix_and_audience(self): - expected_host_suffixes = { - "local": "api.powerplatform.localhost", - "dev": "api.powerplatform.com", - "test": "api.powerplatform.com", - "preprod": "api.powerplatform.com", - "firstrelease": "api.powerplatform.com", - "prod": "api.powerplatform.com", - "gov": "api.gov.powerplatform.microsoft.us", - "high": "api.high.powerplatform.microsoft.us", - "dod": "api.appsplatform.us", - "mooncake": "api.powerplatform.partner.microsoftonline.cn", - "ex": "api.powerplatform.eaglex.ic.gov", - "rx": "api.powerplatform.microsoft.scloud", - } - - for cluster, expected in expected_host_suffixes.items(): - with self.subTest(cluster=cluster): - disc = PowerPlatformApiDiscovery(cluster) # type: ignore[arg-type] - self.assertEqual(disc.get_token_endpoint_host(), expected) - self.assertEqual(disc.get_token_audience(), f"https://{expected}") - - def test_hex_suffix_length_rules(self): - prod = PowerPlatformApiDiscovery("prod") - first = PowerPlatformApiDiscovery("firstrelease") - dev = PowerPlatformApiDiscovery("dev") - - self.assertEqual(prod._get_hex_api_suffix_length(), 2) - self.assertEqual(first._get_hex_api_suffix_length(), 2) - self.assertEqual(dev._get_hex_api_suffix_length(), 1) - - def test_tenant_endpoint_generation_prod(self): - disc = PowerPlatformApiDiscovery("prod") - tenant_id = "abc-012" # normalized -> abc012; suffix length 2 -> '12' - expected = "abc0.12.tenant.api.powerplatform.com" - self.assertEqual(disc.get_tenant_endpoint(tenant_id), expected) - - def test_tenant_endpoint_generation_dev(self): - disc = PowerPlatformApiDiscovery("dev") - tenant_id = "A1B2" # normalized -> a1b2; suffix length 1 -> '2' - expected = "a1b.2.tenant.api.powerplatform.com" - self.assertEqual(disc.get_tenant_endpoint(tenant_id), expected) - - def test_tenant_island_cluster_endpoint(self): - disc = PowerPlatformApiDiscovery("prod") - tenant_id = "abc-1234" # normalized -> abc1234; suffix '34', prefix 'abc12' - expected = "il-abc12.34.tenant.api.powerplatform.com" - self.assertEqual(disc.get_tenant_island_cluster_endpoint(tenant_id), expected) - - def test_invalid_characters_in_tenant_identifier(self): - disc = PowerPlatformApiDiscovery("dev") - with self.assertRaisesRegex(ValueError, r"invalid host name characters"): - disc.get_tenant_endpoint("invalid$name") - - def test_tenant_identifier_too_short_for_suffix(self): - disc = PowerPlatformApiDiscovery("prod") - # prod requires normalized length >= 3 (2 + 1). Provide only 2 characters. - with self.assertRaisesRegex(ValueError, r"must be at least"): - disc.get_tenant_endpoint("ab") - - def test_normalization_of_tenant_id(self): - disc = PowerPlatformApiDiscovery("dev") - tenant_id = "Ab-Cd-Ef" # normalized -> abcdef; suffix 1 -> 'f', prefix 'abcde' - expected = "abcde.f.tenant.api.powerplatform.com" - self.assertEqual(disc.get_tenant_endpoint(tenant_id), expected) - - def test_nodejs_tenant_examples(self): - tenant_id = "e3064512-cc6d-4703-be71-a2ecaecaa98a" - expected_map = { - "local": "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.localhost", - "dev": "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com", - "test": "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com", - "preprod": "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com", - "firstrelease": "e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com", - "prod": "e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com", - "gov": "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.gov.powerplatform.microsoft.us", - "high": "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.high.powerplatform.microsoft.us", - "dod": "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.appsplatform.us", - "mooncake": "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.partner.microsoftonline.cn", - "ex": "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.eaglex.ic.gov", - "rx": "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.microsoft.scloud", - } - - for cluster, expected in expected_map.items(): - with self.subTest(cluster=cluster): - disc = PowerPlatformApiDiscovery(cluster) # type: ignore[arg-type] - self.assertEqual(disc.get_tenant_endpoint(tenant_id), expected) - - def test_nodejs_tenant_island_examples(self): - tenant_id = "e3064512-cc6d-4703-be71-a2ecaecaa98a" - expected_map = { - "local": "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.localhost", - "dev": "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com", - "test": "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com", - "preprod": "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com", - "firstrelease": "il-e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com", - "prod": "il-e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com", - "gov": "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.gov.powerplatform.microsoft.us", - "high": "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.high.powerplatform.microsoft.us", - "dod": "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.appsplatform.us", - "mooncake": "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.partner.microsoftonline.cn", - "ex": "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.eaglex.ic.gov", - "rx": "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.microsoft.scloud", - } - - for cluster, expected in expected_map.items(): - with self.subTest(cluster=cluster): - disc = PowerPlatformApiDiscovery(cluster) # type: ignore[arg-type] - self.assertEqual(disc.get_tenant_island_cluster_endpoint(tenant_id), expected) - - def test_nodejs_invalid_characters_exact_message(self): - disc = PowerPlatformApiDiscovery("local") - expected_msg = "Cannot generate Power Platform API endpoint because the tenant identifier contains invalid host name characters, only alphanumeric and dash characters are expected: invalid?" - with self.assertRaises(ValueError) as cm: - disc.get_tenant_endpoint("invalid?") - self.assertEqual(str(cm.exception), expected_msg) - - def test_nodejs_insufficient_length_exact_messages(self): - disc_local = PowerPlatformApiDiscovery("local") - with self.assertRaises(ValueError) as cm1: - disc_local.get_tenant_endpoint("a") - self.assertEqual( - str(cm1.exception), - "Cannot generate Power Platform API endpoint because the normalized tenant identifier must be at least 2 characters in length: a", - ) - - with self.assertRaises(ValueError) as cm2: - disc_local.get_tenant_endpoint("a-") - self.assertEqual( - str(cm2.exception), - "Cannot generate Power Platform API endpoint because the normalized tenant identifier must be at least 2 characters in length: a", - ) - - disc_prod = PowerPlatformApiDiscovery("prod") - with self.assertRaises(ValueError) as cm3: - disc_prod.get_tenant_endpoint("aa") - self.assertEqual( - str(cm3.exception), - "Cannot generate Power Platform API endpoint because the normalized tenant identifier must be at least 3 characters in length: aa", - ) - - with self.assertRaises(ValueError) as cm4: - disc_prod.get_tenant_endpoint("a-a") - self.assertEqual( - str(cm4.exception), - "Cannot generate Power Platform API endpoint because the normalized tenant identifier must be at least 3 characters in length: aa", - ) - - -if __name__ == "__main__": - unittest.main() +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for PowerPlatformApiDiscovery class.""" + +import pytest + +from microsoft_agents_a365.runtime.power_platform_api_discovery import ( + PowerPlatformApiDiscovery, +) + + +# Tests for get_token_endpoint_host and get_token_audience +@pytest.mark.parametrize( + "cluster,expected_host", + [ + ("local", "api.powerplatform.localhost"), + ("dev", "api.powerplatform.com"), + ("test", "api.powerplatform.com"), + ("preprod", "api.powerplatform.com"), + ("firstrelease", "api.powerplatform.com"), + ("prod", "api.powerplatform.com"), + ("gov", "api.gov.powerplatform.microsoft.us"), + ("high", "api.high.powerplatform.microsoft.us"), + ("dod", "api.appsplatform.us"), + ("mooncake", "api.powerplatform.partner.microsoftonline.cn"), + ("ex", "api.powerplatform.eaglex.ic.gov"), + ("rx", "api.powerplatform.microsoft.scloud"), + ], +) +def test_host_suffix_and_audience(cluster, expected_host): + """Test get_token_endpoint_host and get_token_audience return correct values for each cluster.""" + disc = PowerPlatformApiDiscovery(cluster) + assert disc.get_token_endpoint_host() == expected_host + assert disc.get_token_audience() == f"https://{expected_host}" + + +# Tests for _get_hex_api_suffix_length +@pytest.mark.parametrize( + "cluster,expected_length", + [ + ("prod", 2), + ("firstrelease", 2), + ("dev", 1), + ("test", 1), + ("preprod", 1), + ("local", 1), + ("gov", 1), + ("high", 1), + ("dod", 1), + ("mooncake", 1), + ("ex", 1), + ("rx", 1), + ], +) +def test_hex_suffix_length_rules(cluster, expected_length): + """Test _get_hex_api_suffix_length returns correct suffix length for each cluster.""" + disc = PowerPlatformApiDiscovery(cluster) + assert disc._get_hex_api_suffix_length() == expected_length + + +# Tests for get_tenant_endpoint +@pytest.mark.parametrize( + "cluster,tenant_id,expected", + [ + ("prod", "abc-012", "abc0.12.tenant.api.powerplatform.com"), + ("dev", "A1B2", "a1b.2.tenant.api.powerplatform.com"), + ("dev", "Ab-Cd-Ef", "abcde.f.tenant.api.powerplatform.com"), + ], +) +def test_tenant_endpoint_generation(cluster, tenant_id, expected): + """Test get_tenant_endpoint for various clusters and tenant IDs.""" + disc = PowerPlatformApiDiscovery(cluster) + assert disc.get_tenant_endpoint(tenant_id) == expected + + +@pytest.mark.parametrize( + "cluster,expected", + [ + ("local", "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.localhost"), + ("dev", "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com"), + ("test", "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com"), + ("preprod", "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com"), + ("firstrelease", "e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com"), + ("prod", "e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com"), + ("gov", "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.gov.powerplatform.microsoft.us"), + ("high", "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.high.powerplatform.microsoft.us"), + ("dod", "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.appsplatform.us"), + ( + "mooncake", + "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.partner.microsoftonline.cn", + ), + ("ex", "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.eaglex.ic.gov"), + ("rx", "e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.microsoft.scloud"), + ], +) +def test_tenant_endpoint_with_real_uuid(cluster, expected): + """Test get_tenant_endpoint with real UUID across all clusters.""" + tenant_id = "e3064512-cc6d-4703-be71-a2ecaecaa98a" + disc = PowerPlatformApiDiscovery(cluster) + assert disc.get_tenant_endpoint(tenant_id) == expected + + +# Tests for get_tenant_island_cluster_endpoint +@pytest.mark.parametrize( + "cluster,expected", + [ + ("local", "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.localhost"), + ("dev", "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com"), + ("test", "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com"), + ("preprod", "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com"), + ("firstrelease", "il-e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com"), + ("prod", "il-e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com"), + ("gov", "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.gov.powerplatform.microsoft.us"), + ("high", "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.high.powerplatform.microsoft.us"), + ("dod", "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.appsplatform.us"), + ( + "mooncake", + "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.partner.microsoftonline.cn", + ), + ("ex", "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.eaglex.ic.gov"), + ("rx", "il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.microsoft.scloud"), + ], +) +def test_tenant_island_cluster_endpoint(cluster, expected): + """Test get_tenant_island_cluster_endpoint with real UUID across all clusters.""" + tenant_id = "e3064512-cc6d-4703-be71-a2ecaecaa98a" + disc = PowerPlatformApiDiscovery(cluster) + assert disc.get_tenant_island_cluster_endpoint(tenant_id) == expected + + +# Tests for error handling +@pytest.mark.parametrize( + "tenant_id", + [ + "invalid$name", + "invalid?", + "tenant@id", + "tenant#123", + "tenant with spaces", + "", + "---", + "-", + ], +) +def test_invalid_tenant_identifier(tenant_id): + """Test ValueError is raised for invalid tenant IDs (invalid chars, empty, or all dashes).""" + disc = PowerPlatformApiDiscovery("dev") + with pytest.raises(ValueError): + disc.get_tenant_endpoint(tenant_id) + + +@pytest.mark.parametrize( + "cluster,tenant_id,min_length", + [ + ("local", "a", 2), + ("local", "a-", 2), + ("prod", "aa", 3), + ("prod", "a-a", 3), + ], +) +def test_tenant_identifier_too_short(cluster, tenant_id, min_length): + """Test ValueError is raised when tenant ID is too short after normalization.""" + disc = PowerPlatformApiDiscovery(cluster) + with pytest.raises(ValueError, match=f"must be at least {min_length}"): + disc.get_tenant_endpoint(tenant_id) diff --git a/tests/runtime/test_utility.py b/tests/runtime/test_utility.py index d6cd2d4f..2ce17e2e 100644 --- a/tests/runtime/test_utility.py +++ b/tests/runtime/test_utility.py @@ -1,159 +1,126 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for Utility class.""" -import unittest import uuid -import jwt +from unittest.mock import Mock +import jwt +import pytest from microsoft_agents_a365.runtime.utility import Utility -class TestUtility(unittest.TestCase): - """Test cases for the Utility class.""" +# Fixtures (Mocks and Helpers) +@pytest.fixture +def create_test_jwt(): + """Fixture to create test JWT tokens.""" - def setUp(self): - """Set up test fixtures.""" - self.test_app_id = "12345678-1234-1234-1234-123456789abc" - self.test_azp_id = "87654321-4321-4321-4321-cba987654321" - - def create_test_jwt(self, claims: dict) -> str: - """Create a test JWT token with the given claims.""" - # Use PyJWT to create a proper JWT token (unsigned for testing) + def _create(claims: dict) -> str: return jwt.encode(claims, key="", algorithm="none") - def test_get_app_id_from_token_with_none_token(self): - """Test get_app_id_from_token with None token.""" - result = Utility.get_app_id_from_token(None) - self.assertEqual(result, str(uuid.UUID(int=0))) - - def test_get_app_id_from_token_with_empty_token(self): - """Test get_app_id_from_token with empty token.""" - result = Utility.get_app_id_from_token("") - self.assertEqual(result, str(uuid.UUID(int=0))) - - result = Utility.get_app_id_from_token(" ") - self.assertEqual(result, str(uuid.UUID(int=0))) - - def test_get_app_id_from_token_with_appid_claim(self): - """Test get_app_id_from_token with appid claim.""" - token = self.create_test_jwt({"appid": self.test_app_id, "other": "value"}) - result = Utility.get_app_id_from_token(token) - self.assertEqual(result, self.test_app_id) - - def test_get_app_id_from_token_with_azp_claim(self): - """Test get_app_id_from_token with azp claim.""" - token = self.create_test_jwt({"azp": self.test_azp_id, "other": "value"}) - result = Utility.get_app_id_from_token(token) - self.assertEqual(result, self.test_azp_id) - - def test_get_app_id_from_token_with_both_claims(self): - """Test get_app_id_from_token with both appid and azp claims (appid takes precedence).""" - token = self.create_test_jwt({"appid": self.test_app_id, "azp": self.test_azp_id}) - result = Utility.get_app_id_from_token(token) - self.assertEqual(result, self.test_app_id) - - def test_get_app_id_from_token_without_app_claims(self): - """Test get_app_id_from_token with token containing no app claims.""" - token = self.create_test_jwt({"sub": "user123", "iss": "issuer"}) - result = Utility.get_app_id_from_token(token) - self.assertEqual(result, "") - - def test_get_app_id_from_token_with_invalid_token(self): - """Test get_app_id_from_token with invalid token formats.""" - # Invalid token format - result = Utility.get_app_id_from_token("invalid.token") - self.assertEqual(result, "") - - # Token with only two parts - result = Utility.get_app_id_from_token("header.payload") - self.assertEqual(result, "") - - # Token with invalid base64 - result = Utility.get_app_id_from_token("invalid.!!!invalid!!!.signature") - self.assertEqual(result, "") - - -class MockActivity: - """Mock activity class for testing.""" - - def __init__(self, is_agentic: bool = False, agentic_id: str = ""): - self._is_agentic = is_agentic - self._agentic_id = agentic_id - - def is_agentic_request(self) -> bool: - return self._is_agentic - - def get_agentic_instance_id(self) -> str: - return self._agentic_id - - -class MockContext: - """Mock context class for testing.""" - - def __init__(self, activity=None): - self.activity = activity - - -class TestUtilityResolveAgentIdentity(unittest.TestCase): - """Test cases for the resolve_agent_identity method.""" - - def setUp(self): - """Set up test fixtures.""" - self.test_app_id = "token-app-id-123" - self.agentic_id = "agentic-id-456" - - # Create a test token with PyJWT - claims = {"appid": self.test_app_id} - self.test_token = jwt.encode(claims, key="", algorithm="none") - - def test_resolve_agent_identity_with_agentic_request(self): - """Test resolve_agent_identity with agentic request.""" - activity = MockActivity(is_agentic=True, agentic_id=self.agentic_id) - context = MockContext(activity) - - result = Utility.resolve_agent_identity(context, self.test_token) - self.assertEqual(result, self.agentic_id) - - def test_resolve_agent_identity_with_non_agentic_request(self): - """Test resolve_agent_identity with non-agentic request.""" - activity = MockActivity(is_agentic=False) - context = MockContext(activity) - - result = Utility.resolve_agent_identity(context, self.test_token) - self.assertEqual(result, self.test_app_id) - - def test_resolve_agent_identity_with_context_without_activity(self): - """Test resolve_agent_identity with context that has no activity.""" - context = MockContext() - - result = Utility.resolve_agent_identity(context, self.test_token) - self.assertEqual(result, self.test_app_id) - - def test_resolve_agent_identity_with_none_context(self): - """Test resolve_agent_identity with None context.""" - result = Utility.resolve_agent_identity(None, self.test_token) - self.assertEqual(result, self.test_app_id) - - def test_resolve_agent_identity_with_agentic_but_empty_id(self): - """Test resolve_agent_identity with agentic request but empty agentic ID.""" - activity = MockActivity(is_agentic=True, agentic_id="") - context = MockContext(activity) - - result = Utility.resolve_agent_identity(context, self.test_token) - self.assertEqual(result, "") - - def test_resolve_agent_identity_fallback_on_exception(self): - """Test resolve_agent_identity falls back to token when context access fails.""" - - # Create a context that will raise an exception when accessed - class FaultyContext: - @property - def activity(self): - raise RuntimeError("Context access failed") - - context = FaultyContext() - result = Utility.resolve_agent_identity(context, self.test_token) - self.assertEqual(result, self.test_app_id) - - -if __name__ == "__main__": - unittest.main() + return _create + + +@pytest.fixture +def mock_activity(): + """Fixture to create mock activity.""" + + def _create(is_agentic=False, agentic_id=""): + activity = Mock() + activity.is_agentic_request.return_value = is_agentic + activity.get_agentic_instance_id.return_value = agentic_id + return activity + + return _create + + +@pytest.fixture +def mock_context(): + """Fixture to create mock context.""" + + def _create(activity=None): + context = Mock() + context.activity = activity + return context + + return _create + + +# Tests for get_app_id_from_token +@pytest.mark.parametrize( + "token,expected", + [ + (None, str(uuid.UUID(int=0))), + ("", str(uuid.UUID(int=0))), + (" ", str(uuid.UUID(int=0))), + ("invalid.token", ""), + ], +) +def test_get_app_id_from_token_invalid(token, expected): + """Test get_app_id_from_token handles invalid tokens correctly.""" + result = Utility.get_app_id_from_token(token) + assert result == expected + + +@pytest.mark.parametrize( + "claims,expected", + [ + ({"appid": "test-app-id"}, "test-app-id"), + ({"azp": "azp-app-id"}, "azp-app-id"), + ({"appid": "appid-value", "azp": "azp-value"}, "appid-value"), + ({"sub": "user123"}, ""), + ], +) +def test_get_app_id_from_token_valid_tokens(create_test_jwt, claims, expected): + """Test get_app_id_from_token with valid tokens and various claims.""" + token = create_test_jwt(claims) + result = Utility.get_app_id_from_token(token) + assert result == expected + + +# Tests for resolve_agent_identity +@pytest.mark.parametrize( + "is_agentic,agentic_id,expected", + [ + (True, "agentic-id", "agentic-id"), + (True, "", ""), + (False, "", "token-app-id"), + (False, "ignored-id", "token-app-id"), + ], +) +def test_resolve_agent_identity_with_context( + create_test_jwt, mock_activity, mock_context, is_agentic, agentic_id, expected +): + """Test resolve_agent_identity returns correct ID based on context.""" + token = create_test_jwt({"appid": "token-app-id"}) + activity = mock_activity(is_agentic=is_agentic, agentic_id=agentic_id) + context = mock_context(activity) + + result = Utility.resolve_agent_identity(context, token) + assert result == expected + + +@pytest.mark.parametrize( + "context", + [ + None, + Mock(activity=None), + ], +) +def test_resolve_agent_identity_fallback(create_test_jwt, context): + """Test resolve_agent_identity falls back to token when context is invalid.""" + token = create_test_jwt({"appid": "token-app-id"}) + result = Utility.resolve_agent_identity(context, token) + assert result == "token-app-id" + + +def test_resolve_agent_identity_exception_handling(create_test_jwt, mock_context): + """Test resolve_agent_identity falls back to token when activity methods raise exceptions.""" + token = create_test_jwt({"appid": "token-app-id"}) + activity = Mock() + activity.is_agentic_request.side_effect = AttributeError("Method not available") + context = mock_context(activity) + + result = Utility.resolve_agent_identity(context, token) + assert result == "token-app-id" diff --git a/tests/runtime/test_version_utils.py b/tests/runtime/test_version_utils.py new file mode 100644 index 00000000..5da6851b --- /dev/null +++ b/tests/runtime/test_version_utils.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for version_utils module.""" + +import warnings +import pytest + +from microsoft_agents_a365.runtime.version_utils import build_version + + +# Tests for build_version +@pytest.mark.parametrize( + "env_value,expected", + [ + (None, "0.0.0"), + ("1.2.3", "1.2.3"), + ("2.5.0-beta", "2.5.0-beta"), + ("", ""), + ], +) +def test_build_version(monkeypatch, env_value, expected): + """Test build_version returns correct version based on environment variable.""" + if env_value is None: + monkeypatch.delenv("AGENT365_PYTHON_SDK_PACKAGE_VERSION", raising=False) + else: + monkeypatch.setenv("AGENT365_PYTHON_SDK_PACKAGE_VERSION", env_value) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + result = build_version() + + assert result == expected + + +def test_build_version_deprecation_warning(): + """Test that build_version raises DeprecationWarning with correct message.""" + with pytest.warns( + DeprecationWarning, match="build_version.*deprecated.*setuptools-git-versioning" + ): + build_version()