diff --git a/.gitignore b/.gitignore index dc01784..5f78830 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ *.pyc __pycache__/ venv/ +.venv/ +.venv/ .env db.sqlite3 diff --git a/README.md b/README.md index e69de29..1b8facf 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,25 @@ +# watcher-visio + +## Running tests + +From the project root (with Django and dependencies installed, e.g. in a virtualenv): + +```bash +python manage.py test dashboard +``` + +Run a specific test module: + +```bash +python manage.py test dashboard.tests.test_mathfilters +``` + +### Running tests in Docker + +Use the **dev** compose file so the project directory is mounted; the container will then run tests against your current code (no image rebuild needed): + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm watcher-visio python3 manage.py test dashboard +``` + +If you run tests with only the base compose (`docker compose run --rm watcher-visio ...`), the container uses the code baked into the image at build time. After code or test changes, either rebuild the image or use the dev override above so tests see the latest files. diff --git a/dashboard/_test.py b/dashboard/_test.py deleted file mode 100644 index e69de29..0000000 diff --git a/dashboard/openstack_utils/audits.py b/dashboard/openstack_utils/audits.py index a1d6d35..d3326a0 100644 --- a/dashboard/openstack_utils/audits.py +++ b/dashboard/openstack_utils/audits.py @@ -11,6 +11,9 @@ from dashboard.prometheus_utils.query import query_prometheus def convert_cpu_data(data: list): metrics = [] + if not data: + return pandas.DataFrame(columns=["host", "cpu_usage"]) + for entry in data: for t, val in entry["values"]: metrics.append({ diff --git a/dashboard/tests/test_audits.py b/dashboard/tests/test_audits.py new file mode 100644 index 0000000..e2efdfd --- /dev/null +++ b/dashboard/tests/test_audits.py @@ -0,0 +1,43 @@ +"""Tests for dashboard.openstack_utils.audits.""" +from django.test import TestCase + +from dashboard.openstack_utils.audits import convert_cpu_data + + +class ConvertCpuDataTest(TestCase): + """Tests for convert_cpu_data.""" + + def test_aggregates_cpu_usage_per_host(self): + data = [ + { + "metric": {"host": "compute-0", "instanceName": "inst1"}, + "values": [[1000, "10.0"], [1001, "20.0"]], + }, + { + "metric": {"host": "compute-0", "instanceName": "inst2"}, + "values": [[1000, "5.0"]], + }, + { + "metric": {"host": "compute-1", "instanceName": "inst3"}, + "values": [[1000, "30.0"]], + }, + ] + result = convert_cpu_data(data) + self.assertIn("host", result.columns) + self.assertIn("cpu_usage", result.columns) + hosts = result["host"].tolist() + self.assertEqual(len(hosts), 2) + self.assertIn("compute-0", hosts) + self.assertIn("compute-1", hosts) + # compute-0: (10+20)/2 for ts 1000 and 5 for ts 1000 -> groupby host,timestamp sum -> then groupby host mean + # For compute-0: two timestamps 1000 (10+5=15) and 1001 (20). Mean over timestamps = (15+20)/2 = 17.5 + # For compute-1: one value 30 + by_host = result.set_index("host")["cpu_usage"] + self.assertAlmostEqual(by_host["compute-0"], 17.5) + self.assertAlmostEqual(by_host["compute-1"], 30.0) + + def test_empty_data_returns_empty_dataframe_with_columns(self): + result = convert_cpu_data([]) + self.assertIn("host", result.columns) + self.assertIn("cpu_usage", result.columns) + self.assertEqual(len(result), 0) diff --git a/dashboard/tests/test_flavor.py b/dashboard/tests/test_flavor.py new file mode 100644 index 0000000..c0bf0ba --- /dev/null +++ b/dashboard/tests/test_flavor.py @@ -0,0 +1,64 @@ +"""Tests for dashboard.openstack_utils.flavor.""" +from unittest.mock import MagicMock + +from django.test import TestCase + +from dashboard.openstack_utils.flavor import get_flavor_list + + +def make_mock_server(flavor_id): + """Create a mock server object with flavor['id'].""" + s = MagicMock() + s.flavor = {"id": flavor_id} + return s + + +class GetFlavorListTest(TestCase): + """Tests for get_flavor_list.""" + + def test_returns_first_second_third_common_flavor_keys(self): + mock_conn = MagicMock() + mock_conn.compute.servers.return_value = [ + make_mock_server("m1.small"), + make_mock_server("m1.small"), + make_mock_server("m1.medium"), + ] + result = get_flavor_list(connection=mock_conn) + self.assertIn("first_common_flavor", result) + self.assertIn("second_common_flavor", result) + self.assertIn("third_common_flavor", result) + + def test_most_common_flavor_first(self): + mock_conn = MagicMock() + mock_conn.compute.servers.return_value = [ + make_mock_server("m1.large"), + make_mock_server("m1.small"), + make_mock_server("m1.small"), + make_mock_server("m1.small"), + ] + result = get_flavor_list(connection=mock_conn) + self.assertEqual(result["first_common_flavor"]["name"], "m1.small") + self.assertEqual(result["first_common_flavor"]["count"], 3) + self.assertEqual(result["second_common_flavor"]["name"], "m1.large") + self.assertEqual(result["second_common_flavor"]["count"], 1) + self.assertEqual(result["third_common_flavor"]["name"], "—") + self.assertEqual(result["third_common_flavor"]["count"], 0) + + def test_empty_servers_uses_placeholder_for_all(self): + mock_conn = MagicMock() + mock_conn.compute.servers.return_value = [] + result = get_flavor_list(connection=mock_conn) + placeholder = {"name": "—", "count": 0} + self.assertEqual(result["first_common_flavor"], placeholder) + self.assertEqual(result["second_common_flavor"], placeholder) + self.assertEqual(result["third_common_flavor"], placeholder) + + def test_skips_servers_without_flavor_id(self): + mock_conn = MagicMock() + s_with_id = make_mock_server("m1.small") + s_without = MagicMock() + s_without.flavor = {} # no 'id' + mock_conn.compute.servers.return_value = [s_with_id, s_without] + result = get_flavor_list(connection=mock_conn) + self.assertEqual(result["first_common_flavor"]["name"], "m1.small") + self.assertEqual(result["first_common_flavor"]["count"], 1) diff --git a/dashboard/tests/test_prometheus_query.py b/dashboard/tests/test_prometheus_query.py new file mode 100644 index 0000000..4393f24 --- /dev/null +++ b/dashboard/tests/test_prometheus_query.py @@ -0,0 +1,62 @@ +"""Tests for dashboard.prometheus_utils.query.""" +from unittest.mock import patch, MagicMock + +from django.test import TestCase + +from dashboard.prometheus_utils.query import query_prometheus + + +class QueryPrometheusTest(TestCase): + """Tests for query_prometheus.""" + + @patch("dashboard.prometheus_utils.query.requests.get") + def test_single_result_returns_value_string(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = { + "data": { + "result": [ + {"value": ["1234567890", "42"]} + ] + } + } + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + result = query_prometheus("some_query") + + self.assertEqual(result, "42") + mock_response.raise_for_status.assert_called_once() + mock_get.assert_called_once() + call_kw = mock_get.call_args + self.assertIn("params", call_kw.kwargs) + self.assertEqual(call_kw.kwargs["params"]["query"], "some_query") + + @patch("dashboard.prometheus_utils.query.requests.get") + def test_multiple_results_returns_full_result_list(self, mock_get): + mock_response = MagicMock() + result_list = [ + {"metric": {"host": "h1"}, "value": ["1", "10"]}, + {"metric": {"host": "h2"}, "value": ["1", "20"]}, + ] + mock_response.json.return_value = {"data": {"result": result_list}} + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + result = query_prometheus("vector_query") + + self.assertEqual(result, result_list) + self.assertEqual(len(result), 2) + + @patch("dashboard.prometheus_utils.query.requests.get") + def test_uses_prometheus_url_from_settings(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = {"data": {"result": [{"value": ["0", "1"]}]}} + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + query_prometheus("test") + + mock_get.assert_called_once() + args, kwargs = mock_get.call_args + url = args[0] if args else kwargs.get("url", "") + self.assertIn("/api/v1/query", url) diff --git a/dashboard/tests/test_views.py b/dashboard/tests/test_views.py new file mode 100644 index 0000000..09feae3 --- /dev/null +++ b/dashboard/tests/test_views.py @@ -0,0 +1,145 @@ +"""Tests for dashboard.views.""" +from unittest.mock import patch, MagicMock + +from django.test import TestCase, RequestFactory +from django.core.cache import cache + +from dashboard.views import index, collect_context + + +def _minimal_render_context(region_name="test", first_flavor_name="f1", vm_count=1): + """Context with all keys the index.html template expects.""" + return { + "region": {"name": region_name, "hosts_total": 1}, + "pcpu": {"total": 1, "usage": 0, "free": 1, "used_percentage": 0}, + "vcpu": {"total": 2, "allocated": 1, "free": 1, "allocated_percentage": 50, "overcommit_ratio": 1, "overcommit_max": 2}, + "pram": {"total": 1024**3, "usage": 0, "free": 1024**3, "used_percentage": 0}, + "vram": {"total": 1024**3, "allocated": 0, "free": 1024**3, "allocated_percentage": 0, "overcommit_ratio": 0, "overcommit_max": 1}, + "vm": {"count": vm_count, "active": vm_count, "stopped": 0, "avg_cpu": 1, "avg_ram": 0, "density": float(vm_count)}, + "flavors": { + "first_common_flavor": {"name": first_flavor_name, "count": vm_count}, + "second_common_flavor": {"name": "—", "count": 0}, + "third_common_flavor": {"name": "—", "count": 0}, + }, + "audits": [], + } + + +class IndexViewTest(TestCase): + """Tests for the index view.""" + + def setUp(self): + self.factory = RequestFactory() + + @patch("dashboard.views.settings") + def test_index_use_mock_data_returns_200_and_mock_context(self, mock_settings): + mock_settings.USE_MOCK_DATA = True + mock_settings.DASHBOARD_CACHE_TTL = 120 + request = self.factory.get("/") + response = index(request) + self.assertEqual(response.status_code, 200) + # Mock context contains mock-region and flavors; render uses index.html + content = response.content.decode() + self.assertIn("mock-region", content) + self.assertIn("m1.small", content) + + @patch("dashboard.views.collect_context") + @patch("dashboard.views.get_mock_context") + @patch("dashboard.views.settings") + def test_index_with_real_path_uses_cache_and_collect_context( + self, mock_settings, mock_get_mock_context, mock_collect_context + ): + mock_settings.USE_MOCK_DATA = False + mock_settings.DASHBOARD_CACHE_TTL = 120 + mock_collect_context.return_value = _minimal_render_context( + region_name="cached-region", first_flavor_name="f1" + ) + cache.clear() + request = self.factory.get("/") + with patch.object(cache, "get", return_value=None): + with patch.object(cache, "set") as mock_cache_set: + response = index(request) + self.assertEqual(response.status_code, 200) + mock_collect_context.assert_called_once() + mock_cache_set.assert_called_once() + mock_get_mock_context.assert_not_called() + content = response.content.decode() + self.assertIn("cached-region", content) + self.assertIn("f1", content) + + @patch("dashboard.views.collect_context") + @patch("dashboard.views.settings") + def test_index_with_cache_hit_does_not_call_collect_context( + self, mock_settings, mock_collect_context + ): + mock_settings.USE_MOCK_DATA = False + mock_settings.DASHBOARD_CACHE_TTL = 120 + cached = _minimal_render_context(region_name="from-cache", first_flavor_name="cached-flavor", vm_count=2) + cache.clear() + cache.set("dashboard_context", cached, timeout=120) + request = self.factory.get("/") + response = index(request) + self.assertEqual(response.status_code, 200) + mock_collect_context.assert_not_called() + content = response.content.decode() + self.assertIn("from-cache", content) + + +class CollectContextTest(TestCase): + """Tests for collect_context with mocked dependencies.""" + + def _make_mock_connection(self, region_name="test-region"): + conn = MagicMock() + conn._compute_region = region_name + return conn + + @patch("dashboard.views._fetch_prometheus_metrics") + @patch("dashboard.views.get_audits") + @patch("dashboard.views.get_flavor_list") + @patch("dashboard.views.get_connection") + def test_collect_context_structure_and_calculation( + self, mock_get_connection, mock_get_flavor_list, mock_get_audits, mock_fetch_metrics + ): + mock_get_connection.return_value = self._make_mock_connection("my-region") + mock_get_flavor_list.return_value = { + "first_common_flavor": {"name": "m1.small", "count": 5}, + "second_common_flavor": {"name": "—", "count": 0}, + "third_common_flavor": {"name": "—", "count": 0}, + } + mock_get_audits.return_value = [ + { + "migrations": [], + "host_labels": ["h0", "h1"], + "cpu_current": [30.0, 40.0], + "cpu_projected": [35.0, 35.0], + } + ] + mock_fetch_metrics.return_value = { + "hosts_total": 2, + "pcpu_total": 8, + "pcpu_usage": 2.5, + "vcpu_allocated": 16, + "vcpu_overcommit_max": 2.0, + "pram_total": 32 * 1024**3, + "pram_usage": 8 * 1024**3, + "vram_allocated": 24 * 1024**3, + "vram_overcommit_max": 1.5, + "vm_count": 4, + "vm_active": 4, + } + + context = collect_context() + + self.assertEqual(context["region"]["name"], "my-region") + self.assertEqual(context["region"]["hosts_total"], 2) + self.assertEqual(context["pcpu"]["total"], 8) + self.assertEqual(context["pcpu"]["usage"], 2.5) + self.assertEqual(context["vcpu"]["total"], 8 * 2.0) # pcpu_total * vcpu_overcommit_max + self.assertEqual(context["vcpu"]["allocated"], 16) + self.assertEqual(context["vram"]["total"], 32 * 1024**3 * 1.5) + self.assertEqual(context["flavors"]["first_common_flavor"]["name"], "m1.small") + self.assertEqual(len(context["audits"]), 1) + # Serialized for JS + import json + self.assertIsInstance(context["audits"][0]["migrations"], str) + self.assertEqual(json.loads(context["audits"][0]["host_labels"]), ["h0", "h1"])