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

"""Tests for the views."""
import io
import re
import tarfile
from datetime import datetime, timedelta
from pathlib import Path
from types import GeneratorType
from typing import Any, ClassVar
from unittest import mock

from django.contrib import messages
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.http.response import HttpResponseBase
from django.test import TestCase
from django.urls import reverse
from django.utils.formats import date_format as django_date_format
from django.utils.http import http_date

from rest_framework import status

from debusine.artifacts.models import ArtifactCategory
from debusine.db.models import (
    Artifact,
    FileInArtifact,
    FileStore,
    Token,
    User,
    WorkRequest,
    WorkflowTemplate,
    default_workspace,
)
from debusine.server.file_backend.interface import FileBackendInterface
from debusine.server.file_backend.local import LocalFileBackend
from debusine.test import TestHelpersMixin
from debusine.web.views import (
    DownloadPathView,
    UserTokenDeleteView,
)
from debusine.web.views.tests.utils import ViewTestMixin


class HomepageViewTests(ViewTestMixin, TestHelpersMixin, TestCase):
    """Tests for the Homepage class."""

    no_workspaces_msg = "No workspaces."
    no_workspaces_not_authenticated = (
        "Not authenticated. Only public workspaces are listed."
    )
    list_your_tokens_url = reverse("user:token-list")
    list_your_tokens_html = (
        f'<a class="dropdown-item" href="{list_your_tokens_url}">Tokens</a>'
    )
    create_work_request_url = reverse("work-requests:create")
    create_work_request_html = (
        f'<a class="dropdown-item" href="{create_work_request_url}">'
        "Create work request</a>"
    )

    create_artifact_url = reverse("artifacts:create")
    create_artifact_html = (
        f'<a class="dropdown-item" href="{create_artifact_url}">'
        "Create artifact</a>"
    )

    def _get_logged_in_user_element(self, username: str) -> str:
        return (
            '<a class="nav-link dropdown-toggle" href="#" role="button"'
            ' data-bs-toggle="dropdown" aria-expanded="false">'
            + username
            + '</a>'
        )

    def test_html_check_icon(self) -> None:
        """Test that _html_check_icon returns the right results."""
        self.assertIn("green", _html_check_icon(True))
        self.assertNotIn("green", _html_check_icon(False))
        self.assertIn("red", _html_check_icon(False))
        self.assertNotIn("red", _html_check_icon(True))

    def test_slash(self):
        """
        Slash (/) URL and homepage are the same.

        Make sure that the / is handled by the 'homepage' view.
        """
        self.assertEqual(reverse("homepage:homepage"), "/")

    def test_homepage(self):
        """Homepage view loads."""
        response = self.client.get(reverse("homepage:homepage"))

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertContains(response, "Welcome to debusine!")

        login_url = reverse("accounts:login")

        expected_login_html = (
            f'<a class="nav-link" href="{login_url}">Log in</a>'
        )
        self.assertContains(response, expected_login_html, html=True)

        self.assertNotContains(
            response, self._get_logged_in_user_element("testuser"), html=True
        )

        self.assertNotContains(response, self.list_your_tokens_html, html=True)
        self.assertNotContains(response, self.create_work_request_html)

        self.assertContains(response, self.no_workspaces_msg, html=True)

        self.assertContains(
            response, self.no_workspaces_not_authenticated, html=True
        )

    def test_homepage_logged_in(self):
        """User is logged in: contains "You are authenticated as: username"."""
        username = "testuser"

        user = get_user_model().objects.create_user(
            username=username, password="password"
        )

        self.client.force_login(user)
        response = self.client.get(reverse("homepage:homepage"))
        tree = self.assertHTMLValid(response)

        logged_out_url = reverse("accounts:logout")

        self.assertContains(
            response, self._get_logged_in_user_element(username), html=True
        )

        expected_text = (
            f'<a class="dropdown-item" href="{logged_out_url}">Log out</a>'
        )
        self.assertContains(response, expected_text, html=True)

        self.assertContains(response, self.list_your_tokens_html, html=True)
        self.assertContains(response, self.create_work_request_html, html=True)

        self.assertContains(
            response, f"No work requests created by {username}."
        )

        workspace_rows = self.workspace_list_table_rows(tree)
        self.assertNodeTextEqual(
            workspace_rows[0].td.a, default_workspace().name
        )

        self.assertNotContains(response, self.no_workspaces_msg, html=True)
        self.assertNotContains(
            response, self.no_workspaces_not_authenticated, html=True
        )

    def test_homepage_logged_in_with_work_requests(self):
        """User is logged in and contain user's work requests."""
        username = "testuser"

        user = get_user_model().objects.create_user(
            username=username, password="password"
        )

        self.client.force_login(user)

        work_request = self.create_work_request(created_by=user, id=11)
        work_request = self.create_work_request(created_by=user, id=12)

        response = self.client.get(reverse("homepage:homepage"))

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

        self.assertContains(
            response, _html_work_request_row(work_request), html=True
        )

        # latest first
        self.assertEqual(response.context["work_request_list"][0].id, 12)

    def test_homepage_exclude_internal_work_requests(self):
        """The homepage excludes the user's INTERNAL work requests."""
        username = "testuser"

        user = get_user_model().objects.create_user(
            username=username, password="password"
        )

        self.client.force_login(user)

        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name="noop",
            task_data={},
        )
        root = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=user
        )
        synchronization_point = (
            WorkRequest.objects.create_synchronization_point(
                parent=root, step="test"
            )
        )

        response = self.client.get(reverse("homepage:homepage"))

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertContains(response, _html_work_request_row(root), html=True)
        self.assertNotContains(
            response, _html_work_request_row(synchronization_point), html=True
        )

    def test_messages(self):
        """Messages from django.contrib.messages are displayed."""

        def mocked_get_context_data(self, *args, **kwargs):
            messages.error(self.request, "Error message")
            return {}

        with mock.patch(
            "debusine.web.views.views.HomepageView.get_context_data",
            autospec=True,
            side_effect=mocked_get_context_data,
        ):
            response = self.client.get(reverse("homepage:homepage"))

        tree = self.assertHTMLValid(response)
        div = tree.xpath("//div[@id='user-message-container']")[0]
        msgdiv = div.div.div
        self.assertEqual(msgdiv.get("class"), "toast")
        self.assertEqual(msgdiv.get("role"), "alert")
        self.assertEqual(msgdiv.div[1].text.strip(), "Error message")


class LoginViewTests(TestCase):
    """Tests for the LoginView class."""

    def test_successful_login(self):
        """Successful login redirect to the homepage with authenticated user."""
        username = "testuser"
        password = "testpassword"

        get_user_model().objects.create_user(
            username=username, password=password
        )

        response = self.client.post(
            reverse("accounts:login"),
            data={"username": username, "password": password},
        )

        self.assertRedirects(response, reverse("homepage:homepage"))
        self.assertTrue(response.wsgi_request.user.is_authenticated)


class LogoutViewTests(TestCase):
    """Tests for the accounts:logout view."""

    def test_successful_render(self):
        """
        Logout template is rendered.

        The view is implemented (and tested) as part of Django. The template
        is implemented by Debusine, and it's rendered here to assert that
        no missing template variables and contains the expected strings.
        """
        response = self.client.post(reverse("accounts:logout"))

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


class DownloadPathViewTests(TestHelpersMixin, TestCase):
    """Tests for the DownloadPathView class."""

    playground_memory_file_store = False

    def setUp(self):
        """Set up test data."""
        self.token = self.create_token_enabled()
        self.path_in_artifact = "README.md"
        self.file_size = 100
        self.artifact, self.files_contents = self.create_artifact(
            [self.path_in_artifact],
            files_size=self.file_size,
            create_files=True,
        )

    def get_file(
        self,
        *,
        range_start=None,
        range_end=None,
        range_header=None,
        artifact_id=None,
        path_file=None,
        include_token: bool = True,
    ) -> HttpResponseBase:
        """
        Download file specified in the parameters.

        Unless specified: try to download the whole file (by default
        self.path_file and self.artifact.id).
        """
        headers: dict[str, Any] = {}

        if range_start is not None:
            headers["HTTP_RANGE"] = f"bytes={range_start}-{range_end}"

        if range_header is not None:
            headers["HTTP_RANGE"] = range_header

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

        if path_file is None:
            path_file = self.path_in_artifact

        if include_token:
            headers["HTTP_TOKEN"] = self.token.key

        return self.client.get(
            reverse(
                "artifacts:detail-path",
                kwargs={
                    "artifact_id": artifact_id,
                    "path": path_file,
                },
            ),
            **headers,
        )

    def assertFileResponse(self, response, status_code, range_start, range_end):
        """Assert that response has the expected headers and content."""
        self.assertEqual(response.status_code, status_code)
        headers = response.headers

        self.assertEqual(headers["Accept-Ranges"], "bytes")

        file_contents = self.files_contents[self.path_in_artifact]
        response_contents = file_contents[range_start : range_end + 1]

        self.assertEqual(headers["Content-Length"], str(len(response_contents)))

        if len(response_contents) > 0:
            self.assertEqual(
                headers["Content-Range"],
                f"bytes {range_start}-{range_end}/{self.file_size}",
            )

        filename = Path(self.path_in_artifact).name
        self.assertEqual(
            headers["Content-Disposition"], f'inline; filename="{filename}"'
        )

        self.assertEqual(
            b"".join(response.streaming_content), response_contents
        )

    def test_path_url_does_not_end_in_slash(self):
        """
        URL to download a file does not end in /.

        If ending in / wget or curl -O save the file as index.html
        instead of using Content-Disposition filename.
        """
        url = reverse(
            "artifacts:detail-path",
            kwargs={"artifact_id": 10, "path": "package.deb"},
        )
        self.assertFalse(url.endswith("/"))

    def test_check_permissions_denied(self):
        """Permission denied: no token, logged user or public workspace."""
        response = self.get_file(include_token=False)
        self.assertContains(
            response,
            DownloadPathView.permission_denied_message,
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_check_permissions_valid_token_allowed(self):
        """Permission granted: valid token."""
        self.assertFalse(self.artifact.workspace.public)
        response = self.get_file(artifact_id=self.artifact.id)
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_check_permissions_public_workspace(self):
        """Permission granted: without a token but it is a public workspace."""
        workspace = self.artifact.workspace
        workspace.public = True
        workspace.save()

        response = self.get_file(include_token=False)

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

    def test_check_permission_logged_user(self):
        """Permission granted: user is logged in."""
        self.assertFalse(self.artifact.workspace.public)

        user = get_user_model().objects.create_user(
            username="testuser", password="testpassword"
        )

        self.client.force_login(user)

        response = self.client.get(
            reverse(
                "artifacts:detail",
                kwargs={
                    "artifact_id": self.artifact.id,
                },
            )
        )

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

    def test_get_file(self):
        """Get return the file."""
        response = self.get_file()
        self.assertFileResponse(
            response, status.HTTP_200_OK, 0, self.file_size - 1
        )
        self.assertEqual(
            response.headers["content-type"], "text/markdown; charset=utf-8"
        )

    def test_get_empty_file(self):
        """Test empty downloadable file (which mmap doesn't support)."""
        self.path_in_artifact = "stderr.txt"
        self.file_size = 0
        self.artifact, self.files_contents = self.create_artifact(
            [self.path_in_artifact],
            files_size=self.file_size,
            create_files=True,
        )
        response = self.get_file(path_file=self.path_in_artifact)
        self.assertFileResponse(
            response, status.HTTP_200_OK, 0, self.file_size - 1
        )

    def test_get_file_content_type_for_build(self):
        """Test Content-Type of the view for different file types."""
        file_in_artifact = self.artifact.fileinartifact_set.all().first()

        sub_tests = [
            ("build.changes", "text/plain; charset=utf-8"),
            ("file.log", "text/plain; charset=utf-8"),
            ("file.txt", "text/plain; charset=utf-8"),
            ("hello.build", "text/plain; charset=utf-8"),
            ("hello.buildinfo", "text/plain; charset=utf-8"),
            ("file.sources", "text/plain; charset=utf-8"),
            ("readme.md", "text/markdown; charset=utf-8"),
            ("a.out", "application/octet-stream"),
        ]

        for sub_test in sub_tests:
            with self.subTest(sub_test):
                file_in_artifact.path = sub_test[0]
                file_in_artifact.save()

                response = self.get_file(path_file=sub_test[0])

                self.assertEqual(response.headers["content-type"], sub_test[1])

    def test_get_file_range(self):
        """Get return part of the file (based on Range header)."""
        start = 10
        end = 20
        response = self.get_file(range_start=start, range_end=end)

        self.assertFileResponse(
            response, status.HTTP_206_PARTIAL_CONTENT, start, end
        )

    def test_get_file_content_range_to_end_of_file(self):
        """Server returns a file from a position to the end."""
        start = 5

        end = self.file_size - 1
        response = self.get_file(range_start=start, range_end=end)

        self.assertFileResponse(
            response, status.HTTP_206_PARTIAL_CONTENT, start, end
        )

    def test_get_file_content_range_invalid(self):
        """Get return an error: Range header was invalid."""
        invalid_range_header = "invalid-range"
        response = self.get_file(range_header=invalid_range_header)

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

    def test_get_path_artifact_does_not_exist(self):
        """Get return 404: artifact not found."""
        non_existing_artifact_id = 0

        response = self.get_file(artifact_id=non_existing_artifact_id)

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

    def test_get_file_file_does_not_exist(self):
        """Get return 404: artifact found but file not found."""
        file_path_no_exist = "does-not-exist"

        response = self.get_file(path_file=file_path_no_exist)

        self.assertContains(
            response,
            f'Artifact {self.artifact.id} does not have '
            f'any file or directory for "{file_path_no_exist}"',
            status_code=status.HTTP_404_NOT_FOUND,
            html=True,
        )

    def test_get_file_range_start_greater_file_size(self):
        """Get return 400: client requested an invalid start position."""
        start = self.file_size + 10
        response = self.get_file(range_start=start, range_end=start + 10)

        self.assertResponseProblem(
            response,
            f"Invalid Content-Range start: {start}. "
            f"File size: {self.file_size}",
        )

    def test_get_file_range_end_is_file_size(self):
        """Get return 400: client requested and invalid end position."""
        response = self.get_file(range_start=0, range_end=self.file_size)
        self.assertResponseProblem(
            response,
            f"Invalid Content-Range end: {self.file_size}. "
            f"File size: {self.file_size}",
        )

    def test_get_file_range_end_greater_file_size(self):
        """Get return 400: client requested an invalid end position."""
        end = self.file_size + 10
        response = self.get_file(range_start=0, range_end=end)

        self.assertResponseProblem(
            response,
            f"Invalid Content-Range end: {end}. "
            f"File size: {self.file_size}",
        )

    def test_get_file_url_redirect(self):
        """
        Get file response: redirect if get_url for the file is available.

        This would happen if the file is stored in a FileStore supporting
        get_url (e.g. an object storage) instead of being served from the
        server's file system.
        """
        destination_url = "https://some-backend.net/file?token=asdf"
        patch = mock.patch.object(
            FileStore, "get_backend_object", autospec=True
        )

        file_backend_mocked = mock.create_autospec(spec=FileBackendInterface)
        file_backend_mocked.get_url.return_value = destination_url

        get_backend_object_mocked = patch.start()
        get_backend_object_mocked.return_value = file_backend_mocked
        self.addCleanup(patch.stop)

        response = self.get_file()
        self.assertEqual(response.status_code, status.HTTP_302_FOUND)
        self.assertEqual(response.url, destination_url)

    def get_artifact(
        self,
        artifact_id: int,
        archive: str | None = None,
        subdirectory: str | None = None,
        **get_kwargs,
    ) -> HttpResponseBase:
        """Request to download an artifact_id."""
        reverse_kwargs: dict[str, Any] = {"artifact_id": artifact_id}
        viewname = "artifacts:detail"

        if subdirectory is not None:
            viewname = "artifacts:detail-path"
            reverse_kwargs["path"] = subdirectory

        if archive is not None:
            get_kwargs["archive"] = archive

        return self.client.get(
            reverse(viewname, kwargs=reverse_kwargs),
            get_kwargs,
            HTTP_TOKEN=self.token.key,
        )

    def test_get_subdirectory_does_not_exist_404(self):
        """View return HTTP 404 Not Found: no files in the subdirectory."""
        paths = ["README"]
        artifact, _ = self.create_artifact(paths)

        subdirectory = "does-not-exist"
        response = self.get_artifact(artifact.id, "tar.gz", subdirectory)

        self.assertContains(
            response,
            f'Artifact {artifact.id} does not have any file or '
            f'directory for "{subdirectory}"',
            status_code=status.HTTP_404_NOT_FOUND,
            html=True,
        )

    def test_get_subdirectory_only_tar_gz(self):
        """View return tar.gz file with the files from a subdirectory."""
        paths = [
            "README",
            "doc/README",
            "doc/README2",
            "documentation",
            "src/lib/main.c",
            "src/lib/utils.c",
        ]
        artifact, _ = self.create_artifact(paths, create_files=True)

        subdirectory = "src/lib"
        response = self.get_artifact(artifact.id, "tar.gz", subdirectory)
        self.assertEqual(
            response.headers["Content-Disposition"],
            f'attachment; filename="artifact-{artifact.id}-src_lib.tar.gz"',
        )
        response_content = io.BytesIO(b"".join(response.streaming_content))

        tar = tarfile.open(fileobj=response_content, mode="r:gz")

        expected_files = list(
            filter(lambda x: x.startswith(subdirectory + "/"), paths)
        )
        self.assertEqual(tar.getnames(), expected_files)

    def test_get_unsupported_archive_parameter(self):
        """View return HTTP 400 Bad Request: unsupported archive parameter."""
        archive_format = "tar.xz"
        artifact, _ = self.create_artifact([])

        response = self.get_artifact(artifact.id, archive_format)
        self.assertResponse400(
            response,
            f'Invalid archive parameter: "{archive_format}". '
            'Supported: "tar.gz"',
        )

    def test_get_success_tar_gz(self):
        """View return a .tar.gz file."""
        paths = ["README", "src/main.c"]
        artifact, files_contents = self.create_artifact(
            paths, create_files=True
        )

        response = self.get_artifact(artifact.id, "tar.gz")

        response_content = io.BytesIO(b"".join(response.streaming_content))

        tar = tarfile.open(fileobj=response_content, mode="r:gz")

        # Check contents of the tar file
        for path in paths:
            self.assertEqual(tar.extractfile(path).read(), files_contents[path])

        # Check relevant headers
        self.assertEqual(
            response.headers["Content-Type"], "application/octet-stream"
        )
        self.assertEqual(
            response.headers["Content-Disposition"],
            f'attachment; filename="artifact-{artifact.id}.tar.gz"',
        )
        self.assertEqual(
            response.headers["Last-Modified"],
            http_date(artifact.created_at.timestamp()),
        )

    def test_get_success_html_list_root(self):
        """View returns HTML with list of files."""
        paths = ["README", "AUTHORS"]

        work_request = self.create_work_request()

        artifact, _ = self.create_artifact(
            paths,
            create_files=True,
            expiration_delay=1,
            work_request=work_request,
        )

        response = self.get_artifact(artifact.id)

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

        for fileinartifact in artifact.fileinartifact_set.all():
            self.assertContains(response, fileinartifact.path)
            self.assertContains(response, fileinartifact.file.size)
            self.assertContains(
                response,
                reverse(
                    "artifacts:detail-path",
                    kwargs={
                        "artifact_id": artifact.id,
                        "path": fileinartifact.path,
                    },
                ),
            )

        self.assertContains(response, f"Files in artifact {artifact.id}")
        self.assertContains(response, "Subdirectory: /")

        self.assertContains(response, f"<li>Id: {artifact.id}</li>", html=True)

        self.assertContains(
            response, f"<li>Category: {artifact.category}</li>", html=True
        )
        self.assertContains(
            response,
            f"<li>Workspace: {artifact.workspace.name}</li>",
            html=True,
        )
        self.assertContains(
            response,
            f"<li>Created: {_date_format(artifact.created_at)}</li>",
            html=True,
        )

        self.assertContains(
            response,
            f"<li>Expire: {_date_format(artifact.expire_at)}</li>",
            html=True,
        )

        url_work_request = reverse(
            "work_requests:detail",
            kwargs={"pk": artifact.created_by_work_request_id},
        )

        self.assertContains(
            response,
            f"<li>Created by work request: "
            f'<a href="{url_work_request}">'
            f'{artifact.created_by_work_request_id}'
            f'</a></li>',
            html=True,
        )

        self.assertContains(
            response,
            f"<li>Data: <pre><code>{artifact.data}</code></pre></li>",
            html=True,
        )

        url_download_artifact = (
            reverse(
                "artifacts:detail",
                kwargs={"artifact_id": artifact.id},
            )
            + "?archive=tar.gz"
        )
        self.assertContains(response, url_download_artifact)

        self.assertIsInstance(
            response.context["elided_page_range"], GeneratorType
        )

    def test_get_success_default_values(self):
        """
        Artifact have some default values.

        View display "Expire: -", "Created by by work request: -" and
        "Created by: -".
        """
        artifact, _ = self.create_artifact([])

        response = self.get_artifact(artifact.id)

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

        self.assertContains(
            response,
            "<li>Expire: -</li>",
            html=True,
        )

        self.assertContains(
            response,
            "<li>Created by work request: -</li>",
            html=True,
        )

        self.assertContains(
            response,
            "<li>Created by user: -</li>",
            html=True,
        )

    def test_get_success_created_by_user(self):
        """View render "created by user: username"."""
        user = get_user_model().objects.create_user(username="test")
        artifact, _ = self.create_artifact(created_by=user)

        response = self.get_artifact(artifact.id)

        self.assertContains(
            response,
            f"<li>Created by user: {user.username}</li>",
            html=True,
        )

    def test_get_success_html_list_subdirectory(self):
        """View returns HTML with a subdirectory link."""
        subdirectory = "src"

        artifact, _ = self.create_artifact(
            [subdirectory + "/main.c"], create_files=True
        )

        response = self.get_artifact(artifact.id, subdirectory=subdirectory)

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

        url_download_subdirectory = reverse(
            "artifacts:detail-path",
            kwargs={
                "artifact_id": artifact.id,
                "path": subdirectory,
            },
        )
        self.assertContains(response, url_download_subdirectory)

    def test_get_success_html_empty_artifact(self):
        """Test HTML output if there are no files in the artifact."""
        artifact, _ = self.create_artifact([])

        response = self.get_artifact(artifact.id)

        self.assertContains(response, "The artifact does not have any files.")

    def assert_get_queryset(
        self,
        artifact: Artifact,
        expected_paths: list[str],
        subdirectory: str | None = None,
        parent_directory: str | None = None,
    ):
        """Assert DownloadArtifactView.get_queryset() return expected files."""
        # To help debugging: assert the paths first (and check sorting)
        view = DownloadPathView()
        view.kwargs = {"artifact_id": artifact.id}

        if subdirectory is not None:
            view._subdirectory = subdirectory

        files_in_view = view.get_queryset()

        expected_paths = sorted(expected_paths, key=lambda p: p.lower())

        if parent_directory is not None:
            expected_paths.insert(0, parent_directory)

        # Create list of the expected result (based on expected_paths
        # and FileInArtifact).
        expected: list[dict[str, int | str]] = []
        for path in expected_paths:
            name = Path(path).name
            size: int | str
            if path.endswith("/"):
                # it's a directory
                path = path.removesuffix("/")
                name += "/"
                size = "-"
                hash_hex = "-"
            elif path.endswith("/.."):
                # it's the parent directory
                name = "../"
                size = "-"
                hash_hex = "-"
            else:
                # a file: it fetches size and hash_hex from the DB
                file_in_artifact = FileInArtifact.objects.get(
                    artifact=artifact, path=path
                )
                size = file_in_artifact.file.size
                hash_hex = file_in_artifact.file.hash_digest.hex()

            fileinartifact = {
                "path": path,
                "name": name,
                "size": size,
                "hash": hash_hex,
            }
            expected.append(fileinartifact)

        self.assertEqual(
            [
                view._get_file_list_context(fileinartifact)
                for fileinartifact in files_in_view
            ],
            expected,
        )

    def test_get_queryset_only_root(self):
        """get_queryset() return root files and dirs."""
        paths = [
            "README",
            "doc/README",
            "doc/README2",
            "documentation",
            "src/main.c" "src/lib/libmain.c",
            "src/lib/utils.c",
        ]
        artifact, _ = self.create_artifact(paths, create_files=True)

        expected_paths = ["doc/", "documentation", "README", "src/"]

        self.assert_get_queryset(artifact, expected_paths)

    def test_get_queryset_subdirectory(self):
        """get_queryset() return files and dirs from subdir."""
        paths = [
            "README",
            "doc/README",
            "doc/README2",
            "documentation",
            "src/lib/main.c",
            "src/lib/utils.c",
            "src/main.c",
            "src/time.c",
        ]
        artifact, _ = self.create_artifact(paths, create_files=True)

        expected_paths = ["src/lib/", "src/main.c", "src/time.c"]

        self.assert_get_queryset(
            artifact,
            expected_paths,
            subdirectory="src/",
            parent_directory="src/..",
        )

    def test_pagination(self):
        """Pagination is set up and rendered by the template."""
        artifact, _ = self.create_artifact(["foo"], create_files=True)
        response = self.get_artifact(artifact.id)

        self.assertGreaterEqual(DownloadPathView.paginate_by, 10)
        self.assertContains(response, '<nav aria-label="pagination">')


class UserTokenListViewTests(TestHelpersMixin, TestCase):
    """Tests for UserTokenListView class."""

    user: ClassVar[User]

    @classmethod
    def setUpTestData(cls):
        """Set up common data."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser",
            password="testpassword",
            email="test1@example.com",
        )

    def _get_logged_in_user_element(self, username: str) -> str:
        return (
            '<a class="nav-link dropdown-toggle" href="#" role="button"'
            ' data-bs-toggle="dropdown" aria-expanded="false">'
            + username
            + '</a>'
        )

    def test_logged_no_tokens(self):
        """Logged in user with no tokens."""
        another_user = get_user_model().objects.create_user(
            username="notme", password="testpassword", email="test2@example.com"
        )
        another_token = Token.objects.create(user=another_user)

        self.client.force_login(self.user)
        response = self.client.get(reverse("user:token-list"))

        self.assertContains(
            response,
            self._get_logged_in_user_element(self.user.username),
            html=True,
        )
        self.assertContains(
            response,
            "You do not currently have any tokens",
        )

        create_token_url = reverse("user:token-create")
        self.assertContains(
            response,
            f'<a href="{create_token_url}">create a new token</a>',
            html=True,
        )

        # Ensure that a token for another user is not displayed
        self.assertNotContains(response, another_token.key)

    @staticmethod
    def row_for_token(token: Token) -> str:
        """Return HTML for the row of the table for token."""
        edit_url = reverse("user:token-edit", kwargs={"pk": token.pk})
        delete_url = reverse("user:token-delete", kwargs={"pk": token.pk})

        return (
            "<tr>"
            f"<td>{_html_check_icon(token.enabled)}</td>"
            f"<td>{_date_format(token.created_at)}</td>"
            f"<td>{token.comment}</td>"
            f'<td><a href="{delete_url}">Delete</a>&nbsp;|&nbsp;'
            f'<a href="{edit_url}">Edit</a></td>'
            "</tr>"
        )

    def test_logged_two_tokens(self):
        """
        Logged user with two tokens: both are displayed.

        Assert tokens are ordered by created_at.
        """
        token1 = Token.objects.create(user=self.user, comment="Token 1")
        token2 = Token.objects.create(user=self.user, comment="Token 2")

        self.client.force_login(self.user)
        response = self.client.get(reverse("user:token-list"))

        self.assertContains(response, self.row_for_token(token1), html=True)
        self.assertContains(response, self.row_for_token(token2), html=True)

        # Check tokens are ordered_by created_

        # token1 is displayed earlier than token2 (was created before)
        self.assertLess(
            response.content.index(token1.comment.encode("utf-8")),
            response.content.index(token2.comment.encode("utf-8")),
        )

        # Make token1 to be created_at after token2 created_at
        token1.created_at = token2.created_at + timedelta(minutes=1)
        token1.save()

        response = self.client.get(reverse("user:token-list"))

        # Tokens appear in the opposite order than before
        self.assertGreater(
            response.content.index(token1.comment.encode("utf-8")),
            response.content.index(token2.comment.encode("utf-8")),
        )

    def test_no_logged(self):
        """Request with a non-logged user: does not return any token."""
        response = self.client.get(reverse("user:token-list"))

        self.assertContains(
            response,
            "You need to be authenticated to list tokens",
            status_code=status.HTTP_403_FORBIDDEN,
            html=True,
        )


class UserTokenCreateViewTests(TestCase):
    """Tests for UserTokenCreateView."""

    user: ClassVar[User]

    @classmethod
    def setUpTestData(cls):
        """Initialize class data."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpass"
        )

    def test_create_token(self):
        """Post to "user:token-create" to create a token."""
        self.client.force_login(self.user)
        comment = "Test 1"
        response = self.client.post(
            reverse("user:token-create"), {"comment": comment}
        )

        # The token got created...
        token = Token.objects.get(comment=comment)

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

        # check the returned token matches one in the db
        token_key_re = re.compile(
            r".*<code>(?P<token>[a-z0-9]*).*</code>.*",
            re.DOTALL,
        )
        resp_key = token_key_re.match(response.content.decode()).group("token")
        self.assertEqual(token, Token.objects.get_token_or_none(resp_key))

        self.assertContains(
            response,
            _html_collapse_token(resp_key),
            status_code=status.HTTP_200_OK,
            html=True,
        )

    def test_no_logged(self):
        """Request with a non-logged user: cannot create a token."""
        response = self.client.get(reverse("user:token-create"))

        self.assertContains(
            response,
            "You need to be authenticated to create a token",
            status_code=status.HTTP_403_FORBIDDEN,
            html=True,
        )

    def test_initial_form(self):
        """Get request to ensure the form is displayed."""
        self.client.force_login(self.user)

        response = self.client.get(reverse("user:token-create"))

        self.assertContains(
            response, "<title>Debusine - Create a token</title>", html=True
        )
        self.assertContains(response, "<h1>Create token</h1>", html=True)
        self.assertContains(
            response,
            '<input class="btn btn-primary btn-sm" '
            'type="submit" value="Create">',
            html=True,
        )


class UserTokenUpdateViewTests(TestCase):
    """Tests for UserTokenCreateView when editing a token."""

    user: ClassVar[User]
    token: ClassVar[Token]

    @classmethod
    def setUpTestData(cls):
        """Initialize class data."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpass", email="user@example.com"
        )
        cls.token = Token.objects.create(user=cls.user, comment="A comment")

    def test_edit_token(self):
        """Get "user:token-edit" to edit a token."""
        self.client.force_login(self.user)

        enabled = True
        comment = "This is a token"

        response = self.client.post(
            reverse("user:token-edit", kwargs={"pk": self.token.pk}),
            {"comment": comment, "enabled": enabled},
        )

        self.assertRedirects(response, reverse("user:token-list"))

        self.token.refresh_from_db()

        self.assertEqual(self.token.comment, comment)
        self.assertEqual(self.token.enabled, enabled)

    def test_get_404_not_found(self):
        """Get of token/PK/ that exist but belongs to another user: 404."""
        user2 = get_user_model().objects.create(
            username="testuser2",
            password="testpass2",
            email="user2@example.com",
        )

        self.client.force_login(user2)
        response = self.client.get(
            reverse("user:token-edit", kwargs={"pk": self.token.pk})
        )

        self.assertNotContains(
            response, self.token.hash, status_code=status.HTTP_404_NOT_FOUND
        )

    def test_put_404_not_found(self):
        """Put of token/PK/ that exist but belongs to another user: 404."""
        user2 = get_user_model().objects.create(
            username="testuser2",
            password="testpass2",
            email="user2@example.com",
        )

        self.client.force_login(user2)
        comment = "This is the new comment"
        enabled = False
        response = self.client.put(
            reverse("user:token-edit", kwargs={"pk": self.token.pk}),
            {"comment": comment, "enabled": enabled},
        )

        self.assertNotContains(
            response, self.token.hash, status_code=status.HTTP_404_NOT_FOUND
        )

    def test_initial_form(self):
        """Get request to ensure the form is displayed."""
        self.client.force_login(self.user)

        response = self.client.get(
            reverse("user:token-edit", kwargs={"pk": self.token.pk})
        )

        self.assertContains(
            response, "<title>Debusine - Edit a token</title>", html=True
        )
        self.assertContains(response, "<h1>Edit token</h1>", html=True)
        self.assertContains(
            response,
            '<input class="btn btn-primary btn-sm" type="submit" value="Edit">',
            html=True,
        )
        self.assertEqual(
            response.context_data["form"]["comment"].initial, self.token.comment
        )


class UserTokenDeleteViewTests(TestCase):
    """Tests for UserTokenDeleteView."""

    user: ClassVar[User]
    token: ClassVar[Token]

    @classmethod
    def setUpTestData(cls):
        """Set up objects used in the tests."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpass"
        )

        cls.token = Token.objects.create(user=cls.user)

    def test_get_delete_token_authenticated_user(self):
        """Get request to delete a token. Token information is returned."""
        self.client.force_login(self.user)

        response = self.client.get(
            reverse("user:token-delete", kwargs={"pk": self.token.pk})
        )

        expected_contents = (
            "<ul>"
            f"<li>Comment: {self.token.comment}</li>"
            f"<li>Enabled: {_html_check_icon(self.token.enabled)}</li>"
            f"<li>Created at: {_date_format(self.token.created_at)}</li>"
            "</ul>"
        )

        self.assertContains(response, expected_contents, html=True)

    def test_post_delete_token_authenticated_user(self):
        """Post request to delete a token. The token is deleted."""
        self.client.force_login(self.user)

        response = self.client.post(
            reverse("user:token-delete", kwargs={"pk": self.token.pk})
        )

        self.assertRedirects(response, reverse("user:token-list"))

        # The token was deleted
        self.assertEqual(Token.objects.filter(pk=self.token.pk).count(), 0)

    def test_get_delete_token_non_authenticated(self):
        """
        Non authenticated Get and Post request. Return 403.

        The token is not displayed.
        """
        response = self.client.get(
            reverse("user:token-delete", kwargs={"pk": self.token.pk})
        )

        self.assertContains(
            response,
            UserTokenDeleteView.permission_denied_message,
            status_code=status.HTTP_403_FORBIDDEN,
        )
        self.assertNotContains(
            response, self.token.hash, status_code=status.HTTP_403_FORBIDDEN
        )

        response = self.client.post(
            reverse("user:token-delete", kwargs={"pk": self.token.pk})
        )

        self.assertContains(
            response,
            UserTokenDeleteView.permission_denied_message,
            status_code=status.HTTP_403_FORBIDDEN,
        )
        self.assertNotContains(
            response, self.token.hash, status_code=status.HTTP_403_FORBIDDEN
        )

    def test_get_post_delete_token_another_user(self):
        """
        Get and Post request delete token from another user. Return 404.

        The token is not deleted neither displayed.
        """
        user2 = get_user_model().objects.create_user(
            username="testuser2",
            password="testpass2",
            email="user2@example.com",
        )

        self.client.force_login(user2)

        response = self.client.get(
            reverse("user:token-delete", kwargs={"pk": self.token.pk})
        )

        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
        self.assertNotContains(
            response, self.token.hash, status_code=status.HTTP_404_NOT_FOUND
        )

        response = self.client.get(
            reverse("user:token-delete", kwargs={"pk": self.token.pk})
        )

        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
        self.assertNotContains(
            response, self.token.hash, status_code=status.HTTP_404_NOT_FOUND
        )

        self.assertEqual(Token.objects.filter(pk=self.token.pk).count(), 1)


class CreateArtifactViewTests(TestCase):
    """Tests for CreateArtifactView."""

    user: ClassVar[User]

    @classmethod
    def setUpTestData(cls):
        """Set up test data."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpassword"
        )

    def verify_create_artifact_with_files(
        self, files: list[SimpleUploadedFile]
    ):
        """
        Test CreateArtifactView via POST to downloads_artifact:create.

        Post the files to create an artifact and verify the created artifact
        and file upload.
        """
        self.client.force_login(self.user)

        # Create a dummy file for testing
        workspace = default_workspace()
        category = ArtifactCategory.WORK_REQUEST_DEBUG_LOGS

        files_to_upload: SimpleUploadedFile | list[SimpleUploadedFile]
        if len(files) == 1:
            files_to_upload = files[0]
        else:
            files_to_upload = files

        post_data = {
            "category": category,
            "workspace": workspace.id,
            "files": files_to_upload,
            "data": "",
        }

        response = self.client.post(reverse("artifacts:create"), post_data)
        self.assertEqual(response.status_code, 302)

        artifact = Artifact.objects.first()
        assert artifact is not None

        self.assertRedirects(
            response,
            reverse(
                "artifacts:detail",
                kwargs={"artifact_id": artifact.id},
            ),
        )

        # Verify artifact
        self.assertEqual(artifact.created_by, self.user)
        self.assertEqual(artifact.workspace, workspace)
        self.assertEqual(artifact.category, category)
        self.assertEqual(artifact.data, {})

        # Verify uploaded files
        self.assertEqual(artifact.fileinartifact_set.count(), len(files))

        local_file_backend = LocalFileBackend(workspace.default_file_store)

        for file_in_artifact, file_to_upload in zip(
            artifact.fileinartifact_set.all().order_by("id"), files
        ):
            with local_file_backend.get_stream(file_in_artifact.file) as file:
                assert file_to_upload.file is not None
                file_to_upload.file.seek(0)
                content = file_to_upload.file.read()
                self.assertEqual(file.read(), content)
                self.assertEqual(file_in_artifact.path, file_to_upload.name)

            self.assertEqual(file_in_artifact.path, file_to_upload.name)

    def test_create_artifact_one_file(self):
        """Post to "user:artifact-create" to create an artifact: one file."""
        file = SimpleUploadedFile("testfile.txt", b"some_file_content")
        self.verify_create_artifact_with_files([file])

    def test_create_artifact_two_files(self):
        """Post to "user:artifact-create" to create an artifact: two files."""
        files = [
            SimpleUploadedFile("testfile.txt", b"some_file_content"),
            SimpleUploadedFile("testfile2.txt", b"another_file_content"),
        ]
        self.verify_create_artifact_with_files(files)

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


_sort_table_handle = '<span class="order-active">&#9660;</span>'


def _html_work_request_row(work_request: WorkRequest) -> str:
    """Return HTML for a row containing work request information."""
    work_request_url = reverse(
        "work-requests:detail", kwargs={"pk": work_request.id}
    )

    return (
        f'<tr>'
        f'<td><a href="{work_request_url}">{work_request.id}</a></td>'
        f"<td>{_date_format(work_request.created_at)}</td>"
        f"<td>{work_request.task_type}</td>"
        f"<td>{work_request.task_name}</td>"
        f"<td>{_html_work_request_status(work_request.status)}</td>"
        f"<td>{_html_work_request_result(work_request.result)}</td>"
        f'</tr>'
    )


def _html_collapse_token(key: str) -> str:
    """Return HTML for a show/hide button for token key."""
    return f'''<button class="btn btn-primary btn-sm" type="button"
        data-bs-toggle="collapse"
            data-bs-target="#{key}Div">
        Show / Hide Token
        </button>
        <div class="collapse" id="{key}Div">
            <code>{key}</code>
        </div>
    '''


def _html_work_request_result(result: str) -> str:
    if result == "success":
        text_bg = "text-bg-success"
        text = "Success"
    elif result == "failure":
        text_bg = "text-bg-warning"
        text = "Failure"
    elif result == "error":
        text_bg = "text-bg-danger"
        text = "Error"
    else:
        return ""

    return f'<span class="badge {text_bg}">{text}</span>'


def _html_work_request_status(status: str) -> str:
    if status == "pending":
        text_bg = "text-bg-secondary"
        text = "Pending"
    elif status == "running":
        text_bg = "text-bg-secondary"
        text = "Running"
    elif status == "completed":
        text_bg = "text-bg-primary"
        text = "Completed"
    elif status == "aborted":
        text_bg = "text-bg-dark"
        text = "Aborted"
    elif status == "blocked":
        text_bg = "text-bg-secondary"
        text = "Blocked"
    else:
        assert False

    return f'<span class="badge {text_bg}">{text}</span>'


def _html_check_icon(value: bool) -> str:
    """Return HTML for check icon."""
    if value:
        return '<i style="color:green;" class="bi bi-check2"></i>'
    else:
        return '<i style="color: red;" class="bi bi-x"></i>'


def _date_format(dt: datetime) -> str:
    """Return dt datetime formatted with the Django template format."""
    return django_date_format(dt, "DATETIME_FORMAT")
