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

import datetime as dt
from typing import ClassVar, override
from unittest import mock

from django.db.models import Max
from django.template.response import SimpleTemplateResponse
from django.test import RequestFactory
from django.urls import reverse
from rest_framework import status

from debusine.artifacts.models import (
    ArtifactCategory,
    BareDataCategory,
    CollectionCategory,
)
from debusine.db.models import Collection, CollectionItem, Workspace
from debusine.db.models.collections import CollectionRelation
from debusine.db.playground import scenarios
from debusine.test.django import AllowAll, TestCase, override_permission
from debusine.web.views import ui_shortcuts
from debusine.web.views.collections import (
    CollectionCategoryListView,
    CollectionItemDetailView,
    CollectionListView,
)
from debusine.web.views.places import ContainerPlace
from debusine.web.views.tests.utils import ViewTestMixin
from debusine.web.views.ui.collections import CollectionUI


class CollectionViewsTestsBase(ViewTestMixin, TestCase):
    """Base fixture for testing Collection views."""

    scenario = scenarios.DefaultContext(set_current=True)
    default_collections: ClassVar[list[Collection]]
    sid: ClassVar[Collection]
    trixie: ClassVar[Collection]
    environments: ClassVar[Collection]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        """Set up a database layout for views."""
        super().setUpTestData()
        cls.default_collections = list(cls.scenario.workspace.collections.all())
        cls.sid = cls.playground.create_collection(
            "sid",
            CollectionCategory.SUITE,
        )
        cls.trixie = cls.playground.create_collection(
            "trixie",
            CollectionCategory.SUITE,
        )
        cls.environments = cls.playground.create_collection(
            "debian",
            CollectionCategory.ENVIRONMENTS,
        )


class CollectionListViewTests(CollectionViewsTestsBase):
    """Tests for :py:class:`CollectionListView`."""

    url: ClassVar[str]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        """Set up a database layout for views."""
        super().setUpTestData()
        cls.url = reverse(
            "workspaces:collections:list",
            kwargs={
                "wname": cls.scenario.workspace.name,
            },
        )

    def test_get_place(self) -> None:
        view = CollectionListView()
        view.request = RequestFactory().get("/")
        place = view.get_place()
        self.assertIsInstance(place, ContainerPlace)
        self.assertEqual(place.title, "Collections")
        self.assertEqual(
            place.url,
            reverse(
                "workspaces:collections:list",
                kwargs={"wname": self.scenario.workspace.name},
            ),
        )
        self.assertIsNone(place.icon)
        self.assertEqual(
            place.description, "System public workspace collection list"
        )
        assert place.parent is not None
        self.assertEqual(
            place.parent.url, self.scenario.workspace.get_absolute_url()
        )

    def test_permissions(self) -> None:
        """Test permissions on the collection list view."""
        self.assertSetsCurrentWorkspace(self.scenario.workspace, self.url)

    def test_list(self) -> None:
        """Test collection list view."""
        response = self.client.get(self.url)
        self.assertResponseHTML(response)
        queryset = response.context["collection_list"]
        self.assertQuerySetEqual(
            queryset,
            [
                *self.default_collections,
                self.sid,
                self.trixie,
                self.environments,
            ],
            ordered=False,
        )

    def test_hides_internal(self) -> None:
        """Internal collections are not shown."""
        internal = self.playground.create_collection(
            "internal",
            CollectionCategory.WORKFLOW_INTERNAL,
            workspace=self.scenario.workspace,
        )
        response = self.client.get(self.url)
        self.assertResponseHTML(response)
        queryset = response.context["collection_list"]
        self.assertFalse(queryset.filter(pk=internal.pk).exists())


class CollectionCategoryListViewTests(CollectionViewsTestsBase):
    """Tests for :py:class:`CollectionCategoryListView`."""

    def test_get_place(self) -> None:
        view = CollectionCategoryListView()
        view.kwargs = {"ccat": "testcategory"}
        view.request = RequestFactory().get("/")
        place = view.get_place()
        self.assertEqual(
            place.title,
            f"{self.scenario.workspace.name} testcategory collections",
        )
        self.assertEqual(
            place.url,
            reverse(
                "workspaces:collections:category_list",
                kwargs={
                    "wname": self.scenario.workspace.name,
                    "ccat": "testcategory",
                },
            ),
        )
        self.assertIsNone(place.icon)
        self.assertIsNone(place.description)

    def test_permissions(self) -> None:
        """Test permissions on the collection list view."""
        url = reverse(
            "workspaces:collections:category_list",
            kwargs={
                "wname": self.scenario.workspace.name,
                "ccat": CollectionCategory.SUITE,
            },
        )
        self.assertSetsCurrentWorkspace(self.scenario.workspace, url)

    def test_list_only_selected_category(self) -> None:
        """Test permissions on the collection list view."""
        url = reverse(
            "workspaces:collections:category_list",
            kwargs={
                "wname": self.scenario.workspace.name,
                "ccat": CollectionCategory.SUITE,
            },
        )
        response = self.client.get(url)
        self.assertResponseHTML(response)
        queryset = response.context["collection_list"]
        self.assertQuerySetEqual(
            queryset,
            [self.sid, self.trixie],
            ordered=False,
        )

    def test_hides_internal(self) -> None:
        """Internal collections are not shown."""
        self.playground.create_collection(
            "internal",
            CollectionCategory.WORKFLOW_INTERNAL,
            workspace=self.scenario.workspace,
        )
        url = reverse(
            "workspaces:collections:category_list",
            kwargs={
                "wname": self.scenario.workspace.name,
                "ccat": CollectionCategory.WORKFLOW_INTERNAL,
            },
        )
        response = self.client.get(url)
        self.assertResponseHTML(response)
        queryset = response.context["collection_list"]
        self.assertQuerySetEqual(queryset.all(), [])


class CollectionDetailViewTests(CollectionViewsTestsBase):
    """Tests for :py:class:`CollectionDetailView`."""

    def test_permissions(self) -> None:
        """Test permissions on the collection list view."""
        self.assertSetsCurrentWorkspace(
            self.scenario.workspace, self.trixie.get_absolute_url()
        )

    def test_detail(self) -> None:
        """Test collection detail view."""
        response = self.client.get(self.trixie.get_absolute_url())
        tree = self.assertResponseHTML(response)

        # Check table
        table = self.assertHasElement(
            tree, "//table[@id='collection-details-table']"
        )
        # First row
        element = self.assertHasElement(table, '//tbody/tr[1]/th[1]')
        self.assertTextContentEqual(
            element, "Full history retention period (days)"
        )
        element = self.assertHasElement(table, '//tbody/tr[1]/td[1]')
        self.assertTextContentEqual(element, "Always")

        # Second row
        element = self.assertHasElement(table, '//tbody/tr[2]/th[1]')
        self.assertTextContentEqual(
            element, "Metadata only retention period (days)"
        )
        element = self.assertHasElement(table, '//tbody/tr[2]/td[1]')
        self.assertTextContentEqual(element, "Always")

        # Third row
        element = self.assertHasElement(table, '//tbody/tr[3]/th[1]')
        self.assertTextContentEqual(element, "Retains artifacts")
        element = self.assertHasElement(table, '//tbody/tr[3]/td[1]')
        self.assertTextContentEqual(element, "Always")

        self.assertFalse(tree.xpath("//div[@id='metadata']"))

    def test_detail_disable_data_retention(self) -> None:
        self.trixie.full_history_retention_period = dt.timedelta(0)
        self.trixie.metadata_only_retention_period = dt.timedelta(0)
        self.trixie.save()

        response = self.client.get(self.trixie.get_absolute_url())
        tree = self.assertResponseHTML(response)
        table = self.assertHasElement(
            tree, "//table[@id='collection-details-table']"
        )

        element = self.assertHasElement(table, '//tbody/tr[1]/th[1]')
        self.assertTextContentEqual(
            element, "Full history retention period (days)"
        )
        element = self.assertHasElement(table, '//tbody/tr[1]/td[1]')
        self.assertTextContentEqual(element, "0")

        element = self.assertHasElement(table, '//tbody/tr[2]/th[1]')
        self.assertTextContentEqual(
            element, "Metadata only retention period (days)"
        )
        element = self.assertHasElement(table, '//tbody/tr[2]/td[1]')
        self.assertTextContentEqual(element, "0")

    def test_detail_fixed_data_retention(self) -> None:
        self.trixie.full_history_retention_period = dt.timedelta(days=1)
        self.trixie.metadata_only_retention_period = dt.timedelta(days=7)
        self.trixie.save()

        response = self.client.get(self.trixie.get_absolute_url())
        tree = self.assertResponseHTML(response)
        table = self.assertHasElement(
            tree, "//table[@id='collection-details-table']"
        )

        element = self.assertHasElement(table, '//tbody/tr[1]/th[1]')
        self.assertTextContentEqual(
            element, "Full history retention period (days)"
        )
        element = self.assertHasElement(table, '//tbody/tr[1]/td[1]')
        self.assertTextContentEqual(element, "1")

        element = self.assertHasElement(table, '//tbody/tr[2]/th[1]')
        self.assertTextContentEqual(
            element, "Metadata only retention period (days)"
        )
        element = self.assertHasElement(table, '//tbody/tr[2]/td[1]')
        self.assertTextContentEqual(element, "7")

    def test_metadata(self) -> None:
        """Test collection metadata view."""
        self.trixie.data = {"test": True}
        self.trixie.save()
        response = self.client.get(self.trixie.get_absolute_url())
        tree = self.assertResponseHTML(response)
        metadata = self.assertHasElement(tree, "//div[@id='metadata']")
        self.assertYAMLContentEqual(metadata.div[1], self.trixie.data)

    def test_not_found(self) -> None:
        """Test looking up an invalid collection."""
        response = self.client.get(
            reverse(
                "workspaces:collections:detail",
                kwargs={
                    "wname": self.scenario.workspace.name,
                    "ccat": self.trixie.category,
                    "cname": "bookworm",
                },
            )
        )
        self.assertResponseHTML(response, status_code=status.HTTP_404_NOT_FOUND)

    def test_bare_data_stats(self) -> None:
        for i in range(2):
            self.playground.create_bare_data_item(
                self.trixie, f"test{i}", category=BareDataCategory.TEST
            )
        for i in range(3):
            self.playground.create_bare_data_item(
                self.trixie,
                f"log{i}",
                category=BareDataCategory.PACKAGE_BUILD_LOG,
            )
        for i in range(4):
            item = self.playground.create_bare_data_item(
                self.trixie, f"test_deleted_{i}", category=BareDataCategory.TEST
            )
            self.trixie.manager.remove_item(item)
        for i in range(5):
            item = self.playground.create_bare_data_item(
                self.trixie,
                f"log_deleted_{i}",
                category=BareDataCategory.PACKAGE_BUILD_LOG,
            )
            self.trixie.manager.remove_item(item)

        response = self.client.get(self.trixie.get_absolute_url())
        tree = self.assertResponseHTML(response)
        stats = self.assertHasElement(
            tree, "//table[@id='stats-bare-data']/tbody"
        )
        contents = [
            (
                self.get_node_text_normalized(tr.th),
                self.get_node_text_normalized(tr.td[0]),
                self.get_node_text_normalized(tr.td[1]),
            )
            for tr in stats.tr
        ]
        self.assertEqual(
            contents,
            [
                ('debian:package-build-log', '3', '5'),
                ('debusine:test', '2', '4'),
            ],
        )
        self.assertEqual(
            stats.tr[0].th.a.get("href"),
            reverse(
                "workspaces:collections:search",
                kwargs={
                    "wname": self.scenario.workspace.name,
                    "cname": self.trixie.name,
                    "ccat": self.trixie.category,
                },
            )
            + "?category="
            + BareDataCategory.PACKAGE_BUILD_LOG,
        )
        self.assertEqual(
            stats.tr[1].th.a.get("href"),
            reverse(
                "workspaces:collections:search",
                kwargs={
                    "wname": self.scenario.workspace.name,
                    "cname": self.trixie.name,
                    "ccat": self.trixie.category,
                },
            )
            + "?category="
            + BareDataCategory.TEST,
        )

    def test_suite_based_on_relationship(self) -> None:
        suite_based_on_xpath = '//a[@id="suite-based-on"]'

        # The link is not shown when there is no suite-based-on relation.
        response = self.client.get(self.trixie.get_absolute_url())
        tree = self.assertResponseHTML(response)
        self.assertEqual(tree.xpath(suite_based_on_xpath), [])

        # Create the relationship
        derived_suite = self.playground.create_collection(
            "r-django",
            CollectionCategory.SUITE,
        )

        CollectionRelation.objects.create(
            source=derived_suite,
            target=self.trixie,
            created_by_user=self.scenario.user,
            type=CollectionRelation.Types.SUITE_BASED_ON,
        )

        response = self.client.get(derived_suite.get_absolute_url())
        tree = self.assertResponseHTML(response)

        # Now it's displayed and contains the right link and text
        suite_based_on_a = self.assertHasElement(tree, suite_based_on_xpath)

        self.assertEqual(
            suite_based_on_a.get("href"), self.trixie.get_absolute_url()
        )

        self.assertTextContentEqual(suite_based_on_a, str(self.trixie))


class CollectionCreateTests(CollectionViewsTestsBase):
    """Tests for :py:class:`CollectionCreate` view."""

    scenario = scenarios.DefaultContext()

    @override
    def setUp(self) -> None:
        super().setUp()
        self.url = reverse(
            "workspaces:collections:create",
            kwargs={"wname": self.scenario.workspace.name},
        )

    def test_permissions(self) -> None:
        """Test permissions."""
        self.assertSetsCurrentWorkspace(self.scenario.workspace, self.url)
        self.assertEnforcesPermission(
            self.scenario.workspace.can_create_collections,
            self.url,
            "get_context_data",
        )

    @override_permission(Workspace, "can_create_collections", AllowAll)
    def test_configure(self) -> None:
        response = self.client.post(
            self.url,
            {
                "name": "test",
                "category": CollectionCategory.SUITE,
                "full_history_retention_period": 2,
                "metadata_only_retention_period": 3,
                "data": "created: true",
            },
        )
        coll = Collection.objects.get(
            workspace=self.scenario.workspace,
            name="test",
            category=CollectionCategory.SUITE,
        )
        self.assertRedirects(response, coll.get_absolute_url())

        self.assertEqual(coll.name, "test")
        self.assertEqual(
            coll.retains_artifacts, Collection.RetainsArtifacts.ALWAYS
        )
        self.assertEqual(
            coll.full_history_retention_period, dt.timedelta(days=2)
        )
        self.assertEqual(
            coll.metadata_only_retention_period, dt.timedelta(days=3)
        )
        self.assertEqual(coll.data, {"created": True})

    @override_permission(Workspace, "can_create_collections", AllowAll)
    def test_get(self) -> None:
        response = self.client.get(self.url)
        self.assertResponseHTML(response)
        assert isinstance(response, SimpleTemplateResponse)


class CollectionUpdateTests(CollectionViewsTestsBase):
    """Tests for :py:class:`CollectionUpdate` view."""

    scenario = scenarios.DefaultContext()

    def url(self, collection: Collection) -> str:
        """Return the update URL for a collection."""
        return reverse(
            "workspaces:collections:update",
            kwargs={
                "wname": collection.workspace.name,
                "cname": collection.name,
                "ccat": collection.category,
            },
        )

    def test_permissions(self) -> None:
        """Test permissions."""
        self.assertSetsCurrentWorkspace(
            self.scenario.workspace, self.url(self.sid)
        )
        self.assertEnforcesPermission(
            self.sid.can_configure, self.url(self.sid), "get_context_data"
        )

    @override_permission(Collection, "can_configure", AllowAll)
    def test_configure(self) -> None:
        response = self.client.post(
            self.url(self.sid),
            {
                "name": "test",
                "full_history_retention_period": 2,
                "metadata_only_retention_period": 3,
                "data": "changed: true",
            },
            follow=True,
        )
        self.sid.refresh_from_db()
        self.assertRedirects(response, self.sid.get_absolute_url())

        self.assertEqual(self.sid.name, "test")
        self.assertEqual(
            self.sid.full_history_retention_period, dt.timedelta(days=2)
        )
        self.assertEqual(
            self.sid.metadata_only_retention_period, dt.timedelta(days=3)
        )
        self.assertEqual(self.sid.data, {"changed": True})

        # Configure and delete buttons are displayed
        collection_ui = CollectionUI(RequestFactory().get("/"), self.sid)

        self.assertInHTML(
            collection_ui.place_update.as_button(),
            response.content.decode(),
        )
        self.assertInHTML(
            collection_ui.place_collection_relation_configure.as_button(),
            response.content.decode(),
        )

    @override_permission(Collection, "can_configure", AllowAll)
    def test_initial_form(self) -> None:
        response = self.client.get(self.url(self.sid))
        self.assertResponseHTML(response)
        assert isinstance(response, SimpleTemplateResponse)
        assert response.context_data is not None
        form = response.context_data["form"]
        self.assertIsNone(form["full_history_retention_period"].initial)
        self.assertEqual(form["name"].initial, "sid")


class CollectionDeleteTests(CollectionViewsTestsBase):
    """Tests for :py:class:`CollectionDelete` view."""

    scenario = scenarios.DefaultContext()

    def url(self, collection: Collection) -> str:
        """Return the delete URL for a collection."""
        return reverse(
            "workspaces:collections:delete",
            kwargs={
                "wname": collection.workspace.name,
                "cname": collection.name,
                "ccat": collection.category,
            },
        )

    def test_permissions(self) -> None:
        """Test permissions."""
        self.assertSetsCurrentWorkspace(
            self.scenario.workspace, self.url(self.sid)
        )
        self.assertEnforcesPermission(
            self.sid.can_delete, self.url(self.sid), "get_context_data"
        )

    @override_permission(Collection, "can_delete", AllowAll)
    def test_configure(self) -> None:
        with mock.patch(
            "debusine.db.models.Collection.delete_collection", autospec=True
        ) as delete_collection:
            response = self.client.post(self.url(self.sid), {})
        delete_collection.assert_called_once_with(self.sid)
        self.assertRedirects(
            response, self.scenario.workspace.get_absolute_url()
        )

    @override_permission(Collection, "can_delete", AllowAll)
    def test_form_empty_collection(self) -> None:
        response = self.client.get(self.url(self.sid))
        tree = self.assertResponseHTML(response)
        self.assertHasElement(tree, "//p[@id='collection-is-empty']")

    @override_permission(Collection, "can_delete", AllowAll)
    def test_form_collection_with_items(self) -> None:
        source = self.playground.create_source_artifact()
        self.sid.manager.add_artifact(
            source,
            user=self.scenario.user,
            variables={"component": "main", "section": "extra"},
        )

        response = self.client.get(self.url(self.sid))
        tree = self.assertResponseHTML(response)
        self.assertHasElement(tree, "//p[@id='collection-contains-items']")


class CollectionSearchViewTests(CollectionViewsTestsBase):
    """Tests for :py:class:`CollectionSearchView`."""

    items: ClassVar[dict[str, CollectionItem]]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        """Set up a database layout for views."""
        super().setUpTestData()
        cls.items = {}
        for name in ("foo", "bar"):
            for ver in range(2):
                source = cls.playground.create_source_artifact(
                    name=f"lib{name}{ver}"
                )
                cls.items[f"lib{name}{ver}"] = cls.trixie.manager.add_artifact(
                    source,
                    user=cls.scenario.user,
                    variables={"component": "main", "section": "devel"},
                )
                binary, _ = cls.playground.create_artifact(
                    category=ArtifactCategory.BINARY_PACKAGE,
                    data={
                        "srcpkg_name": source.data["name"],
                        "srcpkg_version": source.data["version"],
                        "deb_fields": {
                            "Package": f"lib{name}{ver}-dev",
                            "Version": source.data["version"],
                            "Architecture": "amd64",
                        },
                        "deb_control_files": [],
                    },
                    paths=[f"lib{name}{ver}-dev_{ver}.0_amd64.deb"],
                    create_files=True,
                    skip_add_files_in_store=True,
                )
                cls.items[f"lib{name}{ver}-dev"] = (
                    cls.trixie.manager.add_artifact(
                        binary,
                        user=cls.scenario.user,
                        variables={
                            "component": "main",
                            "section": "devel",
                            "priority": "optional",
                        },
                    )
                )

        source = cls.playground.create_source_artifact(name="libold0")
        cls.items["libold0"] = cls.trixie.manager.add_artifact(
            source,
            user=cls.scenario.user,
            variables={"component": "main", "section": "devel"},
        )
        cls.trixie.manager.remove_item(
            cls.items["libold0"], user=cls.scenario.user
        )

    def test_permissions(self) -> None:
        """Test permissions on the collection list view."""
        url = reverse(
            "workspaces:collections:search",
            kwargs={
                "wname": self.scenario.workspace.name,
                "ccat": CollectionCategory.SUITE,
                "cname": "trixie",
            },
        )
        self.assertSetsCurrentWorkspace(self.scenario.workspace, url)

    def test_not_found(self) -> None:
        """Test looking up an invalid collection."""
        response = self.client.get(
            reverse(
                "workspaces:collections:search",
                kwargs={
                    "wname": self.scenario.workspace.name,
                    "ccat": self.trixie.category,
                    "cname": "bookworm",
                },
            )
        )
        self.assertResponseHTML(response, status_code=status.HTTP_404_NOT_FOUND)

    def test_defaults(self) -> None:
        """Test collection search view."""
        response = self.client.get(self.trixie.get_absolute_url_search())
        tree = self.assertResponseHTML(response)

        table = self.assertHasElement(
            tree, "//table[@id='collection-item-list']"
        )
        self.assertEqual(len(table.tbody.tr), 8)

        ctx = response.context
        request = response.wsgi_request

        items = ctx["paginator"].page_obj.object_list
        self.assertCountEqual(
            items, [v for k, v in self.items.items() if k != "libold0"]
        )

        self.assertEqual(ctx["collection"], self.trixie)

        sample_item = items[0]
        self.assertEqual(
            sample_item.ui(request).ui_shortcuts,
            [
                ui_shortcuts.create_collection_item(sample_item),
                ui_shortcuts.create_artifact_view(sample_item.artifact),
                ui_shortcuts.create_artifact_download(sample_item.artifact),
            ],
        )

        paginator = ctx["paginator"]
        self.assertEqual(paginator.page_obj.number, 1)
        self.assertEqual(paginator.per_page, 50)
        self.assertEqual(paginator.count, 8)
        self.assertEqual(paginator.num_pages, 1)

    def test_ui_shortcuts(self) -> None:
        """Check generated ui shortcuts."""
        response = self.client.get(self.trixie.get_absolute_url_search())
        request = response.wsgi_request
        self.assertResponseHTML(response)
        item = response.context["paginator"].page_obj.object_list[0]
        self.assertEqual(
            item.ui(request).ui_shortcuts,
            [
                ui_shortcuts.create_collection_item(item),
                ui_shortcuts.create_artifact_view(item.artifact),
                ui_shortcuts.create_artifact_download(item.artifact),
            ],
        )

    def test_ui_shortcuts_bare_items(self) -> None:
        """Check generated ui shortcuts for bare items."""
        collection = self.scenario.workspace.get_singleton_collection(
            pc=self.scenario.context_ui(),
            category=CollectionCategory.PACKAGE_BUILD_LOGS,
        )
        data = {
            "work_request_id": 1,
            "vendor": "debian",
            "codename": "sid",
            "architecture": "amd64",
            "srcpkg_name": "hello",
            "srcpkg_version": "1.0-1",
        }
        collection.manager.add_bare_data(
            BareDataCategory.PACKAGE_BUILD_LOG,
            user=self.scenario.user,
            data=data,
        )

        response = self.client.get(collection.get_absolute_url_search())
        request = response.wsgi_request
        self.assertResponseHTML(response)
        item = response.context["paginator"].page_obj.object_list[0]
        self.assertEqual(
            item.ui(request).ui_shortcuts,
            [ui_shortcuts.create_collection_item(item)],
        )

    def test_search_name(self) -> None:
        """Test collection search view."""
        response = self.client.get(
            self.trixie.get_absolute_url_search(), data={"name": "libf"}
        )
        self.assertResponseHTML(response)
        ctx = response.context
        items = ctx["paginator"].page_obj.object_list
        self.assertCountEqual(
            items, [v for k, v in self.items.items() if k.startswith("libfoo")]
        )

    def test_search_partial_name_matching(self) -> None:
        """Test collection search name allows partial matching."""
        response = self.client.get(
            self.trixie.get_absolute_url_search(), data={"name": "foo"}
        )
        self.assertResponseHTML(response)
        ctx = response.context
        items = ctx["paginator"].page_obj.object_list
        self.assertCountEqual(
            items, [v for k, v in self.items.items() if "foo" in k]
        )

    def test_search_name_case_sensitive(self) -> None:
        """Test collection search name matching is case-sensitive."""
        response = self.client.get(
            self.trixie.get_absolute_url_search(), data={"name": "Foo"}
        )
        self.assertResponseHTML(response)
        ctx = response.context
        items = ctx["paginator"].page_obj.object_list
        self.assertEqual(list(items), [])

    def test_search_category(self) -> None:
        """Test collection search view."""
        response = self.client.get(
            self.trixie.get_absolute_url_search(),
            data={"category": ArtifactCategory.BINARY_PACKAGE},
        )
        self.assertResponseHTML(response)
        ctx = response.context
        items = ctx["paginator"].page_obj.object_list
        self.assertCountEqual(
            items, [v for k, v in self.items.items() if k.endswith("-dev")]
        )

    def test_search_bad_category(self) -> None:
        """Test collection search with an invalid category."""
        response = self.client.get(
            self.trixie.get_absolute_url_search(),
            data={"category": "does-not-exist"},
        )
        self.assertResponseHTML(response)
        ctx = response.context
        items = ctx["paginator"].page_obj.object_list
        self.assertQuerySetEqual(items, [])

    def test_search_historical(self) -> None:
        """Test collection search view."""
        response = self.client.get(
            self.trixie.get_absolute_url_search(),
            data={"historical": True},
        )
        self.assertResponseHTML(response)
        ctx = response.context
        items = ctx["paginator"].page_obj.object_list
        self.assertCountEqual(items, [self.items["libold0"]])

    def test_search_pagination(self) -> None:
        """Test collection search view pagination."""
        with mock.patch(
            "debusine.web.views.collections"
            ".CollectionSearchView.get_paginate_by",
            return_value=4,
        ):
            response = self.client.get(self.trixie.get_absolute_url_search())
        self.assertResponseHTML(response)

        ctx = response.context

        items = ctx["paginator"].page_obj.object_list
        self.assertEqual(len(items), 4)

        paginator = ctx["paginator"]
        self.assertEqual(paginator.page_obj.number, 1)
        self.assertEqual(paginator.per_page, 4)
        self.assertEqual(paginator.count, 8)
        self.assertEqual(paginator.num_pages, 2)

    def test_search_query_count(self) -> None:
        """Test that collection search makes a reasonable amount of queries."""
        with self.assertNumQueries(14):
            self.client.get(self.trixie.get_absolute_url_search())


class CollectionItemDetailViewTests(CollectionViewsTestsBase):
    """Tests for :py:class:`CollectionItemDetailView`."""

    item: ClassVar[CollectionItem]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        """Set up a database layout for views."""
        super().setUpTestData()
        artifact = cls.playground.create_source_artifact(name="libfoo0")
        cls.item = cls.trixie.manager.add_artifact(
            artifact,
            user=cls.scenario.user,
            variables={"component": "main", "section": "shlibs"},
        )

    def test_get_place(self) -> None:
        view = CollectionItemDetailView()
        view.object = self.item
        view.request = RequestFactory().get("/")
        place = view.get_place()
        self.assertEqual(place.title, self.item.name)
        self.assertEqual(place.url, self.item.get_absolute_url())
        self.assertIsNone(place.icon)
        self.assertIsNone(place.description)

    def test_permissions(self) -> None:
        """Test permissions on the collection list view."""
        url = self.item.get_absolute_url()
        self.assertSetsCurrentWorkspace(self.scenario.workspace, url)

    def test_defaults(self) -> None:
        """Test viewing an item."""
        url = self.item.get_absolute_url()
        response = self.client.get(url)
        tree = self.assertResponseHTML(response)
        metadata = self.assertHasElement(tree, "//div[@id='metadata']")
        self.assertYAMLContentEqual(metadata.div[1], self.item.data)

    def test_no_item_data(self) -> None:
        """Test viewing an item."""
        self.item.data = {}
        self.item.save()
        url = self.item.get_absolute_url()
        response = self.client.get(url)
        tree = self.assertResponseHTML(response)
        self.assertFalse(tree.xpath("//div[@id='metadata']"))

    def test_wrong_item(self) -> None:
        """Test viewing an item."""
        max_id = CollectionItem.objects.aggregate(Max('id'))['id__max'] + 1
        response = self.client.get(
            reverse(
                "workspaces:collections:item_detail",
                kwargs={
                    "wname": self.scenario.workspace.name,
                    "ccat": self.trixie.category,
                    "cname": self.trixie.name,
                    "iid": str(max_id),
                    "iname": "test",
                },
            )
        )
        self.assertResponseHTML(response, status_code=status.HTTP_404_NOT_FOUND)
