# Copyright 2021-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 views."""

import binascii
import hashlib
import secrets
import shutil
from base64 import b64encode
from datetime import datetime
from datetime import timezone as tz
from pathlib import Path
from typing import Any, Optional, Union, cast
from unittest import mock
from unittest.mock import MagicMock

from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import PermissionDenied
from django.db.models import Max
from django.http import Http404, HttpRequest, HttpResponse
from django.test import RequestFactory, TestCase, override_settings
from django.urls import ResolverMatch, reverse
from django.utils import timezone
from django.utils.http import urlencode
from django.views import View

from rest_framework import status
from rest_framework.response import Response
from rest_framework.test import APIClient

from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    DEFAULT_WORKSPACE_NAME,
    File,
    FileInArtifact,
    FileInStore,
    FileStore,
    FileUpload,
    Token,
    WorkRequest,
    Worker,
    Workspace,
    default_workspace,
)
from debusine.server.serializers import (
    ArtifactSerializerResponse,
    WorkRequestSerializer,
)
from debusine.server.views import (
    ArtifactInPublicWorkspace,
    GetNextWorkRequestView,
    IsGet,
    IsTokenAuthenticated,
    IsTokenUserAuthenticated,
    IsUserAuthenticated,
    IsWorkerAuthenticated,
    UpdateWorkRequestAsCompletedView,
    UpdateWorkerDynamicMetadataView,
    UploadFileView,
    ValidatePermissionsMixin,
    WorkRequestPagination,
)
from debusine.test import TestHelpersMixin
from debusine.test.utils import (
    data_generator,
    date_time_to_isoformat_rest_framework,
)


class RegisterViewTests(TestHelpersMixin, TestCase):
    """Tests for the RegisterView class."""

    def test_create_token_and_worker(self):
        """Token and Worker are created by the view."""
        self.assertQuerysetEqual(Token.objects.all(), [])
        self.assertQuerysetEqual(Worker.objects.all(), [])

        time_start = timezone.now()

        token_key = secrets.token_hex(32)

        data = {'token': token_key, 'fqdn': 'worker-bee.lan'}

        response = self.client.post(reverse('api:register'), data)

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

        token = Token.objects.get(key=token_key)

        worker = token.worker

        # Assert created worker
        self.assertEqual(worker.name, 'worker-bee-lan')
        self.assertGreaterEqual(worker.registered_at, time_start)
        self.assertLessEqual(worker.registered_at, timezone.now())
        self.assertIsNone(worker.connected_at)

        # Assert created token
        self.assertGreaterEqual(token.created_at, time_start)
        self.assertLessEqual(token.created_at, timezone.now())
        self.assertIsNone(token.user)
        self.assertEqual(token.comment, '')

    def test_create_token_and_worker_duplicate_name(self):
        """Token is created and Worker disambiguated if needed."""
        token_1 = Token()
        token_1.save()

        Worker.objects.create_with_fqdn('worker-lan', token_1)

        token_key = secrets.token_hex(32)
        data = {'token': token_key, 'fqdn': 'worker.lan'}
        response = self.client.post(reverse('api:register'), data)

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

        self.assertTrue(Worker.objects.filter(name='worker-lan-2').exists())

    def test_invalid_data(self):
        """Request is refused if we have bad data."""
        data = {
            'token': secrets.token_hex(128),  # Too long
            'fqdn': 'worker.lan',
        }

        response = self.client.post(reverse('api:register'), data)

        self.assertResponseProblem(response, "Cannot deserialize worker")
        self.assertIsInstance(response.json()["validation_errors"], dict)

        self.assertFalse(Worker.objects.filter(name='worker-lan').exists())


class WorkRequestMixin:
    """Helper methods for tests with WorkRequest as response."""

    def check_response_for_work_request(
        self, response: HttpResponse, work_request: WorkRequest
    ):
        """Ensure the json data corresponds to the supplied work request."""

        def _timestamp(ts: Optional[datetime]) -> Optional[str]:
            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)
        )

        cast(TestCase, 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_data': work_request.task_data,
                '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: HttpResponse,
        work_requests: list[WorkRequest],
        start: int = 0,
        page_size: Optional[int] = None,
    ):
        """Ensure the json data corresponds to this list of work requests."""

        def _timestamp(ts: Optional[datetime]) -> Optional[str]:
            if ts is None:
                return None
            return date_time_to_isoformat_rest_framework(ts)

        def _encode_cursor(
            *, reverse: bool = False, position: Optional[str] = None
        ) -> str:
            tokens: dict[str, Union[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=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=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]

        cast(TestCase, 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_data': work_request.task_data,
                        '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
                ],
            },
        )


class GetNextWorkRequestViewTests(WorkRequestMixin, TestHelpersMixin, TestCase):
    """Tests for WorkRequestView."""

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

    def create_work_request(self, status, created_at):
        """Return a new work_request as specified by the parameters."""
        task_data = {'to_be_written': 'something', 'architecture': 'test'}

        work_request = TestHelpersMixin.create_work_request(
            worker=self.worker,
            task_name="sbuild",
            task_data=task_data,
            status=status,
        )

        work_request.created_at = created_at

        work_request.save()

        return work_request

    def test_check_permissions(self):
        """Only authenticated requests are processed by the view."""
        self.assertIn(
            IsWorkerAuthenticated,
            GetNextWorkRequestView.permission_classes,
        )

    def test_get_running_work_request(self):
        """Assert worker can get a WorkRequest."""
        # Create WorkRequest pending
        self.create_work_request(
            WorkRequest.Statuses.PENDING,
            datetime(2022, 1, 5, 10, 13, 20, 204242, tz.utc),
        )

        # Create WorkRequest running
        work_request_running = self.create_work_request(
            WorkRequest.Statuses.RUNNING,
            datetime(2022, 1, 5, 11, 14, 22, 242178, tz.utc),
        )

        # Request
        response = self.client.get(
            reverse('api:work-request-get-next'),
            HTTP_TOKEN=self.worker.token.key,
        )

        # Assert that we got the WorkRequest running
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.check_response_for_work_request(response, work_request_running)

    def test_get_work_request_change_status(self):
        """Get a WorkRequest change the status to running."""
        work_request = self.create_work_request(
            WorkRequest.Statuses.PENDING,
            datetime(2022, 1, 5, 10, 13, 20, 204242, tz.utc),
        )

        # Request
        response = self.client.get(
            reverse('api:work-request-get-next'),
            HTTP_TOKEN=self.worker.token.key,
        )

        # Assert that we got the WorkRequest running
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.check_response_for_work_request(response, work_request)

        work_request.refresh_from_db()

        self.assertEqual(work_request.status, WorkRequest.Statuses.RUNNING)

    def test_get_older_pending(self):
        """View return the older pending work request."""
        # Create WorkRequest pending
        work_request_pending_01 = self.create_work_request(
            WorkRequest.Statuses.PENDING,
            datetime(2022, 1, 5, 10, 13, 20, 204242, tz.utc),
        )

        work_request_pending_02 = self.create_work_request(
            WorkRequest.Statuses.PENDING,
            datetime(2022, 1, 5, 10, 13, 22, 204242, tz.utc),
        )
        # Request
        response = self.client.get(
            reverse('api:work-request-get-next'),
            HTTP_TOKEN=self.worker.token.key,
        )

        self.check_response_for_work_request(response, work_request_pending_01)

        # work_request_pending_01 status changed. Let's move it back to
        # pending. This is not an allowed transition but helps here to test
        # that clients will receive the older pending
        work_request_pending_01.status = WorkRequest.Statuses.PENDING

        # Change created_at to force a change in the order that debusine
        # sends the WorkRequests to the client
        work_request_pending_01.created_at = datetime(
            2022, 1, 5, 10, 13, 24, 204242, tz.utc
        )

        work_request_pending_01.save()

        # New request to check the new order
        response = self.client.get(
            reverse('api:work-request-get-next'),
            HTTP_TOKEN=self.worker.token.key,
        )

        self.check_response_for_work_request(response, work_request_pending_02)

    def test_get_no_work_request(self):
        """Assert debusine sends HTTP 204 when nothing assigned to Worker."""
        response = self.client.get(
            reverse('api:work-request-get-next'),
            HTTP_TOKEN=self.worker.token.key,
        )

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

    def test_authentication_credentials_not_provided(self):
        """Assert Token is required to use the endpoint."""
        # This is to double-check that IsWorkerAuthenticated (inclusion
        # is verified in test_check_permissions) is returning what it should do.
        # All the views with IsWorkerAuthenticated behave the same way
        # IsWorkerAuthenticated is tested in IsWorkerAuthenticatedTests
        response = self.client.get(reverse('api:work-request-get-next'))
        self.assertEqual(
            response.json(),
            {
                "title": "Error",
                "detail": "Authentication credentials were not provided.",
            },
        )
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)


class WorkRequestViewTests(TestHelpersMixin, WorkRequestMixin, TestCase):
    """Tests for WorkRequestView class."""

    def setUp(self):
        """Set up common data."""
        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)

    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 post_work_requests(self, work_request_serialized: dict) -> Response:
        """Post work_request to the endpoint api:work-requests."""
        response = self.client.post(
            reverse('api:work-requests'),
            data=work_request_serialized,
            HTTP_TOKEN=self.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: Optional[str]):
        """Client POST a WorkRequest and verifies it gets created."""

        def mark_running():
            work_request = WorkRequest.objects.latest('created_at')
            work_request.assign_worker(self.worker_01)
            work_request.mark_running()

        patcher = mock.patch('debusine.server.views.schedule', autospec=True)
        schedule_mock = patcher.start()
        # Patching and using schedule_mock.side_effect to avoid the
        # complexity in this test of using a real worker to let the real
        # schedule() to assign the WorkRequest to the worker
        schedule_mock.side_effect = mark_running
        self.addCleanup(patcher.stop)

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

        if workspace_name is not None:
            Workspace.objects.create(
                name=workspace_name, default_file_store=FileStore.default()
            )

            work_request_serialized['workspace'] = workspace_name

        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)

        # WorkRequest has the worker assigned because the mocked schedule()
        # was called (it is assigned and it's running)
        self.assertEqual(response.json()['worker'], self.worker_01.id)
        self.assertEqual(
            response.json()['status'], WorkRequest.Statuses.RUNNING
        )

        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)

    def test_post_success_workspace_testing(self):
        """Client POST a WorkRequest with a specific workspace."""
        self.verify_post_success(workspace_name="Testing")

    def test_post_success_workspace_default(self):
        """Client POST a WorkRequest without specifying a workspace."""
        self.verify_post_success(workspace_name=None)

    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'},
        }
        response = self.post_work_requests(work_request_serialized)

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

    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)

        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",
            "task_data": {
                "notifications": {"on_failure": [{"channel": channel}]}
            },
        }
        response = self.post_work_requests(work_request_serialized)

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

    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
        )


class UpdateWorkRequestAsCompletedTests(TestHelpersMixin, TestCase):
    """Tests for UpdateWorkRequestAsCompleted class."""

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

        task_data = {'to_be_written': 'something', 'architecture': 'test'}

        self.work_request = self.create_work_request(
            worker=self.worker,
            task_name='sbuild',
            task_data=task_data,
            status=WorkRequest.Statuses.PENDING,
        )

    def test_check_permissions(self):
        """Only authenticated requests are processed by the view."""
        self.assertIn(
            IsWorkerAuthenticated,
            UpdateWorkRequestAsCompletedView.permission_classes,
        )

    def put_api_work_requested_completed(self, result):
        """Assert Worker can update WorkRequest result to completed-success."""
        self.work_request.status = WorkRequest.Statuses.RUNNING
        self.work_request.save()

        response = self.client.put(
            reverse(
                'api:work-request-completed',
                kwargs={'work_request_id': self.work_request.id},
            ),
            data={"result": result},
            HTTP_TOKEN=self.worker.token.key,
            content_type="application/json",
        )

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

        self.work_request.refresh_from_db()
        self.assertEqual(
            self.work_request.status, WorkRequest.Statuses.COMPLETED
        )
        self.assertEqual(self.work_request.result, result)

    def test_update_completed_result_is_success(self):
        """Assert Worker can update WorkRequest result to completed-success."""
        self.put_api_work_requested_completed(WorkRequest.Results.SUCCESS)

    def test_update_completed_result_is_failure(self):
        """Assert Worker can update WorkRequest result to completed-error."""
        self.put_api_work_requested_completed(WorkRequest.Results.ERROR)

    def test_update_work_request_id_not_found(self):
        """Assert API returns HTTP 404 for a non-existing WorkRequest id."""
        max_id = WorkRequest.objects.aggregate(Max('id'))['id__max']

        response = self.client.put(
            reverse(
                'api:work-request-completed',
                kwargs={'work_request_id': max_id + 1},
            ),
            data={"result": WorkRequest.Results.SUCCESS},
            HTTP_TOKEN=self.worker.token.key,
            content_type="application/json",
        )

        self.assertResponseProblem(
            response,
            "Work request not found",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_update_failure_invalid_result(self):
        """Assert Worker get HTTP 400 and error for invalid result field."""
        self.work_request.status = WorkRequest.Statuses.RUNNING
        self.work_request.save()

        response = self.client.put(
            reverse(
                'api:work-request-completed',
                kwargs={'work_request_id': self.work_request.id},
            ),
            data={"result": "something not recognised"},
            HTTP_TOKEN=self.worker.token.key,
            content_type="application/json",
        )

        self.assertResponseProblem(
            response, "Cannot change work request as completed"
        )
        self.assertIsInstance(response.json()["validation_errors"], dict)

    def test_unauthorized(self):
        """Assert worker cannot modify a task of another worker."""
        another_worker = Worker.objects.create_with_fqdn(
            "another-worker-test", self.create_token_enabled()
        )

        task_data = {'to_be_written': 'something', 'architecture': 'test'}

        another_work_request = self.create_work_request(
            worker=another_worker,
            task_name='sbuild',
            task_data=task_data,
            status=WorkRequest.Statuses.PENDING,
        )

        response = self.client.put(
            reverse(
                'api:work-request-completed',
                kwargs={'work_request_id': another_work_request.id},
            ),
            HTTP_TOKEN=self.worker.token.key,
        )

        self.assertResponseProblem(
            response,
            "Invalid worker to update the work request",
            status_code=status.HTTP_401_UNAUTHORIZED,
        )


class UpdateWorkerDynamicMetadataTests(TestHelpersMixin, TestCase):
    """Tests for DynamicMetadata."""

    def setUp(self):
        """Set up common objects."""
        self.worker_01 = Worker.objects.create_with_fqdn(
            'worker-01-lan', self.create_token_enabled()
        )
        self.worker_02 = Worker.objects.create_with_fqdn(
            'worker-02-lan', self.create_token_enabled()
        )

    def test_check_permissions(self):
        """Only authenticated requests are processed by the view."""
        self.assertIn(
            IsWorkerAuthenticated,
            UpdateWorkerDynamicMetadataView.permission_classes,
        )

    def test_update_metadata_success(self):
        """Worker's dynamic_metadata is updated."""
        self.assertEqual(self.worker_01.dynamic_metadata, {})
        self.assertEqual(self.worker_02.dynamic_metadata, {})

        metadata = {"cpu_cores": 4, "ram": 16}
        response = self.client.put(
            reverse('api:worker-dynamic-metadata'),
            data=metadata,
            HTTP_TOKEN=self.worker_01.token.key,
            content_type="application/json",
        )

        self.worker_01.refresh_from_db()

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

        self.assertEqual(self.worker_01.dynamic_metadata, metadata)
        self.assertLessEqual(
            self.worker_01.dynamic_metadata_updated_at, timezone.now()
        )

        self.assertEqual(self.worker_02.dynamic_metadata, {})
        self.assertIsNone(self.worker_02.dynamic_metadata_updated_at)


class ArtifactTests(TestHelpersMixin, TestCase):
    """Tests for Artifact."""

    def setUp(self):
        """Set up common objects."""
        self.default_workspace_name = "test"
        self.create_workspace(name=self.default_workspace_name)

    @classmethod
    def create_token_user(cls) -> Token:
        """Return a Token with a user associated to it."""
        return cls.create_token_enabled(with_user=True)

    @classmethod
    def create_token_worker(cls) -> Token:
        """Return a Token with a WorkRequest associated to it."""
        token = cls.create_token_enabled()
        Worker.objects.create_with_fqdn("worker.lan", token)
        return token

    @staticmethod
    def empty_store_directory():
        """Empty settings.DEBUSINE_STORE_DIRECTORY."""
        shutil.rmtree(settings.DEBUSINE_STORE_DIRECTORY, ignore_errors=True)
        Path(settings.DEBUSINE_STORE_DIRECTORY).mkdir()

    @staticmethod
    def create_file(hash_digest: bytes, size: int) -> File:
        """Create new file with hash_digest and size."""
        fileobj = File()
        fileobj.hash_digest = hash_digest
        fileobj.size = size
        fileobj.save()

        store = FileStore.default()

        FileInStore.objects.create(file=fileobj, store=store, data={})

        return fileobj

    def post_artifact_create(
        self,
        *,
        token_key: str,
        data: dict[str, Any],
        hostname: Optional[str] = None,
    ) -> HttpResponse:
        """
        Call self.client.post() to api-artifact-create.

        Pass the token.key and content_type="application/json".

        :param token_key: value of the Token header for the request
        :param data: data to post
        :param hostname: if not None, use it for HTTP_HOST
        """
        kwargs: dict[str, Any] = {}
        if hostname is not None:
            kwargs["HTTP_HOST"] = hostname

        return self.client.post(
            reverse("api:artifact-create"),
            data=data,
            HTTP_TOKEN=token_key,
            content_type="application/json",
            **kwargs,
        )

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_create_artifact(self):
        """An artifact is created."""
        # file_already_uploaded is in the File and Store tables. The file
        # was already created and uploaded

        # Test will ensure that the empty file is created in the store.
        # Empty the store directory in case that the file existed from
        # a previous test
        self.empty_store_directory()

        file_already_uploaded = {
            "path": "AUTHORS",
            "hash_digest": "164a3bc86c0fe9a7aa15cfa9156e9be1"
            "124aad69ea757a011be1f1a13502409f",
            "size": 3827,
        }
        self.create_file(
            binascii.unhexlify(file_already_uploaded["hash_digest"]),
            file_already_uploaded["size"],
        )

        # file_exists_not_uploaded exists in the File table but is not in
        # the Store. The client should upload it (we do not know if another
        # client would upload it or not)
        file_exists_not_uploaded = {
            "path": "src/dd.c",
            "hash_digest": "69752aa046d1b4f58c739239960d20ac"
            "67de7d7ab2996849f17f2a3653faf7da",
            "size": 78364,
        }
        File.get_or_create(
            hash_digest=binascii.unhexlify(
                file_exists_not_uploaded["hash_digest"]
            ),
            size=file_exists_not_uploaded["size"],
        )

        path_in_artifact_empty_file = "src/.dirstamp"
        category = "testing"
        artifact_serialized = {
            "workspace": self.default_workspace_name,
            "category": category,
            "files": {
                file_already_uploaded["path"]: {
                    "type": "file",
                    "size": file_already_uploaded["size"],
                    "checksums": {
                        "sha256": file_already_uploaded["hash_digest"],
                        "md5": "7d13cb5b2bee07003d2b69ccbd256e65",
                    },
                },
                file_exists_not_uploaded["path"]: {
                    "type": "file",
                    "size": file_exists_not_uploaded["size"],
                    "checksums": {
                        "sha256": file_exists_not_uploaded["hash_digest"],
                        "md5": "11e0fffdb84afaf177be2a7d56fbae31",
                    },
                },
                "src/seq.c": {
                    "type": "file",
                    "size": 21750,
                    "checksums": {
                        "sha256": "acd6454390756911a464b426dcc49027055c"
                        "ddf9258112dfd7a52ee96e231352",
                        "md5": "e13c3d5d2489a7ef6b697379f74c2fd4",
                    },
                },
                path_in_artifact_empty_file: {
                    "type": "file",
                    "size": 0,
                    "checksums": {
                        "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae4"
                        "1e4649b934ca495991b7852b855",
                        "md5": "d41d8cd98f00b204e9800998ecf8427e",
                    },
                },
            },
            "data": {"key1": "value1"},
        }
        self.assertEqual(Artifact.objects.all().count(), 0)

        hostname = "example.com"

        response = self.post_artifact_create(
            token_key=self.create_token_user().key,
            data=artifact_serialized,
            hostname=hostname,
        )

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

        response_json = response.json()
        artifact = Artifact.objects.get(id=response_json["id"])

        # Assert created artifact is as expected
        self.assertEqual(artifact.category, artifact_serialized["category"])
        self.assertEqual(
            artifact.workspace.name, artifact_serialized["workspace"]
        )
        self.assertEqual(artifact.data, artifact_serialized["data"])
        self.assertIsNone(artifact.created_by_work_request)

        # expected_files_to_upload:
        # "AUTHORS" file was already available in the debusine server, it does
        #   not need to be uploaded again.
        # "src/dd.c" file existed in the File table but not in FileInStorage.
        #   It might be uploaded by another client or not: so debusine
        #   server requests this client to upload it.
        # "src/seq.c" is a 100% new file in debusine.
        # "src/.dirstamp" is an empty file: debusine does not request
        #   to be uploaded again.

        self.assertEqual(
            response_json,
            self.artifact_as_dict_response(artifact, hostname),
        )

        self.assertEqual(
            artifact.files.all().count(), len(artifact_serialized["files"])
        )

        file_backend = FileStore.default().get_backend_object()

        # Assert that files have the correct size and hash_digest
        for path, file_data in artifact_serialized["files"].items():
            file_in_artifact = FileInArtifact.objects.get(
                artifact=artifact, path=path
            )
            size = file_in_artifact.file.size
            self.assertEqual(size, file_data["size"])
            self.assertEqual(
                file_in_artifact.file.hash_digest.hex(),
                file_data["checksums"]["sha256"],
            )

            local_path = file_backend.get_local_path(file_in_artifact.file)
            if size == 0:
                # Empty files are created
                empty_file_stat = local_path.stat()
                self.assertEqual(empty_file_stat.st_size, 0)
            else:
                # Non-empty files are not created yet
                self.assertFalse(local_path.exists())

        # Assert that ArtifactView.post() created an empty file for the
        # empty file. Other files would be created when the client makes
        # the PUT request
        file_upload = FileUpload.objects.get(
            file_in_artifact__path=path_in_artifact_empty_file
        )
        path_empty_file = Path(file_upload.absolute_file_path())
        self.assertTrue(path_empty_file.is_file())
        self.assertEqual(path_empty_file.stat().st_size, 0)

        with self.captureOnCommitCallbacks(execute=True):
            # Delete FileUpload to delete the associated file in
            # DEBUSINE_UPLOAD_DIRECTORY. Only for the empty file because
            # ArtifactView.post() created the file only for the empty one
            file_upload.delete()

    @staticmethod
    def artifact_as_dict_response(
        artifact: Artifact, hostname: str
    ) -> dict[str, Any]:
        """Return dict: server return to the client."""
        path = reverse("artifacts:detail", kwargs={"artifact_id": artifact.id})

        with mock.patch.object(
            ArtifactSerializerResponse,
            "_build_absolute_download_url",
            autospec=True,
            return_value=f"http://{hostname}/artifact/{artifact.id}/",
        ):
            unused_request = MagicMock()
            serialized = ArtifactSerializerResponse.from_artifact(
                artifact, unused_request
            ).data
            serialized["download_tar_gz_url"] = (
                f"http://{hostname}{path}?archive=tar.gz"
            )
            return serialized

    def artifact_as_dict_request(self, work_request_id: int) -> dict[str, Any]:
        """Return dict: client post to the server to create an artifact."""
        return {
            "workspace": self.default_workspace_name,
            "category": "testing",
            "files": {},
            "work_request": work_request_id,
            "data": {},
        }

    @override_settings(DISABLE_AUTOMATIC_SCHEDULING=True, ALLOWED_HOSTS=["*"])
    def test_create_artifact_created_by_work_request(self):
        """Request include a work request id. Artifact get created with it."""
        work_request = self.create_work_request()

        token = self.create_token_worker()

        response = self.post_artifact_create(
            token_key=token.key,
            data=self.artifact_as_dict_request(work_request.id),
            hostname="example.com",
        )

        artifact = Artifact.objects.get(id=response.json()["id"])

        self.assertEqual(artifact.created_by_work_request, work_request)

    def test_create_artifact_created_by_work_request_no_permissions(self):
        """
        Request is client authenticated (not worker). Invalid permission.

        Only workers can set the Artifact.created_by_work_request().
        """
        work_request = self.create_work_request()

        response = self.post_artifact_create(
            token_key=self.create_token_user().key,
            data=self.artifact_as_dict_request(work_request.id),
        )

        self.assertResponseProblem(
            response,
            "work_request field can only be used by a request "
            "with a token from a Worker",
            status_code=status.HTTP_403_FORBIDDEN,
        )

        # No artifact is created
        self.assertEqual(Artifact.objects.count(), 0)

    def test_create_artifact_created_by_work_request_not_found(self):
        """
        Request include a non-existing work request id.

        Assert that the view return HTTP 400 error.
        """
        # Associated a Worker to the token
        work_request_id = -1

        response = self.post_artifact_create(
            token_key=self.create_token_worker().key,
            data=self.artifact_as_dict_request(work_request_id),
        )
        self.assertResponseProblem(
            response, f"WorkRequest {work_request_id} does not exist"
        )

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_create_artifact_no_work_request(self):
        """Assert artifact is created without a work request relation."""
        artifact_serialized = {
            "workspace": self.default_workspace_name,
            "category": "testing",
            "files": {},
            "data": {},
        }

        token = self.create_token_user()

        response = self.post_artifact_create(
            token_key=token.key,
            data=artifact_serialized,
            hostname="example.com",
        )

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

        artifact = Artifact.objects.get(id=response.data["id"])
        self.assertIsNone(artifact.created_by_work_request)
        self.assertEqual(artifact.created_by, token.user)

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_create_artifact_fails_no_work_request_id_no_user_token(self):
        """
        Artifact cannot be created.

        The data does not contain a work_request_id and the token is not
        associated to a user.
        """
        artifact_serialized = {
            "workspace": self.default_workspace_name,
            "category": "testing",
            "files": {},
            "data": {},
        }

        response = self.post_artifact_create(
            token_key=self.create_token_enabled().key,
            data=artifact_serialized,
            hostname="example.com",
        )

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

        self.assertEqual(
            response.json(),
            {
                "title": "If work_request field is empty the WorkRequest "
                "must be created using a Token associated to a user"
            },
        )

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_create_artifact_no_workspace(self):
        """Assert artifact is serialized in the default workspace."""
        artifact_serialized = {
            "category": "testing",
            "files": {},
            "data": {},
        }

        response = self.post_artifact_create(
            token_key=self.create_token_user().key,
            data=artifact_serialized,
            hostname="example.com",
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        artifact = Artifact.objects.get(id=response.data["id"])
        self.assertEqual(artifact.workspace.name, DEFAULT_WORKSPACE_NAME)

    def test_create_artifact_when_workspace_does_not_exist(self):
        """No artifact is created: workspace not found."""
        workspace_name = "does-not-exist"

        artifact_serialized = {
            "workspace": "does-not-exist",
            "category": "testing",
            "files": {},
            "data": {},
        }

        response = self.post_artifact_create(
            token_key=self.create_token_user().key, data=artifact_serialized
        )

        self.assertResponseProblem(
            response,
            f'Workspace "{workspace_name}" cannot be found',
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_create_artifact_deserialize_error(self):
        """Send invalid artifact (missing fields)."""
        artifact_serialized = {}

        response = self.post_artifact_create(
            token_key=self.create_token_user().key, data=artifact_serialized
        )

        self.assertResponseProblem(response, "Cannot deserialize artifact")

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_get_information(self):
        """Get information for the artifact."""
        artifact, _ = self.create_artifact()
        token_key = self.create_token_user().key

        # Add a file (without "uploading" it) to test that files_to_upload
        # returns it.
        fileobj, _ = File.get_or_create(
            hash_digest=b"something",
            size=100,
        )
        path_in_artifact = "README.txt"
        FileInArtifact.objects.create(
            artifact=artifact, file=fileobj, path=path_in_artifact
        )

        hostname = "example.com"
        response = self.client.get(
            reverse("api:artifact", kwargs={"artifact_id": artifact.id}),
            HTTP_TOKEN=token_key,
            HTTP_HOST=hostname,
        )
        # HTTP_HOST must be a real hostname. By default, it is "testserver",
        # The Serializer needs a valid hostname for the URLField: otherwise
        # it fails the validation on the serializer
        # (for the download_artifact_tar_gz_url).
        # URLField can be an ipv4 or ipv6 address, a domain
        # or localhost.
        self.assertEqual(response.status_code, status.HTTP_200_OK)

        expected = self.artifact_as_dict_response(artifact, hostname)

        self.assertEqual(response.data, expected)

    def test_get_information_artifact_not_found(self):
        """Get information return 404: artifact not found."""
        artifact_id = 0
        response = self.client.get(
            reverse("api:artifact", kwargs={"artifact_id": artifact_id}),
            HTTP_TOKEN=self.create_token_user().key,
        )

        self.assertResponseProblem(
            response,
            f"Artifact {artifact_id} not found",
            status_code=status.HTTP_404_NOT_FOUND,
        )


class IsWorkerAuthenticatedTests(TestHelpersMixin, TestCase):
    """Tests for IsWorkerAuthenticated."""

    def setUp(self):
        """Set up common objects."""
        self.is_worker_authenticated = IsWorkerAuthenticated()
        self.request = HttpRequest()

    def test_request_with_valid_token_worker_yes_permission(self):
        """IsWorkerAuthenticated.has_permission() return True: valid token."""
        token = self.create_token_enabled()
        Worker.objects.create_with_fqdn('worker.lan', token)

        self.request.META['HTTP_TOKEN'] = token.key

        self.assertTrue(
            self.is_worker_authenticated.has_permission(self.request, None)
        )

    def test_request_without_token_no_permission(self):
        """IsWorkerAuthenticated.has_permission() return False: no token."""
        self.assertFalse(
            self.is_worker_authenticated.has_permission(self.request, None)
        )

    def test_request_with_non_existing_token_no_permission(self):
        """
        IsWorkerAuthenticated.has_permission() return False.

        The token is invalid.
        """
        self.request.META['HTTP_TOKEN'] = 'a-token-that-does-not-exist'
        self.assertFalse(
            self.is_worker_authenticated.has_permission(self.request, None)
        )

    def test_request_with_token_no_associated_worker_no_permission(self):
        """IsWorkerAuthenticated.has_permission() return False: no worker."""
        token = self.create_token_enabled()
        self.request.META['HTTP_TOKEN'] = token.key
        self.assertFalse(
            self.is_worker_authenticated.has_permission(self.request, None)
        )


class IsTokenAuthenticatedTests(TestHelpersMixin, TestCase):
    """Tests for IsTokenAuthenticated."""

    def setUp(self):
        """Set up common objects."""
        self.is_token_authenticated = IsTokenAuthenticated()
        self.request = HttpRequest()

    def test_request_with_valid_token_worker_yes_permission(self):
        """IsTokenAuthenticated.has_permission() return True: valid token."""
        token = self.create_token_enabled()
        Worker.objects.create_with_fqdn('worker.lan', token)

        self.request.META['HTTP_TOKEN'] = token.key

        self.assertTrue(
            self.is_token_authenticated.has_permission(self.request, None)
        )

    def test_request_without_token_no_permission(self):
        """IsTokenAuthenticated.has_permission() return False: no token."""
        self.assertFalse(
            self.is_token_authenticated.has_permission(self.request, None)
        )

    def test_request_with_non_existing_token_no_permission(self):
        """IsTokenAuthenticated.has_permission() return False: invalid token."""
        self.request.META['HTTP_TOKEN'] = 'a-token-that-does-not-exist'
        self.assertFalse(
            self.is_token_authenticated.has_permission(self.request, None)
        )

    def test_request_with_disabled_token_no_permission(self):
        """
        IsTokenAuthenticated.has_permission() return False.

        The token is disabled.
        """
        token = Token.objects.create()
        self.request.META['HTTP_TOKEN'] = token.key
        self.assertFalse(
            self.is_token_authenticated.has_permission(self.request, None)
        )


class IsTokenUserAuthenticatedTests(TestHelpersMixin, TestCase):
    """Tests for IsTokenUserAuthenticated class."""

    def setUp(self):
        """Set up test."""
        self.is_token_user_authenticated = IsTokenUserAuthenticated()
        self.request = HttpRequest()

    def test_request_without_a_token(self):
        """Request without Token header: no permission."""
        self.assertFalse(
            self.is_token_user_authenticated.has_permission(self.request, None)
        )

    def test_request_disabled_token(self):
        """Request with a Token header that is disabled: permission denied."""
        token = Token.objects.create()
        self.assertFalse(token.enabled)

        self.request.META["HTTP_TOKEN"] = token.key
        self.assertFalse(
            self.is_token_user_authenticated.has_permission(self.request, None)
        )

    def test_request_non_existing_token(self):
        """Request with a Token header that is not found: permission denied."""
        self.request.META["HTTP_TOKEN"] = "something-does-not-exist"
        self.assertFalse(
            self.is_token_user_authenticated.has_permission(self.request, None)
        )

    def test_request_enabled_token_without_user(self):
        """Request with a Token that does not have a user: permission denied."""
        token = self.create_token_enabled()
        self.request.META["HTTP_TOKEN"] = token.key
        self.assertFalse(
            self.is_token_user_authenticated.has_permission(self.request, None)
        )

    def test_request_enabled_token_with_user(self):
        """Request with a Token that does have a user: permission granted.."""
        token = self.create_token_enabled()
        user = get_user_model().objects.create_user(
            username="testuser", password="testpassword"
        )
        token.user = user
        token.save()

        self.request.META["HTTP_TOKEN"] = token.key
        self.assertTrue(
            self.is_token_user_authenticated.has_permission(self.request, None)
        )


class IsUserAuthenticatedTests(TestHelpersMixin, TestCase):
    """Tests for IsUserAuthenticated class."""

    def setUp(self):
        """Set up tests."""
        self.is_user_authenticated = IsUserAuthenticated()
        self.http_request = HttpRequest()

    def test_user_is_not_authenticated(self):
        """Request without authenticated user: has_permission is False."""
        self.http_request.user = AnonymousUser()

        self.assertFalse(
            self.is_user_authenticated.has_permission(self.http_request, None)
        )

    def test_user_is_authenticated(self):
        """Request without authenticated user: has_permission is True."""
        user = get_user_model().objects.create_user(
            username="user",
            password="password",
            email="email@example.com",
        )

        self.http_request.user = user

        self.assertTrue(
            self.is_user_authenticated.has_permission(self.http_request, None)
        )


class IsGetTests(TestCase):
    """Tests for IsGet."""

    def setUp(self):
        """Set up tests."""
        self.is_get = IsGet()
        self.http_request = HttpRequest()

    def test_request_is_get(self):
        """Request method is GET. Return True."""
        self.http_request.method = "GET"

        self.assertTrue(self.is_get.has_permission(self.http_request, None))

    def test_request_is_post(self):
        """Request method is POST. Return False."""
        self.http_request.method = "POST"

        self.assertFalse(self.is_get.has_permission(self.http_request, None))


class ArtifactInPublicWorkspaceTests(TestHelpersMixin, TestCase):
    """Tests for ArtifactInPublicWorkspace class."""

    def setUp(self):
        """Initialise object."""
        self.artifact, _ = self.create_artifact(paths=[])
        self.artifact_in_public_workspace = ArtifactInPublicWorkspace()
        self.request = HttpRequest()

    def test_permission_granted_public_workspace(self):
        """Permission granted: artifact is in a public workspace."""
        self.request.resolver_match = ResolverMatch(
            func=None, args=[], kwargs={"artifact_id": self.artifact.id}
        )
        workspace = self.artifact.workspace
        workspace.public = True
        workspace.save()

        self.assertTrue(
            self.artifact_in_public_workspace.has_permission(self.request, None)
        )

    def test_permission_denied_artifact_does_not_exist(self):
        """Raise Http404: artifact does not exist."""
        artifact_id = 0
        self.request.resolver_match = ResolverMatch(
            func=None, args=[], kwargs={"artifact_id": artifact_id}
        )
        with self.assertRaisesRegex(
            Http404, f"Artifact {artifact_id} does not exist"
        ):
            self.artifact_in_public_workspace.has_permission(self.request, None)

    def test_permission_denied_private_workspace(self):
        """Permission denied: artifact's workspace is private."""
        self.assertFalse(self.artifact.workspace.public)

        self.request.resolver_match = ResolverMatch(
            func=None, args=[], kwargs={"artifact_id": self.artifact.id}
        )
        self.assertFalse(
            self.artifact_in_public_workspace.has_permission(self.request, None)
        )


class UploadFileViewTests(TestHelpersMixin, TestCase):
    """Tests for UploadFileView class."""

    def setUp(self):
        """Set up objects for the tests."""
        self.token = self.create_token_enabled()
        self.path_in_artifact = "README"
        self.file_size = 100
        self.artifact, self.files_contents = self.create_artifact(
            [self.path_in_artifact], files_size=self.file_size
        )
        self.file_contents = self.files_contents[self.path_in_artifact]

        self.file_hash = self.hash_data(self.file_contents)

        self.file, _ = File.get_or_create(
            hash_digest=binascii.unhexlify(self.file_hash),
            size=self.file_size,
        )
        self.file_in_artifact = FileInArtifact.objects.create(
            artifact=self.artifact, path=self.path_in_artifact, file=self.file
        )

    def tearDown(self):
        """If FileUpload for the file exist: delete it."""
        try:
            file_upload = FileUpload.objects.get(
                file_in_artifact=self.file_in_artifact
            )
        except FileUpload.DoesNotExist:
            return

        with self.captureOnCommitCallbacks(execute=True):
            file_upload.delete()

    @staticmethod
    def hash_data(data):
        """Return hex digest in hex of data."""
        hasher = hashlib.new(File.current_hash_algorithm)
        hasher.update(data)
        return hasher.digest().hex()

    def test_check_permissions(self):
        """Only authenticated requests are processed by the view."""
        self.assertIn(
            IsTokenAuthenticated,
            UploadFileView.permission_classes,
        )

    def put_file(
        self,
        range_start: Optional[int] = None,
        range_end: Optional[int] = None,
        extra_put_params: Optional[dict[str, str]] = None,
        *,
        path_in_artifact: Optional[str] = None,
        artifact_id: Optional[int] = None,
        forced_data: Optional[bytes] = None,
        include_data=True,
    ):
        """
        Upload file to debusine using the API.

        Use data in self.test_data

        :param range_start: use it with range_end
        :param range_end: if specified, upload a range of data
          use Content-Range: bytes $range_start-$range_end and attaches only
          the correct part of the data
        :param extra_put_params: add parameters to self.client.put
          (e.g. HTTP headers)
        :param path_in_artifact: path in the artifact to upload the file
        :param artifact_id: if not None: artifact_id to use,
          else self.artifact.id
        :param forced_data: if not None: use this data. Else,
          use self.test_data
        :param include_data: if True includes the data (following specified
          range)
        """
        if range_start is not None and range_end is None:
            raise ValueError(
                "range_end must have an int if range_start is not None"
            )  # pragma: no cover

        parameters: dict[str, Any] = {}

        if forced_data is not None:
            data = forced_data
        else:
            data = self.file_contents

        if range_start is not None:
            data = data[range_start : range_end + 1]
            file_size = len(data)
            parameters.update(
                **{
                    "HTTP_CONTENT_LENGTH": range_end + 1 - range_start,
                    "HTTP_CONTENT_RANGE": f"bytes {range_start}-{range_end}"
                    f"/{file_size}",
                }
            )

        if extra_put_params is not None:
            parameters.update(**extra_put_params)

        if include_data:
            parameters["data"] = data

        if path_in_artifact is None:
            path_in_artifact = self.path_in_artifact

        if artifact_id is not None:
            artifact_id = artifact_id
        else:
            artifact_id = self.artifact.id

        with self.captureOnCommitCallbacks(execute=True):
            # captureOnCommitCallbacks(execute=True) to force File.delete()
            # callback to delete the file to be executed
            return self.client.put(
                reverse(
                    "api:upload-file",
                    kwargs={
                        "artifact_id": artifact_id,
                        "file_path": path_in_artifact,
                    },
                ),
                content_type="application/octet-stream",
                HTTP_TOKEN=self.token.key,
                **parameters,
            )

    def test_put_file_in_subdirectory(self):
        """Client PUT a file that is in a subdirectory (src/README)."""
        data = b"test"
        path_in_artifact = "src/README"

        file, _ = File.get_or_create(
            hash_digest=binascii.unhexlify(self.hash_data(data)), size=len(data)
        )
        FileInArtifact.objects.create(
            artifact=self.artifact, path=path_in_artifact, file=file
        )

        response = self.put_file(
            path_in_artifact=path_in_artifact,
            forced_data=b"test",
            include_data=True,
        )
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

    def test_put_whole_file(self):
        """Server receive a file without Content-Range. Whole file."""
        self.assertEqual(FileInStore.objects.count(), 0)

        response = self.put_file()

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(FileInStore.objects.count(), 1)

        file = FileInStore.objects.all().first().file

        self.assertEqual(file.hash_digest.hex(), self.file_hash)
        self.assertEqual(file.size, self.file_size)

    def test_put_wrong_whole_file(self):
        """Server receive a file without Content-Range. Unexpected hash."""
        files_in_uploads_before = FileUpload.objects.all().count()

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

        data = b"x" * self.file_size

        response = self.put_file(forced_data=data)

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

        hash_digest_expected = self.file_hash
        hash_digest_received = self.hash_data(data)

        self.assertResponseProblem(
            response,
            f"Invalid file hash. Expected: {hash_digest_expected} "
            f"Received: {hash_digest_received}",
            status_code=status.HTTP_409_CONFLICT,
        )

        self.assertEqual(
            files_in_uploads_before, FileUpload.objects.all().count()
        )

    def test_put_already_uploaded_file_return_created(self):
        """Server return 201 created for a file that is already uploaded."""
        # Upload the file once
        response = self.put_file()
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

        # Upload the same file again (can happen if two clients are uploading
        # the same file)
        response = self.put_file()
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

    def test_put_big_file(self):
        """
        Server receive a file bigger than DATA_UPLOAD_MAX_MEMORY_SIZE.

        Ensure to exercise code for bigger files (read in multiple chunks and
        Django streaming it from disk instead of memory).
        """
        self.assertEqual(FileInStore.objects.count(), 0)

        file_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE + 1000
        data = next(data_generator(file_size))
        path_in_artifact = self.path_in_artifact + ".2"
        file, _ = File.get_or_create(
            hash_digest=binascii.unhexlify(self.hash_data(data)), size=file_size
        )
        FileInArtifact.objects.create(
            artifact=self.artifact, path=path_in_artifact, file=file
        )

        response = self.put_file(
            path_in_artifact=path_in_artifact,
            forced_data=data,
            include_data=True,
        )

        file = FileInStore.objects.all().first().file

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(file.hash_digest.hex(), self.hash_data(data))
        self.assertEqual(file.size, file_size)

    def test_put_last_activity_updated(self):
        """Server updates FileUpload.last_activity_at on each received part."""
        # Upload first part of the file
        start_range = 0
        end_range = 20

        self.put_file(start_range, end_range)

        file_upload = self.file_in_artifact.fileupload

        # Last activity got updated and is less than now
        last_activity_1 = file_upload.last_activity_at
        self.assertLess(last_activity_1, timezone.now())

        start_range = end_range + 1
        end_range = 30

        # Uploads another part of the file (but not all)
        self.put_file(start_range, end_range)

        file_upload.refresh_from_db()

        last_activity_2 = file_upload.last_activity_at

        # Last activity got updated: greater than earlier, less than now
        self.assertGreater(last_activity_2, last_activity_1)
        self.assertLess(last_activity_2, timezone.now())

    def test_put_two_parts_file_completed(self):
        """Server receive a file in two parts."""
        files_in_uploads_before = FileUpload.objects.all().count()

        # Upload first part of the file
        start_range = 0
        end_range = 44

        response = self.put_file(start_range, end_range)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.headers["range"], f"bytes=0-{end_range + 1}")

        file_upload_path = self.file_in_artifact.fileupload.path
        self.assertFalse(Path(file_upload_path).is_absolute())

        # Upload second part of the file
        start_range = end_range + 1
        end_range = self.file_size = 100 - 1

        response = self.put_file(start_range, end_range)

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

        # Assert that debusine created the file as expected in the database
        self.assertEqual(self.artifact.files.all().count(), 1)

        file = FileInArtifact.objects.get(
            artifact=self.artifact, path=self.path_in_artifact
        ).file
        self.assertEqual(file.hash_digest.hex(), self.file_hash)
        self.assertEqual(self.artifact.files.all().count(), 1)
        self.assertEqual(FileInStore.objects.count(), 1)

        self.assertEqual(
            files_in_uploads_before,
            FileUpload.objects.all().count(),
        )

    def test_put_two_parts_file_completed_wrong_hash(self):
        """Server receive a file in two parts. Hash mismatch with expected."""
        files_in_uploads_before = FileUpload.objects.all().count()
        self.assertEqual(FileUpload.objects.count(), 0)

        # Upload first part of the file
        start_range_1 = 0
        end_range_1 = 44
        data_1 = self.file_contents
        response = self.put_file(start_range_1, end_range_1, forced_data=data_1)
        self.assertEqual(response.status_code, status.HTTP_200_OK)

        # Upload second part of the file (wrong hash)
        start_range_2 = end_range_1 + 1
        end_range_2 = self.file_size - 1
        data_2 = b"x" * self.file_size

        response = self.put_file(start_range_2, end_range_2, forced_data=data_2)

        expected_hash = self.file_hash

        received_hash = self.hash_data(
            data_1[start_range_1 : end_range_1 + 1]
            + data_2[start_range_2 : end_range_2 + 1]
        )

        self.assertResponseProblem(
            response,
            f"Invalid file hash. Expected: {expected_hash} "
            f"Received: {received_hash}",
            status_code=status.HTTP_409_CONFLICT,
        )

        # Temporary uploaded data is removed so the client can try again:
        self.assertEqual(FileUpload.objects.all().count(), 0)
        self.assertEqual(
            files_in_uploads_before,
            FileUpload.objects.all().count(),
        )

    def test_put_content_range_star_size_partial_content(self):
        """
        Client PUT Content-Range: */$SIZE server return HTTP 206: partial file.

        The file has not started to be uploaded: response Range == bytes 0-0
        """  # noqa: RST213
        size = self.file.size

        file_upload = self.create_file_uploaded()
        file_upload.absolute_file_path().unlink()

        response = self.put_file(
            extra_put_params={"HTTP_CONTENT_RANGE": f"bytes */{size}"},
            include_data=False,
        )

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

        # Nothing has been uploaded yet
        self.assertEqual(response.headers["range"], "bytes=0-0")

    def test_put_gap_in_file_return_http_400(self):
        """
        Client PUT Content-Range: 40/99 having previously uploaded 30 bytes.

        Server responds with HTTP 400 because the second range starts after
        the received data.
        """
        upload_to_position = 30
        self.create_file_uploaded(upload_to_position)

        start_range = 40
        end_range = 99
        response = self.put_file(start_range, end_range)

        self.assertResponseProblem(
            response,
            f"Server had received {upload_to_position} bytes of the file. "
            f"New upload starts at {start_range}. "
            f"Please continue from {upload_to_position}",
        )

    def test_put_continue_uploaded_file_without_content_range(self):
        """
        Client PUT without Content-Range and previously uploaded part of a file.

        Server responds with completed (it will overwrite the existing
        uploaded part of the file).
        """
        self.create_file_uploaded(30)

        response = self.put_file()

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

    def create_file_uploaded(self, upload_size=None) -> FileUpload:
        """
        Create an uploaded file (FileUpload and write data).

        Write self.file_in_artifact.file.size bytes or upload_size if not None.
        """
        temporary_file = self.create_temporary_file()

        file_in_artifact = FileInArtifact.objects.get(
            path=self.path_in_artifact, artifact=self.artifact
        )

        file_upload = FileUpload.objects.create(
            file_in_artifact=file_in_artifact,
            path=temporary_file,
        )

        if upload_size is None:
            upload_size = file_in_artifact.file.size

        file_upload.absolute_file_path().write_bytes(
            next(data_generator(upload_size))
        )

        return file_upload

    def test_put_content_range_star_size_file_not_uploaded_yet(self):
        """
        Client PUT Content-Range: */$SIZE. Receive HTTP 206: whole file needed.

        The client had not uploaded any part of the file.
        """  # noqa: RST213
        response = self.put_file(
            extra_put_params={
                "HTTP_CONTENT_RANGE": f"bytes */{self.file_size}"
            },
            include_data=False,
        )
        self.assertEqual(response.status_code, status.HTTP_206_PARTIAL_CONTENT)
        self.assertEqual(response.headers["range"], "bytes=0-0")

    def test_put_content_range_star_size_file_already_uploaded(self):
        """
        Client PUT Content-Range: */$SIZE. Receive HTTP 200.

        The file was already uploaded.
        """  # noqa: RST213
        self.create_file_uploaded()

        response = self.put_file(
            extra_put_params={
                "HTTP_CONTENT_RANGE": f"bytes */{self.file_size}"
            },
            include_data=False,
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_put_content_range_file_size_star(self):
        """
        Client PUT Content-Range: */*. Receive HTTP 200.

        The file was already uploaded.
        """  # noqa: RST213
        self.create_file_uploaded()
        response = self.put_file(
            extra_put_params={"HTTP_CONTENT_RANGE": "bytes */*"},
            include_data=False,
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_put_content_range_invalid_file_size(self):
        """
        Client PUT Content-Range: */$WRONG_SIZE, receive error from the server.

        The server expected a different size than $WRONG_SIZE.
        """  # noqa: RST213
        FileUpload.objects.create(
            file_in_artifact=self.file_in_artifact,
            path="not-used-in-this-test",
        )

        content_range_length = self.file_size + 10
        content_range = f"bytes */{content_range_length}"

        response = self.put_file(
            extra_put_params={"HTTP_CONTENT_RANGE": content_range}
        )

        self.assertResponseProblem(
            response,
            "Invalid file size in Content-Range header. "
            f"Expected {self.file_size} received {content_range_length}",
        )

    def test_put_artifact_does_not_exist(self):
        """Client PUT to artifact_id that does not exist. Return HTTP 404."""
        FileInArtifact.objects.all().delete()
        Artifact.objects.all().delete()

        invalid_artifact_id = 1
        response = self.put_file(artifact_id=invalid_artifact_id)

        self.assertResponseProblem(
            response,
            f"Artifact {invalid_artifact_id} does not exist",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_put_artifact_file_path_does_not_exist(self):
        """
        Client upload a file_path that does not exist in artifact.

        Server return HTTP 404.
        """
        invalid_path = FileInArtifact.objects.all().first().path + ".tmp"

        response = self.put_file(path_in_artifact=invalid_path)

        self.assertResponseProblem(
            response,
            f'No file_path "{invalid_path}" for artifact {self.artifact.id}',
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_put_too_big_data(self):
        """
        Client send more data than it was expected based on the range.

        Server return HTTP 400.
        """
        start_range = 5
        end_range = 10
        data = self.file_contents[start_range:end_range]
        data_length = len(data)
        content_range = f"bytes {start_range}-{end_range}/{data_length}"

        response = self.put_file(
            extra_put_params={
                'HTTP_CONTENT_LENGTH': data_length,
                'HTTP_CONTENT_RANGE': content_range,
            },
            forced_data=data,
        )

        self.assertResponseProblem(
            response,
            f"Expected {end_range - start_range + 1} bytes (based on range "
            f"header: end-start+1). Received: {data_length} bytes",
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_end_less_than_start(self):
        """Client send an invalid range: start is greater than file size."""
        file_length = len(self.file_contents)
        start_range = file_length + 10
        end_range = start_range + file_length - 1

        response = self.put_file(
            range_start=start_range,
            range_end=end_range,
        )

        self.assertResponseProblem(
            response,
            f"Range start ({start_range}) is greater than "
            f"file size ({file_length})",
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_range_upload_data_after_end_of_the_file(self):
        """Client send data that would be written after the end of the file."""
        chunk = self.file_contents[0:20]
        chunk_length = len(chunk)
        start_range = self.file_size - 10
        end_range = start_range + chunk_length - 1
        content_range = f"bytes {start_range}-{end_range}/{self.file_size}"

        response = self.put_file(
            extra_put_params={
                'HTTP_CONTENT_RANGE': content_range,
            },
            forced_data=chunk,
        )

        self.assertResponseProblem(
            response,
            "Cannot process range: attempted to write after end of the file. "
            f"Range start: {start_range} Received: {chunk_length} "
            f"File size: {self.file_size}",
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_invalid_content_range_header_bytes(self):
        """Client send invalid Content-Range header. Server return HTTP 400."""
        invalid_header = "bytes something-not-valid"

        response = self.put_file(
            extra_put_params={
                'HTTP_CONTENT_RANGE': invalid_header,
            }
        )

        self.assertResponseProblem(
            response, f'Invalid Content-Range header: "{invalid_header}"'
        )

    def test_invalid_content_range_header_file_size_partial(self):
        """Client send invalid content-range header. Server return HTTP 400."""
        invalid_header = "a/b"

        response = self.put_file(
            extra_put_params={
                'HTTP_CONTENT_RANGE': invalid_header,
            },
        )

        self.assertResponseProblem(
            response, f'Invalid Content-Range header: "{invalid_header}"'
        )

    def test_create_file_in_storage(self):
        """Test create_file_in_storage() creates in correct FileStore."""
        file_upload = self.create_file_uploaded()
        file_id = file_upload.file_in_artifact.file.id
        workspace = file_upload.file_in_artifact.artifact.workspace

        # Create the file in the storage
        UploadFileView.create_file_in_storage(file_upload)

        file_store_expected = workspace.default_file_store

        # The file got created in the workspace.default_file_store
        self.assertTrue(file_store_expected.files.filter(id=file_id).exists())

        # Create a new FileStore and change workspace.default_file_store
        new_file_store = FileStore.objects.create(
            name="nas-01",
            backend=FileStore.BackendChoices.LOCAL,
            configuration={"base_directory": settings.DEBUSINE_STORE_DIRECTORY},
        )
        workspace.default_file_store = new_file_store
        workspace.save()

        # Create the file in the storage
        UploadFileView.create_file_in_storage(file_upload)

        # It got created in the new FileStore
        self.assertTrue(new_file_store.files.filter(id=file_id).exists())


class ValidatePermissionsMixinTests(TestCase):
    """Tests for ValidatePermissionsMixin."""

    class AllowedPermission:
        """Class that has_permission() return True."""

        def has_permission(self, request, view):  # noqa: U100
            """Return True."""
            return True

    class DeniedPermission:
        """Class that has_permission() return True."""

        def has_permission(self, request, view):  # noqa: U100
            """Return False."""
            return False

    def setUp(self):
        """Set up test objects."""

        class ValidatePermissionView(ValidatePermissionsMixin, View):
            pass

        self.permissions_mixin = ValidatePermissionView()
        self.permissions_mixin.permission_classes = []
        self.permissions_mixin.request = None

    def test_no_permissions(self):
        """Do not raise PermissionDenied if no permission_classes."""
        self.permissions_mixin.check_permissions()

    def test_raise_permission_denied(self):
        """Raise PermissionDenied: permission is denied."""
        self.permissions_mixin.permission_classes.append(self.DeniedPermission)

        with self.assertRaises(PermissionDenied):
            self.permissions_mixin.check_permissions()

    def test_permission_allowed(self):
        """Do not raise PermissionDenied: permissions is not denied."""
        self.permissions_mixin.permission_classes.append(self.AllowedPermission)
        self.permissions_mixin.check_permissions()

    def test_permission_denied_message(self):
        """The message is included in the exception."""
        message = "Cannot browse artifact"
        self.permissions_mixin.permission_classes.append(self.DeniedPermission)
        self.permissions_mixin.permission_denied_message = message

        with self.assertRaisesRegex(PermissionDenied, message):
            self.permissions_mixin.check_permissions()

    def test_dispatch_raise_permission_denied(self):
        """dispatch() raise PermissionDenied()."""
        self.permissions_mixin.permission_classes.append(self.DeniedPermission)

        request = RequestFactory().get("/url/")
        with self.assertRaises(PermissionDenied):
            self.permissions_mixin.dispatch(request)


class ArtifactRelationsViewTests(TestHelpersMixin, TestCase):
    """Tests for ArtifactRelationsView class."""

    def setUp(self):
        """Initialize object."""
        workspace = default_workspace()
        self.artifact = Artifact.objects.create(
            category="test", workspace=workspace
        )
        self.artifact_target = Artifact.objects.create(
            category="test", workspace=workspace
        )
        self.client = APIClient()

    @staticmethod
    def request_relations(
        method,
        *,
        artifact_id: Optional[int],
        target_artifact_id: Optional[int],
        body: Optional[dict[str, Any]] = None,
    ):
        """
        Call method with reverse("api:artifact-relation-list").

        :param method: method to call (usually self.client.get/post/delete)
        :param artifact_id: used to create the URL (passed as artifact_id)
        :param target_artifact_id:
        :param body: if not None passed as method's data argument and
          make content_type="application/json".
        """
        params = {}
        if artifact_id is not None:
            params["artifact"] = artifact_id

        if target_artifact_id is not None:
            params["target_artifact"] = target_artifact_id

        post_kwargs = {}
        post_args = []
        if body is not None:
            post_kwargs["format"] = "json"
            post_args.append(body)

        query_string = urlencode(params)

        return method(
            reverse(
                "api:artifact-relation-list",
            )
            + f"?{query_string}",
            *post_args,
            **post_kwargs,
        )

    def get_relations(
        self,
        *,
        artifact_id: Optional[int] = None,
        target_artifact_id: Optional[int] = None,
    ):
        """
        Make an HTTP GET request to api:artifact-relation-list URL.

        :param artifact_id: used in query parameter "artifact"
        :param target_artifact_id: used in query parameter "target_artifact"
        """
        return self.request_relations(
            self.client.get,
            artifact_id=artifact_id,
            target_artifact_id=target_artifact_id,
        )

    def post_relation(self, body=None):
        """
        Make an HTTP POST request to api:artifact-relation-list URL.

        :param body: body of the request
        """
        return self.request_relations(
            self.client.post,
            artifact_id=None,
            target_artifact_id=None,
            body=body,
        )

    def delete_relation(self, artifact_relation_id: int):
        """Make an HTTP DELETE request to api:artifact-relation-detail URL."""
        return self.client.delete(
            reverse(
                "api:artifact-relation-detail",
                kwargs={"pk": artifact_relation_id},
            )
        )

    def test_get_return_404_artifact_not_found(self):
        """Get relations for a non-existing artifact: return 404."""
        artifact_id = 0
        response = self.get_relations(artifact_id=artifact_id)

        self.assertResponseProblem(
            response,
            f"Artifact {artifact_id} not found",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_get_return_404_target_artifact_not_found(self):
        """Get target relations for a non-existing artifact: return 404."""
        artifact_id = 0
        response = self.get_relations(target_artifact_id=artifact_id)

        self.assertResponseProblem(
            response,
            f"Artifact {artifact_id} not found",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_get_return_400_mandatory_parameters_missing(self):
        """Get relations: fails because missing mandatory parameters."""
        response = self.get_relations()

        self.assertResponseProblem(
            response, '"artifact" or "target_artifact" parameter are mandatory'
        )

    def create_relations(self):
        """Create two relations to be used in the tests."""
        ArtifactRelation.objects.create(
            artifact=self.artifact,
            target=self.artifact_target,
            type=ArtifactRelation.Relations.BUILT_USING,
        )
        ArtifactRelation.objects.create(
            artifact=self.artifact,
            target=self.artifact_target,
            type=ArtifactRelation.Relations.RELATES_TO,
        )

    def test_get_200_target_artifact_relations(self):
        """Get return target artifact relations."""
        self.create_relations()

        another_artifact = Artifact.objects.create(
            category=self.artifact.category, workspace=self.artifact.workspace
        )
        ArtifactRelation.objects.create(
            artifact=self.artifact,
            target=another_artifact,
            type=ArtifactRelation.Relations.RELATES_TO,
        )

        response = self.get_relations(
            target_artifact_id=self.artifact_target.id
        )

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

        self.assertEqual(
            len(response.json()), self.artifact_target.targeted_by.count()
        )

        for artifact_relation in response.json():
            ArtifactRelation.objects.get(
                artifact_id=artifact_relation["artifact"],
                target_id=artifact_relation["target"],
                type=artifact_relation["type"],
            )

    def test_get_200_artifact_relations(self):
        """Get return artifact relations."""
        self.create_relations()

        response = self.get_relations(artifact_id=self.artifact.id)

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

        self.assertEqual(
            len(response.json()), ArtifactRelation.objects.all().count()
        )

        for artifact_relation in response.json():
            ArtifactRelation.objects.get(
                artifact_id=self.artifact.id,
                target_id=artifact_relation["target"],
                type=artifact_relation["type"],
            )

    def test_post_400_artifact_id_does_not_exist(self):
        """Post the relations for a non-existing artifact: return 400."""
        artifact_id = 0
        artifact_relation = {
            "artifact": artifact_id,
            "target": self.artifact.id,
            "type": ArtifactRelation.Relations.RELATES_TO,
        }

        response = self.post_relation(body=artifact_relation)

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="Invalid pk",
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_post_400_cannot_deserialize(self):
        """Add a new relation for an artifact: HTTP 400 cannot deserialize."""
        response = self.post_relation(body={"foo": "bar"})

        self.assertResponseProblem(
            response, title="Error", detail_pattern="This field is required"
        )

    def test_post_201_created(self):
        """Add a new relation for an artifact."""
        self.assertEqual(self.artifact.relations.all().count(), 0)
        relation = {
            "artifact": self.artifact.id,
            "target": self.artifact_target.id,
            "type": ArtifactRelation.Relations.RELATES_TO,
        }

        response = self.post_relation(body=relation)

        response_data = response.json()

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

        relation["id"] = ArtifactRelation.objects.all().first().id
        self.assertEqual(response_data, relation)

        ArtifactRelation.objects.get(
            id=response_data["id"],
            artifact_id=response_data["artifact"],
            target_id=response_data["target"],
            type=response_data["type"],
        )

    def test_post_200_duplicated(self):
        """Try to add a relation that already exists: return HTTP 200."""
        artifact_relation = ArtifactRelation.objects.create(
            artifact=self.artifact,
            target=self.artifact_target,
            type=ArtifactRelation.Relations.BUILT_USING,
        )

        relation = {
            "artifact": artifact_relation.artifact.id,
            "target": artifact_relation.target.id,
            "type": str(artifact_relation.type),
        }

        response = self.post_relation(body=relation)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        relation["id"] = artifact_relation.id
        self.assertEqual(response.json(), relation)

    def test_post_400_invalid_target_id(self):
        """No relations added to the artifact: invalid target id."""
        self.assertEqual(self.artifact.relations.all().count(), 0)

        relation = {
            "artifact": self.artifact.id,
            "target": 0,
            "type": ArtifactRelation.Relations.RELATES_TO,
        }

        response = self.post_relation(body=relation)

        self.assertResponseProblem(
            response, title="Error", detail_pattern="Invalid pk"
        )

    def test_delete_404_artifact_not_found(self):
        """Delete relation: cannot find artifact."""
        artifact_relation_id = 0
        response = self.delete_relation(artifact_relation_id)

        self.assertResponseProblem(
            response,
            title="Error",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_delete_204_success(self):
        """Delete relation."""
        artifact_relation = ArtifactRelation.objects.create(
            artifact=self.artifact,
            target=self.artifact_target,
            type=ArtifactRelation.Relations.RELATES_TO,
        )

        response = self.delete_relation(artifact_relation.id)

        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
        self.assertEqual(self.artifact.relations.all().count(), 0)
