# Copyright 2022 The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Tests for the work request views."""

import textwrap
from datetime import timedelta
from types import GeneratorType
from typing import ClassVar

from django.contrib.auth import get_user_model
from django.contrib.messages import constants as messages_constants
from django.contrib.messages import get_messages
from django.contrib.messages.storage.base import Message
from django.core.exceptions import ValidationError
from django.core.validators import ProhibitNullCharactersValidator
from django.urls import reverse
from django.utils import timezone

from lxml import html

from rest_framework import status

import yaml

from debusine.artifacts import LintianArtifact
from debusine.db.models import (
    Artifact,
    User,
    WorkRequest,
    WorkflowTemplate,
    Workspace,
)
from debusine.server.scheduler import TaskDatabase
from debusine.server.workflows.models import (
    WorkRequestManualUnblockAction,
    WorkRequestManualUnblockData,
    WorkRequestManualUnblockLog,
)
from debusine.tasks import Lintian
from debusine.tasks.tests.test_simplesystemimagebuild import (
    SimpleSystemImageBuildTests,
)
from debusine.test.django import TestCase
from debusine.web.views import ui_shortcuts
from debusine.web.views.lintian import LintianView
from debusine.web.views.tests.test_views import (
    _sort_table_handle,
)
from debusine.web.views.tests.utils import ViewTestMixin
from debusine.web.views.work_request import (
    WorkRequestDetailView,
    WorkRequestListView,
)


class WorkRequestDetailViewTests(ViewTestMixin, TestCase):
    """Tests for WorkRequestDetailView class."""

    workspace: ClassVar[Workspace]
    source: ClassVar[Artifact]
    work_request: ClassVar[WorkRequest]

    @classmethod
    def setUpTestData(cls):
        """Set up common objects."""
        super().setUpTestData()
        cls.workspace = cls.playground.get_default_workspace()
        started_at = timezone.now()
        duration = 73
        completed_at = started_at + timedelta(seconds=duration)

        environment_item = cls.playground.create_debian_environment()
        assert environment_item.artifact is not None
        environment = environment_item.artifact

        cls.source = cls.playground.create_source_artifact()
        cls.work_request = cls.playground.create_sbuild_work_request(
            source=cls.source,
            architecture="all",
            environment=environment,
        )
        cls.playground.compute_dynamic_data(cls.work_request)
        cls.work_request.mark_running()
        cls.work_request.assign_worker(cls.playground.create_worker())
        cls.work_request.mark_completed(WorkRequest.Results.SUCCESS)
        cls.work_request.started_at = started_at
        cls.work_request.completed_at = completed_at
        cls.work_request.save()

    def test_get_work_request(self):
        """View detail return work request information."""
        artifact, _ = self.create_artifact()
        artifact.created_by_work_request = self.work_request
        artifact.save()

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        tree = self.assertHTMLValid(response)

        sidebar = response.context["sidebar_items"]
        self.assertEqual(sidebar[0].value, "build a package")
        self.assertEqual(
            sidebar[0].detail,
            f'{self.work_request.task_type} {self.work_request.task_name} task',
        )
        self.assertEqual(sidebar[1].content, "-")
        self.assertEqual(
            sidebar[2].content,
            '<span class="badge text-bg-primary">Completed</span>'
            ' <span class="badge text-bg-success">Success</span>',
        )
        self.assertEqual(sidebar[3].content, self.work_request.workspace.name)
        self.assertEqual(sidebar[4].content, str(self.work_request.created_by))
        self.assertEqual(sidebar[6].content, self.work_request.worker.name)
        self.assertEqual(sidebar[7].content, "0\xa0minutes")
        self.assertEqual(sidebar[8].content, "1\xa0minute")
        self.assertEqual(sidebar[9].content, "never")

        # Check artifact list
        tbody = tree.xpath("//table[@id='artifacts']")[0].tbody[1]
        self.assertEqual(len(tbody.tr), 2)
        tr = tbody.tr[1]
        self.assertTextContentEqual(tr.td[0], "Artifact")
        self.assertEqual(
            tr.td[1].a.get("href"),
            reverse("artifacts:detail", kwargs={"artifact_id": artifact.id}),
        )
        self.assertTextContentEqual(tr.td[1].a, "debusine:test")

        # Not in a workflow
        self.assertNotContains(
            response, "<h2>Workflow information</h2>", html=True
        )

    def test_get_work_request_no_retry_if_logged_out(self):
        """No retry link if the user is logged out."""
        self.work_request.task_name = "noop"
        self.work_request.task_data = {}
        self.work_request.dynamic_task_data = {}
        self.work_request.status = WorkRequest.Statuses.ABORTED
        self.work_request.save()

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.context["main_ui_shortcuts"], [])

    def test_get_work_request_no_retry_if_successful(self):
        """No retry link if the work request is successful."""
        self.work_request.task_name = "noop"
        self.work_request.task_data = {}
        self.work_request.dynamic_task_data = {}
        self.work_request.save()

        self.client.force_login(self.get_test_user())
        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.context["main_ui_shortcuts"], [])

    def test_get_work_request_can_retry(self):
        """Show the retry link if a work request can be retried."""
        self.work_request.task_name = "noop"
        self.work_request.task_data = {}
        self.work_request.dynamic_task_data = {}
        self.work_request.status = WorkRequest.Statuses.ABORTED
        self.work_request.save()

        self.client.force_login(self.get_test_user())
        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.context["main_ui_shortcuts"],
            [
                ui_shortcuts.create_work_request_retry(self.work_request),
            ],
        )

    def test_get_work_request_superseded(self):
        """Check that superseding/superseded links show up."""
        self.work_request.status = WorkRequest.Statuses.ABORTED
        self.work_request.result = WorkRequest.Results.NONE
        self.work_request.save()

        wr_new = self.work_request.retry()

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)

        sidebar = response.context["sidebar_items"]
        self.assertEqual(sidebar[1].value, str(wr_new))
        self.assertEqual(sidebar[1].label, "Superseded by")
        self.assertEqual(
            sidebar[1].url,
            reverse("work-requests:detail", kwargs={"pk": wr_new.pk}),
        )

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": wr_new.id})
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        sidebar = response.context["sidebar_items"]
        self.assertEqual(sidebar[1].value, str(self.work_request))
        self.assertEqual(sidebar[1].label, "Supersedes")
        self.assertEqual(
            sidebar[1].url,
            reverse(
                "work-requests:detail", kwargs={"pk": self.work_request.pk}
            ),
        )

    def test_get_work_request_running(self):
        """Check that elapsed execution time displays."""
        self.work_request.status = WorkRequest.Statuses.RUNNING
        self.work_request.result = WorkRequest.Results.NONE
        self.work_request.started_at = timezone.now() - timedelta(minutes=10)
        self.work_request.completed_at = None
        self.work_request.save()

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)

        sidebar = response.context["sidebar_items"]
        self.assertEqual(
            [item.content for item in sidebar if item.label == "Duration"],
            ["10\xa0minutes"],
        )

    def make_work_request_lintian(self, work_request: WorkRequest):
        """Change work_request to "lintian", create source artifact."""
        work_request.task_name = "lintian"
        source_artifact = self.playground.create_source_artifact()
        work_request.task_data = {
            "input": {
                "source_artifact": source_artifact.id,
                "binary_artifacts": [],
            }
        }
        work_request.dynamic_task_data = None
        work_request.save()
        work_request.artifact_set.add(self.create_lintian_source_artifact())

    def test_use_specific_plugin(self):
        """Test usage of a plugin instead of generic view."""
        self.make_work_request_lintian(self.work_request)

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )

        # WorkRequest_get_template_names() return the correct template name
        self.assertIn(LintianView.template_name, response.template_name)

        # The LintianView().get_context_data() is in the response
        self.assertDictContainsAll(
            response.context_data,
            LintianView().get_context_data(self.work_request),
        )

    def create_lintian_source_artifact(self) -> Artifact:
        """
        Create a Lintian source artifact result.

        Contains lintian.txt file.
        """
        artifact, _ = self.create_artifact(
            paths=[Lintian.CAPTURE_OUTPUT_FILENAME],
            create_files=True,
            category=LintianArtifact._category,
            data={
                "summary": {
                    "package_filename": {"hello": "hello.dsc"},
                    "tags_count_by_severity": {},
                    "tags_found": [],
                    "overridden_tags_found": [],
                    "lintian_version": "2.117.0",
                    "distribution": "sid",
                },
            },
        )
        artifact.created_by_work_request = self.work_request
        artifact.save()
        return artifact

    def test_do_not_use_available_plugin_use_default(self):
        """Request to detail with view=generic: do not use plugin."""
        self.make_work_request_lintian(self.work_request)

        path = reverse(
            "work-requests:detail", kwargs={"pk": self.work_request.id}
        )

        response = self.client.get(path + "?view=generic")

        self.assertIn(
            WorkRequestDetailView.default_template_name, response.template_name
        )

        with self.assertRaises(AssertionError):
            # The context_data does not contain the specific Lintian
            # work request plugin data
            self.assertDictContainsAll(
                response.context_data,
                LintianView().get_context_data(self.work_request),
            )

        specialized_view_path = path
        self.assertContains(
            response,
            f'<p><a href="{specialized_view_path}">'
            f'{self.work_request.task_name} view</a></p>',
            html=True,
        )

    def test_do_not_use_available_plugin_invalid_task_data(self):
        """If task data is invalid, do not use plugin."""
        self.make_work_request_lintian(self.work_request)
        self.work_request.task_data["input"]["binary_artifacts_ids"] = 1
        self.work_request.dynamic_task_data = {}
        self.work_request.save()

        with self.assertRaises(ValidationError) as cm:
            self.work_request.full_clean()
        validation_error = str(cm.exception)

        path = reverse(
            "work-requests:detail", kwargs={"pk": self.work_request.id}
        )

        response = self.client.get(path)

        self.assertEqual(response.status_code, status.HTTP_200_OK)

        self.assertIn(
            WorkRequestDetailView.default_template_name, response.template_name
        )

        # The context_data does not contain the specific Lintian work
        # request plugin data
        self.assertNotIn("lintian_txt_path", response.context_data)

        # The page does not link to the specialized view; instead, it shows
        # the validation error.
        specialized_view_path = path
        self.assertNotContains(
            response,
            f'<p><a href="{specialized_view_path}">'
            f'{self.work_request.task_name} view</a></p>',
            html=True,
        )
        self.assertContains(
            response,
            "<h2>Validation error</h2>"
            f"<pre><code>{validation_error}</code></pre>",
            html=True,
        )

    def test_multi_line_string(self):
        """Multi-line strings are rendered using the literal style."""
        self.work_request.task_name = "mmdebstrap"
        self.work_request.task_data = {
            "bootstrap_options": {"architecture": "amd64"},
            "bootstrap_repositories": [
                {"mirror": "https://deb.debian.org/debian", "suite": "bookworm"}
            ],
            "customization_script": "multi-line\nstring\n",
        }
        self.work_request.dynamic_task_data = {}
        self.work_request.save()

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        tree = self.assertHTMLValid(response)
        card = tree.xpath("//div[@id='metadata']")[0]

        self.assertTextContentEqual(
            card.div[1],
            """\
            bootstrap_options:
              architecture: amd64
            bootstrap_repositories:
            - mirror: https://deb.debian.org/debian
              suite: bookworm
            customization_script: |
              multi-line
              string
            """,
        )

    def test_workflow_root(self):
        """A workflow root shows information on its descendants."""
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=self.workspace,
            task_name="noop",
            task_data={},
        )
        root = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )
        child_template = WorkflowTemplate.objects.create(
            name="child",
            workspace=self.workspace,
            task_name="noop",
            task_data={},
        )
        child = WorkRequest.objects.create_workflow(
            template=child_template,
            data={},
            created_by=self.get_test_user(),
            parent=root,
        )
        grandchildren = []
        for i in range(2):
            wr = self.create_work_request(
                parent=child,
                workflow_data_json={
                    "display_name": f"Lintian {i + 1}",
                    "step": f"lintian{i + 1}",
                },
            )
            wr.add_dependency(child)
            self.make_work_request_lintian(wr)
            grandchildren.append(wr)

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": root.id})
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)

        root_link = f'<a href="/work-request/{root.id}/">noop</a>'
        child_link = f'<a href="/work-request/{child.id}/">noop</a>'
        grandchild_links = [
            f'<a href="/work-request/{grandchild.id}/">'
            f'{grandchild.workflow_data.display_name}</a>'
            for grandchild in grandchildren
        ]
        pending = '<span class="badge text-bg-secondary">Pending</span>'
        blocked = '<span class="badge text-bg-secondary">Blocked</span>'
        self.assertContains(
            response,
            textwrap.dedent(
                f"""
                <h2>Workflow information</h2>
                <ul>
                    <li>
                        <details>
                            <summary>{root_link} ({pending})</summary>
                            <ul>
                                <li>
                                    <details>
                                        <summary>
                                            {child_link} ({pending})
                                        </summary>
                                        <ul>
                                            <li>
                                                {grandchild_links[0]}
                                                ({blocked})
                                            </li>
                                            <li>
                                                {grandchild_links[1]}
                                                ({blocked})
                                            </li>
                                        </ul>
                                    </details>
                                </li>
                            </ul>
                        </details>
                    </li>
                </ul>
                """
            ),
            html=True,
        )

    def test_workflow_child(self):
        """A workflow child shows information on its root/parent/descendants."""
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=self.workspace,
            task_name="noop",
            task_data={},
        )
        root = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )
        child_template = WorkflowTemplate.objects.create(
            name="child",
            workspace=self.workspace,
            task_name="noop",
            task_data={},
        )
        child = WorkRequest.objects.create_workflow(
            template=child_template,
            data={},
            created_by=self.get_test_user(),
            parent=root,
        )
        grandchildren = []
        for i in range(2):
            wr = self.create_work_request(
                parent=child,
                workflow_data_json={
                    "display_name": f"Lintian {i + 1}",
                    "step": f"lintian{i + 1}",
                },
            )
            wr.add_dependency(child)
            self.make_work_request_lintian(wr)
            grandchildren.append(wr)

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": child.id})
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)

        root_link = f'<a href="/work-request/{root.id}/">noop</a>'
        child_link = f'<a href="/work-request/{child.id}/">noop</a>'
        grandchild_links = [
            f'<a href="/work-request/{grandchild.id}/">'
            f'{grandchild.workflow_data.display_name}</a>'
            for grandchild in grandchildren
        ]
        pending = '<span class="badge text-bg-secondary">Pending</span>'
        blocked = '<span class="badge text-bg-secondary">Blocked</span>'
        self.assertContains(
            response,
            textwrap.dedent(
                f"""
                <h2>Workflow information</h2>
                <ul>
                    <li>Root: {root_link}</li>
                    <li>Parent: {root_link}</li>
                    <li>
                        <details>
                            <summary>{child_link} ({pending})</summary>
                            <ul>
                                <li>{grandchild_links[0]} ({blocked})</li>
                                <li>{grandchild_links[1]} ({blocked})</li>
                            </ul>
                        </details>
                    </li>
                </ul>
                """
            ),
            html=True,
        )

    def test_workflow_manual_unblock_log(self) -> None:
        """A work request's manual unblock log is rendered if present."""
        user = self.get_test_user()
        self.add_user_permission(user, WorkRequest, "change_workrequest")
        template = self.create_workflow_template("test", "noop")
        root = WorkRequest.objects.create_workflow(
            template=template,
            data={},
            created_by=self.get_test_user(),
            status=WorkRequest.Statuses.RUNNING,
        )
        work_request = WorkRequest.objects.create_synchronization_point(
            parent=root, step="test", status=WorkRequest.Statuses.BLOCKED
        )
        work_request.unblock_strategy = WorkRequest.UnblockStrategy.MANUAL
        workflow_data = work_request.workflow_data
        workflow_data.manual_unblock = WorkRequestManualUnblockData(
            log=[
                WorkRequestManualUnblockLog(
                    user_id=self.get_test_user().id,
                    timestamp=timezone.now(),
                    notes="LGTM",
                    action=WorkRequestManualUnblockAction.ACCEPT,
                )
            ]
        )
        work_request.workflow_data = workflow_data
        work_request.save()

        self.client.force_login(user)
        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": work_request.id})
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        tree = self.assertHTMLValid(response)

        tbody = tree.xpath("//table[@id='review-log']")[0].tbody[0]
        self.assertEqual(len(tbody.tr), 1)
        self.assertEqual(tbody.tr.td[1], self.get_test_user().username)
        self.assertHTMLEqual(
            html.tostring(tbody.tr.td[2], encoding="unicode"),
            '<td>'
            '<span class="bi bi-check2 text-success" title="Accept"></span>'
            '</td>',
        )
        self.assertTextContentEqual(tbody.tr.td[3], "LGTM")

        form = tree.xpath("//form[@id='manual-unblock-form']")[0]
        self.assertEqual(
            form.get("action"),
            reverse("work_requests:unblock", kwargs={"pk": work_request.id}),
        )
        submit_buttons = [
            tag for tag in form.input if tag.get("type") == "submit"
        ]
        self.assertEqual(
            ["Accept", "Reject", "Record notes only"],
            [tag.get("value") for tag in submit_buttons],
        )


class WorkRequestListViewTests(ViewTestMixin, TestCase):
    """Tests for WorkRequestListView class."""

    only_public_work_requests_message = (
        "Not authenticated. Only work requests "
        "in public workspaces are listed."
    )

    @classmethod
    def setUpTestData(cls):
        """Create workspaces used in the tests."""
        super().setUpTestData()
        cls.private_workspace = cls.create_workspace(name="Private")
        cls.public_workspace = cls.create_workspace(name="Public", public=True)

    def test_get_no_work_request(self):
        """No work requests in the server: 'No work requests' in response."""
        response = self.client.get(reverse("work-requests:list"))

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertContains(response, "No work requests.", html=True)

    def test_get_work_requests_all_authenticated_request(self):
        """Two work requests: all information appear in the response."""
        public_work_request = self.create_work_request(
            task_name="noop",
            result=WorkRequest.Results.SUCCESS,
            workspace=self.public_workspace,
        )
        private_work_request = self.create_work_request(
            task_name="noop", workspace=self.private_workspace
        )

        user = get_user_model().objects.create_user(
            username="testuser", password="testpassword", email="testemail"
        )
        self.client.force_login(user)

        response = self.client.get(reverse("work-requests:list"))
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        tree = self.assertHTMLValid(response)

        # For all the workspaces does not display "Workspace: "
        self.assertNotContains(response, "Workspace: ")

        self.assertQuerysetEqual(
            response.context_data["object_list"],
            WorkRequest.objects.all().order_by("-created_at"),
        )

        table = tree.xpath("//table[@id='work_request-list']")[0]
        self.assertWorkRequestRow(table.tbody.tr[0], private_work_request)
        self.assertWorkRequestRow(table.tbody.tr[1], public_work_request)
        self.assertEqual(len(table.tbody.tr), 2)

        self.assertNotContains(response, self.only_public_work_requests_message)

        # the view have the handle to sort the table
        self.assertContains(response, _sort_table_handle, html=True)

    def test_get_work_requests_public_not_authenticated(self):
        """One work request: public one only."""
        public_work_request = self.create_work_request(
            task_name="noop",
            result=WorkRequest.Results.SUCCESS,
            workspace=self.public_workspace,
        )
        self.create_work_request(
            task_name="noop", workspace=self.private_workspace
        )

        response = self.client.get(reverse("work-requests:list"))
        tree = self.assertHTMLValid(response)

        table = tree.xpath("//table[@id='work_request-list']")[0]
        self.assertWorkRequestRow(table.tbody.tr[0], public_work_request)
        self.assertEqual(len(table.tbody.tr), 1)

        self.assertContains(response, self.only_public_work_requests_message)

    def test_get_work_requests_filter_by_bad_workspace(self):
        """Work request is created and filtered with an invalid name."""
        self.create_work_request(task_name="noop")

        response = self.client.get(
            reverse("work-requests:list") + "?workspace=does-not-exist"
        )
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

    def test_get_work_requests_filter_by_good_workspace(self):
        """Work request is created and filtered with a valid name."""
        self.create_work_request(task_name="noop")

        response = self.client.get(
            reverse("work-requests:list") + "?workspace=Public"
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_get_work_requests_exclude_internal(self):
        """The list excludes INTERNAL work requests."""
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=self.public_workspace,
            task_name="noop",
            task_data={},
        )
        root = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )
        WorkRequest.objects.create_synchronization_point(
            parent=root, step="test"
        )

        response = self.client.get(reverse("work-requests:list"))
        self.assertEqual(response.status_code, status.HTTP_200_OK)

        tree = self.assertHTMLValid(response)
        self.assertQuerysetEqual(response.context_data["object_list"], [root])

        table = tree.xpath("//table[@id='work_request-list']")[0]
        self.assertWorkRequestRow(table.tbody.tr[0], root)
        self.assertEqual(len(table.tbody.tr), 1)

    def test_pagination(self):
        """Pagination is set up and rendered by the template."""
        self.create_work_request(
            task_name="noop", workspace=self.public_workspace
        )

        self.assertGreaterEqual(WorkRequestListView.paginate_by, 10)
        response = self.client.get(reverse("work-requests:list"))
        self.assertContains(response, '<nav aria-label="pagination">')
        self.assertIsInstance(
            response.context["elided_page_range"], GeneratorType
        )

    def test_sorting(self):
        """Test sorting."""
        for field in ["id", "created_at", "task_name", "status", "result"]:
            for asc in ["0", "1"]:
                response = self.client.get(
                    reverse("work-requests:list") + f"?order={field}&asc={asc}"
                )

            self.assertEqual(response.context["order"], field)
            self.assertEqual(response.context["asc"], asc)

    def test_sorting_invalid_field(self):
        """Test sorting by a non-valid field: sorted by id."""
        response = self.client.get(
            reverse("work-requests:list") + "?order=something"
        )

        self.assertEqual(response.context["order"], "created_at")
        self.assertEqual(response.context["asc"], "0")

    def test_sorting_field_is_valid(self):
        """Test sorting with asc=0: valid order and asc."""
        response = self.client.get(
            reverse("work-requests:list") + "?order=id&asc=0"
        )
        self.assertEqual(response.context["order"], "id")
        self.assertEqual(response.context["asc"], "0")

    def test_filter_by_status(self):
        """Test get_queryset() with status=XX returns expected WorkRequests."""
        work_requests = {
            "pending": self.playground.create_work_request(
                status=WorkRequest.Statuses.PENDING,
                task_name="noop",
                workspace=self.public_workspace,
            ),
            "running": self.playground.create_work_request(
                status=WorkRequest.Statuses.RUNNING,
                task_name="noop",
                workspace=self.public_workspace,
            ),
            "completed": self.playground.create_work_request(
                status=WorkRequest.Statuses.COMPLETED,
                task_name="noop",
                workspace=self.public_workspace,
            ),
            "aborted": self.playground.create_work_request(
                status=WorkRequest.Statuses.ABORTED,
                task_name="noop",
                workspace=self.public_workspace,
            ),
            "blocked": self.playground.create_work_request(
                status=WorkRequest.Statuses.BLOCKED,
                task_name="noop",
                workspace=self.public_workspace,
            ),
        }

        for work_request_status, work_request in work_requests.items():
            with self.subTest(work_request_status):
                response = self.client.get(
                    reverse("work-requests:list")
                    + f"?status={work_request_status}"
                )
                self.assertQuerysetEqual(
                    response.context_data["work_request_list"], [work_request]
                )

    def test_filter_by_architecture_invalid_status(self):
        """
        Test get_queryset() with invalid status returns all work requests.

        It also adds a Message to the page.
        """
        work_request = self.playground.create_work_request(
            status=WorkRequest.Statuses.PENDING,
            task_name="noop",
            workspace=self.public_workspace,
        )

        response = self.client.get(
            reverse("work-requests:list") + "?status=invalid"
        )

        self.assertEqual(
            list(response.context["messages"]),
            [
                Message(
                    messages_constants.WARNING,
                    'Invalid "status" parameter, ignoring it',
                    extra_tags="",
                )
            ],
        )

        self.assertQuerysetEqual(
            response.context_data["work_request_list"], [work_request]
        )

    def test_get_queryset_filter_by_architecture_wrong_status(self):
        """Test get_queryset() filters by architecture only for pending."""
        work_request = self.playground.create_work_request(
            status=WorkRequest.Statuses.RUNNING,
            task_name="noop",
            workspace=self.public_workspace,
        )

        response = self.client.get(
            reverse("work-requests:list") + "?status=running&arch=amd64"
        )

        self.assertEqual(
            list(response.context["messages"]),
            [
                Message(
                    messages_constants.WARNING,
                    'Filter by architecture is only supported when '
                    'also filtering by "status=pending", ignoring architecture'
                    'filtering',
                    extra_tags="",
                )
            ],
        )

        self.assertQuerysetEqual(
            response.context_data["work_request_list"], [work_request]
        )

    def test_filter_by_architecture(self):
        """Test filtering by architecture."""
        # Create two amd64 work requests
        work_request_amd64_1 = self.playground.create_work_request(
            status=WorkRequest.Statuses.PENDING,
            task_name="noop",
            workspace=self.public_workspace,
            task_data={"host_architecture": "amd64"},
        )
        work_request_amd64_2 = self.playground.create_work_request(
            status=WorkRequest.Statuses.PENDING,
            task_name="simplesystemimagebuild",
            workspace=self.public_workspace,
            task_data=SimpleSystemImageBuildTests.SAMPLE_TASK_DATA,
        )
        # Assert architecture is as expected
        self.assertEqual(
            work_request_amd64_2.get_task().host_architecture(), "amd64"
        )

        # Create two i386 work requests
        self.playground.create_work_request(
            status=WorkRequest.Statuses.PENDING,
            task_name="noop",
            workspace=self.public_workspace,
            task_data={"host_architecture": "i386"},
        )

        task_data_i386 = SimpleSystemImageBuildTests.SAMPLE_TASK_DATA.copy()
        task_data_i386["bootstrap_options"]["architecture"] = "i386"
        self.playground.create_work_request(
            status=WorkRequest.Statuses.PENDING,
            task_name="simplesystemimagebuild",
            workspace=self.public_workspace,
            task_data=task_data_i386,
        )

        # Create a work request for which "WorkRequest.get_task()" raise
        # TaskConfigError.
        self.playground.create_work_request(
            status=WorkRequest.Statuses.PENDING,
            task_name="sbuild",
            workspace=self.public_workspace,
        )

        response = self.client.get(
            reverse("work-requests:list") + "?status=pending&arch=amd64"
        )

        self.assertQuerysetEqual(
            response.context_data["work_request_list"],
            [work_request_amd64_2, work_request_amd64_1],
        )


class WorkRequestCreateViewTests(TestCase):
    """Tests for WorkRequestCreateView."""

    user: ClassVar[User]
    superuser: ClassVar[User]

    @classmethod
    def setUpTestData(cls):
        """Initialize class data."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpass"
        )
        cls.superuser = get_user_model().objects.create_superuser(
            username="testsuperuser",
            password="testsuperpass",
            email="superuser@mail.none",
        )

    def test_create_work_request_permission_denied(self):
        """A non-authenticated request cannot get the form (or post)."""
        for method in [self.client.get, self.client.post]:
            with self.subTest(method):
                response = method(reverse("work_requests:create"))
                self.assertContains(
                    response,
                    "You need to be authenticated to create a Work Request",
                    status_code=status.HTTP_403_FORBIDDEN,
                )

    def test_create_work_request(self):
        """Post to "work_requests:create" to create a work request."""
        self.client.force_login(self.user)
        workspace = Workspace.objects.earliest("id")
        source_artifact, _ = self.playground.create_artifact()
        name = "sbuild"

        task_data_yaml = textwrap.dedent(
            f"""
        build_components:
        - any
        - all
        backend: schroot
        distribution: stable
        host_architecture: amd64
        input:
          source_artifact: {source_artifact.id}
        """  # noqa: E501
        )

        self.assertEqual(WorkRequest.objects.count(), 0)

        response = self.client.post(
            reverse("work_requests:create"),
            {
                "workspace": workspace.id,
                "task_name": name,
                "task_data": task_data_yaml,
            },
        )

        # The work request got created
        work_request = WorkRequest.objects.latest("id")
        self.assertIsNotNone(work_request.id)

        # and has the user assigned
        self.assertEqual(work_request.created_by, self.user)

        # the browser got redirected to the work_requests:detail
        self.assertRedirects(
            response,
            reverse("work_requests:detail", kwargs={"pk": work_request.id}),
        )

    def test_create_work_request_user_rights(self):
        """Test "work_requests:create" depending on user rights."""
        self.client.force_login(self.superuser)
        workspace = Workspace.objects.earliest("id")
        name = "servernoop"
        task_data_yaml = textwrap.dedent(
            """
        """  # noqa: E501
        )

        self.assertEqual(WorkRequest.objects.count(), 0)

        response = self.client.post(
            reverse("work_requests:create"),
            {
                "workspace": workspace.id,
                "task_name": name,
                "task_data": task_data_yaml,
            },
        )

        # The work request got created
        work_request = WorkRequest.objects.latest("id")
        self.assertIsNotNone(work_request.id)

        # and has the user assigned
        self.assertEqual(work_request.created_by, self.superuser)

        # the browser got redirected to the work_requests:detail
        self.assertRedirects(
            response,
            reverse("work_requests:detail", kwargs={"pk": work_request.id}),
        )

        self.client.force_login(self.user)
        workspace = Workspace.objects.earliest("id")
        name = "servernoop"
        task_data_yaml = textwrap.dedent(
            """
        """  # noqa: E501
        )

        self.assertEqual(WorkRequest.objects.count(), 1)

        response = self.client.post(
            reverse("work_requests:create"),
            {
                "workspace": workspace.id,
                "task_name": name,
                "task_data": task_data_yaml,
            },
        )
        self.assertIn(
            "Select a valid choice. servernoop is not one",
            response.content.decode(),
        )

    def test_create_work_request_invalid_task_data(self):
        """Test form invalid due to some_method raising a ValidationError."""
        self.client.force_login(self.user)
        workspace = Workspace.objects.earliest("id")
        name = "sbuild"
        source_artifact, _ = self.playground.create_artifact()

        task_data_yaml = textwrap.dedent(
            """
            build_components:
            - any
            - all
            host_architecture: amd64
            input:
                source_artifact: 536
            environment: does-not-exist/match:codename=trixie:variant=sbuild
            """
        )

        self.assertEqual(WorkRequest.objects.count(), 0)

        response = self.client.post(
            reverse("work_requests:create"),
            {
                "workspace": workspace.id,
                "task_name": name,
                "task_data": task_data_yaml,
            },
        )

        # The work request is not created
        self.assertEqual(WorkRequest.objects.count(), 0)

        # The form displays the error on the task_data field
        self.assertContains(response, "Invalid task data")

        # Get the error message
        try:
            work_request = WorkRequest.objects.create(
                task_name=name,
                workspace=workspace,
                task_data=yaml.safe_load(task_data_yaml),
                created_by=self.user,
            )
            work_request.get_task().compute_dynamic_data(
                TaskDatabase(work_request)
            )
        except Exception as exc:
            error_message = f"Invalid task data: {exc}"

        self.assertFormError(response, "form", "task_data", error_message)


class WorkRequestRetryViewTests(TestCase):
    """Tests for WorkRequestRetryView."""

    user: ClassVar[User]
    workspace: ClassVar[Workspace]
    work_request: ClassVar[WorkRequest]

    @classmethod
    def setUpTestData(cls):
        """Initialize class data."""
        super().setUpTestData()
        cls.user = cls.get_test_user()
        cls.workspace = Workspace.objects.earliest("id")
        cls.work_request = cls.create_work_request(
            workspace=cls.workspace,
            task_name="noop",
            task_data={},
            status=WorkRequest.Statuses.ABORTED,
        )

    def test_retry_not_logged_in(self):
        """A non-authenticated request cannot retry."""
        for method in [self.client.get, self.client.post]:
            with self.subTest(method):
                response = method(
                    reverse(
                        "work_requests:retry",
                        kwargs={"pk": self.work_request.pk},
                    )
                )
                self.assertContains(
                    response,
                    "You need to be authenticated to retry a Work Request",
                    status_code=status.HTTP_403_FORBIDDEN,
                )

    def test_retry_invalid(self):
        """Try retrying a work request that cannot be retried."""
        self.work_request.status = WorkRequest.Statuses.COMPLETED
        self.work_request.result = WorkRequest.Results.SUCCESS
        self.work_request.save()

        self.client.force_login(self.user)
        response = self.client.post(
            reverse(
                "work_requests:retry",
                kwargs={"pk": self.work_request.pk},
            )
        )
        self.assertRedirects(
            response,
            reverse(
                "work_requests:detail",
                kwargs={"pk": self.work_request.pk},
            ),
        )
        messages = list(get_messages(response.wsgi_request))
        self.assertEqual(len(messages), 1)
        self.assertEqual(
            messages[0].message,
            "Cannot retry: Only aborted or failed tasks can be retried",
        )

    def test_retry_does_not_exist(self):
        """Try retrying a nonexistent work request."""
        pk = self.work_request.pk
        self.work_request.delete()

        self.client.force_login(self.user)
        response = self.client.post(
            reverse(
                "work_requests:retry",
                kwargs={"pk": pk},
            )
        )
        self.assertContains(
            response,
            f"Work request {pk} not found",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_retry(self):
        """Try retrying a work request that cannot be retried."""
        self.client.force_login(self.user)
        response = self.client.post(
            reverse(
                "work_requests:retry",
                kwargs={"pk": self.work_request.pk},
            )
        )

        self.work_request.refresh_from_db()
        self.assertTrue(getattr(self.work_request, "superseded"))
        new_wr = self.work_request.superseded

        self.assertRedirects(
            response,
            reverse(
                "work_requests:detail",
                kwargs={"pk": new_wr.pk},
            ),
        )


class WorkRequestUnblockViewTests(ViewTestMixin, TestCase):
    """Tests for WorkRequestUnblockView."""

    user: ClassVar[User]
    workflow: ClassVar[WorkRequest]
    work_request: ClassVar[WorkRequest]

    @classmethod
    def setUpTestData(cls) -> None:
        """Initialize class data."""
        super().setUpTestData()
        cls.user = cls.get_test_user()
        template = cls.create_workflow_template("test", "noop")
        cls.workflow = WorkRequest.objects.create_workflow(
            template=template,
            data={},
            created_by=cls.user,
            status=WorkRequest.Statuses.RUNNING,
        )
        cls.work_request = WorkRequest.objects.create_synchronization_point(
            parent=cls.workflow,
            step="test",
            status=WorkRequest.Statuses.BLOCKED,
        )
        cls.work_request.unblock_strategy = WorkRequest.UnblockStrategy.MANUAL
        cls.work_request.save()

    def test_not_logged_in(self) -> None:
        """A non-authenticated request cannot review a blocked work request."""
        for method in ("get", "post"):
            with self.subTest(method):
                response = getattr(self.client, method)(
                    reverse(
                        "work_requests:unblock",
                        kwargs={"pk": self.work_request.pk},
                    )
                )

                self.assertContains(
                    response,
                    "You need to be authenticated to review a blocked Work "
                    "Request",
                    status_code=status.HTTP_403_FORBIDDEN,
                    html=True,
                )

    def test_without_permission(self) -> None:
        """A special permission is required."""
        self.client.force_login(self.user)

        response = self.client.post(
            reverse(
                "work_requests:unblock", kwargs={"pk": self.work_request.pk}
            )
        )

        self.assertContains(
            response,
            "You need to be authenticated to review a blocked Work Request",
            status_code=status.HTTP_403_FORBIDDEN,
            html=True,
        )

    def test_does_not_exist(self) -> None:
        """Try unblocking a nonexistent work request."""
        pk = self.work_request.pk
        self.work_request.delete()
        self.add_user_permission(self.user, WorkRequest, "change_workrequest")
        self.client.force_login(self.user)

        response = self.client.post(
            reverse("work_requests:unblock", kwargs={"pk": pk}),
            {"action": "Accept"},
        )

        self.assertContains(
            response,
            f"Work request {pk} not found",
            status_code=status.HTTP_404_NOT_FOUND,
            html=True,
        )

    def test_invalid_form(self) -> None:
        """The view returns 400 if the form is invalid."""
        self.add_user_permission(self.user, WorkRequest, "change_workrequest")
        self.client.force_login(self.user)

        response = self.client.post(
            reverse(
                "work_requests:unblock", kwargs={"pk": self.work_request.pk}
            ),
            {"notes": "\0"},
        )

        self.assertContains(
            response,
            str(ProhibitNullCharactersValidator.message),
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_invalid_action(self) -> None:
        """The view returns 400 if the action parameter is invalid."""
        self.add_user_permission(self.user, WorkRequest, "change_workrequest")
        self.client.force_login(self.user)

        response = self.client.post(
            reverse(
                "work_requests:unblock", kwargs={"pk": self.work_request.pk}
            ),
            {"action": "Nonsense"},
        )

        self.assertContains(
            response,
            "Invalid action parameter: 'Nonsense'",
            status_code=status.HTTP_400_BAD_REQUEST,
            html=True,
        )

    def test_cannot_unblock(self) -> None:
        """The view returns 400 if the work request cannot be unblocked."""
        self.work_request.unblock_strategy = WorkRequest.UnblockStrategy.DEPS
        self.work_request.save()
        self.add_user_permission(self.user, WorkRequest, "change_workrequest")
        self.client.force_login(self.user)

        response = self.client.post(
            reverse(
                "work_requests:unblock", kwargs={"pk": self.work_request.pk}
            ),
            {"action": "Accept"},
        )

        self.assertContains(
            response,
            f"Cannot unblock: Work request {self.work_request.pk} cannot be "
            f"manually unblocked",
            status_code=status.HTTP_400_BAD_REQUEST,
            html=True,
        )

    def test_accept(self) -> None:
        """Accept a blocked work request."""
        self.add_user_permission(self.user, WorkRequest, "change_workrequest")
        self.client.force_login(self.user)

        response = self.client.post(
            reverse(
                "work_requests:unblock", kwargs={"pk": self.work_request.pk}
            ),
            {"action": "Accept"},
        )

        self.assertRedirects(
            response,
            reverse(
                "work_requests:detail", kwargs={"pk": self.work_request.pk}
            ),
        )
        self.work_request.refresh_from_db()
        self.assertEqual(self.work_request.status, WorkRequest.Statuses.PENDING)
        assert self.work_request.workflow_data is not None
        manual_unblock = self.work_request.workflow_data.manual_unblock
        assert manual_unblock is not None
        self.assertEqual(len(manual_unblock.log), 1)
        self.assertEqual(manual_unblock.log[0].user_id, self.user.id)
        self.assertLess(manual_unblock.log[0].timestamp, timezone.now())
        self.assertIsNone(manual_unblock.log[0].notes)
        self.assertEqual(
            manual_unblock.log[0].action, WorkRequestManualUnblockAction.ACCEPT
        )

    def test_reject(self) -> None:
        """Reject a blocked work request."""
        self.add_user_permission(self.user, WorkRequest, "change_workrequest")
        self.client.force_login(self.user)

        response = self.client.post(
            reverse(
                "work_requests:unblock", kwargs={"pk": self.work_request.pk}
            ),
            {"notes": "Go away", "action": "Reject"},
        )

        self.assertRedirects(
            response,
            reverse(
                "work_requests:detail", kwargs={"pk": self.work_request.pk}
            ),
        )
        self.work_request.refresh_from_db()
        self.assertEqual(self.work_request.status, WorkRequest.Statuses.ABORTED)
        assert self.work_request.workflow_data is not None
        manual_unblock = self.work_request.workflow_data.manual_unblock
        assert manual_unblock is not None
        self.assertEqual(len(manual_unblock.log), 1)
        self.assertEqual(manual_unblock.log[0].user_id, self.user.id)
        self.assertLess(manual_unblock.log[0].timestamp, timezone.now())
        self.assertEqual(manual_unblock.log[0].notes, "Go away")
        self.assertEqual(
            manual_unblock.log[0].action, WorkRequestManualUnblockAction.REJECT
        )

    def test_record_notes_only(self) -> None:
        """Record notes on a blocked work request."""
        self.add_user_permission(self.user, WorkRequest, "change_workrequest")
        self.client.force_login(self.user)

        response = self.client.post(
            reverse(
                "work_requests:unblock", kwargs={"pk": self.work_request.pk}
            ),
            {"notes": "Not sure", "action": "Record notes only"},
        )

        self.assertRedirects(
            response,
            reverse(
                "work_requests:detail", kwargs={"pk": self.work_request.pk}
            ),
        )
        self.work_request.refresh_from_db()
        self.assertEqual(self.work_request.status, WorkRequest.Statuses.BLOCKED)
        assert self.work_request.workflow_data is not None
        manual_unblock = self.work_request.workflow_data.manual_unblock
        assert manual_unblock is not None
        self.assertEqual(len(manual_unblock.log), 1)
        self.assertEqual(manual_unblock.log[0].user_id, self.user.id)
        self.assertLess(manual_unblock.log[0].timestamp, timezone.now())
        self.assertEqual(manual_unblock.log[0].notes, "Not sure")
        self.assertIsNone(manual_unblock.log[0].action)
