diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..530c324 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,4 @@ +"""Test package marker for the omnibioai-hpc-policy-engine unit-test suite. + +Developer: Manish Kumar +""" diff --git a/tests/_srcload.py b/tests/_srcload.py index 6e7276f..3084d02 100644 --- a/tests/_srcload.py +++ b/tests/_srcload.py @@ -27,6 +27,8 @@ regardless of host architecture. It does not change any production behavior -- Cython compiles these files essentially as-is, so the .py source and the compiled extension implement the same logic. + +Developer: Manish Kumar """ import importlib.util import os diff --git a/tests/conftest.py b/tests/conftest.py index b2ea501..40127dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,10 @@ +"""Shared pytest fixtures for the HPC policy engine tests: a mocked database +session and usage record, a client for the policy router, and a client for the +quota router whose database dependency is overridden, so no test connects to +MySQL. It also stubs swagger_ui_bundle when that package is not installed. + +Developer: Manish Kumar +""" import sys import pathlib import tempfile @@ -19,11 +26,15 @@ @pytest.fixture def mock_db(): + """Provide a MagicMock standing in for a SQLAlchemy session.""" return MagicMock() @pytest.fixture def mock_usage_record(): + """Provide a mock usage record for user "u1" with zero CPU hours, zero GPU hours and no running + jobs. + """ record = MagicMock() record.user_id = "u1" record.cpu_hours = 0.0 @@ -34,6 +45,7 @@ def mock_usage_record(): @pytest.fixture def policy_client(): + """Provide a TestClient for a FastAPI app that includes only the policy router.""" from app.api.routes_policy import router app = FastAPI() app.include_router(router) diff --git a/tests/test_config_module.py b/tests/test_config_module.py index d6c8414..fc63bf3 100644 --- a/tests/test_config_module.py +++ b/tests/test_config_module.py @@ -8,6 +8,8 @@ loads a *fresh* copy of config.py by file path (tests/_srcload.py) inside an os.environ patch, so the env vars are only visible to that one exec and nothing else in the suite is affected. + +Developer: Manish Kumar """ import os from unittest.mock import patch @@ -16,6 +18,9 @@ def _load_config_with_env(env: dict): + """Load a fresh copy of app/core/config.py with the configuration environment variables cleared + and the given ones applied, and return the module. + """ # os.environ is read at class-body execution time inside config.py, so # the patch must be active for the load() call itself. clean_env = {k: v for k, v in os.environ.items() if not k.startswith(( @@ -30,6 +35,9 @@ def _load_config_with_env(env: dict): # --------------------------------------------------------------------------- def test_defaults_used_when_no_env_vars_set(): + """With no environment variables set, Config uses the built-in MySQL, Redis, quota-hours, + concurrent-jobs and app-name defaults. + """ cfg = _load_config_with_env({}) assert cfg.Config.MYSQL_HOST == "mysql" assert cfg.Config.MYSQL_PORT == 3306 @@ -48,6 +56,7 @@ def test_defaults_used_when_no_env_vars_set(): # --------------------------------------------------------------------------- def test_env_vars_override_defaults(): + """Each Config setting takes its value from the matching environment variable when it is set.""" cfg = _load_config_with_env({ "MYSQL_HOST": "db.internal", "MYSQL_PORT": "5432", @@ -95,12 +104,14 @@ def test_non_numeric_mysql_port_raises_at_import_time(): def test_non_numeric_default_cpu_hours_raises_at_import_time(): + """A non-numeric DEFAULT_CPU_HOURS raises ValueError when the config module is loaded.""" import pytest with pytest.raises(ValueError): _load_config_with_env({"DEFAULT_CPU_HOURS": "unlimited"}) def test_non_numeric_max_concurrent_jobs_raises_at_import_time(): + """A non-numeric MAX_CONCURRENT_JOBS raises ValueError when the config module is loaded.""" import pytest with pytest.raises(ValueError): _load_config_with_env({"MAX_CONCURRENT_JOBS": "many"}) diff --git a/tests/test_conftest_fixtures.py b/tests/test_conftest_fixtures.py index a77bf42..35add12 100644 --- a/tests/test_conftest_fixtures.py +++ b/tests/test_conftest_fixtures.py @@ -1,6 +1,8 @@ """ Use conftest.py fixtures directly so their bodies are executed and counted by coverage. Covers conftest.py lines: 22, 27-32, 37-40, 46-54. + +Developer: Manish Kumar """ from unittest.mock import MagicMock, patch from app.models.decision import Decision @@ -11,10 +13,12 @@ # --------------------------------------------------------------------------- def test_mock_db_is_mock_instance(mock_db): + """The mock_db fixture is a MagicMock.""" assert isinstance(mock_db, MagicMock) def test_mock_db_supports_arbitrary_attribute_access(mock_db): + """mock_db accepts arbitrary attribute access and method calls, and records the call.""" _ = mock_db.some_attr mock_db.some_method() mock_db.some_method.assert_called_once() @@ -25,18 +29,22 @@ def test_mock_db_supports_arbitrary_attribute_access(mock_db): # --------------------------------------------------------------------------- def test_mock_usage_record_user_id(mock_usage_record): + """mock_usage_record has user_id "u1".""" assert mock_usage_record.user_id == "u1" def test_mock_usage_record_zero_cpu_hours(mock_usage_record): + """mock_usage_record starts with zero CPU hours.""" assert mock_usage_record.cpu_hours == 0.0 def test_mock_usage_record_zero_gpu_hours(mock_usage_record): + """mock_usage_record starts with zero GPU hours.""" assert mock_usage_record.gpu_hours == 0.0 def test_mock_usage_record_zero_jobs_running(mock_usage_record): + """mock_usage_record starts with zero running jobs.""" assert mock_usage_record.jobs_running == 0 @@ -45,21 +53,25 @@ def test_mock_usage_record_zero_jobs_running(mock_usage_record): # --------------------------------------------------------------------------- def test_policy_client_evaluate_returns_200(policy_client): + """POST /jobs/evaluate with a user_id returns 200.""" response = policy_client.post("/jobs/evaluate", json={"user_id": "u-fixture"}) assert response.status_code == 200 def test_policy_client_evaluate_allows_job(policy_client): + """POST /jobs/evaluate for a user with no other constraints returns allow true.""" response = policy_client.post("/jobs/evaluate", json={"user_id": "u-fixture"}) assert response.json()["allow"] is True def test_policy_client_evaluate_missing_user_id_returns_422(policy_client): + """POST /jobs/evaluate without a user_id is rejected with 422.""" response = policy_client.post("/jobs/evaluate", json={"cpu_hours": 2.0}) assert response.status_code == 422 def test_policy_client_evaluate_returns_partition(policy_client): + """POST /jobs/evaluate echoes the requested partition in its response.""" response = policy_client.post("/jobs/evaluate", json={ "user_id": "u-fixture", "partition": "gpu", @@ -72,11 +84,15 @@ def test_policy_client_evaluate_returns_partition(policy_client): # --------------------------------------------------------------------------- def test_quota_client_is_tuple_of_client_and_db(quota_client): + """The quota_client fixture yields a client together with the mock database session.""" tc, db = quota_client assert isinstance(db, MagicMock) def test_quota_client_check_allow(quota_client): + """Through the quota_client fixture, /quota/check returns 200 with allow true when the quota + service allows the request. + """ tc, mock_db = quota_client usage = MagicMock(cpu_hours=10.0, gpu_hours=2.0) decision = Decision( @@ -100,6 +116,9 @@ def test_quota_client_check_allow(quota_client): def test_quota_client_check_deny(quota_client): + """/quota/check returns 200 with allow false and a reason mentioning CPU when the quota service + denies the request. + """ tc, mock_db = quota_client usage = MagicMock(cpu_hours=119.0, gpu_hours=0.0) decision = Decision( @@ -124,6 +143,9 @@ def test_quota_client_check_deny(quota_client): def test_quota_client_db_dependency_injected(quota_client): + """The quota route passes the overridden mock database session and the requested user id to + get_or_create_user_usage. + """ tc, mock_db = quota_client usage = MagicMock(cpu_hours=0.0, gpu_hours=0.0) decision = Decision( diff --git a/tests/test_core_gpu_source.py b/tests/test_core_gpu_source.py index e2f8d60..962d741 100644 --- a/tests/test_core_gpu_source.py +++ b/tests/test_core_gpu_source.py @@ -6,6 +6,8 @@ why). Behavior is already exercised indirectly via test_quota_service.py and test_routes_policy.py; this file targets the function directly with the full input space, including edge cases those higher-level tests don't hit. + +Developer: Manish Kumar """ from tests._srcload import load @@ -13,6 +15,7 @@ def test_zero_gpus_needs_no_role(): + """Requesting zero GPUs is allowed for any roles, with reason "no gpu needed".""" ok, reason = gpu.validate_gpu_access([], 0) assert ok is True assert reason == "no gpu needed" @@ -27,17 +30,20 @@ def test_negative_gpus_short_circuits_to_no_gpu_needed(): def test_positive_gpus_without_any_roles_denied(): + """Requesting a GPU with no roles is denied with reason "gpu access denied".""" ok, reason = gpu.validate_gpu_access([], 1) assert ok is False assert reason == "gpu access denied" def test_positive_gpus_with_unrelated_roles_denied(): + """Requesting a GPU with roles that do not include gpu_user is denied.""" ok, _reason = gpu.validate_gpu_access(["researcher", "viewer"], 1) assert ok is False def test_positive_gpus_with_gpu_user_role_allowed(): + """Requesting a GPU with the gpu_user role is allowed, with reason "gpu allowed".""" ok, reason = gpu.validate_gpu_access(["researcher", "gpu_user"], 1) assert ok is True assert reason == "gpu allowed" @@ -50,15 +56,21 @@ def test_role_match_is_exact_not_substring(): def test_role_match_is_case_sensitive(): + """The role "GPU_USER" does not satisfy the gpu_user requirement, so the GPU request is denied. + """ ok, _reason = gpu.validate_gpu_access(["GPU_USER"], 1) assert ok is False def test_large_gpu_request_with_role_allowed(): + """A large GPU request (64 GPUs) is allowed for a gpu_user, since only the role is checked and + not the count. + """ ok, _reason = gpu.validate_gpu_access(["gpu_user"], 64) assert ok is True def test_empty_roles_list_with_zero_gpus_allowed(): + """An empty roles list with zero GPUs is allowed.""" ok, _reason = gpu.validate_gpu_access([], 0) assert ok is True diff --git a/tests/test_core_policies_source.py b/tests/test_core_policies_source.py index 66ca4fb..97cdc29 100644 --- a/tests/test_core_policies_source.py +++ b/tests/test_core_policies_source.py @@ -3,6 +3,8 @@ Loaded via tests/_srcload.py (see that module's docstring) so its lines are measured even where the checked-in .so shadows the .py import. + +Developer: Manish Kumar """ from tests._srcload import load @@ -10,23 +12,31 @@ def test_dgx_partition_denied_without_dgx_access_role(): + """Requesting the dgx-a100 partition without the dgx_access role is denied with reason "dgx + partition denied". + """ ok, reason = policies.validate_partition_access([], "dgx-a100") assert ok is False assert reason == "dgx partition denied" def test_dgx_partition_denied_with_unrelated_roles(): + """Requesting the dgx-a100 partition with roles other than dgx_access is denied.""" ok, _reason = policies.validate_partition_access(["gpu_user", "researcher"], "dgx-a100") assert ok is False def test_dgx_partition_allowed_with_dgx_access_role(): + """Requesting the dgx-a100 partition with the dgx_access role is allowed, with reason "partition + allowed". + """ ok, reason = policies.validate_partition_access(["dgx_access"], "dgx-a100") assert ok is True assert reason == "partition allowed" def test_cpu_partition_allowed_with_no_roles(): + """A CPU partition is allowed without any roles.""" ok, _reason = policies.validate_partition_access([], "cpu") assert ok is True @@ -47,6 +57,7 @@ def test_unknown_partition_name_allowed_by_default(): def test_empty_partition_string_allowed(): + """An empty partition name is allowed.""" ok, _reason = policies.validate_partition_access([], "") assert ok is True diff --git a/tests/test_core_quota_source.py b/tests/test_core_quota_source.py index c90be4d..fe8d922 100644 --- a/tests/test_core_quota_source.py +++ b/tests/test_core_quota_source.py @@ -8,6 +8,8 @@ (Config.DEFAULT_CPU_HOURS / DEFAULT_GPU_HOURS are plain class attributes), so tests patch those attributes directly to pin deterministic limits rather than depending on whatever env vars happen to be set for the process. + +Developer: Manish Kumar """ from unittest.mock import patch @@ -17,6 +19,7 @@ def _patched_limits(cpu_limit, gpu_limit): + """Patch the configured default CPU and GPU hour limits to the given values.""" return patch.multiple( quota.Config, DEFAULT_CPU_HOURS=cpu_limit, @@ -29,6 +32,9 @@ def _patched_limits(cpu_limit, gpu_limit): # --------------------------------------------------------------------------- def test_within_both_limits_allowed(): + """A request within both limits is allowed with reason "quota ok", reporting the remaining CPU + and GPU hours (limit minus current usage). + """ with _patched_limits(100, 20): ok, reason, rem_cpu, rem_gpu = quota.evaluate_quota(10, 5, 5, 5) assert ok is True @@ -47,6 +53,8 @@ def test_request_exactly_equal_to_remaining_cpu_is_allowed(): def test_request_one_over_remaining_cpu_denied(): + """A CPU request one hour over the remaining budget is denied with reason "cpu quota exceeded". + """ with _patched_limits(100, 20): ok, reason, _rem_cpu, _rem_gpu = quota.evaluate_quota(90, 0, 10.0001, 0) assert ok is False @@ -54,6 +62,9 @@ def test_request_one_over_remaining_cpu_denied(): def test_request_exactly_equal_to_remaining_gpu_is_allowed(): + """A GPU request exactly equal to the remaining GPU hours is allowed, and 5 remaining GPU hours + are reported. + """ with _patched_limits(100, 20): ok, _reason, _rem_cpu, rem_gpu = quota.evaluate_quota(0, 15, 0, 5) assert ok is True @@ -61,6 +72,8 @@ def test_request_exactly_equal_to_remaining_gpu_is_allowed(): def test_request_one_over_remaining_gpu_denied(): + """A GPU request one hour over the remaining budget is denied with reason "gpu quota exceeded". + """ with _patched_limits(100, 20): ok, reason, _rem_cpu, _rem_gpu = quota.evaluate_quota(0, 15, 0, 5.0001) assert ok is False @@ -81,6 +94,9 @@ def test_cpu_exceeded_denies_before_checking_gpu(): def test_gpu_exceeded_when_cpu_is_within_limits(): + """When the CPU request fits but the GPU request does not, the request is denied with reason + "gpu quota exceeded". + """ with _patched_limits(100, 10): ok, reason, _rem_cpu, _rem_gpu = quota.evaluate_quota(0, 9, 1, 5) assert ok is False @@ -103,6 +119,7 @@ def test_remaining_hours_reported_even_when_denied(): # --------------------------------------------------------------------------- def test_zero_request_always_allowed_even_at_zero_remaining(): + """A zero request is allowed even when no hours remain, and reports zero remaining.""" with _patched_limits(10, 10): ok, _reason, rem_cpu, rem_gpu = quota.evaluate_quota(10, 10, 0, 0) assert ok is True diff --git a/tests/test_core_scheduler_source.py b/tests/test_core_scheduler_source.py index c13fa21..1737563 100644 --- a/tests/test_core_scheduler_source.py +++ b/tests/test_core_scheduler_source.py @@ -14,6 +14,8 @@ scenarios the code doesn't implement. SchedulerAdapter/SchedulerService are also not wired into any API route (grep of app/api/ finds no reference to either), so there is no HTTP-level test to add for them. + +Developer: Manish Kumar """ import asyncio @@ -23,16 +25,21 @@ def test_get_cluster_load_returns_expected_keys(): + """get_cluster_load returns exactly the keys cpu_load, gpu_load and running_jobs.""" result = asyncio.run(scheduler.SchedulerAdapter().get_cluster_load()) assert set(result.keys()) == {"cpu_load", "gpu_load", "running_jobs"} def test_get_cluster_load_values_are_stable_stub_values(): + """get_cluster_load returns the fixed stub values 0.45 CPU load, 0.60 GPU load and 21 running + jobs. + """ result = asyncio.run(scheduler.SchedulerAdapter().get_cluster_load()) assert result == {"cpu_load": 0.45, "gpu_load": 0.60, "running_jobs": 21} def test_get_cluster_load_is_independent_across_instances(): + """Two SchedulerAdapter instances return equal cluster load.""" a = asyncio.run(scheduler.SchedulerAdapter().get_cluster_load()) b = asyncio.run(scheduler.SchedulerAdapter().get_cluster_load()) assert a == b diff --git a/tests/test_main.py b/tests/test_main.py index aaff553..6cf3493 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,6 +2,8 @@ Tests for app/main.py and app/api/deps.py. app/main.py calls Base.metadata.create_all at import time — we patch it before importing so no MySQL connection is needed. + +Developer: Manish Kumar """ import sys import pytest @@ -25,6 +27,8 @@ def hpc_app(): # --------------------------------------------------------------------------- def test_root_returns_service_info(hpc_app): + """GET / returns 200 with the service name "omnibioai-hpc-policy-engine" and status "running". + """ client = TestClient(hpc_app) response = client.get("/") assert response.status_code == 200 @@ -34,11 +38,13 @@ def test_root_returns_service_info(hpc_app): def test_app_includes_jobs_router(hpc_app): + """The app registers a job evaluation route.""" paths = [r.path for r in hpc_app.routes] assert any("evaluate" in p for p in paths) def test_app_includes_quota_router(hpc_app): + """The app registers a quota route.""" paths = [r.path for r in hpc_app.routes] assert any("quota" in p for p in paths) @@ -48,6 +54,7 @@ def test_app_includes_quota_router(hpc_app): # --------------------------------------------------------------------------- def test_get_db_yields_session(): + """get_db yields the session created by SessionLocal.""" mock_session = MagicMock() with patch("app.api.deps.SessionLocal", return_value=mock_session): from app.api.deps import get_db @@ -57,6 +64,7 @@ def test_get_db_yields_session(): def test_get_db_closes_session_on_exit(): + """get_db closes the session once after its consumer finishes.""" mock_session = MagicMock() with patch("app.api.deps.SessionLocal", return_value=mock_session): from app.api.deps import get_db @@ -70,6 +78,7 @@ def test_get_db_closes_session_on_exit(): def test_get_db_closes_session_on_exception(): + """get_db still closes the session once when its consumer raises.""" mock_session = MagicMock() with patch("app.api.deps.SessionLocal", return_value=mock_session): from app.api.deps import get_db diff --git a/tests/test_main_extra.py b/tests/test_main_extra.py index 0b0b67d..77498f8 100644 --- a/tests/test_main_extra.py +++ b/tests/test_main_extra.py @@ -3,6 +3,8 @@ - GET /health → line 79 - GET /docs → lines 46-70 - GET /swagger-static/ → lines 38-41 + +Developer: Manish Kumar """ import sys import pytest @@ -12,6 +14,7 @@ @pytest.fixture(scope="module") def main_client(): + """Provide a TestClient for app.main with the database table creation patched out.""" sys.modules.pop("app.main", None) from app.db.session import Base with patch.object(Base.metadata, "create_all"): @@ -24,6 +27,7 @@ def main_client(): # --------------------------------------------------------------------------- def test_health_returns_ok(main_client): + """GET /health returns 200 with {"status": "ok"}.""" response = main_client.get("/health") assert response.status_code == 200 assert response.json() == {"status": "ok"} @@ -34,17 +38,20 @@ def test_health_returns_ok(main_client): # --------------------------------------------------------------------------- def test_docs_returns_html(main_client): + """GET /docs returns 200 with an HTML content type.""" response = main_client.get("/docs") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] def test_docs_contains_swagger_ui_bundle(main_client): + """The /docs page references swagger-ui.""" response = main_client.get("/docs") assert "swagger-ui" in response.text.lower() def test_docs_contains_spec_json(main_client): + """The /docs page includes the SwaggerUIBundle initialization.""" response = main_client.get("/docs") assert "SwaggerUIBundle" in response.text @@ -54,16 +61,19 @@ def test_docs_contains_spec_json(main_client): # --------------------------------------------------------------------------- def test_swagger_static_missing_file_returns_404(main_client): + """Requesting a nonexistent file under /swagger-static returns 404.""" response = main_client.get("/swagger-static/definitely-does-not-exist-xyz123.js") assert response.status_code == 404 def test_swagger_static_existing_file_returns_200(main_client): + """Requesting swagger-ui-bundle.js under /swagger-static returns 200.""" # swagger-ui-bundle.js is always present (real package or conftest mock) response = main_client.get("/swagger-static/swagger-ui-bundle.js") assert response.status_code == 200 def test_swagger_static_css_file_returns_200(main_client): + """Requesting swagger-ui.css under /swagger-static returns 200.""" response = main_client.get("/swagger-static/swagger-ui.css") assert response.status_code == 200 diff --git a/tests/test_models_validation.py b/tests/test_models_validation.py index 5b213c5..763fb00 100644 --- a/tests/test_models_validation.py +++ b/tests/test_models_validation.py @@ -5,6 +5,8 @@ they're already traced normally by coverage; this file targets validation behavior -- defaults, required fields, type coercion, and the absence of value constraints -- that the route-level tests don't exhaustively cover. + +Developer: Manish Kumar """ import pytest from pydantic import ValidationError @@ -18,17 +20,20 @@ # --------------------------------------------------------------------------- def test_decision_requires_allow_and_reason(): + """Decision raises ValidationError when allow and reason are not supplied.""" with pytest.raises(ValidationError): Decision() def test_decision_remaining_hours_default_to_zero(): + """Decision defaults remaining CPU and GPU hours to 0.""" d = Decision(allow=True, reason="ok") assert d.remaining_cpu_hours == 0 assert d.remaining_gpu_hours == 0 def test_decision_reason_must_be_string(): + """Decision raises ValidationError for a non-string reason.""" with pytest.raises(ValidationError): Decision(allow=True, reason=123) @@ -38,11 +43,15 @@ def test_decision_reason_must_be_string(): # --------------------------------------------------------------------------- def test_job_request_requires_user_id(): + """JobRequest raises ValidationError when user_id is missing.""" with pytest.raises(ValidationError): JobRequest() def test_job_request_defaults(): + """JobRequest defaults resources to 0, partition to "cpu", roles to an empty list and org_id to + None. + """ r = JobRequest(user_id="u1") assert r.cpu_hours == 0 assert r.gpu_hours == 0 @@ -54,6 +63,7 @@ def test_job_request_defaults(): def test_job_request_numeric_string_coerced_to_float(): + """JobRequest coerces a numeric string such as "4.5" to the float 4.5.""" r = JobRequest(user_id="u1", cpu_hours="4.5") assert r.cpu_hours == 4.5 @@ -75,11 +85,13 @@ def test_job_request_roles_default_is_not_shared_between_instances(): def test_job_request_roles_reject_non_string_items(): + """JobRequest raises ValidationError when roles contains a non-string item.""" with pytest.raises(ValidationError): JobRequest(user_id="u1", roles=[1, 2, 3]) def test_job_request_org_id_accepts_none_explicitly(): + """JobRequest accepts an explicit org_id of None.""" r = JobRequest(user_id="u1", org_id=None) assert r.org_id is None @@ -89,11 +101,13 @@ def test_job_request_org_id_accepts_none_explicitly(): # --------------------------------------------------------------------------- def test_quota_check_requires_user_id(): + """QuotaCheck raises ValidationError when user_id is missing.""" with pytest.raises(ValidationError): QuotaCheck() def test_quota_check_defaults(): + """QuotaCheck defaults hours and GPUs to 0, partition to "cpu" and roles to an empty list.""" q = QuotaCheck(user_id="u1") assert q.cpu_hours == 0 assert q.gpu_hours == 0 @@ -112,10 +126,12 @@ def test_quota_check_has_no_org_id_field(): def test_quota_check_negative_values_accepted_no_lower_bound(): + """QuotaCheck accepts negative resource values because no lower bound is defined.""" q = QuotaCheck(user_id="u1", cpu_hours=-5, gpu_hours=-5, gpus=-5) assert q.cpu_hours == -5 def test_quota_check_roles_reject_non_string_items(): + """QuotaCheck raises ValidationError when roles contains a non-string item.""" with pytest.raises(ValidationError): QuotaCheck(user_id="u1", roles=[{"not": "a string"}]) diff --git a/tests/test_quota_service.py b/tests/test_quota_service.py index 9d17c50..27d2985 100644 --- a/tests/test_quota_service.py +++ b/tests/test_quota_service.py @@ -1,3 +1,9 @@ +"""Unit tests for QuotaService.evaluate as a whole: GPU access by role, DGX +partition access by role, and CPU and GPU quota limits, using mocked usage and +request objects with no database. + +Developer: Manish Kumar +""" import pytest from unittest.mock import MagicMock from app.services.quota_service import QuotaService @@ -6,6 +12,7 @@ def _usage(cpu_hours=0.0, gpu_hours=0.0): + """Build a mock usage record with the given CPU and GPU hours.""" u = MagicMock() u.cpu_hours = cpu_hours u.gpu_hours = gpu_hours @@ -13,6 +20,7 @@ def _usage(cpu_hours=0.0, gpu_hours=0.0): def _request(gpus=0, partition="cpu", cpu_hours=1.0, gpu_hours=0.0): + """Build a mock request with the given GPU count, partition and CPU and GPU hours.""" r = MagicMock() r.gpus = gpus r.partition = partition @@ -26,6 +34,7 @@ def _request(gpus=0, partition="cpu", cpu_hours=1.0, gpu_hours=0.0): # --------------------------------------------------------------------------- def test_no_gpu_needed_always_passes(): + """A request that needs no GPUs is allowed without a GPU role.""" decision = QuotaService.evaluate( usage=_usage(), request=_request(gpus=0), @@ -35,6 +44,7 @@ def test_no_gpu_needed_always_passes(): def test_gpu_request_denied_without_gpu_user_role(): + """A GPU request without the gpu_user role is denied with a reason mentioning gpu.""" decision = QuotaService.evaluate( usage=_usage(), request=_request(gpus=2), @@ -45,6 +55,7 @@ def test_gpu_request_denied_without_gpu_user_role(): def test_gpu_request_allowed_with_gpu_user_role(): + """A GPU request from a caller holding the gpu_user role is allowed.""" decision = QuotaService.evaluate( usage=_usage(), request=_request(gpus=2, gpu_hours=1.0), @@ -58,6 +69,9 @@ def test_gpu_request_allowed_with_gpu_user_role(): # --------------------------------------------------------------------------- def test_dgx_a100_denied_without_dgx_access(): + """A request for the dgx-a100 partition without the dgx_access role is denied with a reason + mentioning dgx. + """ decision = QuotaService.evaluate( usage=_usage(), request=_request(partition="dgx-a100"), @@ -68,6 +82,7 @@ def test_dgx_a100_denied_without_dgx_access(): def test_dgx_a100_allowed_with_dgx_access(): + """A request for the dgx-a100 partition with the dgx_access role is allowed.""" decision = QuotaService.evaluate( usage=_usage(), request=_request(gpus=0, partition="dgx-a100"), @@ -77,6 +92,7 @@ def test_dgx_a100_allowed_with_dgx_access(): def test_standard_partition_requires_no_special_role(): + """A standard partition is allowed without any special role.""" decision = QuotaService.evaluate( usage=_usage(), request=_request(partition="cpu"), @@ -90,6 +106,7 @@ def test_standard_partition_requires_no_special_role(): # --------------------------------------------------------------------------- def test_cpu_quota_exceeded_denied(): + """A request that exceeds the remaining CPU hours is denied with a reason mentioning cpu.""" decision = QuotaService.evaluate( usage=_usage(cpu_hours=119.0), # 1 hour remaining request=_request(cpu_hours=2.0), # requesting 2 @@ -100,6 +117,7 @@ def test_cpu_quota_exceeded_denied(): def test_gpu_quota_exceeded_denied(): + """A request that exceeds the remaining GPU hours is denied with a reason mentioning gpu.""" decision = QuotaService.evaluate( usage=_usage(gpu_hours=23.5), # 0.5 hours remaining request=_request(gpus=1, gpu_hours=1.0, partition="cpu"), @@ -110,6 +128,7 @@ def test_gpu_quota_exceeded_denied(): def test_quota_ok_within_limits(): + """A request within both quota limits is allowed with reason "quota ok".""" decision = QuotaService.evaluate( usage=_usage(cpu_hours=10.0, gpu_hours=5.0), request=_request(gpus=1, cpu_hours=5.0, gpu_hours=1.0), @@ -120,6 +139,9 @@ def test_quota_ok_within_limits(): def test_decision_includes_remaining_hours(): + """The decision reports the remaining hours as the limit minus current usage (100 CPU and 20 GPU + hours), not minus the requested hours. + """ # evaluate_quota returns (limit - current_used), not (limit - used - requested) decision = QuotaService.evaluate( usage=_usage(cpu_hours=20.0, gpu_hours=4.0), @@ -132,6 +154,7 @@ def test_decision_includes_remaining_hours(): def test_zero_request_always_passes(): + """A zero-hour request is allowed.""" decision = QuotaService.evaluate( usage=_usage(cpu_hours=0.0, gpu_hours=0.0), request=_request(cpu_hours=0.0, gpu_hours=0.0), diff --git a/tests/test_quota_service_source.py b/tests/test_quota_service_source.py index bb83ff7..63f3300 100644 --- a/tests/test_quota_service_source.py +++ b/tests/test_quota_service_source.py @@ -13,6 +13,8 @@ Scenarios mirror tests/test_quota_service.py (kept intact, not modified); this file adds check-ordering/precedence coverage that file doesn't target. + +Developer: Manish Kumar """ from unittest.mock import MagicMock, patch @@ -23,6 +25,7 @@ def _usage(cpu_hours=0.0, gpu_hours=0.0): + """Build a mock usage record with the given CPU and GPU hours.""" u = MagicMock() u.cpu_hours = cpu_hours u.gpu_hours = gpu_hours @@ -30,6 +33,7 @@ def _usage(cpu_hours=0.0, gpu_hours=0.0): def _request(gpus=0, partition="cpu", cpu_hours=1.0, gpu_hours=0.0): + """Build a mock request with the given GPU count, partition and CPU and GPU hours.""" r = MagicMock() r.gpus = gpus r.partition = partition @@ -68,6 +72,9 @@ def test_partition_denied_short_circuits_before_quota_check(): def test_all_checks_pass_falls_through_to_quota_result(): + """When the GPU and partition checks pass, QuotaService returns the quota evaluation's allowed + decision with reason "quota ok". + """ decision = QuotaService.evaluate( usage=_usage(cpu_hours=10.0, gpu_hours=5.0), request=_request(gpus=1, partition="cpu", cpu_hours=5.0, gpu_hours=1.0), @@ -92,6 +99,9 @@ def test_denied_decision_has_zero_remaining_hours_defaults(): def test_quota_exceeded_after_passing_gpu_and_partition_checks(): + """When the GPU and partition checks pass but the CPU request exceeds the quota, the decision is + denied with reason "cpu quota exceeded". + """ decision = QuotaService.evaluate( usage=_usage(cpu_hours=119.5), request=_request(gpus=0, partition="cpu", cpu_hours=1.0), @@ -102,6 +112,7 @@ def test_quota_exceeded_after_passing_gpu_and_partition_checks(): def test_returns_decision_instance(): + """QuotaService.evaluate returns a Decision instance.""" from app.models.decision import Decision decision = QuotaService.evaluate( usage=_usage(), diff --git a/tests/test_routes_policy.py b/tests/test_routes_policy.py index ed579b3..6369d64 100644 --- a/tests/test_routes_policy.py +++ b/tests/test_routes_policy.py @@ -1,3 +1,9 @@ +"""HTTP-level tests for POST /jobs/evaluate: job approval for ordinary requests, +the GPU and DGX role checks driven by the roles supplied in the request, the +returned partition, and 422 validation of the request body. + +Developer: Manish Kumar +""" import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -6,6 +12,7 @@ @pytest.fixture def client(): + """Provide a TestClient for a FastAPI app that includes only the policy router.""" app = FastAPI() app.include_router(router) return TestClient(app) @@ -16,6 +23,7 @@ def client(): # --------------------------------------------------------------------------- def test_evaluate_job_allow_default(client): + """An ordinary CPU job request is allowed with reason "job approved".""" response = client.post("/jobs/evaluate", json={ "user_id": "u1", "cpu_hours": 4.0, @@ -31,6 +39,7 @@ def test_evaluate_job_allow_default(client): def test_evaluate_job_returns_partition(client): + """The response echoes the requested partition ("gpu").""" response = client.post("/jobs/evaluate", json={ "user_id": "u1", "partition": "gpu", @@ -40,6 +49,7 @@ def test_evaluate_job_returns_partition(client): def test_evaluate_job_with_gpu_partition_and_gpu_user_role(client): + """A GPU job request whose roles include gpu_user is allowed.""" response = client.post("/jobs/evaluate", json={ "user_id": "u2", "gpus": 4, @@ -69,6 +79,7 @@ def test_evaluate_job_with_gpu_partition_denied_without_gpu_user_role(client): def test_evaluate_job_default_partition_is_cpu(client): + """When no partition is given the response reports the "cpu" partition.""" response = client.post("/jobs/evaluate", json={"user_id": "u3"}) assert response.status_code == 200 assert response.json()["partition"] == "cpu" @@ -90,6 +101,9 @@ def test_evaluate_job_dgx_partition_denied_without_roles(client): def test_evaluate_job_dgx_partition_denied_without_dgx_access_role(client): + """A dgx-a100 request whose roles include gpu_user but not dgx_access is denied with a reason + mentioning dgx. + """ response = client.post("/jobs/evaluate", json={ "user_id": "u4", "partition": "dgx-a100", @@ -103,6 +117,7 @@ def test_evaluate_job_dgx_partition_denied_without_dgx_access_role(client): def test_evaluate_job_dgx_partition_allowed_with_both_roles(client): + """A dgx-a100 request whose roles include both gpu_user and dgx_access is allowed.""" response = client.post("/jobs/evaluate", json={ "user_id": "u4", "partition": "dgx-a100", @@ -114,5 +129,6 @@ def test_evaluate_job_dgx_partition_allowed_with_both_roles(client): def test_evaluate_job_missing_user_id_returns_422(client): + """A job evaluation request without a user_id is rejected with 422.""" response = client.post("/jobs/evaluate", json={"cpu_hours": 4.0}) assert response.status_code == 422 diff --git a/tests/test_routes_policy_source.py b/tests/test_routes_policy_source.py index 87785d9..b76af63 100644 --- a/tests/test_routes_policy_source.py +++ b/tests/test_routes_policy_source.py @@ -9,6 +9,8 @@ This file adds validation/edge-case coverage that file doesn't target: negative resource values, malformed roles, unknown/extra fields, malformed JSON bodies, and the org_id no-op characterization. + +Developer: Manish Kumar """ import pytest from fastapi import FastAPI @@ -21,6 +23,9 @@ @pytest.fixture def client(): + """Provide a TestClient for a FastAPI app that includes the policy router loaded directly from + its .py source. + """ app = FastAPI() app.include_router(routes_policy_mod.router) return TestClient(app) @@ -31,12 +36,16 @@ def client(): # --------------------------------------------------------------------------- def test_default_request_approved(client): + """A minimal request with only a user_id is approved and returns exactly allow true, reason "job + approved" and partition "cpu". + """ response = client.post("/jobs/evaluate", json={"user_id": "u1"}) assert response.status_code == 200 assert response.json() == {"allow": True, "reason": "job approved", "partition": "cpu"} def test_gpu_request_without_role_denied(client): + """A request for GPUs without the gpu_user role is denied with reason "gpu access denied".""" response = client.post("/jobs/evaluate", json={"user_id": "u1", "gpus": 2}) data = response.json() assert data["allow"] is False @@ -44,6 +53,9 @@ def test_gpu_request_without_role_denied(client): def test_dgx_request_with_gpu_role_but_no_dgx_role_denied(client): + """A dgx-a100 request whose roles include gpu_user but not dgx_access is denied with reason "dgx + partition denied". + """ response = client.post("/jobs/evaluate", json={ "user_id": "u1", "gpus": 1, "partition": "dgx-a100", "roles": ["gpu_user"], }) @@ -53,6 +65,7 @@ def test_dgx_request_with_gpu_role_but_no_dgx_role_denied(client): def test_dgx_request_with_both_roles_allowed(client): + """A dgx-a100 request whose roles include gpu_user and dgx_access is allowed.""" response = client.post("/jobs/evaluate", json={ "user_id": "u1", "gpus": 1, "partition": "dgx-a100", "roles": ["gpu_user", "dgx_access"], @@ -65,16 +78,20 @@ def test_dgx_request_with_both_roles_allowed(client): # --------------------------------------------------------------------------- def test_missing_user_id_rejected(client): + """With the router loaded from source, a job evaluation without a user_id is rejected with 422. + """ response = client.post("/jobs/evaluate", json={"gpus": 1}) assert response.status_code == 422 def test_empty_body_rejected(client): + """An empty JSON body is rejected with 422.""" response = client.post("/jobs/evaluate", json={}) assert response.status_code == 422 def test_malformed_json_body_rejected(client): + """A request body that is not valid JSON is rejected with 422.""" response = client.post( "/jobs/evaluate", content="{not valid json", @@ -90,6 +107,7 @@ def test_roles_as_wrong_type_rejected(client): def test_gpus_as_wrong_type_rejected(client): + """A non-numeric gpus value is rejected with 422.""" response = client.post("/jobs/evaluate", json={"user_id": "u1", "gpus": "not-a-number"}) assert response.status_code == 422 @@ -123,6 +141,9 @@ def test_unknown_extra_fields_are_ignored(client): def test_duplicate_role_entries_do_not_change_outcome(client): + """Repeating a role in the request does not change the outcome: a GPU request with gpu_user + listed three times is allowed. + """ response = client.post("/jobs/evaluate", json={ "user_id": "u1", "gpus": 1, "roles": ["gpu_user", "gpu_user", "gpu_user"], }) @@ -146,6 +167,7 @@ def test_org_id_accepted_but_has_no_effect_on_decision(client): def test_missing_org_id_defaults_to_none_and_still_evaluates(client): + """A request without an org_id is still evaluated and returns 200.""" response = client.post("/jobs/evaluate", json={"user_id": "u1"}) assert response.status_code == 200 @@ -158,5 +180,6 @@ def test_empty_user_id_string_is_accepted_by_validation(client): def test_user_id_wrong_type_rejected(client): + """A numeric user_id is rejected with 422.""" response = client.post("/jobs/evaluate", json={"user_id": 12345}) assert response.status_code == 422 diff --git a/tests/test_routes_quota.py b/tests/test_routes_quota.py index f9194c1..c062b3e 100644 --- a/tests/test_routes_quota.py +++ b/tests/test_routes_quota.py @@ -10,6 +10,8 @@ logic (covered by test_quota_service.py) -- but see test_quota_check_passes_caller_roles_not_hardcoded below for the regression test locking in the hardcoded-roles fix. + +Developer: Manish Kumar """ import pytest from unittest.mock import MagicMock, patch @@ -20,17 +22,20 @@ def _allow(**kwargs): + """Build an allowing Decision with quota-ok defaults and optional field overrides.""" defaults = dict(allow=True, reason="quota ok", remaining_cpu_hours=100.0, remaining_gpu_hours=20.0) defaults.update(kwargs) return Decision(**defaults) def _deny(reason="cpu quota exceeded"): + """Build a denying Decision with the given reason.""" return Decision(allow=False, reason=reason, remaining_cpu_hours=2.0, remaining_gpu_hours=20.0) @pytest.fixture def quota_app(): + """Provide a FastAPI app that includes only the quota router.""" from app.api.routes_quota import router app = FastAPI() app.include_router(router) @@ -39,6 +44,9 @@ def quota_app(): @pytest.fixture def client(quota_app): + """Provide a TestClient for the quota app with get_db overridden by a mock session, together + with that session. + """ mock_db = MagicMock() quota_app.dependency_overrides[get_db] = lambda: mock_db return TestClient(quota_app), mock_db @@ -49,6 +57,7 @@ def client(quota_app): # --------------------------------------------------------------------------- def test_quota_check_allow_within_limits(client): + """/quota/check returns 200 with allow true when the quota service allows the request.""" tc, mock_db = client usage = MagicMock(cpu_hours=10.0, gpu_hours=2.0, jobs_running=0) @@ -69,6 +78,9 @@ def test_quota_check_allow_within_limits(client): def test_quota_check_deny_cpu_exceeded(client): + """/quota/check returns 200 with allow false and a reason mentioning cpu when the quota service + denies for CPU. + """ tc, mock_db = client usage = MagicMock(cpu_hours=118.0, gpu_hours=0.0) @@ -91,6 +103,9 @@ def test_quota_check_deny_cpu_exceeded(client): def test_quota_check_deny_gpu_exceeded(client): + """/quota/check returns 200 with allow false and a reason mentioning gpu when the quota service + denies for GPU. + """ tc, mock_db = client usage = MagicMock(cpu_hours=0.0, gpu_hours=23.0) @@ -112,6 +127,7 @@ def test_quota_check_deny_gpu_exceeded(client): def test_quota_check_returns_remaining_hours(client): + """/quota/check returns the remaining CPU hours reported by the quota service.""" tc, mock_db = client usage = MagicMock(cpu_hours=20.0, gpu_hours=4.0) @@ -133,6 +149,9 @@ def test_quota_check_returns_remaining_hours(client): def test_quota_check_new_user_creation(client): + """/quota/check looks up the user's usage through get_or_create_user_usage with the injected + session and the requested user id. + """ tc, mock_db = client new_usage = MagicMock(cpu_hours=0.0, gpu_hours=0.0) @@ -153,6 +172,9 @@ def test_quota_check_new_user_creation(client): def test_quota_check_passes_db_to_usage_service(client): + """The session that get_or_create_user_usage receives is the one provided by the get_db + dependency. + """ tc, mock_db = client usage = MagicMock(cpu_hours=0.0, gpu_hours=0.0) @@ -190,6 +212,8 @@ def test_quota_check_passes_caller_roles_not_hardcoded(client): def test_quota_check_defaults_to_no_roles_when_unsupplied(client): + """When the request supplies no roles, the quota service is evaluated with an empty roles list. + """ tc, mock_db = client usage = MagicMock(cpu_hours=0.0, gpu_hours=0.0) diff --git a/tests/test_routes_quota_source.py b/tests/test_routes_quota_source.py index 0a7d271..2f95f90 100644 --- a/tests/test_routes_quota_source.py +++ b/tests/test_routes_quota_source.py @@ -15,6 +15,8 @@ test_quota_service_source.py and test_core_quota_source.py). A couple of tests run the real QuotaService end to end to prove the route's roles/ partition forwarding actually reaches policy-critical decisions. + +Developer: Manish Kumar """ from unittest.mock import MagicMock, patch @@ -29,6 +31,7 @@ def _allow(**kwargs): + """Build an allowing Decision with quota-ok defaults.""" defaults = {"allow": True, "reason": "quota ok", "remaining_cpu_hours": 100.0, "remaining_gpu_hours": 20.0} defaults.update(kwargs) return Decision(**defaults) @@ -36,6 +39,9 @@ def _allow(**kwargs): @pytest.fixture def client(): + """Provide a TestClient for the quota router loaded directly from its .py source, with get_db + overridden by a mock session, together with that session. + """ app = FastAPI() app.include_router(routes_quota_mod.router) mock_db = MagicMock() @@ -48,6 +54,9 @@ def client(): # --------------------------------------------------------------------------- def test_quota_check_allow(client): + """With the router loaded from source, /quota/check returns 200 with allow true when the quota + service allows the request. + """ tc, _mock_db = client usage = MagicMock(cpu_hours=10.0, gpu_hours=2.0) with patch.object(routes_quota_mod.UsageService, "get_or_create_user_usage", return_value=usage), \ @@ -58,6 +67,7 @@ def test_quota_check_allow(client): def test_quota_check_deny(client): + """/quota/check returns allow false when the quota service denies the request.""" tc, _mock_db = client usage = MagicMock(cpu_hours=119.0, gpu_hours=0.0) denied = Decision(allow=False, reason="cpu quota exceeded", remaining_cpu_hours=1.0, remaining_gpu_hours=24.0) @@ -68,6 +78,9 @@ def test_quota_check_deny(client): def test_roles_forwarded_to_quota_service_not_hardcoded(client): + """The roles supplied in the request (viewer) are forwarded to QuotaService.evaluate instead of + a hardcoded role list. + """ tc, _mock_db = client usage = MagicMock(cpu_hours=0.0, gpu_hours=0.0) with patch.object(routes_quota_mod.UsageService, "get_or_create_user_usage", return_value=usage), \ @@ -77,6 +90,7 @@ def test_roles_forwarded_to_quota_service_not_hardcoded(client): def test_roles_default_to_empty_list_when_unsupplied(client): + """When the request supplies no roles, QuotaService.evaluate receives an empty roles list.""" tc, _mock_db = client usage = MagicMock(cpu_hours=0.0, gpu_hours=0.0) with patch.object(routes_quota_mod.UsageService, "get_or_create_user_usage", return_value=usage), \ @@ -90,6 +104,9 @@ def test_roles_default_to_empty_list_when_unsupplied(client): # --------------------------------------------------------------------------- def test_end_to_end_gpu_request_denied_without_role(client): + """With the real QuotaService, a GPU request without the gpu_user role is denied with reason + "gpu access denied". + """ tc, _mock_db = client usage = MagicMock(cpu_hours=0.0, gpu_hours=0.0) with patch.object(routes_quota_mod.UsageService, "get_or_create_user_usage", return_value=usage): @@ -100,6 +117,7 @@ def test_end_to_end_gpu_request_denied_without_role(client): def test_end_to_end_dgx_partition_allowed_with_correct_roles(client): + """With the real QuotaService, a dgx-a100 request with the required roles is allowed.""" tc, _mock_db = client usage = MagicMock(cpu_hours=0.0, gpu_hours=0.0) with patch.object(routes_quota_mod.UsageService, "get_or_create_user_usage", return_value=usage): @@ -115,12 +133,14 @@ def test_end_to_end_dgx_partition_allowed_with_correct_roles(client): # --------------------------------------------------------------------------- def test_missing_user_id_rejected(client): + """A quota check without a user_id is rejected with 422.""" tc, _mock_db = client response = tc.post("/quota/check", json={"cpu_hours": 1.0}) assert response.status_code == 422 def test_malformed_json_body_rejected(client): + """A quota check whose body is not valid JSON is rejected with 422.""" tc, _mock_db = client response = tc.post( "/quota/check", @@ -131,6 +151,7 @@ def test_malformed_json_body_rejected(client): def test_roles_wrong_type_rejected(client): + """A quota check whose roles is a bare string instead of a list is rejected with 422.""" tc, _mock_db = client response = tc.post("/quota/check", json={"user_id": "u7", "roles": "gpu_user"}) assert response.status_code == 422 @@ -148,6 +169,9 @@ def test_negative_cpu_hours_accepted_without_validation(client): def test_db_session_from_dependency_override_is_used(client): + """The session passed to get_or_create_user_usage is the one supplied by the get_db dependency + override. + """ tc, mock_db = client usage = MagicMock(cpu_hours=0.0, gpu_hours=0.0) with patch.object(routes_quota_mod.UsageService, "get_or_create_user_usage", return_value=usage) as mock_svc, \ diff --git a/tests/test_scheduler_service_source.py b/tests/test_scheduler_service_source.py index 2797c11..51cf14f 100644 --- a/tests/test_scheduler_service_source.py +++ b/tests/test_scheduler_service_source.py @@ -8,6 +8,8 @@ it is not reachable through any HTTP endpoint -- these are plain unit tests of the class itself. See test_core_scheduler_source.py for why there is no scheduler-failure path to test (SchedulerAdapter is a hardcoded stub). + +Developer: Manish Kumar """ import asyncio @@ -18,17 +20,20 @@ def test_init_creates_a_scheduler_adapter(): + """A new SchedulerService holds a SchedulerAdapter as its scheduler.""" svc = SchedulerService() assert svc.scheduler.__class__.__name__ == "SchedulerAdapter" def test_cluster_status_delegates_to_adapter(): + """cluster_status returns the adapter's cluster load values.""" svc = SchedulerService() result = asyncio.run(svc.cluster_status()) assert result == {"cpu_load": 0.45, "gpu_load": 0.60, "running_jobs": 21} def test_each_instance_gets_its_own_adapter(): + """Each SchedulerService instance creates its own SchedulerAdapter.""" a = SchedulerService() b = SchedulerService() assert a.scheduler is not b.scheduler diff --git a/tests/test_setup_module.py b/tests/test_setup_module.py index df1f7b7..d559828 100644 --- a/tests/test_setup_module.py +++ b/tests/test_setup_module.py @@ -6,6 +6,8 @@ def make_extensions, and the setup() call (which calls make_extensions with the real EXTENSIONS list, covering the "file exists" branch). Explicit tests cover the "file not found" branch (warning + skip). + +Developer: Manish Kumar """ import os import sys @@ -37,6 +39,7 @@ def _load_setup(): @pytest.fixture(scope="module") def setup_mod(): + """Load setup.py with Cython mocked and provide the resulting module.""" return _load_setup() @@ -45,15 +48,18 @@ def setup_mod(): # --------------------------------------------------------------------------- def test_setup_module_has_make_extensions(setup_mod): + """setup.py exposes a callable make_extensions.""" assert callable(setup_mod.make_extensions) def test_extensions_list_is_nonempty(setup_mod): + """setup.py defines a non-empty EXTENSIONS list.""" assert isinstance(setup_mod.EXTENSIONS, list) assert len(setup_mod.EXTENSIONS) > 0 def test_extensions_entries_are_python_files(setup_mod): + """Every EXTENSIONS entry is a path to a .py file.""" for path in setup_mod.EXTENSIONS: assert path.endswith(".py"), f"Expected .py, got: {path}" @@ -63,6 +69,7 @@ def test_extensions_entries_are_python_files(setup_mod): # --------------------------------------------------------------------------- def test_make_extensions_returns_extension_for_existing_file(setup_mod, tmp_path): + """make_extensions returns one extension for an existing source file.""" src = tmp_path / "mymod.py" src.write_text("x = 1") result = setup_mod.make_extensions([str(src)]) @@ -70,6 +77,7 @@ def test_make_extensions_returns_extension_for_existing_file(setup_mod, tmp_path def test_make_extensions_module_name_strips_py_suffix(setup_mod, tmp_path): + """The extension created for a .py file has a module name that does not end in .py.""" src = tmp_path / "alpha.py" src.write_text("pass") result = setup_mod.make_extensions([str(src)]) @@ -84,6 +92,9 @@ def test_make_extensions_module_name_strips_py_suffix(setup_mod, tmp_path): # --------------------------------------------------------------------------- def test_make_extensions_skips_missing_file(setup_mod, capsys): + """make_extensions skips a source path that does not exist, returning an empty list and printing + a WARNING. + """ result = setup_mod.make_extensions(["this_path_does_not_exist_xyz.py"]) assert result == [] captured = capsys.readouterr() @@ -91,6 +102,7 @@ def test_make_extensions_skips_missing_file(setup_mod, capsys): def test_make_extensions_warning_contains_path(setup_mod, capsys): + """The warning for a missing source file includes that file's path.""" missing = "no_such_file_abc.py" setup_mod.make_extensions([missing]) captured = capsys.readouterr() @@ -102,6 +114,9 @@ def test_make_extensions_warning_contains_path(setup_mod, capsys): # --------------------------------------------------------------------------- def test_make_extensions_mixed_existing_and_missing(setup_mod, tmp_path, capsys): + """With one existing and one missing file, make_extensions returns only the existing one and + prints a WARNING for the missing one. + """ existing = tmp_path / "real.py" existing.write_text("pass") result = setup_mod.make_extensions([str(existing), "nonexistent_file.py"]) @@ -111,5 +126,6 @@ def test_make_extensions_mixed_existing_and_missing(setup_mod, tmp_path, capsys) def test_make_extensions_empty_list(setup_mod): + """make_extensions returns an empty list for an empty input list.""" result = setup_mod.make_extensions([]) assert result == [] diff --git a/tests/test_usage_service.py b/tests/test_usage_service.py index 4934bb7..99756e5 100644 --- a/tests/test_usage_service.py +++ b/tests/test_usage_service.py @@ -1,3 +1,9 @@ +"""Unit tests for UsageService.get_or_create_user_usage against a mocked database +session: an existing usage record is returned as-is, and a missing one is +created with zero usage, committed and refreshed. + +Developer: Manish Kumar +""" import pytest from unittest.mock import MagicMock, patch, call from app.services.usage_service import UsageService @@ -19,6 +25,7 @@ def _make_db(existing_record=None): # --------------------------------------------------------------------------- def test_returns_existing_record_if_found(): + """An existing usage record is returned without adding to or committing the session.""" record = MagicMock() record.user_id = "u1" db = _make_db(existing_record=record) @@ -31,6 +38,7 @@ def test_returns_existing_record_if_found(): def test_creates_new_record_if_not_found(): + """When no record exists, one is added, committed and refreshed once each.""" db = _make_db(existing_record=None) result = UsageService.get_or_create_user_usage(db, "new-user") @@ -41,6 +49,9 @@ def test_creates_new_record_if_not_found(): def test_new_record_has_zero_usage(): + """A newly created usage record carries the requested user id and zero CPU hours, GPU hours and + running jobs. + """ captured = [] def capture_add(record): @@ -60,6 +71,9 @@ def capture_add(record): def test_returns_refreshed_record_for_new_user(): + """The new record is refreshed after creation, and db.refresh is called once with the returned + record. + """ db = _make_db(existing_record=None) refreshed = MagicMock() db.refresh.side_effect = lambda r: setattr(r, "_refreshed", True) @@ -70,6 +84,7 @@ def test_returns_refreshed_record_for_new_user(): def test_queries_correct_user_id(): + """get_or_create_user_usage queries the UsageRecord table.""" from app.db.models import UsageRecord record = MagicMock() db = _make_db(existing_record=record)