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
from operations.utils import say
class BusyMessageTestCase(SimpleTestCase):
def test_the_state_of_the_job_in_the_way_is_named_in_the_users_language(self):
from types import SimpleNamespace
from operations.utils import busy_message
op = Operation(kind="snapshot.create", state=Operation.RUNNING, created_at=django_timezone.now())
with translation.override("tr"):
text = busy_message(SimpleNamespace(blocker=op), SimpleNamespace(is_superuser=False, pk=1))
self.assertIn(" çalışıyor ", text)
self.assertNotIn("running", text)
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_host_and_user_filters_match_part_of_the_name(self):
self.client.force_login(self.admin)
link = reverse("operations:job", args=[self.op.pk])
for filters in ({"compute": "C"}, {"username": "PAGE_U"}):
self.assertContains(self.client.get(reverse("operations:jobs"), filters), link)
self.assertNotContains(self.client.get(reverse("operations:jobs"), {"username": "other"}), link)
def test_resolve_needs_a_superuser_an_outcome_and_a_note(self):
jobs.finish(self.op, Outcome(Operation.UNCERTAIN, error=say("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=say(""), 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, "")
self.assertContains(response, "Waiting for recovery")
def test_the_detail_shows_the_results_warning_escaped(self):
Operation.objects.filter(pk=self.op.pk).update(result={"warning": say("not thawed")},
state=Operation.SUCCEEDED)
self.client.force_login(self.user)
response = self.client.get(reverse("operations:job", args=[self.op.pk]))
self.assertContains(response, "not <b>thawed</b>")
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-")
self.client.force_login(self.admin)
self.assertContains(self.client.get(reverse("operations:jobs")),
'wvc-test-host/'
'wvc-test-<vm>')
@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, "2026-10-13 23:04:00 +03")
self.assertNotContains(response, "2026-10-13T20:04")
def test_the_job_page_shows_how_long_it_waited_and_ran(self):
at = datetime(2026, 10, 13, 20, 4, 0, tzinfo=timezone.utc)
Operation.objects.filter(pk=self.op.pk).update(created_at=at, started_at=at + timedelta(seconds=3),
finished_at=at + timedelta(minutes=4, seconds=3),
state=Operation.SUCCEEDED)
self.client.force_login(self.user)
response = self.client.get(reverse("operations:job", args=[self.op.pk]))
self.assertContains(response, "queued for 3 seconds")
self.assertContains(response, "ran for 4\xa0minutes")
# cancelled while queued: it never started
Operation.objects.filter(pk=self.op.pk).update(started_at=None, state=Operation.CANCELLED)
response = self.client.get(reverse("operations:job", args=[self.op.pk]))
self.assertNotContains(response, "queued for")
self.assertNotContains(response, "ran for")
self.assertContains(response, "Finished")
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=say("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, 'failed')
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=say(""))
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=say("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, '')
self.assertContains(response, '')
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, worker="" if state == Operation.QUEUED else "h:1:a")
response = self.client.get(reverse("operations:jobs"))
(self.assertContains if warned else self.assertNotContains)(response, "Worker unavailable", msg_prefix=state)
# a job a request runs does not wait for the worker
for state in (Operation.RUNNING, Operation.CANCELLING):
Operation.objects.filter(pk=self.op.pk).update(state=state, worker=Operation.REQUEST)
self.assertNotContains(self.client.get(reverse("operations:jobs")), "Worker unavailable", msg_prefix=state)
Operation.objects.filter(pk=self.op.pk).update(state=Operation.RUNNING, worker="h:1:a")
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")),
'uncertain')
def test_a_narrow_list_shows_resource_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 ("Resource", "User"):
self.assertIn(f'| {header} | ', html)
self.assertIn(' · page_user', html)
narrow = re.search(r'(.*?)
', html, re.S).group(1)
self.assertIn('wvc-test-vm', narrow)
# a plain user's list has no user
self.client.force_login(self.user)
self.assertNotContains(self.client.get(reverse("operations:jobs")), "· page_user")
def test_the_list_shows_an_active_jobs_progress(self):
Operation.objects.filter(pk=self.op.pk).update(state=Operation.RUNNING, progress_done=3, progress_total=10)
self.client.force_login(self.user)
response = self.client.get(reverse("operations:jobs"))
self.assertContains(response, "3 / 10")
self.assertContains(response, 'aria-valuenow="30"')
Operation.objects.filter(pk=self.op.pk).update(state=Operation.SUCCEEDED)
self.assertNotContains(self.client.get(reverse("operations:jobs")), 'role="progressbar"')
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, 'T[running]')
self.assertContains(response, "T[Merging the snapshot]")
self.assertContains(listed, '')
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")