mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
The list and the detail page were rendered once, so a job that finished kept showing running until the page was reloaded by hand. While a shown job is queued, running or cancelling, the page now reloads every five seconds (not while hidden or while a form field has focus).
221 lines
13 KiB
Python
221 lines
13 KiB
Python
import re
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import patch
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.contrib.auth.models import Permission
|
|
from django.test import SimpleTestCase, TransactionTestCase, override_settings
|
|
from django.urls import reverse
|
|
from django.utils import timezone as django_timezone
|
|
from django.utils import translation
|
|
|
|
from operations import jobs
|
|
from operations.jobs import Outcome
|
|
from operations.models import Claim, Operation, WorkerLease
|
|
from operations.templatetags.operations import STATE_COLORS, STATE_LABELS, state_color, state_label
|
|
from operations.tests import fakes
|
|
|
|
|
|
class StateColorsTestCase(SimpleTestCase):
|
|
def test_every_state_has_its_colour(self):
|
|
# job_panel.test.js checks the same map in job_panel.js
|
|
self.assertEqual(STATE_COLORS, {
|
|
"queued": "secondary", "running": "primary", "cancelling": "info", "succeeded": "success",
|
|
"failed": "danger", "cancelled": "secondary", "uncertain": "warning",
|
|
})
|
|
self.assertEqual(set(STATE_COLORS), {state for state, _label in Operation.STATES})
|
|
self.assertEqual(state_color("failed"), "danger")
|
|
|
|
def test_every_state_has_its_label(self):
|
|
self.assertEqual({state: str(label) for state, label in STATE_LABELS.items()},
|
|
{state: state for state, _label in Operation.STATES})
|
|
self.assertEqual(state_label("uncertain"), "uncertain")
|
|
|
|
|
|
class JobsPageTestCase(TransactionTestCase):
|
|
def setUp(self):
|
|
fakes.reset()
|
|
U = get_user_model()
|
|
self.user = U.objects.create_user("page_user", "p@example.com", "pw")
|
|
self.admin = U.objects.create_superuser("page_admin", "a@example.com", "pw")
|
|
self.op = jobs.submit("test.noop", self.user, {}, "key-00000001")
|
|
|
|
def test_a_user_sees_own_jobs_only(self):
|
|
other = get_user_model().objects.create_user("page_other", "o@example.com", "pw")
|
|
self.client.force_login(other)
|
|
self.assertNotContains(self.client.get(reverse("operations:jobs")), "No-op")
|
|
self.assertEqual(self.client.get(reverse("operations:job", args=[self.op.pk])).status_code, 404)
|
|
self.client.force_login(self.user)
|
|
self.assertContains(self.client.get(reverse("operations:jobs")), "No-op")
|
|
|
|
def test_superusers_filter_by_compute_and_state(self):
|
|
self.client.force_login(self.admin)
|
|
link = reverse("operations:job", args=[self.op.pk])
|
|
self.assertContains(self.client.get(reverse("operations:jobs"), {"compute": "c"}), link)
|
|
self.assertNotContains(self.client.get(reverse("operations:jobs"), {"state": "failed"}), link)
|
|
|
|
def test_resolve_needs_a_superuser_an_outcome_and_a_note(self):
|
|
jobs.finish(self.op, Outcome(Operation.UNCERTAIN, error="check"))
|
|
self.client.force_login(self.user)
|
|
self.assertEqual(self.client.post(reverse("operations:resolve", args=[self.op.pk]),
|
|
{"outcome": "failed", "note": "x"}).status_code, 403)
|
|
self.client.force_login(self.admin)
|
|
self.client.post(reverse("operations:resolve", args=[self.op.pk]), {"outcome": "failed", "note": ""})
|
|
self.assertTrue(Claim.objects.filter(operation=self.op).exists())
|
|
self.client.post(reverse("operations:resolve", args=[self.op.pk]), {"outcome": "failed", "note": "checked"})
|
|
self.assertFalse(Claim.objects.filter(operation=self.op).exists())
|
|
|
|
def test_the_detail_shows_what_recovery_saw_escaped(self):
|
|
Operation.objects.filter(pk=self.op.pk).update(error="<script>x</script>", phase="Waiting for recovery",
|
|
state=Operation.UNCERTAIN)
|
|
self.client.force_login(self.user)
|
|
response = self.client.get(reverse("operations:job", args=[self.op.pk]))
|
|
self.assertNotContains(response, "<script>x</script>")
|
|
self.assertContains(response, "Waiting for recovery")
|
|
|
|
def test_pagination_keeps_the_filters(self):
|
|
for i in range(51):
|
|
Operation.objects.create(kind="test.noop", user=self.user, username="page_user",
|
|
submission_key=f"page-{i:08d}", resource="r", resource_label="c/vm")
|
|
self.client.force_login(self.admin)
|
|
response = self.client.get(reverse("operations:jobs"), {"compute": "c"})
|
|
self.assertContains(response, "compute=c")
|
|
self.assertContains(response, "page=2")
|
|
|
|
def _pages_seen_by(self, user):
|
|
Operation.objects.filter(pk=self.op.pk).update(resource_label="wvc-test-host/wvc-test-vm")
|
|
self.client.force_login(user)
|
|
return [self.client.get(reverse("operations:jobs")), self.client.get(reverse("operations:job", args=[self.op.pk]))]
|
|
|
|
def test_a_plain_owner_sees_only_the_vm_name(self):
|
|
for response in self._pages_seen_by(self.user):
|
|
self.assertContains(response, "wvc-test-vm")
|
|
self.assertNotContains(response, "wvc-test-host")
|
|
|
|
def test_users_who_may_see_hosts_see_the_compute_name(self):
|
|
self.user.user_permissions.add(Permission.objects.get(codename="view_instances",
|
|
content_type__app_label="instances"))
|
|
for response in self._pages_seen_by(self.user) + self._pages_seen_by(self.admin):
|
|
self.assertContains(response, "wvc-test-host")
|
|
self.assertContains(response, "wvc-test-vm")
|
|
|
|
def test_the_list_wraps_a_resource_after_its_slash_only(self):
|
|
Operation.objects.filter(pk=self.op.pk).update(resource_label="wvc-test-host/wvc-test-<vm>")
|
|
self.client.force_login(self.admin)
|
|
self.assertContains(self.client.get(reverse("operations:jobs")),
|
|
'<span class="text-nowrap">wvc-test-host</span>/<wbr>'
|
|
'<span class="text-nowrap">wvc-test-<vm></span>')
|
|
|
|
@override_settings(TIME_ZONE="Europe/Istanbul")
|
|
def test_dates_show_in_the_app_format_and_time_zone(self):
|
|
at = datetime(2026, 10, 13, 20, 4, 0, 801375, tzinfo=timezone.utc)
|
|
Operation.objects.filter(pk=self.op.pk).update(created_at=at, started_at=at, finished_at=at,
|
|
state=Operation.SUCCEEDED)
|
|
self.client.force_login(self.admin)
|
|
for response in (self.client.get(reverse("operations:jobs")),
|
|
self.client.get(reverse("operations:job", args=[self.op.pk]))):
|
|
self.assertContains(response, "Oct 13 23:04:00")
|
|
self.assertNotContains(response, "2026-10-13T20:04")
|
|
|
|
def test_list_and_detail_refresh_while_a_job_is_active(self):
|
|
self.client.force_login(self.user)
|
|
pages = (reverse("operations:jobs"), reverse("operations:job", args=[self.op.pk]))
|
|
for url in pages: # the job is queued
|
|
self.assertContains(self.client.get(url), 'id="follow-active-jobs"')
|
|
Operation.objects.filter(pk=self.op.pk).update(state=Operation.SUCCEEDED)
|
|
for url in pages:
|
|
self.assertNotContains(self.client.get(url), 'id="follow-active-jobs"')
|
|
|
|
def test_list_and_detail_show_a_state_badge(self):
|
|
Operation.objects.filter(pk=self.op.pk).update(state=Operation.FAILED, error="disk busy")
|
|
self.client.force_login(self.admin)
|
|
for response in (self.client.get(reverse("operations:jobs")),
|
|
self.client.get(reverse("operations:job", args=[self.op.pk]))):
|
|
self.assertContains(response, '<span class="badge bg-danger">failed</span>')
|
|
detail = self.client.get(reverse("operations:job", args=[self.op.pk]))
|
|
self.assertContains(detail, 'class="alert alert-danger"')
|
|
|
|
def test_the_list_shows_the_phase_while_active_and_the_error_after_a_failure(self):
|
|
Operation.objects.filter(pk=self.op.pk).update(state=Operation.RUNNING, phase="Copying", error="")
|
|
self.client.force_login(self.admin)
|
|
self.assertContains(self.client.get(reverse("operations:jobs")), "Copying")
|
|
Operation.objects.filter(pk=self.op.pk).update(state=Operation.SUCCEEDED)
|
|
self.assertNotContains(self.client.get(reverse("operations:jobs")), "Copying")
|
|
Operation.objects.filter(pk=self.op.pk).update(state=Operation.FAILED, error="x" * 200)
|
|
response = self.client.get(reverse("operations:jobs"))
|
|
self.assertNotContains(response, "Copying")
|
|
self.assertContains(response, "x" * 79 + "\u2026")
|
|
|
|
def test_the_filters_offer_states_and_kinds_and_a_reset(self):
|
|
self.client.force_login(self.admin)
|
|
response = self.client.get(reverse("operations:jobs"), {"state": "queued", "kind": "test.noop"})
|
|
self.assertContains(response, '<option value="queued" selected>queued</option>')
|
|
self.assertContains(response, '<option value="test.noop" selected>No-op</option>')
|
|
self.assertContains(response, f'href="{reverse("operations:jobs")}"')
|
|
self.assertNotContains(response, 'class="pagination')
|
|
|
|
def test_the_worker_warning_shows_when_a_visible_job_waits_for_a_stale_worker(self):
|
|
# the panel uses the same rule (job_panel.test.js)
|
|
stale = django_timezone.now() - timedelta(minutes=2)
|
|
WorkerLease.objects.create(name="worker", holder="h:1:a", host="h", heartbeat_at=stale)
|
|
self.client.force_login(self.user)
|
|
for state, warned in ((Operation.QUEUED, True), (Operation.RUNNING, True), (Operation.CANCELLING, True),
|
|
(Operation.UNCERTAIN, False), (Operation.SUCCEEDED, False)):
|
|
Operation.objects.filter(pk=self.op.pk).update(state=state)
|
|
response = self.client.get(reverse("operations:jobs"))
|
|
(self.assertContains if warned else self.assertNotContains)(response, "Worker unavailable", msg_prefix=state)
|
|
Operation.objects.filter(pk=self.op.pk).update(state=Operation.RUNNING)
|
|
WorkerLease.objects.update(heartbeat_at=django_timezone.now())
|
|
self.assertNotContains(self.client.get(reverse("operations:jobs")), "Worker unavailable")
|
|
# another user's running job is not this user's concern
|
|
other = get_user_model().objects.create_user("page_other2", "o2@example.com", "pw")
|
|
WorkerLease.objects.update(heartbeat_at=stale)
|
|
self.client.force_login(other)
|
|
self.assertNotContains(self.client.get(reverse("operations:jobs")), "Worker unavailable")
|
|
|
|
def test_an_uncertain_badge_has_dark_text(self):
|
|
Operation.objects.filter(pk=self.op.pk).update(state=Operation.UNCERTAIN)
|
|
self.client.force_login(self.admin)
|
|
# the app's theme draws white text on bg-warning
|
|
self.assertContains(self.client.get(reverse("operations:jobs")),
|
|
'<span class="badge bg-warning text-dark">uncertain</span>')
|
|
|
|
def test_a_narrow_list_shows_resource_date_and_user_under_the_job(self):
|
|
Operation.objects.filter(pk=self.op.pk).update(resource_label="wvc-test-host/wvc-test-vm")
|
|
self.client.force_login(self.admin)
|
|
html = self.client.get(reverse("operations:jobs")).content.decode()
|
|
# below md only Job and State are columns
|
|
for header in ("Created", "Resource", "User"):
|
|
self.assertIn(f'<th scope="col" class="d-none d-md-table-cell">{header}</th>', html)
|
|
narrow = re.search(r'<div class="d-md-none small text-body-secondary">(.*?)</div>', html, re.S).group(1)
|
|
for text in ('<span class="text-nowrap">wvc-test-vm</span>', "page_user"):
|
|
self.assertIn(text, narrow)
|
|
self.assertRegex(narrow, r"[A-Z][a-z]{2} \d{2} \d{2}:\d{2}:\d{2}")
|
|
# a plain user's narrow line has no user
|
|
self.client.force_login(self.user)
|
|
html = self.client.get(reverse("operations:jobs")).content.decode()
|
|
narrow = re.search(r'<div class="d-md-none small text-body-secondary">(.*?)</div>', html, re.S).group(1)
|
|
self.assertNotIn("page_user", narrow)
|
|
|
|
def test_states_and_phases_show_translated(self):
|
|
Operation.objects.filter(pk=self.op.pk).update(state=Operation.RUNNING, phase="Merging the snapshot")
|
|
self.client.force_login(self.admin)
|
|
fake = lambda *args: f"T[{args[-1]}]" # noqa: E731
|
|
with patch.object(translation._trans, "gettext", side_effect=fake), \
|
|
patch.object(translation._trans, "pgettext", side_effect=fake):
|
|
listed = self.client.get(reverse("operations:jobs"))
|
|
detail = self.client.get(reverse("operations:job", args=[self.op.pk]))
|
|
for response in (listed, detail):
|
|
self.assertContains(response, '<span class="badge bg-primary">T[running]</span>')
|
|
self.assertContains(response, "T[Merging the snapshot]")
|
|
self.assertContains(listed, '<option value="failed">T[failed]</option>')
|
|
|
|
def test_the_detail_shows_progress_in_bytes_for_kinds_that_count_bytes(self):
|
|
Operation.objects.filter(pk=self.op.pk).update(progress_done=3 * 1024 ** 3, progress_total=6 * 1024 ** 3)
|
|
self.client.force_login(self.admin)
|
|
url = reverse("operations:job", args=[self.op.pk])
|
|
response = self.client.get(url)
|
|
self.assertContains(response, 'aria-valuenow="50"')
|
|
self.assertContains(response, str(3 * 1024 ** 3))
|
|
with patch.object(fakes.NoopKind, "progress_bytes", True):
|
|
self.assertContains(self.client.get(url), "3.0\xa0GB / 6.0\xa0GB")
|