# Copyright 2021-2024 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 re
from base64 import b64encode
from contextlib import contextmanager
from datetime import datetime
from datetime import timezone as tz
from typing import Any, ClassVar, TYPE_CHECKING
from unittest import mock

from django.contrib.auth import get_user_model
from django.db.models import Max
from django.http import HttpResponse
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from django.utils.http import urlencode
from rest_framework import status

from debusine.artifacts.models import ArtifactCategory
from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    DEFAULT_WORKSPACE_NAME,
    Token,
    WorkRequest,
    Worker,
)
from debusine.server.serializers import WorkRequestSerializer
from debusine.server.views.work_requests import WorkRequestPagination
from debusine.server.workflows.models import WorkRequestManualUnblockAction
from debusine.tasks.models import TaskTypes
from debusine.test.django import TestCase
from debusine.test.utils import date_time_to_isoformat_rest_framework

if TYPE_CHECKING:
    from django.test.client import _MonkeyPatchedWSGIResponse


class WorkRequestTestCase(TestCase):
    """Helper methods for tests with WorkRequest as response."""

    def check_response_for_work_request(
        self,
        response: "_MonkeyPatchedWSGIResponse",
        work_request: WorkRequest,
        dynamic_task_data: dict[str, Any] | None = None,
    ):
        """Ensure the json data corresponds to the supplied work request."""

        def _timestamp(ts: datetime | None) -> str | None:
            if ts is None:
                return None
            return date_time_to_isoformat_rest_framework(ts)

        artifact_ids = list(
            work_request.artifact_set.all()
            .order_by("id")
            .values_list("id", flat=True)
        )

        self.assertEqual(
            response.json(),
            {
                'id': work_request.pk,
                'task_name': work_request.task_name,
                'created_at': _timestamp(work_request.created_at),
                'started_at': _timestamp(work_request.started_at),
                'completed_at': _timestamp(work_request.completed_at),
                'duration': work_request.duration,
                'worker': work_request.worker_id,
                'task_type': work_request.task_type,
                'task_data': work_request.task_data,
                'dynamic_task_data': (
                    dynamic_task_data
                    if work_request.dynamic_task_data is None
                    else work_request.dynamic_task_data
                ),
                'priority_base': work_request.priority_base,
                'priority_adjustment': work_request.priority_adjustment,
                'workflow_data': work_request.workflow_data_json,
                'event_reactions': work_request.event_reactions_json,
                'status': work_request.status,
                'result': work_request.result,
                'artifacts': artifact_ids,
                'workspace': DEFAULT_WORKSPACE_NAME,
                'created_by': work_request.created_by.id,
            },
        )

    def check_response_for_work_request_list(
        self,
        response: "_MonkeyPatchedWSGIResponse",
        work_requests: list[WorkRequest],
        start: int = 0,
        page_size: int | None = None,
    ):
        """Ensure the json data corresponds to this list of work requests."""

        def _timestamp(ts: datetime | None) -> str | None:
            if ts is None:
                return None
            return date_time_to_isoformat_rest_framework(ts)

        def _encode_cursor(
            *, reverse: bool = False, position: str | None = None
        ) -> str:
            tokens: dict[str, int | str] = {}
            if reverse:
                tokens['r'] = 1
            if position is not None:  # pragma: no cover
                tokens['p'] = position
            querystring = urlencode(tokens, doseq=True)
            return b64encode(querystring.encode('ascii')).decode('ascii')

        next_url = None
        previous_url = None
        if page_size is not None:
            if start + page_size < len(work_requests):
                next_cursor = _encode_cursor(
                    position=str(
                        work_requests[start + page_size - 1].created_at
                    )
                )
                next_url = (
                    'http://testserver'
                    + reverse('api:work-requests')
                    + '?'
                    + urlencode({'cursor': next_cursor})
                )
            if start > 0:
                previous_cursor = _encode_cursor(
                    position=str(work_requests[start].created_at),
                    reverse=True,
                )
                previous_url = (
                    'http://testserver'
                    + reverse('api:work-requests')
                    + '?'
                    + urlencode({'cursor': previous_cursor})
                )
            work_requests = work_requests[start : start + page_size]

        self.assertEqual(
            response.json(),
            {
                'next': next_url,
                'previous': previous_url,
                'results': [
                    {
                        'id': work_request.pk,
                        'task_name': work_request.task_name,
                        'created_at': _timestamp(work_request.created_at),
                        'started_at': _timestamp(work_request.started_at),
                        'completed_at': _timestamp(work_request.completed_at),
                        'duration': work_request.duration,
                        'worker': work_request.worker_id,
                        'task_type': work_request.task_type,
                        'task_data': work_request.task_data,
                        'dynamic_task_data': None,
                        'priority_base': work_request.priority_base,
                        'priority_adjustment': work_request.priority_adjustment,
                        'workflow_data': work_request.workflow_data_json,
                        'event_reactions': work_request.event_reactions_json,
                        'status': work_request.status,
                        'result': work_request.result,
                        'artifacts': list(
                            work_request.artifact_set.all()
                            .order_by("id")
                            .values_list("id", flat=True)
                        ),
                        'workspace': DEFAULT_WORKSPACE_NAME,
                        'created_by': work_request.created_by.id,
                    }
                    for work_request in work_requests
                ],
            },
        )

    def assert_response_work_request_id_exists(self, response):
        """Assert that there is a WorkRequest with id == response["id"]."""
        work_request_id = response.json()["id"]
        self.assertTrue(WorkRequest.objects.filter(id=work_request_id).exists())


class WorkRequestViewTests(WorkRequestTestCase):
    """Tests for WorkRequestView class."""

    artifact: Artifact

    def setUp(self):
        """Set up common data."""
        super().setUp()
        self.worker_01 = Worker.objects.create_with_fqdn(
            "worker-01", self.create_token_enabled()
        )

        # This token would be used by a debusine client using the API
        self.token = self.create_token_enabled(with_user=True)

    @classmethod
    def setUpTestData(cls) -> None:
        """Set up common test data."""
        super().setUpTestData()
        cls.artifact, _ = cls.playground.create_artifact()

    def create_test_work_request(self):
        """Create a work request for testing."""
        self.work_request_01 = self.create_work_request(
            worker=self.worker_01,
            task_name='sbuild',
            task_data={'architecture': 'amd64'},
            status=WorkRequest.Statuses.PENDING,
        )
        self.work_request_01.created_at = datetime(
            2022, 2, 22, 16, 31, 24, 242244, tz.utc
        )
        self.work_request_01.save()

    def create_superuser_token(self):
        """Create a token for a superuser."""
        user = get_user_model().objects.create_superuser(
            username="testsuperuser",
            email="superuser@mail.none",
            password="testsuperpass",
        )
        return self.create_token_enabled(user=user)

    def post_work_requests(
        self,
        work_request_serialized: dict[str, Any],
        token: Token | None = None,
    ) -> "_MonkeyPatchedWSGIResponse":
        """Post work_request to the endpoint api:work-requests."""
        if token is None:
            token = self.token

        response = self.client.post(
            reverse('api:work-requests'),
            data=work_request_serialized,
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

        return response

    def test_check_permissions_get_token_must_be_enabled(self):
        """An enabled token is needed to request a WorkRequest."""
        token = self.create_token_enabled()
        token.disable()
        token.save()
        self.create_test_work_request()

        response = self.client.get(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            HTTP_TOKEN=token.key,
        )

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

    def test_check_permissions_post_token_must_have_user_associated(self):
        """The token must have a user associated for creating a WorkRequest."""
        token = self.create_token_enabled()

        work_request_serialized = {
            "task_name": "sbuild",
            "task_data": {"foo": "bar"},
        }

        response = self.client.post(
            reverse('api:work-requests'),
            data=work_request_serialized,
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

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

    def test_get_work_request_success(self):
        """Return HTTP 200 and the correct information for the WorkRequest."""
        self.create_test_work_request()

        response = self.client.get(
            reverse(
                'api:work-request-detail',
                kwargs={'work_request_id': self.work_request_01.id},
            ),
            HTTP_TOKEN=self.worker_01.token.key,
        )

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

    def test_get_work_request_not_found(self):
        """Return HTTP 404 if the WorkRequest id does not exist."""
        self.create_test_work_request()

        max_id = WorkRequest.objects.aggregate(Max('id'))['id__max']
        response = self.client.get(
            reverse(
                'api:work-request-detail',
                kwargs={'work_request_id': max_id + 1},
            ),
            HTTP_TOKEN=self.worker_01.token.key,
        )

        self.assertEqual(
            response.json(), {'detail': 'Work request id not found'}
        )
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

    def verify_post_success(
        self,
        *,
        workspace_name: str | None = None,
        input_artifact_id: int | None = None,
    ):
        """Client POST a WorkRequest and verifies it gets created."""
        work_request_serialized = {
            'task_name': 'sbuild',
            'task_data': {
                'input': {
                    'source_artifact': input_artifact_id or self.artifact.id
                },
                'host_architecture': 'amd64',
                'backend': 'schroot',
                'distribution': 'bookworm',
            },
        }

        if workspace_name is not None:
            work_request_serialized['workspace'] = workspace_name

        with (
            self.captureOnCommitCallbacks(execute=True),
            mock.patch(
                "debusine.server.scheduler.schedule_task.delay"
            ) as mock_schedule_task_delay,
        ):
            response = self.post_work_requests(work_request_serialized)
        work_request = WorkRequest.objects.latest('created_at')

        # Response contains the serialized WorkRequest
        self.assertEqual(
            response.json(), WorkRequestSerializer(work_request).data
        )

        # Response contains fields that were posted
        self.assertDictContainsAll(response.json(), work_request_serialized)

        # A scheduler run was requested via Celery
        mock_schedule_task_delay.assert_called_once()

        expected_workspace_name = workspace_name or DEFAULT_WORKSPACE_NAME
        self.assertEqual(response.json()["workspace"], expected_workspace_name)

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

    @override_settings(DISABLE_AUTOMATIC_SCHEDULING=False)
    def test_post_success_workspace_testing(self):
        """Client POST a WorkRequest with a specific workspace."""
        workspace = self.playground.create_workspace()
        workspace.name = "Testing"
        workspace.save()

        artifact, _ = self.playground.create_artifact(workspace=workspace)
        self.verify_post_success(
            workspace_name=workspace.name, input_artifact_id=artifact.id
        )

    @override_settings(DISABLE_AUTOMATIC_SCHEDULING=False)
    def test_post_success_workspace_default(self):
        """Client POST a WorkRequest without specifying a workspace."""
        self.verify_post_success()

    @override_settings(DISABLE_AUTOMATIC_SCHEDULING=False)
    def test_post_success_workspace_default_superuser(self):
        """Client POST a WorkRequest without specifying a workspace."""
        self.token = self.create_superuser_token()
        self.verify_post_success()

    def test_post_task_name_invalid(self):
        """Client POST a WorkRequest its task_name is invalid."""
        work_request_serialized = {
            'task_name': 'bad-name',
            'task_data': {'foo': 'bar'},
        }
        with self.assert_work_request_count_unchanged():
            response = self.post_work_requests(work_request_serialized)

        self.assertResponseProblem(
            response,
            "Cannot create work request: task name is not registered",
            '^Task name: "bad-name". Registered task names: .*sbuild.*$',
        )

    def test_post_non_worker_task(self):
        """Client POST a WorkRequest without enough rights for that type."""
        work_request_serialized = {
            'task_name': 'servernoop',
            'task_data': {'foo': 'bar'},
        }
        with self.assert_work_request_count_unchanged():
            response = self.post_work_requests(work_request_serialized)

        self.assertResponseProblem(
            response,
            "Cannot create work request: task name is not registered",
            '^Task name: "servernoop"',
        )

    def test_post_non_worker_task_superuser(self):
        """Superusers can POST a server task."""
        token = self.create_superuser_token()

        work_request_serialized = {
            'task_name': 'servernoop',
            'task_data': {},
        }
        response = self.post_work_requests(work_request_serialized, token=token)

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

    def test_post_work_request_too_many_fields(self):
        """Post request with too many fields: returns HTTP 400 and error."""
        work_request_serialized = {
            'task_name': 'sbuild',
            'task_data': {'foo': 'bar'},
            'result': 'SUCCESS',
        }
        response = self.post_work_requests(work_request_serialized)

        with self.assert_work_request_count_unchanged():
            self.assertResponseProblem(
                response, "Cannot deserialize work request"
            )

        self.assertIsInstance(response.json()["validation_errors"], dict)

    def test_post_work_request_invalid_channel(self):
        """Post request with an invalid channel: returns HTTP 400 and error."""
        channel = "non-existing"
        work_request_serialized = {
            "task_name": "sbuild",
            "event_reactions": {
                "on_failure": [
                    {"action": "send-notification", "channel": channel}
                ]
            },
        }
        with self.assert_work_request_count_unchanged():
            response = self.post_work_requests(work_request_serialized)

        self.assertResponseProblem(
            response, f"Non-existing channels: ['{channel}']"
        )

    def test_post_work_request_invalid_reaction_events(self):
        """Post request with an invalid channel: returns HTTP 400 and error."""
        work_request_serialized = {
            "task_name": "sbuild",
            "event_reactions": {"on_invalid": []},
        }
        with self.assert_work_request_count_unchanged():
            response = self.post_work_requests(work_request_serialized)

        self.assertResponseProblem(response, "Cannot deserialize work request")
        self.assertIn(
            "Invalid event_reactions", str(response.json()["validation_errors"])
        )

    def test_post_work_request_invalid_data(self):
        """Post request with an invalid data: returns HTTP 400 and error."""
        work_request_serialized = {
            "task_name": "sbuild",
            "event_reactions": {"on_failure": [{"channel": "my-changes"}]},
        }
        with self.assert_work_request_count_unchanged():
            response = self.post_work_requests(work_request_serialized)

        self.assertResponseProblem(response, "Cannot deserialize work request")
        self.assertIn(
            "Invalid event_reactions", str(response.json()["validation_errors"])
        )

    def test_post_work_request_compute_dynamic_data_raise_exception(self):
        """
        Post request with data which compute_dynamic_data_raise exception.

        Returns HTTP 400 and error.
        """
        work_request_serialized = {
            "task_name": "sbuild",
            "task_data": {
                "build_components": ["any", "all"],
                "host_architecture": "amd64",
                "input": {"source_artifact": 536},
                "environment": "NON-EXISTING/match:codename=trixie",
            },
        }
        with self.assert_work_request_count_unchanged():
            response = self.post_work_requests(work_request_serialized)

        self.assertResponseProblem(
            response,
            "Cannot create work request: error computing dynamic data",
            detail_pattern="^Task data: .*Error: .*",
        )

    def test_post_work_request_disallowed_action(self):
        """Action other than send-notification: returns HTTP 400 and error."""
        work_request_serialized = {
            "task_name": "sbuild",
            "event_reactions": {
                "on_success": [
                    {
                        "action": "update-collection-with-artifacts",
                        "collection": 1,
                        "artifact_filters": {},
                    }
                ]
            },
        }
        with self.assert_work_request_count_unchanged():
            response = self.post_work_requests(work_request_serialized)

        self.assertResponseProblem(
            response,
            "Invalid event_reactions",
            detail_pattern=(
                "Action type 'update-collection-with-artifacts' is not "
                "allowed here"
            ),
        )

    def test_list_work_request_empty(self):
        """Return an empty list if there are no work requests."""
        response = self.client.get(
            reverse('api:work-requests'), HTTP_TOKEN=self.worker_01.token.key
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.check_response_for_work_request_list(response, [])

    def test_list_work_request_one_page(self):
        """Return a single page if there are only a few work requests."""
        work_requests = []
        for _ in range(3):
            work_request = self.create_work_request(
                worker=self.worker_01,
                task_name='sbuild',
                task_data={'architecture': 'amd64'},
                status=WorkRequest.Statuses.PENDING,
            )
            work_request.save()
            work_requests.insert(0, work_request)

        with mock.patch.object(WorkRequestPagination, 'page_size', 5):
            response = self.client.get(
                reverse('api:work-requests'),
                HTTP_TOKEN=self.worker_01.token.key,
            )

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

    def test_list_work_request_multiple_pages(self):
        """Return multiple pages if there are many work requests."""
        work_requests = []
        for _ in range(10):
            work_request = self.create_work_request(
                worker=self.worker_01,
                task_name='sbuild',
                task_data={'architecture': 'amd64'},
                status=WorkRequest.Statuses.PENDING,
            )
            work_request.save()
            work_requests.insert(0, work_request)

        with mock.patch.object(WorkRequestPagination, 'page_size', 5):
            response = self.client.get(
                reverse('api:work-requests'),
                HTTP_TOKEN=self.worker_01.token.key,
            )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.check_response_for_work_request_list(
            response, work_requests, start=0, page_size=5
        )

        with mock.patch.object(WorkRequestPagination, 'page_size', 5):
            response = self.client.get(
                response.json()['next'],
                HTTP_TOKEN=self.worker_01.token.key,
            )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.check_response_for_work_request_list(
            response, work_requests, start=5, page_size=5
        )

    def test_patch_work_request_success(self):
        """Patch a work request and return its new representation."""
        self.create_test_work_request()
        token = self.create_token_enabled(with_user=True)
        self.add_user_permission(
            token.user, WorkRequest, "manage_workrequest_priorities"
        )

        response = self.client.patch(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            data={"priority_adjustment": 100},
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.work_request_01.refresh_from_db()
        self.assertEqual(self.work_request_01.priority_adjustment, 100)
        self.check_response_for_work_request(response, self.work_request_01)

    def test_patch_work_request_empty(self):
        """An empty patch to a work request does nothing."""
        self.create_test_work_request()
        token = self.create_token_enabled(with_user=True)

        response = self.client.patch(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            data={},
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.work_request_01.refresh_from_db()
        self.assertEqual(self.work_request_01.priority_adjustment, 0)
        self.check_response_for_work_request(response, self.work_request_01)

    def test_patch_work_request_anonymous(self):
        """Anonymous users may not patch work requests."""
        self.create_test_work_request()

        response = self.client.patch(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            data={"priority_adjustment": -100},
            content_type="application/json",
        )

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="Authentication credentials were not provided.",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_patch_work_request_own_request_negative_adjustment(self):
        """Users can set negative adjustments on their own work requests."""
        self.create_test_work_request()
        token = self.create_token_enabled(user=self.work_request_01.created_by)

        response = self.client.patch(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            data={"priority_adjustment": -100},
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.work_request_01.refresh_from_db()
        self.assertEqual(self.work_request_01.priority_adjustment, -100)
        self.check_response_for_work_request(response, self.work_request_01)

    def test_patch_work_request_own_request_positive_adjustment(self):
        """Users cannot set positive adjustments on their own work requests."""
        self.create_test_work_request()
        token = self.create_token_enabled(user=self.work_request_01.created_by)

        response = self.client.patch(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            data={"priority_adjustment": 100},
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

        self.assertResponseProblem(
            response,
            "You are not permitted to set priority adjustments",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_patch_work_request_without_permission(self):
        """Setting positive adjustments requires a special permission."""
        self.create_test_work_request()
        token = self.create_token_enabled(with_user=True)

        response = self.client.patch(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            data={"priority_adjustment": 100},
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

        self.assertResponseProblem(
            response,
            "You are not permitted to set priority adjustments",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_patch_work_request_disallowed_property(self):
        """Only certain properties of work requests may be patched."""
        self.create_test_work_request()
        token = self.create_token_enabled(with_user=True)
        self.add_user_permission(
            token.user, WorkRequest, "manage_workrequest_priorities"
        )

        response = self.client.patch(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            data={"priority_base": 100},
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

        self.assertResponseProblem(
            response, "Error", detail_pattern="Invalid fields: priority_base"
        )

    @contextmanager
    def assert_work_request_count_unchanged(self):
        """Context manager to assert count of WorkRequests is unchanged."""
        initial_count = WorkRequest.objects.count()
        try:
            yield
        finally:
            self.assertEqual(WorkRequest.objects.count(), initial_count)


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

    work_request: ClassVar[WorkRequest]
    token: ClassVar[Token]

    @classmethod
    def setUpTestData(cls):
        """Set up common data."""
        super().setUpTestData()
        cls.work_request = cls.create_work_request(task_name="noop")
        # This token would be used by a debusine client using the API
        cls.token = cls.create_token_enabled(with_user=True)

    def post(
        self,
        work_request_id: int | None = None,
        token: Token | None = None,
    ) -> "_MonkeyPatchedWSGIResponse":
        """Post work_request to the endpoint api:work-requests."""
        return self.client.post(
            reverse(
                'api:work-requests-retry',
                kwargs={
                    "work_request_id": (
                        self.work_request.id
                        if work_request_id is None
                        else work_request_id
                    )
                },
            ),
            data={},
            HTTP_TOKEN=(token or self.token).key,
            content_type="application/json",
        )

    def test_check_permissions_post_token_must_be_enabled(self):
        """An enabled token is needed to retry a WorkRequest."""
        self.token.disable()
        self.token.save()
        response = self.post(token=self.token)
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_check_permissions_post_token_must_have_user_associated(self):
        """The token must have a user for retrying a WorkRequest."""
        self.token.user = None
        self.token.save()
        response = self.post(token=self.token)
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_post_work_request_not_found(self):
        """Return HTTP 404 if the WorkRequest id does not exist."""
        max_id = WorkRequest.objects.aggregate(Max('id'))['id__max']
        response = self.post(work_request_id=max_id + 1)
        self.assertEqual(
            response.json(), {'detail': 'Work request id not found'}
        )
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

    def test_post_cannot_retry(self):
        """Target work request cannot be retried."""
        self.assertFalse(self.work_request.can_retry())
        response = self.post()
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertResponseProblem(
            response,
            "Cannot retry work request",
            detail_pattern="Only aborted or failed tasks can be retried",
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_post_success(self):
        """Client POST retries the WorkRequest and the new gets created."""
        self.work_request.status = WorkRequest.Statuses.ABORTED
        self.work_request.save()
        self.assertFalse(hasattr(self.work_request, "superseded"))
        self.assertTrue(self.work_request.can_retry())
        response = self.post()
        self.assertEqual(response.status_code, status.HTTP_200_OK)

        self.work_request.refresh_from_db()
        self.assertTrue(hasattr(self.work_request, "superseded"))

        # Response contains the new WorkRequest
        self.assertEqual(
            response.json(),
            WorkRequestSerializer(self.work_request.superseded).data,
        )


class WorkRequestUnblockViewTests(WorkRequestTestCase):
    """Tests for :py:class:`WorkRequestUnblockView`."""

    workflow: ClassVar[WorkRequest]
    work_request: ClassVar[WorkRequest]
    token: ClassVar[Token]

    @classmethod
    def setUpTestData(cls) -> None:
        """Set up common data."""
        super().setUpTestData()
        template = cls.playground.create_workflow_template("test", "noop")
        cls.workflow = WorkRequest.objects.create_workflow(
            template=template,
            data={},
            created_by=cls.get_test_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()
        # This token would be used by a debusine client using the API
        cls.token = cls.create_token_enabled(with_user=True)

    def post(
        self,
        work_request_id: int | None = None,
        token: Token | None = None,
        notes: str | None = None,
        action: WorkRequestManualUnblockAction | None = None,
    ) -> "_MonkeyPatchedWSGIResponse":
        """Post work_request to the endpoint api:work-request-unblock."""
        data: dict[str, str] = {}
        if notes is not None:
            data["notes"] = notes
        if action is not None:
            data["action"] = action
        return self.client.post(
            reverse(
                'api:work-request-unblock',
                kwargs={
                    "work_request_id": (
                        self.work_request.id
                        if work_request_id is None
                        else work_request_id
                    )
                },
            ),
            data=data,
            HTTP_TOKEN=(token or self.token).key,
            content_type="application/json",
        )

    def test_post_token_must_be_enabled(self) -> None:
        """An enabled token is needed to unblock a WorkRequest."""
        self.token.disable()
        self.token.save()

        response = self.post(token=self.token)

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

    def test_post_token_must_have_user_associated(self) -> None:
        """The token must have a user for unblocking a WorkRequest."""
        self.token.user = None
        self.token.save()

        response = self.post(token=self.token)

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

    def test_post_without_permission(self) -> None:
        """Unblocking work requests requires a special permission."""
        response = self.post(work_request_id=self.work_request.id, notes="x")

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="You do not have permission to perform this action.",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_post_neither_notes_nor_action(self) -> None:
        """At least one of notes and action must be set."""
        assert self.token.user is not None
        self.add_user_permission(
            self.token.user, WorkRequest, "change_workrequest"
        )
        response = self.post(work_request_id=self.work_request.id)

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="At least one of notes and action must be set",
        )

    def test_post_work_request_not_found(self) -> None:
        """Return HTTP 404 if the WorkRequest id does not exist."""
        assert self.token.user is not None
        self.add_user_permission(
            self.token.user, WorkRequest, "change_workrequest"
        )
        max_id = WorkRequest.objects.aggregate(Max("id"))["id__max"]

        response = self.post(work_request_id=max_id + 1)

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="No WorkRequest matches the given query.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_post_not_manual(self) -> None:
        """The work request must have the manual unblock strategy."""
        assert self.token.user is not None
        self.add_user_permission(
            self.token.user, WorkRequest, "change_workrequest"
        )
        self.work_request.unblock_strategy = WorkRequest.UnblockStrategy.DEPS
        self.work_request.save()

        response = self.post(work_request_id=self.work_request.id, notes="x")

        self.assertResponseProblem(
            response,
            f"Work request {self.work_request.id} cannot be manually unblocked",
        )

    def test_post_not_part_of_workflow(self) -> None:
        """The work request must be part of a workflow."""
        assert self.token.user is not None
        self.add_user_permission(
            self.token.user, WorkRequest, "change_workrequest"
        )
        work_request = self.create_work_request(
            task_name="noop",
            status=WorkRequest.Statuses.BLOCKED,
            unblock_strategy=WorkRequest.UnblockStrategy.MANUAL,
        )

        response = self.post(work_request_id=work_request.id, notes="x")

        self.assertResponseProblem(
            response,
            f"Work request {work_request.id} is not part of a workflow",
        )

    def test_post_not_blocked(self) -> None:
        """The work request must currently be blocked."""
        assert self.token.user is not None
        self.add_user_permission(
            self.token.user, WorkRequest, "change_workrequest"
        )
        self.work_request.status = WorkRequest.Statuses.PENDING
        self.work_request.save()

        response = self.post(work_request_id=self.work_request.id, notes="x")

        self.assertResponseProblem(
            response, f"Work request {self.work_request.id} cannot be unblocked"
        )

    def test_post_accept(self) -> None:
        """Accept a blocked work request."""
        assert self.token.user is not None
        self.add_user_permission(
            self.token.user, WorkRequest, "change_workrequest"
        )

        response = self.post(
            work_request_id=self.work_request.id,
            action=WorkRequestManualUnblockAction.ACCEPT,
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        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.token.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
        )
        assert isinstance(response, HttpResponse)
        self.check_response_for_work_request(response, self.work_request)

    def test_post_reject(self) -> None:
        """Reject a blocked work request."""
        assert self.token.user is not None
        self.add_user_permission(
            self.token.user, WorkRequest, "change_workrequest"
        )

        response = self.post(
            work_request_id=self.work_request.id,
            notes="Go away",
            action=WorkRequestManualUnblockAction.REJECT,
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        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.token.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
        )
        assert isinstance(response, HttpResponse)
        self.check_response_for_work_request(response, self.work_request)

    def test_post_record_notes_only(self) -> None:
        """Record notes on a blocked work request."""
        assert self.token.user is not None
        self.add_user_permission(
            self.token.user, WorkRequest, "change_workrequest"
        )

        response = self.post(
            work_request_id=self.work_request.id, notes="Not sure"
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        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.token.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)
        assert isinstance(response, HttpResponse)
        self.check_response_for_work_request(response, self.work_request)

    def test_post_reject_retry_accept(self) -> None:
        """Reject a blocked work request, then retry it, then accept it."""
        assert self.token.user is not None
        self.add_user_permission(
            self.token.user, WorkRequest, "change_workrequest"
        )

        response = self.post(
            work_request_id=self.work_request.id,
            notes="Go away",
            action=WorkRequestManualUnblockAction.REJECT,
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        reject_timestamp = timezone.now()

        self.work_request.refresh_from_db()
        retried_work_request = self.work_request.retry()

        response = self.post(
            work_request_id=retried_work_request.id,
            notes="OK then",
            action=WorkRequestManualUnblockAction.ACCEPT,
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        retried_work_request.refresh_from_db()
        self.assertEqual(
            retried_work_request.status, WorkRequest.Statuses.PENDING
        )
        assert retried_work_request.workflow_data is not None
        manual_unblock = retried_work_request.workflow_data.manual_unblock
        assert manual_unblock is not None
        self.assertEqual(len(manual_unblock.log), 2)
        self.assertEqual(manual_unblock.log[0].user_id, self.token.user.id)
        self.assertLess(manual_unblock.log[0].timestamp, reject_timestamp)
        self.assertEqual(manual_unblock.log[0].notes, "Go away")
        self.assertEqual(
            manual_unblock.log[0].action, WorkRequestManualUnblockAction.REJECT
        )
        self.assertEqual(manual_unblock.log[1].user_id, self.token.user.id)
        self.assertGreater(manual_unblock.log[1].timestamp, reject_timestamp)
        self.assertLess(manual_unblock.log[1].timestamp, timezone.now())
        self.assertEqual(manual_unblock.log[1].notes, "OK then")
        self.assertEqual(
            manual_unblock.log[1].action, WorkRequestManualUnblockAction.ACCEPT
        )
        assert isinstance(response, HttpResponse)
        self.check_response_for_work_request(response, retried_work_request)


class WorkRequestExternalDebsignViewTests(WorkRequestTestCase):
    """Tests for :py:class:`WorkRequestExternalDebsignView`."""

    unsigned_files: ClassVar[dict[str, bytes]]
    unsigned: ClassVar[Artifact]
    work_request: ClassVar[WorkRequest]
    token: ClassVar[Token]

    @classmethod
    def setUpTestData(cls) -> None:
        """Set up common data."""
        super().setUpTestData()
        cls.unsigned_files = {
            "foo_1.0.orig.tar.gz": b"orig.tar.gz",
            "foo_1.0.dsc": b"unsigned dsc",
            "foo_1.0_source.changes": b"unsigned changes",
        }
        cls.unsigned, _ = cls.playground.create_artifact(
            paths=cls.unsigned_files,
            category=ArtifactCategory.UPLOAD,
            create_files=True,
            skip_add_files_in_store=True,
        )
        cls.work_request = cls.playground.create_work_request(
            mark_running=True,
            task_type=TaskTypes.WAIT,
            task_name="externaldebsign",
            task_data={"unsigned": f"{cls.unsigned.id}@artifacts"},
        )
        cls.token = cls.create_token_enabled(with_user=True)

    def get(
        self, *, work_request_id: int | None = None, token: Token | None = None
    ) -> "_MonkeyPatchedWSGIResponse":
        """
        Get the work request from api:work-request-external-debsign.

        :param work_request_id: ID of the work request to get, defaulting to
          the ID of `self.work_request`.
        :param token: authentication token to send, defaulting to
          `self.token`.
        """
        return self.client.get(
            reverse(
                "api:work-request-external-debsign",
                kwargs={
                    "work_request_id": (
                        self.work_request.id
                        if work_request_id is None
                        else work_request_id
                    )
                },
            ),
            HTTP_TOKEN=(token or self.token).key,
        )

    def post(
        self,
        *,
        work_request_id: int | None = None,
        token: Token | None = None,
        signed: Artifact,
    ) -> "_MonkeyPatchedWSGIResponse":
        """
        Post a signed artifact to api:work-request-external-debsign.

        :param work_request_id: ID of the work request to post to,
          defaulting to the ID of `self.work_request`.
        :param token: authentication token to send, defaulting to
          `self.token`.
        :param signed: the signed artifact to post.
        """
        return self.client.post(
            reverse(
                "api:work-request-external-debsign",
                kwargs={
                    "work_request_id": (
                        self.work_request.id
                        if work_request_id is None
                        else work_request_id
                    )
                },
            ),
            data={"signed_artifact": signed.id},
            HTTP_TOKEN=(token or self.token).key,
            content_type="application/json",
        )

    @classmethod
    def create_signed_artifact(
        cls,
        paths: list[str] | dict[str, bytes] | None = None,
        category: ArtifactCategory = ArtifactCategory.UPLOAD,
    ) -> Artifact:
        """
        Create a signed upload artifact.

        :param paths: list of paths to create (with random data), or
          dictionary mapping paths to create to their data; defaults to a
          structurally-valid signed upload
        :param category: the upload artifact category, defaulting to
          `debian:upload`
        """
        signed, _ = cls.playground.create_artifact(
            paths=(
                paths
                or {
                    **cls.unsigned_files,
                    "foo_1.0.dsc": b"signed dsc",
                    "foo_1.0_source.changes": b"signed changes",
                }
            ),
            category=category,
            create_files=True,
            skip_add_files_in_store=True,
        )
        return signed

    def test_get_token_must_be_enabled(self) -> None:
        """A `GET` request needs an enabled token."""
        self.token.disable()
        self.token.save()

        response = self.get()

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

    def test_get_token_must_have_user_associated(self) -> None:
        """A `GET` request needs a token with a user."""
        self.token.user = None
        self.token.save()

        response = self.get()

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

    def test_get_work_request_not_found(self) -> None:
        """A `GET` request returns 404 if the work request does not exist."""
        max_id = WorkRequest.objects.aggregate(Max("id"))["id__max"]

        response = self.get(work_request_id=max_id + 1)

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="No WorkRequest matches the given query.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_get_wrong_work_request(self) -> None:
        """A `GET` request requires the right kind of work request."""
        work_request = self.create_work_request(
            task_type=TaskTypes.WORKER, task_name="noop"
        )

        response = self.get(work_request_id=work_request.id)

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern=(
                "Expected work request to be Wait/ExternalDebsign; got "
                "Worker/noop"
            ),
        )

    def test_get_work_request_not_running(self) -> None:
        """A `GET` request requires that the work request be running."""
        self.work_request.mark_aborted()

        response = self.get()

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="Work request is not running. Status: aborted",
        )

    def test_get_bad_task_data(self) -> None:
        """A `GET` request fails on bad task data."""
        self.work_request.task_data = {"unsigned": ""}
        self.work_request.save()

        response = self.get()

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="Cannot process task data: Empty lookup",
        )

    def test_get_success(self) -> None:
        """A successful `GET` request returns the serialized work request."""
        response = self.get()

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.work_request.refresh_from_db()
        self.check_response_for_work_request(
            response,
            self.work_request,
            dynamic_task_data={"unsigned_id": self.unsigned.id},
        )

    def test_get_different_user(self) -> None:
        """A `GET` request does not have to be sent by the WR creator."""
        user = get_user_model().objects.create_user(
            username="another", email="another@example.org"
        )
        token = self.create_token_enabled(user=user)

        response = self.get(token=token)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.work_request.refresh_from_db()
        self.maxDiff = None
        self.check_response_for_work_request(
            response,
            self.work_request,
            dynamic_task_data={"unsigned_id": self.unsigned.id},
        )

    def test_post_token_must_be_enabled(self) -> None:
        """A `POST` request needs an enabled token."""
        self.token.disable()
        self.token.save()

        response = self.post(signed=self.create_signed_artifact())

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

    def test_post_token_must_have_user_associated(self) -> None:
        """A `POST` request needs a token with a user."""
        self.token.user = None
        self.token.save()

        response = self.post(signed=self.create_signed_artifact())

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

    def test_post_work_request_not_found(self) -> None:
        """A `POST` request returns 404 if the work request does not exist."""
        max_id = WorkRequest.objects.aggregate(Max("id"))["id__max"]

        response = self.post(
            work_request_id=max_id + 1, signed=self.create_signed_artifact()
        )

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="No WorkRequest matches the given query.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_post_wrong_work_request(self) -> None:
        """A `POST` request requires the right kind of work request."""
        work_request = self.create_work_request(
            task_type=TaskTypes.WORKER, task_name="noop"
        )

        response = self.post(
            work_request_id=work_request.id,
            signed=self.create_signed_artifact(),
        )

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern=(
                "Expected work request to be Wait/ExternalDebsign; got "
                "Worker/noop"
            ),
        )

    def test_post_work_request_not_running(self) -> None:
        """A `POST` request requires that the work request be running."""
        self.work_request.mark_aborted()

        response = self.post(signed=self.create_signed_artifact())

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="Work request is not running. Status: aborted",
        )

    def test_post_bad_task_data(self) -> None:
        """A `POST` request fails on bad task data."""
        self.work_request.task_data = {"unsigned": ""}
        self.work_request.save()

        response = self.post(signed=self.create_signed_artifact())

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="Cannot process task data: Empty lookup",
        )

    def test_post_wrong_user(self) -> None:
        """A `POST` request may only be sent by the user who created the WR."""
        user = get_user_model().objects.create_user(
            username="another", email="another@example.org"
        )
        token = self.create_token_enabled(user=user)

        response = self.post(token=token, signed=self.create_signed_artifact())

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern=(
                "Only the user who created the work request can provide a "
                "signature"
            ),
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_post_wrong_category(self) -> None:
        """A `POST` request checks the category of the signed artifact."""
        signed = self.create_signed_artifact(
            category=ArtifactCategory.SOURCE_PACKAGE
        )

        response = self.post(signed=signed)

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern=(
                "Expected signed artifact of category debian:upload; got "
                "debian:source-package"
            ),
        )

    def test_post_signed_artifact_new_files(self) -> None:
        """The signed artifact may not add new files."""
        signed = self.create_signed_artifact(
            paths={
                **self.unsigned_files,
                "foo_1.0-1.debian.tar.xz": b"debian.tar.xz",
            }
        )

        response = self.post(signed=signed)

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern=re.escape(
                "Signed upload adds extra files when compared to unsigned "
                "upload. New paths: ['foo_1.0-1.debian.tar.xz']"
            ),
        )

    def test_post_signed_artifact_unchanged_changes_file(self) -> None:
        """The signed artifact must change the `.changes` file."""
        signed = self.create_signed_artifact(paths={**self.unsigned_files})

        response = self.post(signed=signed)

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="Signed upload does not change the .changes file",
        )

    def test_post_signed_artifact_changed_wrong_files(self) -> None:
        """The signed artifact may not change unsignable files."""
        signed = self.create_signed_artifact(
            paths={
                **self.unsigned_files,
                "foo_1.0.orig.tar.gz": b"new orig.tar.gz",
                "foo_1.0_source.changes": b"signed changes",
            }
        )

        response = self.post(signed=signed)

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern=re.escape(
                "Signed upload changes more than .changes/.dsc/.buildinfo. "
                "Changed paths: "
                "['foo_1.0.orig.tar.gz', 'foo_1.0_source.changes']"
            ),
        )

    def test_post_signed_artifact_success(self) -> None:
        """A successful `POST` request completes and returns 200."""
        signed = self.create_signed_artifact()

        response = self.post(signed=signed)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertQuerysetEqual(
            ArtifactRelation.objects.filter(artifact=signed).values(
                "artifact", "target", "type"
            ),
            [
                {
                    "artifact": signed.id,
                    "target": self.unsigned.id,
                    "type": ArtifactRelation.Relations.RELATES_TO,
                }
            ],
        )
        self.work_request.refresh_from_db()
        self.check_response_for_work_request(
            response,
            self.work_request,
            dynamic_task_data={"unsigned_id": self.unsigned.id},
        )
        self.assertEqual(
            self.work_request.status, WorkRequest.Statuses.COMPLETED
        )
        self.assertEqual(self.work_request.result, WorkRequest.Results.SUCCESS)
