1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/operations/tests/test_views.py
catborise 1e66367bb4 feat(operations): a job's error and warning show in the reader's language
A job stored its error and its result's warning as English text, much
of it built with the values in it (volume paths, a disk, libvirt's
error), so no catalog could translate it, and the job pages and panel
showed English whatever the language.

A job now stores them as messages: our English text, marked for
translation, and its values; libvirt's own text is a message without
values and is shown as it is. The jobs API and pages put them in the
reader's language. OperationError keeps its text and values apart for
the same reason, so a refusal raised while a job is submitted is shown
translated too. Messages one after another still read as sentences.

Errors stored before are kept as one message each, shown as they were.
2026-11-02 23:49:45 +03:00

274 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("<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_the_detail_shows_the_results_warning_escaped(self):
Operation.objects.filter(pk=self.op.pk).update(result={"warning": say("not <b>thawed</b>")},
state=Operation.SUCCEEDED)
self.client.force_login(self.user)
response = self.client.get(reverse("operations:job", args=[self.op.pk]))
self.assertContains(response, "not &lt;b&gt;thawed&lt;/b&gt;")
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-&lt;vm&gt;</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, "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, '<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=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, '<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, 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")),
'<span class="badge bg-warning text-dark">uncertain</span>')
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'<th scope="col" class="d-none d-md-table-cell">{header}</th>', html)
self.assertIn('<span class="d-md-none"> &middot; page_user</span>', html)
narrow = re.search(r'<div class="d-md-none small">(.*?)</div>', html, re.S).group(1)
self.assertIn('<span class="text-nowrap">wvc-test-vm</span>', narrow)
# a plain user's list has no user
self.client.force_login(self.user)
self.assertNotContains(self.client.get(reverse("operations:jobs")), "&middot; 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, '<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")