diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..bf5a932 Binary files /dev/null and b/.coverage differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..828102e --- /dev/null +++ b/.coveragerc @@ -0,0 +1,18 @@ +[run] +source = app +omit = + app/migrations/* + app/conftest.py + */tests/* + */__pycache__/* + */.venv/* + */venv/* + +[report] +exclude_lines = + pragma: no cover + def __repr__ + raise NotImplementedError + +[html] +directory = htmlcov diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42e1fa8..a4cc476 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,9 +53,7 @@ jobs: DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/testdb run: alembic upgrade head - # 7️⃣ Run tests (code coverage step commented out) - # - name: Run tests with coverage - # working-directory: app - # env: - # DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/testdb - # run: pytest --cov=app --cov-report=xml + - name: Run tests with coverage + env: + DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/testdb + run: pytest tests/ -v --cov=app --cov-report=xml --cov-report=term-missing diff --git a/app/Controllers/User/statistics_controller.py b/app/Controllers/User/statistics_controller.py index e2ea0d3..bde78e4 100644 --- a/app/Controllers/User/statistics_controller.py +++ b/app/Controllers/User/statistics_controller.py @@ -16,7 +16,7 @@ async def root(): @router.get('/github/{userName}') async def getGitHubData(userName: str): logger.info("GET Request GitHub Data for user: " + userName) - return await GitHubService.getAllGitHubData(userName) + return GitHubService.getAllGitHubData(userName) @router.get('/lc/{userName}') async def getLeetCodeData(userName: str): diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..84c4a14 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +pythonpath = app +asyncio_mode = auto +markers = + unit: Unit tests (isolated, mocked I/O). + integration: Integration tests (controller + mocked services). + e2e: End-to-end API tests (full app, mocked external HTTP). +testpaths = tests diff --git a/requirements.txt b/requirements.txt index d4c05f6..bb82a7a 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bc32eeb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +""" +Shared pytest fixtures. App is imported with pythonpath=app (see pytest.ini). +""" +import pytest +from fastapi.testclient import TestClient + +from main import app + + +@pytest.fixture +def client() -> TestClient: + """FastAPI TestClient for the main app.""" + return TestClient(app) diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/test_statistics_api.py b/tests/e2e/test_statistics_api.py new file mode 100644 index 0000000..579c5b2 --- /dev/null +++ b/tests/e2e/test_statistics_api.py @@ -0,0 +1,49 @@ +"""E2E API tests for statistics routes (full app, external HTTP mocked).""" +import pytest +from unittest.mock import patch, MagicMock + +from fastapi.testclient import TestClient + +from main import app + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.mark.e2e +class TestStatisticsApiE2E: + """End-to-end tests for /Dijkstra/v1/statistics/*.""" + + def test_health_endpoint_no_mocks(self, client: TestClient): + response = client.get("/Dijkstra/v1/statistics/health") + assert response.status_code == 200 + assert response.json().get("message") == "Dijkstra Statistics Health Endpoint Triggered!!!" + + def test_github_endpoint_returns_structure(self, client: TestClient): + response = client.get("/Dijkstra/v1/statistics/github/anyuser") + assert response.status_code == 200 + data = response.json() + assert "general_data" in data + assert "dijkstra_statistics" in data + assert "overall_github_statistics" in data + + def test_lc_endpoint_with_mocked_leetcode_api(self, client: TestClient): + mock_response = MagicMock() + mock_response.json.return_value = { + "data": { + "matchedUser": {"username": "e2euser"}, + "userContestRanking": None, + } + } + with patch( + "Services.User.leetcode_service.requests.post", + return_value=mock_response, + ): + response = client.get("/Dijkstra/v1/statistics/lc/e2euser") + + assert response.status_code == 200 + data = response.json() + assert "leetcode" in data + assert data["leetcode"].get("profile", {}).get("username") == "e2euser" diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_statistics_controller.py b/tests/integration/test_statistics_controller.py new file mode 100644 index 0000000..7c17c78 --- /dev/null +++ b/tests/integration/test_statistics_controller.py @@ -0,0 +1,46 @@ +"""Integration tests for statistics controller (router + mocked services).""" +import pytest +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from main import app + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.mark.integration +class TestStatisticsController: + """Statistics routes with patched GitHubService and LeetCodeService.""" + + def test_health_returns_200_and_message(self, client: TestClient): + response = client.get("/Dijkstra/v1/statistics/health") + assert response.status_code == 200 + data = response.json() + assert data.get("status") == 200 + assert "Dijkstra Statistics Health" in data.get("message", "") + + def test_github_returns_patched_service_response(self, client: TestClient): + mock_data = {"general_data": {"username": "testuser"}, "dijkstra_statistics": {}, "overall_github_statistics": {}} + with patch( + "Controllers.User.statistics_controller.GitHubService.getAllGitHubData", + return_value=mock_data, + ): + response = client.get("/Dijkstra/v1/statistics/github/testuser") + + assert response.status_code == 200 + assert response.json() == mock_data + + def test_lc_returns_patched_service_response(self, client: TestClient): + mock_data = {"leetcode": {"profile": {"username": "lcuser"}, "contestRanking": None}} + with patch( + "Controllers.User.statistics_controller.LeetCodeService.getAllLeetcodeData", + return_value=mock_data, + ): + response = client.get("/Dijkstra/v1/statistics/lc/lcuser") + + assert response.status_code == 200 + assert response.json() == mock_data diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_github_service.py b/tests/unit/test_github_service.py new file mode 100644 index 0000000..329c38b --- /dev/null +++ b/tests/unit/test_github_service.py @@ -0,0 +1,50 @@ +"""Unit tests for GitHubService (statistics).""" +import pytest + +from Services.User.github_service import GitHubService + + +@pytest.mark.unit +class TestGitHubService: + """Tests for GitHubService.getAllGitHubData.""" + + def test_get_all_github_data_returns_dict_with_expected_top_level_keys(self): + result = GitHubService.getAllGitHubData("johndoe") + assert isinstance(result, dict) + assert "general_data" in result + assert "dijkstra_statistics" in result + assert "overall_github_statistics" in result + + def test_get_all_github_data_general_data_structure(self): + result = GitHubService.getAllGitHubData("anyuser") + general = result["general_data"] + assert "username" in general + assert "full_name" in general + assert "avatar_img_link" in general + assert "bio" in general + assert "followers" in general + assert "following" in general + assert "current_company" in general + assert "current_location" in general + assert "time_zone" in general + assert "websites_links" in general + assert "organizations_list" in general + + def test_get_all_github_data_dijkstra_statistics_structure(self): + result = GitHubService.getAllGitHubData("anyuser") + dijkstra = result["dijkstra_statistics"] + assert "team" in dijkstra + assert "repositories_contributed_to" in dijkstra + assert "total_prs" in dijkstra + assert "total_lines_contributed" in dijkstra + assert "total_commits" in dijkstra + assert "dijkstra_rank" in dijkstra + + def test_get_all_github_data_overall_statistics_structure(self): + result = GitHubService.getAllGitHubData("anyuser") + overall = result["overall_github_statistics"] + assert "total_lines_contributed" in overall + assert "total_prs_raised" in overall + assert "total_commits" in overall + assert "languages_used" in overall + assert "contribution_graph_link" in overall diff --git a/tests/unit/test_leetcode_service.py b/tests/unit/test_leetcode_service.py new file mode 100644 index 0000000..7966c63 --- /dev/null +++ b/tests/unit/test_leetcode_service.py @@ -0,0 +1,57 @@ +"""Unit tests for LeetCodeService.getAllLeetcodeData (statistics).""" +import pytest +from unittest.mock import patch, MagicMock + +from Services.User.leetcode_service import LeetCodeService + + +@pytest.mark.unit +class TestLeetCodeServiceGetAllLeetcodeData: + """Tests for LeetCodeService.getAllLeetcodeData.""" + + def test_returns_leetcode_profile_and_contest_ranking_on_success(self): + mock_response = MagicMock() + mock_response.json.return_value = { + "data": { + "matchedUser": {"username": "testuser", "profile": {}}, + "userContestRanking": {"rating": 1500}, + } + } + mock_response.raise_for_status = MagicMock() + + with patch( + "Services.User.leetcode_service.requests.post", + return_value=mock_response, + ): + result = LeetCodeService.getAllLeetcodeData("testuser") + + assert "leetcode" in result + assert result["leetcode"]["profile"] == {"username": "testuser", "profile": {}} + assert result["leetcode"]["contestRanking"] == {"rating": 1500} + assert "error" not in result + + def test_returns_leetcode_error_when_api_returns_errors_key(self): + mock_response = MagicMock() + mock_response.json.return_value = { + "errors": [{"message": "User not found"}] + } + + with patch( + "Services.User.leetcode_service.requests.post", + return_value=mock_response, + ): + result = LeetCodeService.getAllLeetcodeData("nonexistent") + + assert "leetcode" in result + assert "error" in result["leetcode"] + assert result["leetcode"]["error"] == [{"message": "User not found"}] + + def test_returns_error_key_on_request_exception(self): + with patch( + "Services.User.leetcode_service.requests.post", + side_effect=Exception("Connection failed"), + ): + result = LeetCodeService.getAllLeetcodeData("anyuser") + + assert "error" in result + assert "Connection failed" in result["error"]