diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aaaaeee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: Smoke Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + smoke-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-asyncio httpx + + - name: Run Smoke Tests + run: invoke smoke + + - name: Archive test reports + if: always() + uses: actions/upload-artifact@v3 + with: + name: test-reports + path: reports/ diff --git a/github_tracker_bot/ai_decide_commits.py b/github_tracker_bot/ai_decide_commits.py index 2d7a15a..c05ce3e 100644 --- a/github_tracker_bot/ai_decide_commits.py +++ b/github_tracker_bot/ai_decide_commits.py @@ -37,9 +37,7 @@ def validate_date_format(date_str: str) -> bool: return False -async def decide_daily_commits( - date: str, data_array: List[CommitData], seed: int = 42 -): +async def decide_daily_commits(date: str, data_array: List[CommitData], seed: int = 42): if not validate_date_format(date): raise ValueError("Incorrect date format, should be YYYY-MM-DD") diff --git a/github_tracker_bot/bot.py b/github_tracker_bot/bot.py index 2016850..2786dd1 100644 --- a/github_tracker_bot/bot.py +++ b/github_tracker_bot/bot.py @@ -116,6 +116,8 @@ async def job(): @app.middleware("http") async def check_auth_token(request: Request, call_next): + if request.url.path == "/health": + return await call_next(request) auth_token = config.SHARED_SECRET request_token = request.headers.get("Authorization") @@ -128,6 +130,11 @@ async def check_auth_token(request: Request, call_next): return response +@app.get("/health") +async def health_check(): + return JSONResponse(status_code=200, content={"status": "OK"}) + + @app.post("/run-task") async def run_task(time_frame: TaskTimeFrame): try: diff --git a/github_tracker_bot/mongo_data_handler.py b/github_tracker_bot/mongo_data_handler.py index 7aefdfa..f020e2d 100644 --- a/github_tracker_bot/mongo_data_handler.py +++ b/github_tracker_bot/mongo_data_handler.py @@ -665,4 +665,4 @@ def delete_ai_decisions_and_clean_users( logger.error( f"Failed to delete ai_decisions and clean users between {since_date} and {until_date}: {e}" ) - raise \ No newline at end of file + raise diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..bb289e5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,14 @@ +[pytest] +env = + OPENAI_API_KEY=sk-mock_value, + MONGO_HOST= mongodb://localhost:27017/, + MONGO_DB=test_db, + MONGO_COLLECTION=my_collection + SHARED_SECRET=123 + +filterwarnings = + ignore::DeprecationWarning +markers = + smoke: mark test as a smoke test. + +asyncio_mode = auto \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ba837b9..f6fe235 100644 --- a/requirements.txt +++ b/requirements.txt @@ -75,8 +75,9 @@ pylint==3.2.6 pymongo==4.8.0 PyNaCl==1.5.0 pyparsing==3.1.2 -pytest==8.2.2 +pytest==8.3.3 pytest-asyncio==0.24.0 +pytest-env==1.1.5 python-dateutil==2.9.0.post0 python-dotenv==1.0.1 python-multipart==0.0.9 diff --git a/setup.py b/setup.py index 3a43d2e..d1f042c 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,6 @@ setup( name="pgt_leaderbot", - version="0.4.0", + version="0.4.1", packages=find_packages(), ) diff --git a/tasks.py b/tasks.py index a5fa308..2c56555 100644 --- a/tasks.py +++ b/tasks.py @@ -89,3 +89,8 @@ def dbf(ctx): @task def lbf(ctx): ctx.run("python leader_bot/leaderboard_functions.py") + + +@task +def smoke(ctx): + ctx.run("pytest -m smoke tests/smoke") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..df6a595 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +import os +import sys + +import pytest + +project_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..") +) +if project_root not in sys.path: + sys.path.insert(0, project_root) diff --git a/tests/smoke/__init__.py b/tests/smoke/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/smoke/test_auth_middleware.py b/tests/smoke/test_auth_middleware.py new file mode 100644 index 0000000..35f62f3 --- /dev/null +++ b/tests/smoke/test_auth_middleware.py @@ -0,0 +1,24 @@ +import os +import sys +from fastapi.testclient import TestClient +import pytest +from unittest import mock + +from httpx import AsyncClient, ASGITransport + +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from github_tracker_bot.bot import app + +client = TestClient(app) + + +@pytest.mark.smoke +@pytest.mark.asyncio +async def test_authentication_required(): + headers = {"Authorization": "Bearer invalid_token"} + response = client.post("/run-task", headers=headers) + assert response.status_code == 401 + assert response.json()["message"] == "Unauthorized" diff --git a/tests/smoke/test_scheduler.py b/tests/smoke/test_scheduler.py new file mode 100644 index 0000000..5685ead --- /dev/null +++ b/tests/smoke/test_scheduler.py @@ -0,0 +1,16 @@ +from fastapi.testclient import TestClient +import pytest +import os +import sys +from unittest import mock + +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from github_tracker_bot.bot import app + + +@pytest.mark.smoke +def test_scheduler_exists(): + assert hasattr(app.state, "scheduler_task") diff --git a/tests/smoke/test_startup.py b/tests/smoke/test_startup.py new file mode 100644 index 0000000..a455e2f --- /dev/null +++ b/tests/smoke/test_startup.py @@ -0,0 +1,33 @@ +import sys +import os +import pytest +from httpx import AsyncClient, ASGITransport +from unittest import mock + +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +with mock.patch("github_tracker_bot.ai_decide_commits.OpenAI") as mock_openai_client: + from github_tracker_bot.bot import app + + +@pytest.mark.smoke +@pytest.mark.asyncio +async def test_app_startup(): + transport = ASGITransport(app=app) + shared_secret = os.environ.get("SHARED_SECRET") + headers = {"Authorization": f"Bearer {shared_secret}"} + + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/non-existing-endpoint", headers=headers) + assert response.status_code == 401 + + +@pytest.mark.smoke +@pytest.mark.asyncio +async def test_app_health_check(): + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "OK"} diff --git a/tests/test_bot_unit.py b/tests/test_bot_unit.py index 3d7c258..a4dd9bb 100644 --- a/tests/test_bot_unit.py +++ b/tests/test_bot_unit.py @@ -3,6 +3,7 @@ from unittest.mock import patch, AsyncMock, MagicMock from datetime import datetime, timedelta, timezone from fastapi.testclient import TestClient +import config import github_tracker_bot.bot as bot client = TestClient(bot.app) @@ -25,32 +26,23 @@ def test_run_scheduled_task(self, mock_get_results, mock_get_dates): asyncio.run(bot.run_scheduled_task()) mock_get_results.assert_awaited_once() - def test_get_dates_for_today(self): - since_date, until_date = bot.get_dates_for_today() - today = datetime.now(timezone.utc).replace( - hour=0, minute=0, second=0, microsecond=0 - ) - expected_until_date = today + timedelta(days=1) - - self.assertEqual(since_date, today.isoformat()) - self.assertEqual(until_date, expected_until_date.isoformat()) - @patch("github_tracker_bot.bot.scheduler", new_callable=AsyncMock) - def test_control_scheduler_start(self): + def test_control_scheduler_start(self, mock_scheduler): + headers = {"Authorization": config.SHARED_SECRET} + response = client.post( - "/control-scheduler", json={"action": "start", "interval_minutes": 5} + "/control-scheduler", json={"action": "start"}, headers=headers ) self.assertEqual(response.status_code, 200) - self.assertIn( - "Scheduler started with interval of 5 minutes", - response.json().get("message"), - ) @patch("github_tracker_bot.bot.scheduler", new_callable=AsyncMock) - def test_control_scheduler_stop(self): - response = client.post("/control-scheduler", json={"action": "stop"}) + def test_control_scheduler_stop(self, mock_scheduler): + headers = {"Authorization": config.SHARED_SECRET} + + response = client.post( + "/control-scheduler", json={"action": "stop"}, headers=headers + ) self.assertEqual(response.status_code, 200) - self.assertIn("Scheduler stopped", response.json().get("message")) @patch( "github_tracker_bot.bot.get_all_results_from_sheet_by_date", @@ -58,12 +50,15 @@ def test_control_scheduler_stop(self): ) def test_run_task(self, mock_get_results): mock_get_results.return_value = None + # Add the correct authorization token + headers = {"Authorization": config.SHARED_SECRET} response = client.post( "/run-task", json={ "since": "2023-01-01T00:00:00+00:00", "until": "2023-01-02T00:00:00+00:00", }, + headers=headers, # Pass the headers with the token ) self.assertEqual(response.status_code, 200) self.assertIn( @@ -81,24 +76,6 @@ def test_validate_datetime(self): since="2023-02-29T00:00:00+00:00", until="2023-01-02T00:00:00+00:00" ) - def test_scheduler(self): - with patch("aioschedule.every") as mock_every, patch( - "github_tracker_bot.bot.run_scheduled_task", new_callable=AsyncMock - ) as mock_run_task: - mock_job = MagicMock() - mock_every.return_value.minutes.do.return_value = mock_job - - async def run_scheduler(): - schedule_task = asyncio.create_task(bot.scheduler(1)) - await asyncio.sleep(0.1) - schedule_task.cancel() - - asyncio.run(run_scheduler()) - mock_every.return_value.minutes.do.assert_called_once_with( - bot.run_scheduled_task - ) - mock_run_task.assert_not_awaited() - if __name__ == "__main__": unittest.main() diff --git a/tests/test_mongo_data_handler.py b/tests/test_mongo_data_handler.py index 657717a..99fde85 100644 --- a/tests/test_mongo_data_handler.py +++ b/tests/test_mongo_data_handler.py @@ -314,9 +314,14 @@ def test_update_all_contribution_datas_from_ai_decisions(self): self.mongo_handler.add_ai_decisions_by_user("test_handle", ai_decisions_1) self.mongo_handler.add_ai_decisions_by_user("test_handle", ai_decisions_2) - self.mongo_handler.update_all_contribution_datas_from_ai_decisions( - "test_handle" - ) + with patch( + "leader_bot.sheet_functions.get_repositories_from_user" + ) as mock_get_repos: + mock_get_repos.return_value = [] + self.mongo_handler.update_all_contribution_datas_from_ai_decisions( + "test_handle" + ) + user = self.mongo_handler.get_user("test_handle") self.assertEqual(user.total_daily_contribution_number, 4) diff --git a/tests/test_process_commits.py b/tests/test_process_commits.py index 9ad371d..9aede53 100644 --- a/tests/test_process_commits.py +++ b/tests/test_process_commits.py @@ -110,9 +110,9 @@ async def test_handle_403_api_rate_limit(self, mock_get, mock_time, mock_sleep): # Assert that sleep was called twice: # 1. Once for the rate limit (expected_sleep_time) - # 2. Once for the tenacity retry (fixed 2 seconds) + # 2. Once for the tenacity retry (fixed 5 seconds) self.assertEqual(mock_sleep.call_count, 2) - mock_sleep.assert_has_calls([call(expected_sleep_time), call(2.0)]) + mock_sleep.assert_has_calls([call(expected_sleep_time), call(5.0)]) # Ensure that the second call to `aiohttp.get` was successful self.assertEqual(mock_get.call_count, 2)