Add tests for audits and flavor utilities, update .gitignore, and enhance CPU data handling

This commit is contained in:
2026-02-06 16:29:34 +03:00
parent 57a2933f28
commit e3a9500352
8 changed files with 344 additions and 0 deletions

2
.gitignore vendored
View File

@@ -2,6 +2,8 @@
*.pyc *.pyc
__pycache__/ __pycache__/
venv/ venv/
.venv/
.venv/
.env .env
db.sqlite3 db.sqlite3

View File

@@ -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.

View File

View File

@@ -11,6 +11,9 @@ from dashboard.prometheus_utils.query import query_prometheus
def convert_cpu_data(data: list): def convert_cpu_data(data: list):
metrics = [] metrics = []
if not data:
return pandas.DataFrame(columns=["host", "cpu_usage"])
for entry in data: for entry in data:
for t, val in entry["values"]: for t, val in entry["values"]:
metrics.append({ metrics.append({

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"])