# Copyright 2019, 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.

"""Unit tests for the models."""

import binascii
import hashlib
import logging
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, ClassVar, cast

from asgiref.sync import sync_to_async

import django.db.utils
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from django.test import TestCase
from django.utils import timezone

try:
    import pydantic.v1 as pydantic
except ImportError:
    import pydantic as pydantic  # type: ignore

from debusine.artifacts.local_artifact import WorkRequestDebugLogs
from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    Collection,
    CollectionItem,
    File,
    FileInArtifact,
    FileInStore,
    FileStore,
    FileUpload,
    Identity,
    NotificationChannel,
    Token,
    User,
    WorkRequest,
    Worker,
    WorkflowTemplate,
    Workspace,
    default_workspace,
)
from debusine.db.models.workers import WorkerManager
from debusine.db.tests.utils import _calculate_hash_from_data
from debusine.server.collections import (
    CollectionManagerInterface,
    DebianSuiteManager,
)
from debusine.server.workflows import Workflow
from debusine.server.workflows.models import (
    BaseWorkflowData,
    WorkRequestWorkflowData,
)
from debusine.tasks.models import (
    ActionUpdateCollectionWithArtifacts,
    EventReactions,
    TaskTypes,
)
from debusine.test import TestHelpersMixin
from debusine.test.django import ChannelsHelpersMixin
from debusine.test.utils import data_generator, preserve_task_registry


class TokenTests(ChannelsHelpersMixin, TestCase):
    """Unit tests of the ``Token`` model."""

    user: ClassVar[User]

    @classmethod
    def setUpTestData(cls):
        """Set up common test data."""
        cls.user = get_user_model().objects.create_user(
            username="john", email="john@example.com"
        )

    def test_save(self):
        """The model creates a Token.key on save or keeps it if it existed."""
        token = Token.objects.create(user=self.user)

        self.assertIsNotNone(token.id)
        self.assertEqual(len(token.key), 64)

        key = token.key
        token.save()
        self.assertEqual(token.key, key)

    def test_str(self):
        """Test Token.__str__."""
        token = Token.objects.create(user=self.user)
        self.assertEqual(token.__str__(), token.hash)

    def test_user_field(self):
        """Test User field is None by default."""
        token = Token.objects.create()
        self.assertIsNone(token.user)

    def test_get_token_or_none_found(self):
        """get_token_or_none looks up a token and returns it."""
        token_hash = Token._generate_hash('some_key')
        token = Token.objects.create(hash=token_hash)

        self.assertEqual(token, Token.objects.get_token_or_none('some_key'))

    def test_get_token_or_none_not_found(self):
        """get_token_or_none cannot find a token and returns None."""
        self.assertIsNone(Token.objects.get_token_or_none('a_non_existing_key'))

    def test_enable(self):
        """enable() enables the token."""
        token = Token.objects.create()

        # Assert the default is disabled tokens
        self.assertFalse(token.enabled)

        token.enable()
        token.refresh_from_db()

        self.assertTrue(token.enabled)

    async def test_disable(self):
        """disable() disables the token."""
        token = await sync_to_async(Token.objects.create)(enabled=True)
        await sync_to_async(Worker.objects.create)(
            token=token, registered_at=timezone.now()
        )

        channel = await self.create_channel(token.hash)

        await sync_to_async(token.disable)()

        await self.assert_channel_received(channel, {"type": "worker.disabled"})
        await sync_to_async(token.refresh_from_db)()

        self.assertFalse(token.enabled)


class TokenManagerTests(TestCase):
    """Unit tests for the ``TokenManager`` class."""

    user_john: ClassVar[User]
    user_bev: ClassVar[User]
    token_john: ClassVar[Token]
    token_bev: ClassVar[Token]

    @classmethod
    def setUpTestData(cls):
        """Test data used by all the tests."""
        cls.user_john = get_user_model().objects.create_user(
            username="John", email="john@example.com"
        )
        cls.user_bev = get_user_model().objects.create_user(
            username="Bev", email="bev@example.com"
        )
        cls.token_john = Token.objects.create(user=cls.user_john)
        cls.token_bev = Token.objects.create(user=cls.user_bev)

    def test_get_tokens_all(self):
        """get_tokens returns all the tokens if no filter is applied."""
        self.assertQuerysetEqual(
            Token.objects.get_tokens(),
            {self.token_bev, self.token_john},
            ordered=False,
        )

    def test_get_tokens_by_owner(self):
        """get_tokens returns the correct tokens when filtering by owner."""
        self.assertQuerysetEqual(
            Token.objects.get_tokens(username='John'), [self.token_john]
        )
        self.assertQuerysetEqual(
            Token.objects.get_tokens(username='Bev'), [self.token_bev]
        )
        self.assertQuerysetEqual(
            Token.objects.get_tokens(username='Someone'), []
        )

    def test_get_tokens_by_key(self):
        """get_tokens returns the correct tokens when filtering by key."""
        self.assertQuerysetEqual(
            Token.objects.get_tokens(key=self.token_john.key),
            [self.token_john],
        )
        self.assertQuerysetEqual(
            Token.objects.get_tokens(key='non-existing-key'), []
        )

    def test_get_tokens_by_key_owner_empty(self):
        """
        get_tokens returns nothing if using a key and username without matches.

        Key for the key parameter or username for the user parameter exist
        but are for different tokens.
        """
        self.assertQuerysetEqual(
            Token.objects.get_tokens(
                key=self.token_john.key, username=self.user_bev.username
            ),
            [],
        )


class WorkerManagerTests(TestHelpersMixin, TestCase):
    """Tests for the WorkerManager."""

    def test_connected(self):
        """WorkerManager.connected() return the connected Workers."""
        worker_connected = Worker.objects.create_with_fqdn(
            'connected-worker', Token.objects.create()
        )

        worker_connected.mark_connected()

        Worker.objects.create_with_fqdn(
            'not-connected-worker',
            Token.objects.create(),
        )

        self.assertQuerysetEqual(Worker.objects.connected(), [worker_connected])

    def test_waiting_for_work_request(self):
        """Test WorkerManager.waiting_for_work_request() return a Worker."""
        worker = Worker.objects.create_with_fqdn(
            'worker-a', Token.objects.create(enabled=True)
        )

        # WorkerManagement.waiting_for_work_request: returns no workers because
        # the worker is not connected
        self.assertQuerysetEqual(Worker.objects.waiting_for_work_request(), [])

        worker.mark_connected()

        # Now the Worker is ready to have a task assigned
        self.assertQuerysetEqual(
            Worker.objects.waiting_for_work_request(), [worker]
        )

        # A task is assigned to the worker
        work_request = self.create_work_request(worker=worker)

        # The worker is not ready (it is busy with a task assigned)
        self.assertQuerysetEqual(Worker.objects.waiting_for_work_request(), [])

        # The task finished
        work_request.status = WorkRequest.Statuses.COMPLETED
        work_request.save()

        # The worker is ready: the WorkRequest that had assigned finished
        self.assertQuerysetEqual(
            Worker.objects.waiting_for_work_request(), [worker]
        )

    def test_waiting_for_work_request_internal(self):
        """
        Test WorkerManager.waiting_for_work_request() with internal workers.

        Internal workers have no tokens, so they require special handling.
        """
        worker = Worker.objects.get_or_create_celery()
        worker.concurrency = 3
        worker.save()

        # WorkerManagement.waiting_for_work_request: returns no workers because
        # the worker is not connected
        self.assertQuerysetEqual(Worker.objects.waiting_for_work_request(), [])

        worker.mark_connected()

        # Now the Worker is ready to have a task assigned
        self.assertQuerysetEqual(
            Worker.objects.waiting_for_work_request(), [worker]
        )

        # Assign two tasks to the worker; it can still accept more work
        for _ in range(2):
            work_request = self.create_work_request(worker=worker)
            self.assertQuerysetEqual(
                Worker.objects.waiting_for_work_request(), [worker]
            )

        # Assign a third task to the worker; it is now as busy as it allows
        work_request = self.create_work_request(worker=worker)
        self.assertQuerysetEqual(Worker.objects.waiting_for_work_request(), [])

        # The third task finished
        work_request.status = WorkRequest.Statuses.COMPLETED
        work_request.save()

        # Although it is still running some tasks, the worker can accept
        # more work again
        self.assertQuerysetEqual(
            Worker.objects.waiting_for_work_request(), [worker]
        )

    def test_waiting_for_work_request_no_return_disabled_workers(self):
        """Test WorkerManager.waiting_for_work_request() no return disabled."""
        worker_enabled = Worker.objects.create_with_fqdn(
            "worker-enabled", Token.objects.create(enabled=True)
        )
        worker_enabled.mark_connected()

        worker_disabled = Worker.objects.create_with_fqdn(
            "worker-disabled", Token.objects.create(enabled=False)
        )
        worker_disabled.mark_connected()

        self.assertQuerysetEqual(
            Worker.objects.waiting_for_work_request(), [worker_enabled]
        )

    def test_create_with_fqdn_new_fqdn(self):
        """WorkerManager.create_with_fqdn() return a worker."""
        token = Token.objects.create()
        worker = Worker.objects.create_with_fqdn(
            'a-new-and-unique-name', token=token
        )

        self.assertEqual(worker.name, 'a-new-and-unique-name')
        self.assertEqual(worker.token, token)
        self.assertIsNotNone(worker.pk)

    def test_create_with_fqdn_duplicate_fqdn(self):
        """
        WorkerManager.create_with_fqdn() return a worker.

        The name ends with -2 because 'connected-worker' is already used.
        """
        Worker.objects.create_with_fqdn(
            'connected-worker', token=Token.objects.create()
        )

        token = Token.objects.create()
        worker = Worker.objects.create_with_fqdn(
            'connected-worker', token=token
        )

        self.assertEqual(worker.name, 'connected-worker-2')
        self.assertEqual(worker.token, token)
        self.assertFalse(worker.internal)
        self.assertIsNotNone(worker.pk)

    def test_get_or_create_celery(self):
        """WorkerManager.get_or_create_celery returns an internal worker."""
        worker = Worker.objects.get_or_create_celery()

        self.assertEqual(worker.name, "celery")
        self.assertIsNone(worker.token)
        self.assertTrue(worker.internal)
        self.assertIsNotNone(worker.pk)

        self.assertEqual(Worker.objects.get_or_create_celery(), worker)

    def test_slugify_with_suffix_counter_1(self):
        """WorkerManager._generate_unique_name does not append '-1'."""
        self.assertEqual(
            WorkerManager._generate_unique_name('worker.lan', 1), 'worker-lan'
        )

    def test_slugify_with_suffix_counter_3(self):
        """WorkerManager._generate_unique_name appends '-3'."""
        self.assertEqual(
            WorkerManager._generate_unique_name('worker.lan', 3), 'worker-lan-3'
        )

    def test_get_worker_by_token_or_none_return_none(self):
        """WorkerManager.get_worker_by_token_or_none() return None."""
        self.assertIsNone(
            Worker.objects.get_worker_by_token_key_or_none('non-existing-key')
        )

    def test_get_worker_by_token_or_none_return_worker(self):
        """WorkerManager.get_worker_by_token_or_none() return the Worker."""
        token = Token.objects.create()

        worker = Worker.objects.create_with_fqdn('worker-a', token)

        self.assertEqual(
            Worker.objects.get_worker_by_token_key_or_none(token.key), worker
        )

    def test_get_worker_or_none_return_worker(self):
        """WorkerManager.get_worker_or_none() return the Worker."""
        token = Token.objects.create()

        worker = Worker.objects.create_with_fqdn('worker-a', token)

        self.assertEqual(Worker.objects.get_worker_or_none('worker-a'), worker)

    def test_get_worker_or_none_return_none(self):
        """WorkerManager.get_worker_or_none() return None."""
        self.assertIsNone(Worker.objects.get_worker_or_none('does-not-exist'))


class WorkerTests(TestHelpersMixin, TestCase):
    """Tests for the Worker model."""

    worker: ClassVar[Worker]

    @classmethod
    def setUpTestData(cls):
        """Set up the Worker for the tests."""
        cls.worker = Worker.objects.create_with_fqdn(
            "computer.lan", Token.objects.create()
        )
        cls.worker.static_metadata = {"os": "debian"}
        cls.worker.set_dynamic_metadata({"cpu_cores": "4"})
        cls.worker.save()

    def test_mark_connected(self):
        """Test mark_connect method."""
        time_before = timezone.now()
        self.assertIsNone(self.worker.connected_at)

        self.worker.mark_connected()

        self.assertGreaterEqual(self.worker.connected_at, time_before)
        self.assertLessEqual(self.worker.connected_at, timezone.now())

    def test_mark_disconnected(self):
        """Test mark_disconnected method."""
        self.worker.mark_connected()

        self.assertTrue(self.worker.connected())
        self.worker.mark_disconnected()

        self.assertFalse(self.worker.connected())
        self.assertIsNone(self.worker.connected_at)

    def test_connected(self):
        """Test connected method."""
        self.assertFalse(self.worker.connected())

        self.worker.connected_at = timezone.now()

        self.assertTrue(self.worker.connected())

    def test_is_busy(self):
        """Test is_busy method."""
        worker = Worker.objects.create_with_fqdn(
            "test", token=Token.objects.create()
        )
        self.assertFalse(worker.is_busy())

        work_request = self.create_work_request(worker=worker)

        self.assertEqual(work_request.status, WorkRequest.Statuses.PENDING)
        self.assertTrue(worker.is_busy())

        work_request.assign_worker(worker)

        self.assertTrue(worker.is_busy())

        work_request.mark_running()

        self.assertTrue(worker.is_busy())

        work_request.mark_aborted()

        self.assertFalse(worker.is_busy())

    def test_is_busy_concurrency(self):
        """The is_busy method handles workers with concurrency > 1."""
        worker = Worker.objects.get_or_create_celery()
        worker.concurrency = 3
        worker.save()
        for _ in range(3):
            self.assertFalse(worker.is_busy())
            work_request = self.create_work_request(worker=worker)
            work_request.assign_worker(worker)
        self.assertTrue(worker.is_busy())
        work_request.mark_aborted()
        self.assertFalse(worker.is_busy())

    def test_metadata_no_conflict(self):
        """Test metadata method: return all the metadata."""
        self.assertEqual(
            self.worker.metadata(), {'cpu_cores': '4', 'os': 'debian'}
        )

    def test_metadata_with_conflict(self):
        """
        Test metadata method: return all the metadata.

        static_metadata has priority over dynamic_metadata
        """
        # Assert initial state
        self.assertEqual(self.worker.dynamic_metadata['cpu_cores'], '4')

        # Add new static_metadata key
        self.worker.static_metadata['cpu_cores'] = '8'

        self.assertEqual(
            self.worker.metadata(), {'cpu_cores': '8', 'os': 'debian'}
        )

    def test_metadata_is_deep_copy(self):
        """Test metadata does a deep copy."""
        self.worker.dynamic_metadata['schroots'] = ['buster', 'bullseye']
        self.worker.metadata()['schroots'].append('bookworm')

        self.assertEqual(
            self.worker.dynamic_metadata['schroots'], ['buster', 'bullseye']
        )

    def test_set_dynamic_metadata(self):
        """Worker.set_dynamic_metadata sets the dynamic metadata."""
        self.worker.dynamic_metadata = {}
        self.worker.dynamic_metadata_updated_at = None
        self.worker.save()

        dynamic_metadata = {"cpu_cores": 4, "ram": "16"}
        self.worker.set_dynamic_metadata(dynamic_metadata)

        self.worker.refresh_from_db()

        self.assertEqual(self.worker.dynamic_metadata, dynamic_metadata)
        self.assertLessEqual(
            self.worker.dynamic_metadata_updated_at, timezone.now()
        )

    def test_str(self):
        """Test WorkerTests.__str__."""
        self.assertEqual(
            self.worker.__str__(),
            f"Id: {self.worker.id} Name: {self.worker.name}",
        )


class TestWorkflowData(BaseWorkflowData):
    """Workflow data to test instantiation."""

    ivar: int = 1
    dvar: dict[str, Any] = pydantic.Field(default_factory=dict)


class WorkRequestManagerTestsTestWorkflow(Workflow[TestWorkflowData]):
    """Workflow used to test instantiation of data."""

    # This needs a unique name to avoid conflicting with other tests, because
    # it gets registered as a Workflow subclass


class WorkRequestManagerTests(TestHelpersMixin, TestCase):
    """Tests for WorkRequestManager."""

    def test_create_workflow(self) -> None:
        """Workflows are created correctly."""
        user = self.get_test_user()

        workflow_name = "workrequestmanagerteststestworkflow"

        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name=workflow_name,
            task_data={},
            priority=10,
        )

        wr = WorkRequest.objects.create_workflow(
            template=template, created_by=user, data={}
        )
        self.assertEqual(wr.workspace, template.workspace)
        self.assertIsNone(wr.parent)
        self.assertEqual(wr.created_by, user)
        self.assertEqual(wr.status, WorkRequest.Statuses.PENDING)
        self.assertEqual(wr.task_type, TaskTypes.WORKFLOW)
        self.assertEqual(wr.task_name, workflow_name)
        self.assertEqual(wr.task_data, {})
        self.assertEqual(wr.priority_base, 10)
        self.assertQuerysetEqual(wr.dependencies.all(), [])
        assert wr.internal_collection is not None
        self.assertEqual(wr.internal_collection.name, f"workflow-{wr.id}")
        self.assertEqual(
            wr.internal_collection.category, "debusine:workflow-internal"
        )
        self.assertEqual(wr.internal_collection.workspace, template.workspace)
        self.assertEqual(
            wr.internal_collection.retains_artifacts,
            Collection.RetainsArtifacts.WORKFLOW,
        )
        self.assertEqual(wr.internal_collection.workflow, wr)

    def test_create_workflow_with_parent(self) -> None:
        """A sub-workflow does not have its own internal collection."""
        user = self.get_test_user()
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name="workrequestmanagerteststestworkflow",
        )
        root = WorkRequest.objects.create_workflow(
            template=template, created_by=user, data={}
        )
        sub_template = WorkflowTemplate.objects.create(
            name="subtest", workspace=default_workspace(), task_name="noop"
        )
        sub = WorkRequest.objects.create_workflow(
            template=sub_template, created_by=user, data={}, parent=root
        )

        self.assertEqual(sub.status, WorkRequest.Statuses.PENDING)
        self.assertEqual(sub.parent, root)
        self.assertQuerysetEqual(sub.dependencies.all(), [])
        self.assertIsNone(sub.internal_collection)

    def test_create_workflow_with_parms(self) -> None:
        """Check that WorkflowTemplate parameters have precedence."""
        user = self.get_test_user()

        workflow_name = "workrequestmanagerteststestworkflow"

        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name=workflow_name,
            task_data={"ivar": 2, "dvar": {"a": 1}},
        )

        wr = WorkRequest.objects.create_workflow(
            template=template, created_by=user, data={}
        )
        self.assertEqual(wr.task_data, {"ivar": 2, "dvar": {"a": 1}})

        wr = WorkRequest.objects.create_workflow(
            template=template,
            created_by=user,
            data={"ivar": 3, "dvar": {"b": 2}},
        )
        self.assertEqual(wr.task_data, {"ivar": 2, "dvar": {"a": 1}})

        template.task_data = {"ivar": 1}
        wr = WorkRequest.objects.create_workflow(
            template=template,
            created_by=user,
            data={"ivar": 3, "dvar": {"b": 2}},
        )
        self.assertEqual(wr.task_data, {"ivar": 1, "dvar": {"b": 2}})

    def test_create_workflow_validation(self) -> None:
        """Check validation of parameters on workflow instantiation."""
        user = self.get_test_user()

        workflow_name = "workrequestmanagerteststestworkflow"

        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name=workflow_name,
            task_data={},
        )

        with self.assertLogsContains(
            "Cannot create a workflow",
            logger="debusine.db.models",
            level=logging.ERROR,
        ):
            wr = WorkRequest.objects.create_workflow(
                template=template,
                created_by=user,
                data={"noise": 1},
            )
        self.assertEqual(wr.status, WorkRequest.Statuses.COMPLETED)
        self.assertEqual(wr.result, WorkRequest.Results.ERROR)

    def test_create_synchronization_point(self) -> None:
        """Synchronization points are created correctly."""
        user = self.get_test_user()

        parent = WorkRequest.objects.create(
            workspace=default_workspace(),
            created_by=user,
            task_type=TaskTypes.WORKFLOW,
            task_name="test",
            task_data={},
            priority_base=5,
            priority_adjustment=1,
        )

        wr = WorkRequest.objects.create_synchronization_point(
            parent=parent, step="test"
        )
        self.assertEqual(wr.workspace, parent.workspace)
        self.assertEqual(wr.parent, parent)
        self.assertIsNotNone(wr.created_at)
        self.assertIsNone(wr.started_at)
        self.assertIsNone(wr.completed_at)
        self.assertEqual(wr.created_by, user)
        self.assertEqual(wr.status, WorkRequest.Statuses.PENDING)
        self.assertEqual(wr.result, WorkRequest.Results.NONE)
        self.assertIsNone(wr.worker)
        self.assertEqual(wr.task_type, TaskTypes.INTERNAL)
        self.assertEqual(wr.task_name, "synchronization_point")
        self.assertEqual(wr.task_data, {})
        self.assertEqual(wr.priority_base, 6)
        self.assertQuerysetEqual(wr.dependencies.all(), [])
        self.assertEqual(
            wr.workflow_data,
            WorkRequestWorkflowData(step="test", display_name="test"),
        )
        wr.full_clean()

        workspace = Workspace.objects.create_with_name(name="test")

        parent = WorkRequest.objects.create(
            workspace=workspace,
            created_by=user,
            task_type=TaskTypes.WORKFLOW,
            task_name="test",
            task_data={},
        )
        wr = WorkRequest.objects.create_synchronization_point(
            parent=parent,
            step="test",
            display_name="Test",
            status=WorkRequest.Statuses.PENDING,
        )
        self.assertEqual(wr.workspace, workspace)
        self.assertEqual(wr.parent, parent)
        self.assertIsNotNone(wr.created_at)
        self.assertIsNone(wr.started_at)
        self.assertIsNone(wr.completed_at)
        self.assertEqual(wr.created_by, user)
        self.assertEqual(wr.status, WorkRequest.Statuses.PENDING)
        self.assertEqual(wr.result, WorkRequest.Results.NONE)
        self.assertIsNone(wr.worker)
        self.assertEqual(wr.task_type, TaskTypes.INTERNAL)
        self.assertEqual(wr.task_name, "synchronization_point")
        self.assertEqual(wr.task_data, {})
        self.assertEqual(
            wr.workflow_data,
            WorkRequestWorkflowData(step="test", display_name="Test"),
        )
        wr.full_clean()

    def test_create_workflow_callback(self) -> None:
        """Workflow callbacks are created correctly."""
        user = self.get_test_user()

        parent = WorkRequest.objects.create(
            workspace=default_workspace(),
            created_by=user,
            task_type=TaskTypes.WORKFLOW,
            task_name="test",
            task_data={},
        )

        wr = WorkRequest.objects.create_workflow_callback(
            parent=parent, step="test"
        )
        self.assertEqual(wr.workspace, parent.workspace)
        self.assertEqual(wr.parent, parent)
        self.assertIsNotNone(wr.created_at)
        self.assertIsNone(wr.started_at)
        self.assertIsNone(wr.completed_at)
        self.assertEqual(wr.created_by, user)
        self.assertEqual(wr.status, WorkRequest.Statuses.PENDING)
        self.assertEqual(wr.result, WorkRequest.Results.NONE)
        self.assertIsNone(wr.worker)
        self.assertEqual(wr.task_type, TaskTypes.INTERNAL)
        self.assertEqual(wr.task_name, "workflow")
        self.assertEqual(wr.task_data, {})
        self.assertEqual(wr.priority_base, 0)
        self.assertQuerysetEqual(wr.dependencies.all(), [])
        self.assertEqual(
            wr.workflow_data,
            WorkRequestWorkflowData(step="test", display_name="test"),
        )
        wr.full_clean()

        workspace = Workspace.objects.create_with_name(name="test")

        parent = WorkRequest.objects.create(
            workspace=workspace,
            created_by=user,
            task_type=TaskTypes.WORKFLOW,
            task_name="test",
            task_data={},
            priority_base=20,
            priority_adjustment=10,
        )
        wr = WorkRequest.objects.create_workflow_callback(
            parent=parent,
            status=WorkRequest.Statuses.PENDING,
            step="test1",
            display_name="Test One",
        )
        self.assertEqual(wr.workspace, workspace)
        self.assertEqual(wr.parent, parent)
        self.assertIsNotNone(wr.created_at)
        self.assertIsNone(wr.started_at)
        self.assertIsNone(wr.completed_at)
        self.assertEqual(wr.created_by, user)
        self.assertEqual(wr.status, WorkRequest.Statuses.PENDING)
        self.assertEqual(wr.result, WorkRequest.Results.NONE)
        self.assertIsNone(wr.worker)
        self.assertEqual(wr.task_type, TaskTypes.INTERNAL)
        self.assertEqual(wr.task_name, "workflow")
        self.assertEqual(wr.task_data, {})
        self.assertEqual(wr.priority_base, 30)
        self.assertEqual(
            wr.workflow_data,
            WorkRequestWorkflowData(step="test1", display_name="Test One"),
        )
        wr.full_clean()

    def test_pending(self):
        """WorkRequestManager.pending() returns pending WorkRequests."""
        work_request_1 = self.create_work_request(
            status=WorkRequest.Statuses.PENDING
        )

        work_request_2 = self.create_work_request(
            status=WorkRequest.Statuses.PENDING
        )

        self.create_work_request(status=WorkRequest.Statuses.ABORTED)

        self.assertQuerysetEqual(
            WorkRequest.objects.pending(), [work_request_1, work_request_2]
        )

        work_request_1.created_at = timezone.now()
        work_request_1.save()

        self.assertQuerysetEqual(
            WorkRequest.objects.pending(), [work_request_2, work_request_1]
        )

        work_request_1.priority_adjustment = 1
        work_request_1.save()

        self.assertQuerysetEqual(
            WorkRequest.objects.pending(), [work_request_1, work_request_2]
        )

        work_request_2.priority_base = 2
        work_request_2.save()

        self.assertQuerysetEqual(
            WorkRequest.objects.pending(), [work_request_2, work_request_1]
        )

    def test_pending_filter_by_worker(self):
        """WorkRequestManager.pending() returns WorkRequest for the worker."""
        self.create_work_request(
            status=WorkRequest.Statuses.PENDING, task_name="sbuild"
        )

        worker = Worker.objects.create_with_fqdn(
            "computer.lan", Token.objects.create()
        )

        work_request_2 = self.create_work_request(
            status=WorkRequest.Statuses.PENDING, worker=worker
        )

        self.assertQuerysetEqual(
            WorkRequest.objects.pending(worker=worker), [work_request_2]
        )

    def test_raise_value_error_exclude_assigned_and_worker(self):
        """WorkRequestManager.pending() raises ValueError."""
        worker = Worker.objects.create_with_fqdn(
            "computer.lan", Token.objects.create()
        )

        with self.assertRaisesRegex(
            ValueError, "Cannot exclude_assigned and filter by worker"
        ):
            WorkRequest.objects.pending(exclude_assigned=True, worker=worker)

    def test_pending_exclude_assigned(self):
        """
        Test WorkRequestManager.pending(exclude_assigned=True).

        It excludes work requests that are assigned to a worker.
        """
        # Pending, not assigned to a worker WorkRequest
        work_request = self.create_work_request(
            status=WorkRequest.Statuses.PENDING, task_name="sbuild"
        )

        # Is returned as expected
        self.assertQuerysetEqual(
            WorkRequest.objects.pending(exclude_assigned=True), [work_request]
        )

        # Creates a worker
        worker = Worker.objects.create_with_fqdn(
            'test', token=Token.objects.create()
        )

        # Assigns the worker to the work_request
        work_request.worker = worker
        work_request.save()

        # pending(exclude_assigned=True) doesn't return it anymore
        self.assertQuerysetEqual(
            WorkRequest.objects.pending(exclude_assigned=True), []
        )

        # without the exclude_assigned it returns it
        self.assertQuerysetEqual(
            WorkRequest.objects.pending(exclude_assigned=False), [work_request]
        )

    def test_running(self):
        """WorkRequestManager.running() returns running WorkRequests."""
        work_request = self.create_work_request(
            status=WorkRequest.Statuses.RUNNING
        )
        self.create_work_request(status=WorkRequest.Statuses.ABORTED)

        self.assertQuerysetEqual(WorkRequest.objects.running(), [work_request])

    def test_completed(self):
        """WorkRequestManager.completed() returns completed WorkRequests."""
        work_request = self.create_work_request(
            status=WorkRequest.Statuses.COMPLETED
        )
        self.create_work_request(status=WorkRequest.Statuses.RUNNING)

        self.assertQuerysetEqual(
            WorkRequest.objects.completed(), [work_request]
        )

    def test_aborted(self):
        """WorkRequestManager.aborted() returns aborted WorkRequests."""
        work_request = self.create_work_request(
            status=WorkRequest.Statuses.ABORTED
        )
        self.create_work_request(status=WorkRequest.Statuses.RUNNING)

        self.assertQuerysetEqual(WorkRequest.objects.aborted(), [work_request])

    def test_expired(self):
        """Test expired() method."""
        wr = self.create_work_request(task_name="noop")

        wr.created_at = timezone.now() - timedelta(days=2)
        wr.expiration_delay = timedelta(days=1)
        wr.save()
        self.assertQuerysetEqual(
            WorkRequest.objects.expired(timezone.now()), [wr]
        )

        wr.expiration_delay = timedelta(0)
        wr.save()
        self.assertQuerysetEqual(
            WorkRequest.objects.expired(timezone.now()), []
        )

        wr.created_at = timezone.now()
        wr.expiration_delay = timedelta(days=1)
        wr.save()
        self.assertQuerysetEqual(
            WorkRequest.objects.expired(timezone.now()), []
        )

        workspace = Workspace.objects.create_with_name(name="test")
        workspace.default_expiration_delay = timedelta(days=2)
        workspace.save()
        wr.workspace = workspace
        wr.created_at = timezone.now() - timedelta(days=3)
        wr.expiration_delay = None
        wr.save()
        self.assertQuerysetEqual(
            WorkRequest.objects.expired(timezone.now()), [wr]
        )

        workspace.default_expiration_delay = timedelta(days=5)
        workspace.save()
        self.assertQuerysetEqual(
            WorkRequest.objects.expired(timezone.now()), []
        )

        workspace.default_expiration_delay = timedelta(days=0)
        workspace.save()
        self.assertQuerysetEqual(
            WorkRequest.objects.expired(timezone.now()), []
        )


class FileHelpersMixin(TestHelpersMixin):
    """Helper methods for File model related tests."""

    def create_file_upload(self):
        """
        Create a new FileUpload object.

        Create the workspace, artifact, file and file_in_artifact associated
        to the file_upload object.
        """
        artifact, _ = self.create_artifact(
            paths=["README"], create_files=True, skip_add_files_in_store=True
        )

        file_in_artifact = artifact.fileinartifact_set.first()

        file_upload = FileUpload.objects.create(
            file_in_artifact=file_in_artifact,
            path="temp_file_aaaa",
        )

        file_path = file_upload.absolute_file_path()

        self.addCleanup(file_path.unlink, missing_ok=True)

        return file_upload

    def create_file(self, contents: bytes = b"test"):
        """
        Create a File model and return the saved fileobj.

        :param contents: used to compute hash digest and size
        """
        hashed = _calculate_hash_from_data(contents)

        file, _ = File.get_or_create(hash_digest=hashed, size=len(contents))

        cast(TestCase, self).assertEqual(file.hash_digest, hashed)
        cast(TestCase, self).assertEqual(file.size, len(contents))

        return file


class WorkRequestTests(TestHelpersMixin, TestCase):
    """Tests for the WorkRequest class."""

    def setUp(self) -> None:
        """Add a sample user to the test fixture."""
        super().setUp()
        self.user = get_user_model().objects.create_user(
            username="usertest", password="userpassword"
        )

    def assert_data_must_be_dictionary(
        self, wr: WorkRequest, messages: list[str] | None = None
    ):
        """Check that a WorkRequest's task_data is a dictionary."""
        if messages is None:
            messages = ["task data must be a dictionary"]
        with self.assertRaises(ValidationError) as raised:
            wr.full_clean()
        self.assertEqual(
            raised.exception.message_dict,
            {"task_data": messages},
        )

    def test_clean_task_data_must_be_dict(self) -> None:
        """Ensure that task_data is a dict."""
        wr = WorkRequest.objects.create(
            workspace=default_workspace(),
            created_by=self.user,
            task_name="noop",
            task_data={},
        )
        wr.full_clean()

        wr.task_data = None
        self.assert_data_must_be_dictionary(wr)

        wr.task_data = ""
        self.assert_data_must_be_dictionary(wr)

        wr.task_data = 3
        self.assert_data_must_be_dictionary(wr)

        wr.task_data = []
        self.assert_data_must_be_dictionary(wr)

        wr.task_data = object()
        self.assert_data_must_be_dictionary(
            wr,
            messages=[
                'Value must be valid JSON.',
                'task data must be a dictionary',
            ],
        )

    def test_clean_task_name_must_be_valid(self) -> None:
        """Check validation of the task name."""
        wr = WorkRequest.objects.create(
            workspace=default_workspace(),
            created_by=self.user,
            task_name="noop",
            task_data={},
        )
        wr.full_clean()

        wr.task_name = "does-not-exist"
        with self.assertRaises(ValidationError) as raised:
            wr.full_clean()

        self.assertEqual(
            raised.exception.message_dict,
            {"task_name": ['does-not-exist: invalid Worker task name']},
        )

        wr.task_name = "does_not_exist"
        with self.assertRaises(ValidationError) as raised:
            wr.full_clean()

        self.assertEqual(
            raised.exception.message_dict,
            {
                "task_name": [
                    "does_not_exist: invalid Worker task name",
                ]
            },
        )

        wr.task_type = TaskTypes.INTERNAL

        wr.task_name = "synchronization_point"
        wr.full_clean()

        wr.task_name = "workflow"
        wr.full_clean()

        wr.task_name = "does_not_exist"
        with self.assertRaises(ValidationError) as raised:
            wr.full_clean()
        self.assertEqual(
            raised.exception.message_dict,
            {
                "task_name": [
                    "does_not_exist: invalid task name for internal task",
                ]
            },
        )

        wr.task_type = TaskTypes.SERVER
        wr.task_name = "internalnoop"
        wr.full_clean()

        wr.task_type = TaskTypes.WORKFLOW
        with self.assertRaises(ValidationError) as raised:
            wr.full_clean()
        self.assertEqual(
            raised.exception.message_dict,
            {
                "task_name": [
                    "internalnoop: invalid workflow name",
                ]
            },
        )

        wr.task_name = "noop"
        wr.full_clean()

        wr.task_type = "does-not-exist"
        with self.assertRaises(NotImplementedError):
            wr.full_clean()

    def test_clean_data_task_data_raise_validation_error(self) -> None:
        """Check validation of task data."""

        def make_task_data():
            return {
                "build_components": ["any", "all"],
                "host_architecture": "amd64",
                "environment": (
                    "debian/match:codename=bookworm:architecture=amd64"
                ),
                "input": {
                    "source_artifact": 5,
                },
            }

        wr = WorkRequest.objects.create(
            created_by=self.user,
            workspace=default_workspace(),
            task_name="sbuild",
            task_data=make_task_data(),
        )
        wr.full_clean()

        wr.task_data = make_task_data()
        wr.task_data["invalid-key"] = None
        with self.assertRaisesRegex(
            ValidationError,
            "extra fields not permitted",
        ):
            wr.full_clean()

        wr.task_data = make_task_data()
        del wr.task_data["environment"]
        with self.assertRaisesRegex(
            ValidationError,
            'The backend "auto" requires "environment" to be set',
        ):
            wr.full_clean()

    def test_clean_workflow_data_raise_validation_error(self) -> None:
        """Check validation of workflow data."""
        wr = WorkRequest.objects.create(
            created_by=self.user,
            workspace=default_workspace(),
            task_type=TaskTypes.WORKFLOW,
            task_name="noop",
            task_data={},
        )
        wr.full_clean()

        wr.task_data["spurious"] = True
        with self.assertRaisesRegex(
            ValidationError,
            "extra fields not permitted",
        ):
            wr.full_clean()

    def test_workflow_data_accessors(self) -> None:
        """Test the custom pydantic accessors for workflow_data."""
        common_args = {
            "created_by": self.user,
            "workspace": default_workspace(),
            "task_type": TaskTypes.WORKER,
            "task_name": "noop",
            "task_data": {},
        }

        # Empty workflow_data
        wr = WorkRequest.objects.create(**common_args)
        self.assertEqual(wr.workflow_data_json, {})
        self.assertIsNone(wr.workflow_data)

        wr = WorkRequest.objects.create(**common_args)
        self.assertEqual(wr.workflow_data_json, {})
        self.assertIsNone(wr.workflow_data)

        # Filled workflow data
        wr = WorkRequest.objects.create(
            workflow_data_json={"display_name": "Test", "step": "test"},
            **common_args,
        )
        self.assertEqual(
            wr.workflow_data_json,
            {"display_name": "Test", "step": "test"},
        )
        self.assertEqual(
            wr.workflow_data,
            WorkRequestWorkflowData(display_name="Test", step="test"),
        )

        wr.workflow_data = None
        self.assertEqual(wr.workflow_data_json, {})
        self.assertIsNone(wr.workflow_data)

        wr.workflow_data = WorkRequestWorkflowData(
            display_name="Test1", step="test1"
        )
        self.assertEqual(
            wr.workflow_data_json,
            {"display_name": "Test1", "step": "test1"},
        )
        self.assertEqual(
            wr.workflow_data,
            WorkRequestWorkflowData(display_name="Test1", step="test1"),
        )

    def test_is_part_of_workflow_standalone(self) -> None:
        """A standalone work request is not part of a workflow."""
        wr = self.create_work_request()

        self.assertFalse(wr.is_part_of_workflow)

    def test_is_part_of_workflow_root(self) -> None:
        """A work request of type WORKFLOW is part of a workflow."""
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name="noop",
            task_data={},
        )
        wr = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )

        self.assertTrue(wr.is_part_of_workflow)

    def test_is_part_of_workflow_child(self) -> None:
        """A work request with a parent is part of a workflow."""
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name="noop",
            task_data={},
        )
        root = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )
        wr = self.create_work_request(parent=root)

        self.assertTrue(wr.is_part_of_workflow)

    def test_get_workflow_root_standalone(self) -> None:
        """A standalone work request has no workflow root."""
        wr = self.create_work_request()

        self.assertIsNone(wr.get_workflow_root())

    def test_get_workflow_root_root(self) -> None:
        """A top-level work request in a workflow is its own root."""
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name="noop",
            task_data={},
        )
        wr = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )

        self.assertEqual(wr.get_workflow_root(), wr)

    def test_get_workflow_root_child(self) -> None:
        """A child work request in a workflow has a root."""
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name="noop",
            task_data={},
        )
        root = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )
        wr = self.create_work_request(parent=root)

        self.assertEqual(wr.get_workflow_root(), root)

    def test_get_workflow_root_grandchild(self) -> None:
        """A grandchild work request in a workflow has a root."""
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name="workrequestmanagerteststestworkflow",
            task_data={},
        )
        root = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )
        child_template = WorkflowTemplate.objects.create(
            name="child",
            workspace=default_workspace(),
            task_name="noop",
            task_data={},
        )
        child = WorkRequest.objects.create_workflow(
            template=child_template,
            data={},
            created_by=self.get_test_user(),
            parent=root,
        )
        wr = self.create_work_request(parent=child)

        self.assertEqual(wr.get_workflow_root(), root)

    def test_workflow_display_name_explicit(self) -> None:
        """`workflow_display_name` returns an explicitly-set display name."""
        wr = self.create_work_request(
            workflow_data_json={"display_name": "Foo", "step": "foo"}
        )

        self.assertEqual(wr.workflow_display_name, "Foo")

    def test_workflow_display_name_task_name(self) -> None:
        """`workflow_display_name` defaults to the task name."""
        wr = self.create_work_request(task_name="noop")

        self.assertEqual(wr.workflow_display_name, "noop")

    def test_effective_expiration_delay(self):
        """Test WorkRequest.effective_expiration_delay() method."""
        wr = self.create_work_request(task_name="noop")

        wr.expiration_delay = timedelta(days=1)
        self.assertEqual(wr.effective_expiration_delay(), timedelta(days=1))

        wr.expiration_delay = timedelta(days=0)
        self.assertEqual(wr.effective_expiration_delay(), timedelta(days=0))

        workspace = Workspace.objects.create_with_name(name="test")
        workspace.default_expiration_delay = timedelta(days=2)
        wr.workspace = workspace

        wr.expiration_delay = timedelta(days=1)
        self.assertEqual(wr.effective_expiration_delay(), timedelta(days=1))

        wr.expiration_delay = timedelta(days=0)
        self.assertEqual(wr.effective_expiration_delay(), timedelta(days=0))

        wr.expiration_delay = None
        self.assertEqual(wr.effective_expiration_delay(), timedelta(days=2))


class WorkRequestWorkerTests(ChannelsHelpersMixin, TestHelpersMixin, TestCase):
    """Tests for the WorkRequest class scheduling."""

    def setUp(self):
        """Set up WorkRequest to be used in the tests."""
        worker = Worker.objects.create_with_fqdn(
            "computer.lan", Token.objects.create()
        )

        self.work_request = self.create_work_request(
            task_name='request-01',
            worker=worker,
            status=WorkRequest.Statuses.PENDING,
        )

    def test_str(self):
        """Test WorkerRequest.__str__ return WorkRequest.id."""
        self.assertEqual(self.work_request.__str__(), str(self.work_request.id))

    def test_mark_running_from_aborted(self):
        """Test WorkRequest.mark_running() doesn't change (was aborted)."""
        self.work_request.status = WorkRequest.Statuses.ABORTED
        self.work_request.save()

        self.assertFalse(self.work_request.mark_running())

        self.work_request.refresh_from_db()
        self.assertEqual(self.work_request.status, WorkRequest.Statuses.ABORTED)

    def test_mark_running(self):
        """Test WorkRequest.mark_running() change status to running."""
        self.work_request.status = WorkRequest.Statuses.PENDING
        self.work_request.save()
        self.assertIsNone(self.work_request.started_at)

        self.assertTrue(self.work_request.mark_running())

        self.work_request.refresh_from_db()
        self.assertEqual(self.work_request.status, WorkRequest.Statuses.RUNNING)
        self.assertLess(self.work_request.started_at, timezone.now())

        # Marking as running again (a running WorkRequest) is a no-op
        started_at = self.work_request.started_at
        self.assertTrue(self.work_request.mark_running())

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

    def test_mark_running_fails_worker_already_running(self):
        """WorkRequest.mark_running() return False: worker already running."""
        self.work_request.status = WorkRequest.Statuses.PENDING
        self.work_request.save()

        other_work_request = self.create_work_request(
            status=WorkRequest.Statuses.RUNNING, worker=self.work_request.worker
        )

        with self.assertLogsContains(
            f"Cannot mark WorkRequest {self.work_request.pk} as running - the "
            f"assigned worker {self.work_request.worker} is running too many "
            f"other WorkRequests: [{other_work_request!r}]",
            logger="debusine.db.models",
            level=logging.DEBUG,
        ):
            self.assertFalse(self.work_request.mark_running())

    def test_mark_running_fails_no_assigned_worker(self):
        """WorkRequest.mark_running() return False: no worker assigned."""
        self.work_request.status = WorkRequest.Statuses.PENDING
        self.work_request.worker = None
        self.work_request.save()

        self.assertFalse(self.work_request.mark_running())

    def test_mark_completed_from_aborted(self):
        """Test WorkRequest.mark_completed() doesn't change (was aborted)."""
        self.work_request.status = WorkRequest.Statuses.ABORTED
        self.work_request.save()

        self.work_request.refresh_from_db()

        self.assertFalse(
            self.work_request.mark_completed(WorkRequest.Results.SUCCESS)
        )

        self.work_request.refresh_from_db()
        self.assertEqual(self.work_request.status, WorkRequest.Statuses.ABORTED)

    def test_mark_completed(self):
        """Test WorkRequest.mark_completed() changes status to completed."""
        self.work_request.status = WorkRequest.Statuses.RUNNING
        self.work_request.save()

        self.assertIsNone(self.work_request.completed_at)
        self.assertEqual(self.work_request.result, WorkRequest.Results.NONE)

        self.assertTrue(
            self.work_request.mark_completed(WorkRequest.Results.SUCCESS)
        )

        self.work_request.refresh_from_db()
        self.assertEqual(
            self.work_request.status, WorkRequest.Statuses.COMPLETED
        )
        self.assertEqual(self.work_request.result, WorkRequest.Results.SUCCESS)
        self.assertLess(self.work_request.completed_at, timezone.now())

    def test_mark_aborted(self):
        """Test WorkRequest.mark_aborted() changes status to aborted."""
        self.assertIsNone(self.work_request.completed_at)

        self.assertTrue(self.work_request.mark_aborted())

        self.work_request.refresh_from_db()
        self.assertEqual(self.work_request.status, WorkRequest.Statuses.ABORTED)
        self.assertLess(self.work_request.completed_at, timezone.now())

    def test_maybe_finish_workflow_not_workflow(self):
        """`maybe_finish_workflow` does nothing on non-workflows."""
        self.assertFalse(self.work_request.maybe_finish_workflow())

    def test_maybe_finish_workflow_already_aborted(self):
        """`maybe_finish_workflow` does nothing if it was already aborted."""
        template = self.create_workflow_template("test", "noop")
        workflow = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )
        workflow.create_child("noop", status=WorkRequest.Statuses.COMPLETED)
        self.assertTrue(workflow.mark_aborted())

        self.assertFalse(workflow.maybe_finish_workflow())

        workflow.refresh_from_db()
        self.assertEqual(workflow.status, WorkRequest.Statuses.ABORTED)

    def test_maybe_finish_workflow_has_children_in_progress(self):
        """`maybe_finish_workflow` does nothing if a child is in progress."""
        template = self.create_workflow_template("test", "noop")
        workflow = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )
        children = [workflow.create_child("noop") for _ in range(2)]
        workflow.mark_running()
        workflow.unblock_workflow_children()
        children[0].refresh_from_db()
        self.assertTrue(children[0].mark_completed(WorkRequest.Results.SUCCESS))

        self.assertFalse(workflow.maybe_finish_workflow())

        workflow.refresh_from_db()
        self.assertEqual(workflow.status, WorkRequest.Statuses.RUNNING)

    def test_maybe_finish_workflow_all_succeeded(self):
        """All children succeeded: COMPLETED/SUCCESS."""
        template = self.create_workflow_template("test", "noop")
        workflow = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )
        children = [workflow.create_child("noop") for _ in range(2)]
        workflow.mark_running()
        workflow.unblock_workflow_children()

        for child in children:
            child.refresh_from_db()
            self.assertTrue(child.mark_completed(WorkRequest.Results.SUCCESS))

        workflow.refresh_from_db()
        self.assertEqual(workflow.status, WorkRequest.Statuses.COMPLETED)
        self.assertEqual(workflow.result, WorkRequest.Results.SUCCESS)
        self.assertLess(workflow.completed_at, timezone.now())

    def test_maybe_finish_workflow_allow_failure(self):
        """Some children failed but are allowed to fail: COMPLETED/SUCCESS."""
        template = self.create_workflow_template("test", "noop")
        workflow = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )
        children = [
            workflow.create_child(
                "noop",
                workflow_data=WorkRequestWorkflowData(
                    allow_failure=True, display_name="test", step="test"
                ),
            )
            for _ in range(2)
        ]
        workflow.mark_running()
        workflow.unblock_workflow_children()

        children[0].refresh_from_db()
        children[0].mark_completed(WorkRequest.Results.FAILURE)
        children[1].refresh_from_db()
        children[1].mark_completed(WorkRequest.Results.SUCCESS)

        workflow.refresh_from_db()
        self.assertEqual(workflow.status, WorkRequest.Statuses.COMPLETED)
        self.assertEqual(workflow.result, WorkRequest.Results.SUCCESS)
        self.assertLess(workflow.completed_at, timezone.now())

    def test_maybe_finish_workflow_some_failed(self):
        """Some children failed: COMPLETED/FAILURE."""
        template = self.create_workflow_template("test", "noop")
        workflow = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )
        children = [workflow.create_child("noop") for _ in range(2)]
        workflow.mark_running()
        workflow.unblock_workflow_children()

        children[0].refresh_from_db()
        children[0].mark_completed(WorkRequest.Results.FAILURE)
        children[1].refresh_from_db()
        children[1].mark_completed(WorkRequest.Results.SUCCESS)

        workflow.refresh_from_db()
        self.assertEqual(workflow.status, WorkRequest.Statuses.COMPLETED)
        self.assertEqual(workflow.result, WorkRequest.Results.FAILURE)
        self.assertLess(workflow.completed_at, timezone.now())

    def test_maybe_finish_workflow_some_aborted(self):
        """Some children were aborted: `maybe_finish_workflow` sets ABORTED."""
        template = self.create_workflow_template("test", "noop")
        workflow = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )
        children = [workflow.create_child("noop") for _ in range(2)]
        workflow.mark_running()
        workflow.unblock_workflow_children()

        children[0].refresh_from_db()
        children[0].mark_aborted()
        children[1].refresh_from_db()
        children[1].mark_completed(WorkRequest.Results.SUCCESS)

        workflow.refresh_from_db()
        self.assertEqual(workflow.status, WorkRequest.Statuses.ABORTED)
        self.assertLess(workflow.completed_at, timezone.now())

    async def test_assign_worker(self):
        """Assign Worker to WorkRequest."""

        def setup_work_request() -> Worker:
            worker = self.work_request.worker

            self.work_request.worker = None
            self.work_request.save()

            self.work_request.refresh_from_db()

            # Initial status (no worker)
            self.assertIsNone(self.work_request.worker)
            self.assertEqual(
                self.work_request.status, WorkRequest.Statuses.PENDING
            )

            return worker

        worker = await sync_to_async(setup_work_request)()

        channel = await self.create_channel(worker.token.hash)

        # Assign the worker to the WorkRequest
        await sync_to_async(self.work_request.assign_worker)(worker)

        await self.assert_channel_received(
            channel, {"type": "work_request.assigned"}
        )

        def assert_final_status():
            self.work_request.refresh_from_db()

            # Assert final status
            self.assertEqual(self.work_request.worker, worker)
            self.assertEqual(
                self.work_request.status, WorkRequest.Statuses.PENDING
            )

        await sync_to_async(assert_final_status)()

    def test_duration(self):
        """duration() returns the correct duration."""
        self.work_request.started_at = datetime(2022, 3, 7, 10, 51)
        self.work_request.completed_at = datetime(2022, 3, 9, 12, 53)
        duration = self.work_request.completed_at - self.work_request.started_at

        self.assertEqual(
            self.work_request.duration,
            duration.total_seconds(),
        )

    def test_duration_is_none_completed_at_is_none(self):
        """duration() returns None because completed_at is None."""
        self.work_request.started_at = datetime(2022, 3, 7, 10, 51)
        self.work_request.completed_at = None

        self.assertIsNone(self.work_request.duration)

    def test_duration_is_none_started_at_is_none(self):
        """duration() returns None because started_at is None."""
        self.work_request.started_at = None
        self.work_request.completed_at = datetime(2022, 3, 7, 10, 51)

        self.assertIsNone(self.work_request.duration)

    def test_priority_effective(self):
        """priority_effective() returns the effective priority."""
        self.work_request.priority_base = 10
        self.work_request.priority_adjustment = 1

        self.assertEqual(self.work_request.priority_effective, 11)

    def test_invalid_pending(self):
        """Reject invalid pending state change."""
        self.work_request.status = WorkRequest.Statuses.RUNNING
        self.assertFalse(self.work_request.mark_pending())
        self.assertEqual(self.work_request.status, WorkRequest.Statuses.RUNNING)

    def test_add_dependency_within_workflow(self):
        """Dependencies within a workflow are allowed."""
        template = WorkflowTemplate.objects.create(
            name="test", workspace=default_workspace(), task_name="noop"
        )
        workflow = WorkRequest.objects.create_workflow(
            template=template, created_by=self.get_test_user(), data={}
        )
        children = [workflow.create_child("noop") for _ in range(2)]

        children[0].add_dependency(children[1])

        self.assertQuerysetEqual(children[0].dependencies.all(), [children[1]])

    def test_add_dependency_outside_workflow(self):
        """Dependencies from within a workflow to outside are forbidden."""
        template = WorkflowTemplate.objects.create(
            name="test", workspace=default_workspace(), task_name="noop"
        )
        workflow = WorkRequest.objects.create_workflow(
            template=template, created_by=self.get_test_user(), data={}
        )
        child = workflow.create_child("noop")
        outside = self.create_work_request()

        with self.assertRaisesRegex(
            ValueError,
            "Work requests in a workflow may not depend on other work "
            "requests outside that workflow",
        ):
            child.add_dependency(outside)

    def test_add_dependency_auto_block(self):
        """`add_dependency` automatically blocks PENDING/DEPS work requests."""
        rdep = self.create_work_request()
        rdep.add_dependency(self.work_request)
        self.assertEqual(rdep.status, WorkRequest.Statuses.BLOCKED)

    def test_add_dependency_no_auto_block_not_pending(self):
        """`add_dependency` doesn't automatically block non-PENDING requests."""
        rdep = self.create_work_request()
        rdep.status = WorkRequest.Statuses.ABORTED
        rdep.save()
        rdep.add_dependency(self.work_request)
        self.assertEqual(rdep.status, WorkRequest.Statuses.ABORTED)

    def test_add_dependency_no_auto_block_not_deps(self):
        """`add_dependency` doesn't automatically block non-DEPS requests."""
        rdep = self.create_work_request()
        rdep.unblock_strategy = WorkRequest.UnblockStrategy.MANUAL
        rdep.save()
        rdep.add_dependency(self.work_request)
        self.assertEqual(rdep.status, WorkRequest.Statuses.PENDING)

    def test_add_dependency_no_auto_block_already_completed(self):
        """`add_dependency` doesn't auto-block on completed dependencies."""
        self.work_request.mark_completed(WorkRequest.Results.SUCCESS)
        rdep = self.create_work_request()
        rdep.add_dependency(self.work_request)
        self.assertEqual(rdep.status, WorkRequest.Statuses.PENDING)

    def create_rdep(self) -> WorkRequest:
        """Create a reverse-dependency for the default work request."""
        rdep = self.create_work_request(
            task_name='rdep', worker=self.work_request.worker
        )
        rdep.add_dependency(self.work_request)
        return rdep

    def test_dependencies_failure(self):
        """Failure result gets rdep aborted."""
        rdep = self.create_rdep()
        self.work_request.mark_completed(WorkRequest.Results.FAILURE)
        rdep.refresh_from_db()
        self.assertEqual(rdep.status, WorkRequest.Statuses.ABORTED)

    def test_dependencies_success_only_dep(self):
        """Success result, with nothing else to do, gets rdep pending."""
        rdep = self.create_rdep()
        self.work_request.mark_completed(WorkRequest.Results.SUCCESS)
        rdep.refresh_from_db()
        self.assertEqual(rdep.status, WorkRequest.Statuses.PENDING)

    def test_dependencies_allow_failure(self):
        """Failure result, with allow_failure, gets rdep pending."""
        rdep = self.create_rdep()
        self.work_request.workflow_data = WorkRequestWorkflowData(
            allow_failure=True, display_name="test", step="test"
        )
        self.work_request.mark_completed(WorkRequest.Results.FAILURE)
        rdep.refresh_from_db()
        self.assertEqual(rdep.status, WorkRequest.Statuses.PENDING)

    def test_dependencies_success_manual(self):
        """Success result with manual unblock keeps rdep blocked."""
        rdep = self.create_rdep()
        rdep.unblock_strategy = WorkRequest.UnblockStrategy.MANUAL
        rdep.save()
        self.work_request.mark_completed(WorkRequest.Results.SUCCESS)
        rdep.refresh_from_db()
        self.assertEqual(rdep.status, WorkRequest.Statuses.BLOCKED)

    def test_dependencies_success_more_deps(self):
        """Success result with something else to do keeps rdep blocked."""
        rdep = self.create_rdep()
        work_request2 = self.create_work_request(
            task_name='request-02',
            worker=self.work_request.worker,
            status=WorkRequest.Statuses.RUNNING,
        )
        rdep.add_dependency(work_request2)
        self.work_request.mark_completed(WorkRequest.Results.SUCCESS)
        rdep.refresh_from_db()
        self.assertEqual(rdep.status, WorkRequest.Statuses.BLOCKED)

    def test_dependencies_in_pending_workflow(self):
        """Parent workflow that isn't running yet keeps rdep blocked."""
        template = self.create_workflow_template("test", "noop")
        workflow = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )
        rdep = workflow.create_child("noop")
        self.work_request.parent = workflow
        self.work_request.save()
        rdep.add_dependency(self.work_request)
        self.work_request.mark_completed(WorkRequest.Results.SUCCESS)
        rdep.refresh_from_db()
        self.assertEqual(rdep.status, WorkRequest.Statuses.BLOCKED)

    def test_dependencies_in_running_workflow(self):
        """Parent workflow in running status gets rdep pending."""
        template = self.create_workflow_template("test", "noop")
        workflow = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=self.get_test_user()
        )
        rdep = workflow.create_child("noop")
        self.work_request.parent = workflow
        self.work_request.save()
        rdep.add_dependency(self.work_request)
        workflow.mark_running()
        self.work_request.mark_completed(WorkRequest.Results.SUCCESS)
        rdep.refresh_from_db()
        self.assertEqual(rdep.status, WorkRequest.Statuses.PENDING)

    def test_create_child_non_workflow(self):
        """Non-WORKFLOW work requests may not have children."""
        with self.assertRaisesRegex(
            ValueError, r"Only workflows may have child work requests\."
        ):
            self.work_request.create_child("noop")

    def test_event_reactions_update_collection(self):
        """Update collection on task completion."""
        wr = WorkRequest.objects.create(
            workspace=default_workspace(),
            created_by=self.get_test_user(),
            task_type=TaskTypes.WORKFLOW,
            task_name="test",
        )
        c = self.create_collection(
            name=f"workflow-{wr.id}", category="debusine:workflow-internal"
        )
        cm = CollectionManagerInterface.get_manager_for(c)
        a_replaced, _ = self.create_artifact(
            category="debusine:test",
            data={
                "deb_fields": {
                    "Package": "actiontest",
                    "Version": "1.0-1",
                },
                "type": "rebuild",
            },
        )
        cm.add_artifact(
            a_replaced, user=wr.created_by, name="actiontest_rebuild"
        )
        item = cm.lookup("name:actiontest_rebuild")
        self.assertEqual(item.artifact, a_replaced)

        a_ignored, _ = self.create_artifact(
            category="debusine:test",
            data={
                "deb_fields": {
                    "Package": "ignored",
                    "Version": "1.0-1",
                },
                "type": "rebuild",
            },
        )
        cm.add_artifact(a_ignored, user=wr.created_by, name="ignored_rebuild")
        item = cm.lookup("name:ignored_rebuild")
        self.assertEqual(item.artifact, a_ignored)

        a_replacement, _ = self.create_artifact(
            category="debusine:test",
            data={
                'deb_fields': {
                    "Package": "actiontest",
                    "Version": "1.0-1+b1",
                },
                "type": "rebuild",
            },
            work_request=wr,
        )

        a_added, _ = self.create_artifact(
            category="debusine:test",
            data={
                "deb_fields": {
                    "Package": "actiontest2",
                    "Version": "2.0-1+b1",
                },
                "type": "rebuild",
            },
            work_request=wr,
        )

        action = ActionUpdateCollectionWithArtifacts.parse_obj(
            {
                "artifact_filters": {
                    "category": "debusine:test",
                    "data__deb_fields__Package__startswith": "actiontest",
                },
                "collection": "internal@collections",
                "name_template": "{name}_{type}",
                "variables": {
                    "$name": "deb_fields.Package",
                    "$type": "type",
                },
            }
        )
        wr.event_reactions = EventReactions(
            on_success=[action],
            on_failure=[action],
        )
        wr.mark_completed(WorkRequest.Results.SUCCESS)
        self.assertIsNotNone(
            CollectionItem.objects.get(
                parent_collection=c, artifact=a_replaced
            ).removed_at
        )
        item = cm.lookup("name:ignored_rebuild")
        self.assertEqual(item.artifact, a_ignored)
        item = cm.lookup("name:actiontest_rebuild")
        self.assertEqual(item.artifact, a_replacement)
        item = cm.lookup("name:actiontest2_rebuild")
        self.assertEqual(item.artifact.id, a_added.id)

        # For completeness, test NONE
        wr.result = WorkRequest.Results.NONE
        actions = wr.get_triggered_actions()
        for action_type, actions in actions.items():
            self.assertEqual(len(actions), 0)

    def test_event_reactions_update_collection_no_name_template(self):
        """Without name_template, variables are passed through."""
        wr = WorkRequest.objects.create(
            workspace=default_workspace(),
            created_by=self.get_test_user(),
            task_type=TaskTypes.WORKFLOW,
            task_name="test",
        )
        c = Collection.objects.create(
            name="debian",
            category="debian:environments",
            workspace=default_workspace(),
        )
        tarball, _ = self.create_artifact(
            category="debian:system-tarball",
            data={"architecture": "amd64"},
            work_request=wr,
        )
        action = ActionUpdateCollectionWithArtifacts.parse_obj(
            {
                "artifact_filters": {"category": "debian:system-tarball"},
                "collection": "debian@debian:environments",
                "variables": {"codename": "bookworm", "variant": "buildd"},
            }
        )
        wr.event_reactions = EventReactions(on_success=[action])

        wr.mark_completed(WorkRequest.Results.SUCCESS)

        self.assertIsNotNone(
            c.manager.lookup(
                "match:format=tarball:codename=bookworm:architecture=amd64:"
                "variant=buildd"
            ).artifact,
            tarball,
        )

    def test_event_reactions_update_collection_constraint_violation(self):
        """Violating collection constraints is an error."""
        wr = WorkRequest.objects.create(
            workspace=default_workspace(),
            created_by=self.get_test_user(),
            task_type=TaskTypes.WORKFLOW,
            task_name="test",
        )
        self.create_collection(
            name=f"workflow-{wr.id}", category="debusine:workflow-internal"
        )
        self.create_artifact(category="debusine:test", work_request=wr)
        # This action does not specify an item name.
        action = ActionUpdateCollectionWithArtifacts(
            artifact_filters={"category": "debusine:test"},
            collection="internal@collections",
        )
        wr.event_reactions = EventReactions(on_success=[action])
        wr.status = WorkRequest.Statuses.RUNNING
        with self.assertLogsContains(
            "Cannot replace or add artifact",
            logger="debusine.db.models",
            level=logging.ERROR,
        ):
            wr.mark_completed(WorkRequest.Results.SUCCESS)

    def test_event_reactions_update_collection_invalid_artifact_filters(self):
        """A failure to filter artifacts is an error."""
        wr = WorkRequest.objects.create(
            workspace=default_workspace(),
            created_by=self.get_test_user(),
            task_type=TaskTypes.WORKFLOW,
            task_name="test",
        )
        self.create_collection(
            name=f"workflow-{wr.id}", category="debusine:workflow-internal"
        )
        action = ActionUpdateCollectionWithArtifacts.parse_obj(
            {
                "artifact_filters": {"xxx": "yyy"},
                "collection": "internal@collections",
            }
        )
        wr.event_reactions = EventReactions(on_success=[action])
        with self.assertLogsContains(
            "Invalid update-collection-with-artifacts artifact_filters",
            logger="debusine.db.models",
            level=logging.ERROR,
        ):
            wr.mark_completed(WorkRequest.Results.SUCCESS)

    def test_event_reactions_update_collection_invalid_variables(self):
        """A failure to expand variables is an error."""
        wr = WorkRequest.objects.create(
            workspace=default_workspace(),
            created_by=self.get_test_user(),
            task_type=TaskTypes.WORKFLOW,
            task_name="test",
        )
        self.create_collection(
            name=f"workflow-{wr.id}", category="debusine:workflow-internal"
        )
        self.create_artifact(category="debusine:test", work_request=wr)
        action = ActionUpdateCollectionWithArtifacts.parse_obj(
            {
                "artifact_filters": {"category": "debusine:test"},
                "collection": "internal@collections",
                "name_template": "{name}",
                "variables": {"$name": "zzz"},
            }
        )
        wr.event_reactions = EventReactions(on_success=[action])
        with self.assertLogsContains(
            "Invalid update-collection-with-artifacts variables",
            logger="debusine.db.models",
            level=logging.ERROR,
        ):
            wr.mark_completed(WorkRequest.Results.SUCCESS)

    def test_event_reactions_update_collection_different_workspace(self):
        """The updated collection must be in the same workspace."""
        wr = WorkRequest.objects.create(
            workspace=default_workspace(),
            created_by=self.get_test_user(),
            task_type=TaskTypes.WORKFLOW,
            task_name="test",
        )
        different_workspace = Workspace.objects.create_with_name(name="test")
        Collection.objects.create(
            name="test_differentworkspace",
            category="debusine:workflow-internal",
            workspace=different_workspace,
        )
        action = ActionUpdateCollectionWithArtifacts.parse_obj(
            {
                "collection": (
                    "test_differentworkspace@debusine:workflow-internal"
                ),
                "artifact_filters": {"category": "debusine:test"},
            }
        )
        wr.event_reactions = EventReactions(on_success=[action])
        with self.assertLogsContains(
            "'test_differentworkspace@debusine:workflow-internal' does not "
            "exist or is hidden",
            logger="debusine.db.models",
            level=logging.ERROR,
        ):
            wr.mark_completed(WorkRequest.Results.SUCCESS)


class WorkflowTemplateTests(TestCase):
    """Unit tests for the WorkflowTemplate class."""

    def assert_data_must_be_dictionary(
        self, wt: WorkflowTemplate, messages: list[str] | None = None
    ):
        """Check that a WorkflowTemplate's task_data is a dictionary."""
        if messages is None:
            messages = ["task data must be a dictionary"]
        with self.assertRaises(ValidationError) as raised:
            wt.full_clean()
        self.assertEqual(
            raised.exception.message_dict,
            {"task_data": messages},
        )

    def test_clean_task_data_must_be_dict(self) -> None:
        """Ensure that task_data is a dict."""
        wt = WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name="noop",
            task_data={},
        )
        wt.full_clean()

        wt.task_data = None
        self.assert_data_must_be_dictionary(wt)

        wt.task_data = ""
        self.assert_data_must_be_dictionary(wt)

        wt.task_data = 3
        self.assert_data_must_be_dictionary(wt)

        wt.task_data = []
        self.assert_data_must_be_dictionary(wt)

        wt.task_data = object()
        self.assert_data_must_be_dictionary(
            wt,
            messages=[
                'Value must be valid JSON.',
                'task data must be a dictionary',
            ],
        )

    def test_validate_task_data(self) -> None:
        """Test orchestrator validation of task data."""
        with preserve_task_registry():

            class ValidatingWorkflow(Workflow[BaseWorkflowData]):
                """Workflow used to test validation of template data."""

                @classmethod
                def validate_template_data(cls, data: dict[str, Any]) -> None:
                    """data-controlled validation."""
                    if msg := data.get("error"):
                        raise ValueError(msg)

            wt = WorkflowTemplate.objects.create(
                name="test",
                workspace=default_workspace(),
                task_name="validatingworkflow",
                task_data={},
            )
            wt.full_clean()

            wt.task_data = {"error": "example message"}
            with self.assertRaises(ValidationError) as raised:
                wt.full_clean()
            self.assertEqual(
                raised.exception.message_dict,
                {"task_data": ["example message"]},
            )


class FileManagerTests(FileHelpersMixin, TestCase):
    """Unit tests for the ``FileManager`` class."""

    def test_create(self):
        """FileManager.create() supports the generic hash_digest field."""
        file_hash = (
            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
        )
        file = File.objects.create(
            hash_digest=binascii.unhexlify(file_hash), size=5
        )

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


class FileTests(FileHelpersMixin, TestCase):
    """Tests for the File class."""

    def setUp(self):
        """Set up File to be used in the tests."""
        self.file_contents = b"test"
        self.file_hash = _calculate_hash_from_data(self.file_contents)

        self.file = self.create_file(self.file_contents)

    def test_sha256(self):
        """File.sha256 have the expected hash."""
        self.assertEqual(self.file.sha256, self.file_hash)
        self.assertEqual(self.file.sha256.hex(), self.file_hash.hex())

    def test_hash_digest_getter(self):
        """File.hash_digest return the expected hash digest."""
        self.assertIsInstance(self.file.hash_digest, bytes)
        self.assertEqual(self.file.hash_digest, self.file_hash)
        self.assertEqual(self.file.hash_digest.hex(), self.file_hash.hex())

    def test_hash_digest_setter(self):
        """File.hash_digest setter sets the hash digest."""
        the_hash = (
            '960fd32831fc6d6dbf5c4d141bff1a75d37bec521e52cb89e69780612a4ca04a'
        )
        self.file.hash_digest = binascii.unhexlify(the_hash)

        self.file.save()
        self.file.refresh_from_db()

        self.assertEqual(self.file.hash_digest.hex(), the_hash)

    def test_calculate_hash(self):
        """calculate_hash returns the expected hash digest."""
        file_contents = b"testing"
        local_file = self.create_temporary_file(contents=file_contents)
        expected_hash = _calculate_hash_from_data(file_contents)

        self.assertEqual(
            self.file.calculate_hash(
                local_file,
            ),
            expected_hash,
        )

    def test_str(self):
        """__str__() return sha256 and size."""
        self.assertEqual(
            str(self.file),
            f"id: {self.file.id} "
            f"sha256: {self.file_hash.hex()} "
            f"size: {len(self.file_contents)}",
        )

    def test_unique_constraint_hash_size(self):
        """File with same hash_digest and size cannot be created."""
        with self.assertRaisesRegex(
            django.db.utils.IntegrityError, "db_file_unique_sha256_size"
        ):
            File.objects.create(
                **{
                    File.current_hash_algorithm: self.file.hash_digest,
                    "size": self.file.size,
                }
            )

    def test_hash_can_be_duplicated(self):
        """File with the same hash_digest and different size can be created."""
        file_existing = File.objects.all().first()
        file_new = File.objects.create(
            hash_digest=file_existing.hash_digest, size=file_existing.size + 10
        )
        self.assertIsNotNone(file_new.id)

    def test_constraint_hash_digest_not_empty(self):
        """File.hash_digest cannot be empty."""
        with self.assertRaisesRegex(
            django.db.utils.IntegrityError, "db_file_sha256_not_empty"
        ):
            File.objects.create(hash_digest=b"", size=5)

    def test_get_or_create_not_created(self):
        """
        File.get_or_create returns created=False.

        File.get_or_create() tried to create a file with the same file_hash
        and size as one already existing.
        """
        fileobj, created = File.get_or_create(
            hash_digest=self.file_hash, size=len(self.file_contents)
        )

        self.assertEqual(self.file, fileobj)
        self.assertFalse(created)

    def test_get_or_create_created(self):
        """
        File.get_or_create returns created=True.

        File.get_or_create() created a new File (file_hash and size did not
        exist).
        """
        file_contents = self.file_contents + b"-new-contents"
        hash_digest = _calculate_hash_from_data(file_contents)
        size = len(file_contents)

        fileobj, created = File.get_or_create(
            hash_digest=hash_digest, size=size
        )

        self.assertIsInstance(fileobj, File)
        self.assertEqual(fileobj.hash_digest, hash_digest)
        self.assertEqual(fileobj.size, size)
        self.assertTrue(created)


class FileStoreTests(FileHelpersMixin, TestCase):
    """Tests for the FileStore class."""

    def setUp(self):
        """Set up FileStore to be used in the tests."""
        self.backend = FileStore.BackendChoices.LOCAL
        self.file_store_name = "nas-01"

        self.file_store = FileStore.objects.create(
            name=self.file_store_name,
            backend=self.backend,
        )

    def test_default_values(self):
        """Test default values."""
        file_store = FileStore.objects.create(
            name="nas-02", backend=FileStore.BackendChoices.LOCAL
        )
        self.assertEqual(file_store.configuration, {})
        file_store.clean_fields()
        file_store.save()

    def test_backend_choice(self):
        """Assert FileStore.BackendChoices is correctly accessed."""
        self.assertEqual(self.file_store.backend, self.backend)

    def test_configuration_validation_error(self):
        """`ValidationError` is raised if configuration is invalid."""
        file_store = FileStore.objects.create(
            name="test",
            backend=FileStore.BackendChoices.LOCAL,
            configuration=[],
        )
        with self.assertRaises(ValidationError) as raised:
            file_store.full_clean()
        self.assertEqual(
            raised.exception.message_dict,
            {"configuration": ["configuration must be a dictionary"]},
        )

        file_store.configuration = {"nonexistent": ""}
        with self.assertRaises(ValidationError) as raised:
            file_store.full_clean()
        messages = raised.exception.message_dict
        self.assertCountEqual(messages.keys(), ["configuration"])
        self.assertRegex(
            messages["configuration"][0],
            r"(?s)invalid file store configuration:.*"
            r"extra fields not permitted",
        )

    def test_files_through(self):
        """FileStore.files() return the expected file."""
        # Create a new file
        file = self.create_file()

        # Add the file in the store
        FileInStore.objects.create(file=file, store=self.file_store, data={})

        # Assert that self.file_store through model return the expected file
        self.assertEqual(self.file_store.files.first(), file)

    def test_default(self):
        """default() returns FileStore with name=="Default"."""
        self.assertEqual(FileStore.default().name, "Default")

    def test_str(self):
        """__str__() return the correct information."""
        self.assertEqual(
            self.file_store.__str__(),
            f"Id: {self.file_store.id} "
            f"Name: {self.file_store.name} "
            f"Backend: {self.file_store.backend}",
        )


class FileInStoreTests(TestCase):
    """Tests for the FileInStore class."""

    def setUp(self):
        """Set up FileInStore to be used in the tests."""
        self.file_store_name = "nas-01"
        self.file_store = FileStore.objects.create(
            name="nas-01",
            backend=FileStore.BackendChoices.LOCAL,
            configuration={},
        )

        file_contents = b"test"

        self.file_hash = hashlib.new(File.current_hash_algorithm).digest()
        self.file_length = len(file_contents)

        self.file = File.objects.create(
            hash_digest=self.file_hash, size=self.file_length
        )
        self.file_in_store = FileInStore(
            store=self.file_store,
            file=self.file,
        )

    def test_default_values(self):
        """Test default values."""
        # Delete all FileInStore to create a new one, in this test,
        # reusing self.file_store and self.file
        FileInStore.objects.all().delete()
        file_in_store = FileInStore(store=self.file_store, file=self.file)

        self.assertEqual(file_in_store.data, {})
        file_in_store.clean_fields()
        file_in_store.save()

    def test_str(self):
        """__str__() return the correct string."""
        self.assertEqual(
            self.file_in_store.__str__(),
            f"Id: {self.file_in_store.id} "
            f"Store: {self.file_in_store.store.name} "
            f"File: {self.file_in_store.file.hash_digest.hex()}",
        )


class ArtifactManagerTests(TestHelpersMixin, TestCase):
    """Tests for the ArtifactManager class."""

    def setUp(self):
        """Set up test."""
        self.workspace = Workspace.objects.create_with_name(name="test")

    def test_create_from_local_artifact(self):
        """create_from_local_artifact() creates a matching Artifact."""
        temp_dir = self.create_temporary_directory()
        paths = [temp_dir / "1.log", temp_dir / "2.log"]
        paths[0].write_text("1\n")
        paths[1].write_text("2\n")
        local_artifact = WorkRequestDebugLogs.create(files=paths)
        work_request = self.create_work_request()

        artifact = Artifact.objects.create_from_local_artifact(
            local_artifact, self.workspace, created_by_work_request=work_request
        )

        self.assertEqual(artifact.category, local_artifact.category)
        self.assertEqual(artifact.workspace, self.workspace)
        self.assertEqual(artifact.data, local_artifact.data)
        self.assertEqual(artifact.created_by_work_request, work_request)
        self.assertEqual(artifact.fileinartifact_set.count(), 2)
        file_store = self.workspace.default_file_store.get_backend_object()
        for file_in_artifact, path in zip(
            artifact.fileinartifact_set.order_by("id"), paths
        ):
            self.assertEqual(file_in_artifact.path, path.name)
            with file_store.get_stream(file_in_artifact.file) as file:
                self.assertEqual(file.read(), path.read_bytes())

    def test_not_expired_return_not_expired_artifacts(self):
        """not_expired() return only the not expired artifacts."""
        artifact_1 = Artifact.objects.create(
            workspace=self.workspace, expiration_delay=timedelta(days=1)
        )
        artifact_1.created_at = timezone.now() - timedelta(days=2)
        artifact_1.save()
        artifact_2 = Artifact.objects.create(
            workspace=self.workspace, expiration_delay=timedelta(days=1)
        )
        artifact_3 = Artifact.objects.create(
            workspace=self.workspace, expiration_delay=timedelta(0)
        )

        self.assertQuerysetEqual(
            Artifact.objects.not_expired(timezone.now()),
            {artifact_2, artifact_3},
            ordered=False,
        )

    def test_expired_return_expired_artifacts(self):
        """expired() return only the expired artifacts."""
        artifact_1 = Artifact.objects.create(
            workspace=self.workspace, expiration_delay=timedelta(days=1)
        )
        artifact_1.created_at = timezone.now() - timedelta(days=2)
        artifact_1.save()

        Artifact.objects.create(
            workspace=self.workspace, expiration_delay=timedelta(days=1)
        )
        Artifact.objects.create(
            workspace=self.workspace, expiration_delay=timedelta(0)
        )

        self.assertQuerysetEqual(
            Artifact.objects.expired(timezone.now()), {artifact_1}
        )


class ArtifactTests(TestCase):
    """Tests for the Artifact class."""

    def test_default_values_of_fields(self):
        """Define the fields of the models and test their default values."""
        artifact = Artifact()

        self.assertEqual(artifact.category, "")

        artifact.category = "sample-type"

        self.assertEqual(artifact.data, {})

        artifact.workspace = Workspace.objects.create_with_name(name="test")

        artifact.clean_fields()

        date_time_before_save = timezone.now()

        artifact.save()

        # date_time_before_save <= artifact.created_at <= timezone.now()
        self.assertLessEqual(date_time_before_save, artifact.created_at)
        self.assertLessEqual(artifact.created_at, timezone.now())

        # By default, the expiration_delay is None
        self.assertIsNone(artifact.expiration_delay)

        # By default, the created_by_work_request is None
        self.assertIsNone(artifact.created_by_work_request)

        # By default, the created_by is None
        self.assertIsNone(artifact.created_by)

    def test_expired(self):
        """Test Artifact.expired() method."""
        artifact = Artifact()

        artifact.created_at = timezone.now() - timedelta(days=2)
        artifact.expiration_delay = timedelta(days=1)
        self.assertTrue(artifact.expired(timezone.now()))

        artifact.expiration_delay = timedelta(0)
        self.assertFalse(artifact.expired(timezone.now()))

        artifact.created_at = timezone.now()
        artifact.expiration_delay = timedelta(days=1)
        self.assertFalse(artifact.expired(timezone.now()))

    def test_default_expiration(self):
        """Test workspace's default_expiration_delay."""
        default_ws = default_workspace()
        self.assertEqual(default_ws.default_expiration_delay, timedelta(0))
        artifact = Artifact.objects.create(workspace=default_ws)
        self.assertIsNone(artifact.expiration_delay)

        expiring_ws = Workspace.objects.create_with_name(name="test")
        expiring_ws.default_expiration_delay = timedelta(days=7)
        test_creation_time = timezone.now()
        artifact = Artifact.objects.create(workspace=expiring_ws)
        delta = expiring_ws.default_expiration_delay
        self.assertEqual(
            (artifact.expire_at - test_creation_time).days, delta.days
        )

    def test_str(self):
        """Test for Artifact.__str__."""
        artifact = Artifact.objects.create(
            workspace=Workspace.objects.create_with_name(name="test")
        )
        self.assertEqual(
            artifact.__str__(),
            f"Id: {artifact.id} "
            f"Category: {artifact.category} "
            f"Workspace: {artifact.workspace.id}",
        )

    def test_data_validation_error(self):
        """Assert that ValidationError is raised if data is not valid."""
        default_ws = default_workspace()

        artifact = Artifact.objects.create(
            category="debusine:work-request-debug-logs",
            workspace=default_ws,
            data=[],
        )
        with self.assertRaises(ValidationError) as raised:
            artifact.full_clean()
        self.assertEqual(
            raised.exception.message_dict,
            {"data": ["data must be a dictionary"]},
        )

        artifact.category = "missing:does-not-exist"
        artifact.data = {}
        with self.assertRaises(ValidationError) as raised:
            artifact.full_clean()
        self.assertEqual(
            raised.exception.message_dict,
            {"category": ["missing:does-not-exist: invalid artifact category"]},
        )

        artifact.category = "debian:package-build-log"
        with self.assertRaises(ValidationError) as raised:
            artifact.full_clean()
        messages = raised.exception.message_dict
        self.assertCountEqual(messages.keys(), ["data"])
        self.assertRegex(
            messages["data"][0],
            r"invalid artifact data:"
            r" 3 validation errors for DebianPackageBuildLog\n",
        )


class FileInArtifactTests(FileHelpersMixin, TestHelpersMixin, TestCase):
    """Tests for the FileInArtifact class."""

    def create_file_in_artifact(
        self,
        fileobj: File | None = None,
        artifact: Artifact | None = None,
        path: str | None = None,
    ) -> FileInArtifact:
        """Return FileInArtifact to be used in tests."""
        if fileobj is None:
            fileobj = self.create_file()

        if artifact is None:
            artifact, _ = self.create_artifact()

        if path is None:
            path = "/usr/bin/test"

        return FileInArtifact.objects.create(
            artifact=artifact, file=fileobj, path=path
        )

    def test_artifact_path_unique_constraint(self):
        """Test two FileInArtifact cannot have the same artifact and path."""
        artifact, _ = self.create_artifact()

        file1 = self.create_file(b"contents1")
        file2 = self.create_file(b"contents2")

        self.create_file_in_artifact(file1, artifact, "/usr/bin/test")

        with self.assertRaisesRegex(
            django.db.utils.IntegrityError,
            "db_fileinartifact_unique_artifact_path",
        ):
            self.create_file_in_artifact(file2, artifact, "/usr/bin/test")

    def test_str(self):
        """Test FileInArtifact.__str__."""
        file_in_artifact = self.create_file_in_artifact()

        self.assertEqual(
            file_in_artifact.__str__(),
            f"Id: {file_in_artifact.id} "
            f"Artifact: {file_in_artifact.artifact.id} "
            f"Path: {file_in_artifact.path} "
            f"File: {file_in_artifact.file.id}",
        )


class WorkspaceManagerTests(TestCase):
    """Tests for the WorkspaceManager class."""

    def test_create_with_name(self):
        """Test create_with_name return Workspace with default's FileStore."""
        name = "nas-01"
        created = Workspace.objects.create_with_name(name)
        created.refresh_from_db()
        self.assertEqual(created.name, name)
        self.assertEqual(created.default_file_store, FileStore.default())


class WorkspaceTests(FileHelpersMixin, TestCase):
    """Tests for the Workspace class."""

    def test_default_values_fields(self):
        """Test basic behavior."""
        name = "test"
        workspace = Workspace(name=name, default_file_store=FileStore.default())

        self.assertEqual(workspace.name, name)
        self.assertFalse(workspace.public)

        workspace.clean_fields()
        workspace.save()

    def test_is_file_in_workspace_default_file_store(self):
        """
        Test is_file_in_workspace return the correct value.

        The file is added in the default FileStore.
        """
        fileobj = self.create_file()
        workspace = default_workspace()
        self.assertFalse(workspace.is_file_in_workspace(fileobj))

        # Add file in store
        FileInStore.objects.create(
            file=fileobj, store=workspace.default_file_store
        )

        self.assertTrue(workspace.is_file_in_workspace(fileobj))

    def test_is_file_in_workspace_other_file_stores(self):
        """
        Test is_file_in_workspace return the correct value.

        The file is added in a FileStore that is the non-default.
        """
        fileobj = self.create_file()
        workspace = default_workspace()
        self.assertFalse(workspace.is_file_in_workspace(fileobj))

        # Add file in a store which is not the default one
        store = FileStore.objects.create(
            name="nas-01", backend=FileStore.BackendChoices.LOCAL
        )
        workspace.other_file_stores.add(store)
        workspace.refresh_from_db()

        # Add file in the store
        FileInStore.objects.create(file=fileobj, store=store)

        self.assertTrue(workspace.is_file_in_workspace(fileobj))

    def test_str(self):
        """Test __str__ method."""
        workspace = Workspace(name="test")

        self.assertEqual(
            workspace.__str__(),
            f"Id: {workspace.id} Name: {workspace.name}",
        )


class FileUploadTests(FileHelpersMixin, TestCase):
    """Tests for FileUpload class."""

    def setUp(self):
        """Set up basic objects for the tests."""
        self.file_upload = self.create_file_upload()
        self.artifact = self.file_upload.file_in_artifact.artifact
        self.workspace = self.artifact.workspace
        self.file_path = self.file_upload.file_in_artifact.path
        self.file_size = self.file_upload.file_in_artifact.file.size

    def test_current_size_raise_no_file_in_artifact(self):
        """Test current_size() raise ValueError (FileInArtifact not found)."""
        wrong_path = "no-exist"
        with self.assertRaisesRegex(
            ValueError,
            f'^No FileInArtifact for Artifact {self.artifact.id} '
            f'and path "{wrong_path}"$',
        ):
            FileUpload.current_size(self.artifact, wrong_path)

    def test_current_size_raise_no_fileupload_for_file_in_artifact(self):
        """Test current_size() raise ValueError (FileUpload not found)."""
        artifact = Artifact.objects.create(
            category="test", workspace=self.workspace
        )
        file_in_artifact = FileInArtifact.objects.create(
            artifact=artifact, path="something", file=self.create_file()
        )

        with self.assertRaisesRegex(
            ValueError,
            f"^No FileUpload for FileInArtifact {file_in_artifact.id}$",
        ):
            FileUpload.current_size(artifact, "something")

    def test_current_size_return_last_position_received(self):
        """Test current_size() method return size of the file."""
        write_to_position = 30
        self.file_upload.absolute_file_path().write_bytes(
            next(data_generator(write_to_position))
        )
        self.assertEqual(
            FileUpload.current_size(
                artifact=self.artifact, path_in_artifact=self.file_path
            ),
            write_to_position,
        )

    def test_no_two_fileobj_to_same_path(self):
        """Test cannot create two FileUpload with same path."""
        data = next(data_generator(self.file_size))

        file, _ = File.get_or_create(
            hash_digest=_calculate_hash_from_data(data), size=len(data)
        )

        new_file_in_artifact = FileInArtifact.objects.create(
            artifact=self.artifact, path="README-2", file=file
        )

        with self.assertRaisesRegex(
            django.db.utils.IntegrityError, "db_fileupload_path_key"
        ):
            FileUpload.objects.create(
                file_in_artifact=new_file_in_artifact,
                path=self.file_upload.path,
            )

    def test_delete(self):
        """Test FileUpload delete() try to unlink file."""
        file_path = self.file_upload.absolute_file_path()
        Path(file_path).write_bytes(next(data_generator(self.file_size)))

        self.assertTrue(file_path.exists())

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

        self.assertFalse(file_path.exists())

        self.assertEqual(len(callbacks), 1)

    def test_delete_file_does_not_exist(self):
        """Test FileUpload delete() try to unlink file but did not exist."""
        file_path = self.file_upload.absolute_file_path()

        self.assertFalse(Path(file_path).exists())

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

        self.assertEqual(len(callbacks), 1)

    def test_absolute_file_path(self):
        """Test absolute_file_path() is in DEBUSINE_UPLOAD_DIRECTORY."""
        self.assertEqual(
            self.file_upload.absolute_file_path(),
            Path(settings.DEBUSINE_UPLOAD_DIRECTORY) / self.file_upload.path,
        )

    def test_str(self):
        """Test __str__."""
        self.assertEqual(self.file_upload.__str__(), f"{self.file_upload.id}")


class ArtifactRelationTests(FileInArtifactTests, TestCase):
    """Implement ArtifactRelation tests."""

    def setUp(self):
        """Initialize test object."""
        self.artifact_1, _ = self.create_artifact()
        self.artifact_2 = Artifact.objects.create(
            workspace=self.artifact_1.workspace,
            category=self.artifact_1.category,
        )

    def test_type_not_valid_raise_validation_error(self):
        """Assert that ValidationError is raised if type is not valid."""
        artifact_relation = ArtifactRelation.objects.create(
            artifact=self.artifact_1,
            target=self.artifact_2,
            type="wrong-type",
        )
        with self.assertRaises(ValidationError):
            artifact_relation.full_clean()

    def test_str(self):
        """Assert __str__ return expected value."""
        artifact_relation = ArtifactRelation.objects.create(
            artifact=self.artifact_1,
            target=self.artifact_2,
            type=ArtifactRelation.Relations.RELATES_TO,
        )
        self.assertEqual(
            str(artifact_relation),
            f"{self.artifact_1.id} {ArtifactRelation.Relations.RELATES_TO} "
            f"{self.artifact_2.id}",
        )


class NotificationChannelTests(TestCase):
    """Tests for NotificationChannel class."""

    def setUp(self):
        """Set up test."""
        self.email_data = {
            "from": "sender@debusine.example.org",
            "to": ["recipient@example.com"],
        }

    def test_create_email(self):
        """Create email NotificationChannel."""
        name = "deblts-email"
        method = NotificationChannel.Methods.EMAIL

        notification_channel = NotificationChannel.objects.create(
            name=name, method=method, data=self.email_data
        )

        notification_channel.refresh_from_db()

        self.assertEqual(notification_channel.name, name)
        self.assertEqual(notification_channel.method, method)
        self.assertEqual(notification_channel.data, self.email_data)

    def test_create_email_invalid_data(self):
        """Create email NotificationChannel: invalid data, failure."""
        with self.assertRaises(ValidationError):
            NotificationChannel.objects.create(
                name="deblts-email",
                method=NotificationChannel.Methods.EMAIL,
                data={"something": "something"},
            )

    def test_name_unique(self):
        """Field name is unique."""
        name = "deblts-email"
        NotificationChannel.objects.create(
            name=name,
            method=NotificationChannel.Methods.EMAIL,
            data=self.email_data,
        )

        with self.assertRaises(ValidationError):
            NotificationChannel.objects.create(
                name=name,
                method=NotificationChannel.Methods.EMAIL,
                data=self.email_data,
            )

    def test_str(self):
        """Assert __str__ return the expected string."""
        name = "deblts-email"
        notification_channel = NotificationChannel.objects.create(
            name=name,
            method=NotificationChannel.Methods.EMAIL,
            data=self.email_data,
        )

        self.assertEqual(str(notification_channel), name)


class IdentityTests(TestCase):
    """Test for Identity class."""

    def test_str(self):
        """Stringification should show the unique key."""
        ident = Identity(issuer="salsa", subject="test@debian.org")
        self.assertEqual(str(ident), "salsa:test@debian.org")


class CollectionTests(TestHelpersMixin, TestCase):
    """Tests for Collection class."""

    def setUp(self):
        """Set up tests."""
        self.user = get_user_model().objects.create_user(
            username="John", email="john@example.org"
        )
        self.workspace = self.create_workspace(name="System")

    def test_str(self):
        """Stringification should return Id, Name and Category."""
        name = "Environments"
        category = "debian:environments"
        collection = Collection.objects.create(
            name=name, category=category, workspace=self.workspace
        )
        self.assertEqual(
            str(collection),
            f"Id: {collection.id} Name: {name} Category: {category}",
        )

    def test_constraint_name_category(self):
        """Raise integrity error: name and category must be unique."""
        name = "Environments"
        category = "debian:environments"

        Collection.objects.create(
            name=name, category=category, workspace=self.workspace
        )

        with self.assertRaisesRegex(
            IntegrityError, "db_collection_unique_name_category"
        ):
            Collection.objects.create(
                name=name, category=category, workspace=self.workspace
            )

    def test_name_not_empty(self):
        """Cannot create Collection with an empty name."""
        with self.assertRaisesRegex(
            IntegrityError, "db_collection_name_not_empty"
        ):
            Collection.objects.create(
                name="",
                category="debian:environments",
                workspace=self.workspace,
            )

    def test_category_not_empty(self):
        """Cannot create Collection with an empty category."""
        with self.assertRaisesRegex(
            IntegrityError, "db_collection_category_not_empty"
        ):
            Collection.objects.create(
                name="name", category="", workspace=self.workspace
            )

    def test_retains_artifacts(self):
        """By default, a new Collection stops its artifacts expiring."""
        collection = Collection.objects.create(
            name="Environments",
            category="debian:environments",
            workspace=self.workspace,
        )
        self.assertEqual(
            collection.retains_artifacts, Collection.RetainsArtifacts.ALWAYS
        )

    def test_manager(self):
        """Test getting specialized manager."""
        collection = Collection.objects.create(
            name="Testing", category="debian:suite", workspace=self.workspace
        )
        self.assertEqual(type(collection.manager), DebianSuiteManager)

    def test_child_items_artifacts_collections(self):
        """Child items returns expected CollectionItems, artifacts, etc."""
        collection_parent_1 = Collection.objects.create(
            name="collection-1",
            category="collection-1",
            workspace=self.workspace,
        )
        collection_parent_2 = Collection.objects.create(
            name="collection-2",
            category="collection-2",
            workspace=self.workspace,
        )

        collection = Collection.objects.create(
            name="collection-3",
            category="collection-3",
            workspace=self.workspace,
        )
        collection_item_collection_1 = CollectionItem.objects.create(
            name="test",
            category="test",
            parent_collection=collection_parent_1,
            child_type=CollectionItem.Types.COLLECTION,
            collection=collection,
            created_by_user=self.user,
        )

        artifact, _ = self.create_artifact()
        artifact_item_collection_1 = CollectionItem.objects.create(
            name="test-2",
            category="test-2",
            parent_collection=collection_parent_1,
            child_type=CollectionItem.Types.ARTIFACT,
            artifact=artifact,
            created_by_user=self.user,
        )

        # Used to see that is not returned when querying
        # collection_parent_1.child_items.all()
        CollectionItem.objects.create(
            name="test",
            category="test",
            parent_collection=collection_parent_2,
            child_type=CollectionItem.Types.COLLECTION,
            collection=Collection.objects.create(
                name="collection-4",
                category="collection-3",
                workspace=self.workspace,
            ),
            created_by_user=self.user,
        )

        # Collection.child_items returns all CollectionItems
        self.assertQuerysetEqual(
            collection_parent_1.child_items.all(),
            [collection_item_collection_1, artifact_item_collection_1],
            ordered=False,
        )

        # Collection.child_artifacts returns only the artifacts
        self.assertQuerysetEqual(
            collection_parent_1.child_artifacts.all(), [artifact]
        )

        # Collection.child_collections returns only the collections
        self.assertQuerysetEqual(
            collection_parent_1.child_collections.all(), [collection]
        )

        # Given the artifact: there is a reverse relationship
        # with the collections that belongs to
        self.assertQuerysetEqual(
            artifact.parent_collections.all(),
            [collection_parent_1],
        )

        # Given a collection: there is a reverse relationship
        # with the collections that belongs to
        self.assertQuerysetEqual(
            collection.parent_collections.all(),
            [collection_parent_1],
        )

        # Given a artifact: there is a reverse relationship
        # to the CollectionItems that belongs to
        self.assertQuerysetEqual(
            artifact.collection_items.all(),
            [artifact_item_collection_1],
        )

        # Given a collection: there is a reverse relationship
        # to the CollectionItems that belongs to
        self.assertQuerysetEqual(
            collection.collection_items.all(),
            [collection_item_collection_1],
        )


class CollectionItemManagerTests(TestHelpersMixin, TestCase):
    """Tests for CollectionItemManager class."""

    def setUp(self):
        """Create objects for the tests."""
        self.workspace = self.create_workspace(name="System")
        self.collection = Collection.objects.create(
            name="Name",
            category="debian:environments",
            workspace=self.workspace,
        )
        self.user = get_user_model().objects.create_user(
            username="John", email="john@example.org"
        )

    def test_create_from_artifact(self):
        """Verify create_from_artifact method."""
        data = {"a": "b"}
        artifact, _ = self.create_artifact(data=data)

        name = "some-name"
        collection_item = CollectionItem.objects.create_from_artifact(
            artifact,
            parent_collection=self.collection,
            name=name,
            data=data,
            created_by_user=self.user,
        )

        self.assertEqual(collection_item.parent_collection, self.collection)
        self.assertEqual(collection_item.name, name)
        self.assertEqual(collection_item.artifact, artifact)
        self.assertEqual(
            collection_item.child_type, CollectionItem.Types.ARTIFACT
        )
        self.assertEqual(collection_item.category, artifact.category)
        self.assertEqual(collection_item.data, data)
        self.assertEqual(collection_item.created_by_user, self.user)

    def test_create_from_collection(self):
        """Verify create_from_collection method."""
        category = "some-category"
        name = "collection-name"
        data = {"a": "b"}

        collection = Collection.objects.create(
            name="collection", category=category, workspace=self.workspace
        )

        collection_item = CollectionItem.objects.create_from_collection(
            collection,
            parent_collection=self.collection,
            name=name,
            data=data,
            created_by_user=self.user,
        )

        self.assertEqual(collection_item.parent_collection, self.collection)
        self.assertEqual(collection_item.name, name)
        self.assertEqual(collection_item.collection, collection)
        self.assertEqual(
            collection_item.child_type, CollectionItem.Types.COLLECTION
        )
        self.assertEqual(collection_item.category, category)
        self.assertEqual(collection_item.data, data)
        self.assertEqual(collection_item.created_by_user, self.user)

    def test_drop_full_history(self):
        """Verify drop_full_history method."""
        data = {"a": "b"}
        artifact, _ = self.create_artifact(data=data)

        self.collection.full_history_retention_period = timedelta(days=2)
        self.collection.save()

        collection_item_removed = CollectionItem.objects.create(
            parent_collection=self.collection,
            name="some-name",
            artifact=artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            category=artifact.category,
            data=data,
            created_by_user=self.user,
            removed_at=timezone.now() - timedelta(days=3),
        )

        collection_item_young = CollectionItem.objects.create(
            parent_collection=self.collection,
            name="some-name",
            artifact=artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            category=artifact.category,
            data=data,
            created_by_user=self.user,
            removed_at=timezone.now() - timedelta(days=1),
        )

        collection_item_keep = CollectionItem.objects.create(
            parent_collection=self.collection,
            name="some-name",
            artifact=artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            category=artifact.category,
            data=data,
            created_by_user=self.user,
        )

        CollectionItem.objects.drop_full_history(timezone.now())

        collection_item_removed.refresh_from_db()
        self.assertIsNone(collection_item_removed.artifact)

        collection_item_young.refresh_from_db()
        self.assertIsNotNone(collection_item_young.artifact)

        collection_item_keep.refresh_from_db()
        self.assertIsNotNone(collection_item_keep.artifact)

    def test_drop_full_history_no_retention_period(self):
        """Verify drop_full_history method without retention_period."""
        data = {"a": "b"}
        artifact, _ = self.create_artifact(data=data)

        collection_item_removed = CollectionItem.objects.create(
            parent_collection=self.collection,
            name="some-name",
            artifact=artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            category=artifact.category,
            data=data,
            created_by_user=self.user,
            removed_at=timezone.now() - timedelta(days=3),
        )

        collection_item_young = CollectionItem.objects.create(
            parent_collection=self.collection,
            name="some-name",
            artifact=artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            category=artifact.category,
            data=data,
            created_by_user=self.user,
            removed_at=timezone.now() - timedelta(days=1),
        )

        collection_item_keep = CollectionItem.objects.create(
            parent_collection=self.collection,
            name="some-name",
            artifact=artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            category=artifact.category,
            data=data,
            created_by_user=self.user,
        )

        CollectionItem.objects.drop_full_history(timezone.now())

        collection_item_removed.refresh_from_db()
        self.assertIsNotNone(collection_item_removed.artifact)

        collection_item_young.refresh_from_db()
        self.assertIsNotNone(collection_item_young.artifact)

        collection_item_keep.refresh_from_db()
        self.assertIsNotNone(collection_item_keep.artifact)

    def test_drop_metadata(self):
        """Verify drop_metadata method."""
        data = {"a": "b"}
        artifact, _ = self.create_artifact(data=data)

        self.collection.full_history_retention_period = timedelta(days=1)
        self.collection.metadata_only_retention_period = timedelta(days=1)
        self.collection.save()

        collection_item_removed = CollectionItem.objects.create(
            parent_collection=self.collection,
            name="some-name",
            artifact=artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            category=artifact.category,
            data=data,
            created_by_user=self.user,
            removed_at=timezone.now() - timedelta(days=3),
        )

        collection_item_young = CollectionItem.objects.create(
            parent_collection=self.collection,
            name="some-name",
            artifact=artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            category=artifact.category,
            data=data,
            created_by_user=self.user,
            removed_at=timezone.now() - timedelta(days=1),
        )

        collection_item_keep = CollectionItem.objects.create(
            parent_collection=self.collection,
            name="some-name",
            artifact=artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            category=artifact.category,
            data=data,
            created_by_user=self.user,
        )

        CollectionItem.objects.drop_metadata(timezone.now())

        with self.assertRaises(CollectionItem.DoesNotExist):
            CollectionItem.objects.get(id=collection_item_removed.id)

        collection_item_young.refresh_from_db()
        self.assertIsNotNone(collection_item_young)

        collection_item_keep.refresh_from_db()
        self.assertIsNotNone(collection_item_keep)

    def test_drop_metadata_no_retention_period(self):
        """Verify drop_metadata method."""
        data = {"a": "b"}
        artifact, _ = self.create_artifact(data=data)

        collection_item_removed = CollectionItem.objects.create(
            parent_collection=self.collection,
            name="some-name",
            artifact=artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            category=artifact.category,
            data=data,
            created_by_user=self.user,
            removed_at=timezone.now() - timedelta(days=3),
        )

        collection_item_young = CollectionItem.objects.create(
            parent_collection=self.collection,
            name="some-name",
            artifact=artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            category=artifact.category,
            data=data,
            created_by_user=self.user,
            removed_at=timezone.now() - timedelta(days=1),
        )

        collection_item_keep = CollectionItem.objects.create(
            parent_collection=self.collection,
            name="some-name",
            artifact=artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            category=artifact.category,
            data=data,
            created_by_user=self.user,
        )

        CollectionItem.objects.drop_metadata(timezone.now())

        collection_item_removed.refresh_from_db()
        self.assertIsNotNone(collection_item_removed)

        collection_item_young.refresh_from_db()
        self.assertIsNotNone(collection_item_young)

        collection_item_keep.refresh_from_db()
        self.assertIsNotNone(collection_item_keep)


class CollectionItemTests(TestHelpersMixin, TestCase):
    """Tests for CollectionItem class."""

    def setUp(self):
        """Create objects for the tests."""
        self.artifact, _ = self.create_artifact()

        self.workspace = self.create_workspace(name="System")
        self.collection = Collection.objects.create(
            name="Name",
            category="debian:environments",
            workspace=self.workspace,
        )
        self.user = get_user_model().objects.create_user(
            username="John", email="john@example.org"
        )

    def test_artifact_collection_item_same_name(self):
        """Two CollectionItem: same name, category different child_type."""
        name = "name:duplicated"
        category = "category:duplicated"

        CollectionItem.objects.create(
            name=name,
            category=category,
            parent_collection=self.collection,
            child_type=CollectionItem.Types.ARTIFACT,
            artifact=self.artifact,
            created_by_user=self.user,
        )
        with self.assertRaisesRegex(
            IntegrityError, "db_collectionitem_unique_active_name"
        ):
            CollectionItem.objects.create(
                name=name,
                category=category,
                parent_collection=self.collection,
                child_type=CollectionItem.Types.COLLECTION,
                collection=Collection.objects.create(
                    name="Test", category="test", workspace=self.workspace
                ),
                created_by_user=self.user,
            )

    def test_collection_and_parent_collection_not_the_same(self):
        """Cannot create CollectionItem with collection == parent_collection."""
        with self.assertRaisesRegex(
            IntegrityError, "db_collectionitem_distinct_parent_collection"
        ):
            CollectionItem.objects.create(
                name="Test",
                category="Category",
                parent_collection=self.collection,
                child_type=CollectionItem.Types.COLLECTION,
                collection=self.collection,
                created_by_user=self.user,
            )

    def test_only_one_active_item_in_collection(self):
        """Cannot create duplicated CollectionItem (not removed)."""
        name = "Name of the item"
        category = "debian:source-package"

        CollectionItem.objects.create(
            name=name,
            category=category,
            artifact=self.artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            parent_collection=self.collection,
            created_by_user=self.user,
        )

        with self.assertRaisesRegex(
            IntegrityError,
            "db_collectionitem_unique_active_name",
        ):
            CollectionItem.objects.create(
                name=name,
                category=category,
                artifact=self.artifact,
                child_type=CollectionItem.Types.ARTIFACT,
                parent_collection=self.collection,
                created_by_user=self.user,
            )

    def test_second_active_item_added_first_one_removed(self):
        """Can create "duplicated" CollectionItem if first one is removed."""
        name = "Name of the item"
        category = "debian:source-package"

        CollectionItem.objects.create(
            name=name,
            category=category,
            child_type=CollectionItem.Types.ARTIFACT,
            parent_collection=self.collection,
            created_by_user=self.user,
            removed_at=timezone.now(),
        )

        # Collection item can be added (no exception raised) because
        # the first one is removed
        CollectionItem.objects.create(
            name=name,
            category=category,
            artifact=self.artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            parent_collection=self.collection,
            created_by_user=self.user,
        )

    def test_artifact_related_name(self):
        """Test artifact model "related_name" to CollectionItem."""
        artifact_in_collection_1 = CollectionItem.objects.create(
            name="name-1",
            category="category",
            artifact=self.artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            parent_collection=self.collection,
            created_by_user=self.user,
        )

        artifact_in_collection_2 = CollectionItem.objects.create(
            name="name-2",
            category="category",
            artifact=self.artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            parent_collection=self.collection,
            created_by_user=self.user,
        )

        self.assertQuerysetEqual(
            self.artifact.collection_items.all(),
            {artifact_in_collection_1, artifact_in_collection_2},
            ordered=False,
        )

    def test_artifact_constraint_are_valid_or_raise_integrity_error(self):
        """Verify constraints specific to an ARTIFACT type CollectionItem."""
        # CollectionItem of type ARTIFACT with an artifact
        CollectionItem.objects.create(
            name="Name",
            category="Category",
            artifact=self.artifact,
            parent_collection=self.collection,
            child_type=CollectionItem.Types.ARTIFACT,
            created_by_user=self.user,
        )

        # CollectionItem of type ARTIFACT without an artifact
        # (the artifact was removed)
        CollectionItem.objects.create(
            name="Name-2",
            category="Category-2",
            parent_collection=self.collection,
            child_type=CollectionItem.Types.ARTIFACT,
            created_by_user=self.user,
            removed_at=timezone.now(),
        )

        collection = Collection.objects.create(
            name="Collection-2", category="Category-2", workspace=self.workspace
        )

        # CollectionItem of type ARTIFACT with a collection
        with self.assertRaisesRegex(
            IntegrityError, "db_collectionitem_childtype_removedat_consistent"
        ):
            CollectionItem.objects.create(
                name="Name-3",
                category="Category-3",
                parent_collection=self.collection,
                child_type=CollectionItem.Types.ARTIFACT,
                collection=collection,
                created_by_user=self.user,
            )

    def test_collection_constraint_are_valid_or_raise_integrity_error(self):
        """Verify constraints specific to a COLLECTION type CollectionItem."""
        CollectionItem.objects.create(
            name="Name",
            category="Category",
            parent_collection=self.collection,
            child_type=CollectionItem.Types.ARTIFACT,
            artifact=self.artifact,
            removed_at=timezone.now(),
            created_by_user=self.user,
        )

        collection = Collection.objects.create(
            name="Collection-2", category="Category-2", workspace=self.workspace
        )

        CollectionItem.objects.create(
            name="Name-2",
            category="Category-2",
            parent_collection=self.collection,
            child_type=CollectionItem.Types.COLLECTION,
            collection=collection,
            removed_at=timezone.now(),
            created_by_user=self.user,
        )

    def test_collection_type_item_constraints(self):
        """Verify constraints specific to a Collection type CollectionItem."""
        collection = Collection.objects.create(
            name="Collection-2", category="Category-2", workspace=self.workspace
        )

        # CollectionItem of type COLLECTION with a collection
        CollectionItem.objects.create(
            name="Name",
            category="Category",
            collection=collection,
            parent_collection=self.collection,
            child_type=CollectionItem.Types.COLLECTION,
            created_by_user=self.user,
        )

        # CollectionItem of type COLLECTION without a collection (collection
        # was removed)
        CollectionItem.objects.create(
            name="Name-2",
            category="Category-2",
            parent_collection=self.collection,
            child_type=CollectionItem.Types.COLLECTION,
            created_by_user=self.user,
            removed_at=timezone.now(),
        )

        # CollectionItem of type COLLECTION with an artifact
        with self.assertRaisesRegex(
            IntegrityError, "db_collectionitem_childtype_removedat_consistent"
        ):
            CollectionItem.objects.create(
                name="Name-3",
                category="Category-3",
                parent_collection=self.collection,
                child_type=CollectionItem.Types.COLLECTION,
                artifact=self.artifact,
                created_by_user=self.user,
            )

    def test_bare_collection_item_constraints(self):
        """Verify constraints specific to a Bare type CollectionItem."""
        CollectionItem.objects.create(
            name="Name",
            category="Bare",
            parent_collection=self.collection,
            child_type=CollectionItem.Types.BARE,
            created_by_user=self.user,
        )

        with self.assertRaisesRegex(
            IntegrityError, "db_collectionitem_childtype_removedat_consistent"
        ):
            CollectionItem.objects.create(
                name="Name",
                category="Category",
                artifact=self.artifact,
                parent_collection=self.collection,
                child_type=CollectionItem.Types.BARE,
                created_by_user=self.user,
            )

    def test_debian_environments_no_more_than_one_codename_architecture(self):
        """Cannot create more than one duplicated active debian:environments."""
        category = "debian:environments"
        data = {"codename": "bookworm", "architecture": "amd64"}

        artifact_1, _ = self.create_artifact(category=category)
        CollectionItem.objects.create(
            name="bookworm-amd64",
            category=category,
            parent_collection=self.collection,
            child_type=CollectionItem.Types.ARTIFACT,
            artifact=artifact_1,
            created_by_user=self.user,
            data=data,
            removed_at=timezone.now(),
        )

        # Can be created because the previous one is removed
        artifact_2, _ = self.create_artifact(category=category)
        CollectionItem.objects.create(
            name="bookworm-amd64-2",
            category=category,
            parent_collection=self.collection,
            child_type=CollectionItem.Types.ARTIFACT,
            artifact=artifact_2,
            created_by_user=self.user,
            data=data,
        )

        # Can be created because it's different variant
        artifact_3, _ = self.create_artifact(category=category)
        CollectionItem.objects.create(
            name="bookworm-amd64-3",
            category=category,
            parent_collection=self.collection,
            child_type=CollectionItem.Types.ARTIFACT,
            artifact=artifact_3,
            created_by_user=self.user,
            data={**data, "variant": "buildd"},
        )

        # Can be created because it's different backend
        artifact_4, _ = self.create_artifact(category=category)
        CollectionItem.objects.create(
            name="bookworm-amd64-4",
            category=category,
            parent_collection=self.collection,
            child_type=CollectionItem.Types.ARTIFACT,
            artifact=artifact_4,
            created_by_user=self.user,
            data={**data, "backend": "unshare"},
        )

        # Can be created: duplicated but in a different collection
        artifact_5, _ = self.create_artifact(category=category)
        CollectionItem.objects.create(
            name="bookworm-amd64-5",
            category=category,
            parent_collection=Collection.objects.create(
                name="Testing",
                category="debian:environments",
                workspace=self.workspace,
            ),
            child_type=CollectionItem.Types.ARTIFACT,
            artifact=artifact_5,
            created_by_user=self.user,
            data={**data, "variant": "buildd"},
        )

        # Cannot be created because already one active artifact
        # with the same data in the same collection (from artifact_3).
        artifact_6, _ = self.create_artifact(category=category)
        msg = "db_collectionitem_unique_debian_environment"
        with self.assertRaisesRegex(IntegrityError, msg):
            CollectionItem.objects.create(
                name="bookworm-amd64-6",
                category=category,
                parent_collection=self.collection,
                child_type=CollectionItem.Types.ARTIFACT,
                artifact=artifact_6,
                created_by_user=self.user,
                data={**data, "variant": "buildd"},
            )

    def test_str_collection_item_artifact(self):
        """Stringification is correct for CollectionItem for ARTIFACT."""
        name = "Name of the item"
        category = "debian:source-package"
        artifact_item = CollectionItem.objects.create(
            name=name,
            category=category,
            artifact=self.artifact,
            child_type=CollectionItem.Types.ARTIFACT,
            parent_collection=self.collection,
            created_by_user=self.user,
        )
        self.assertEqual(
            str(artifact_item),
            f"Id: {artifact_item.id} Name: {name} "
            f"Parent collection id: {self.collection.id} "
            f"Child type: {artifact_item.child_type} "
            f"Artifact id: {self.artifact.id}",
        )

    def test_str_collection_item_collection(self):
        """Stringification contains collection id.."""
        collection = Collection.objects.create(
            name="Collection-2", category="Category-2", workspace=self.workspace
        )

        collection_item = CollectionItem.objects.create(
            name="Name of the item",
            category="debian:source-package",
            collection=collection,
            child_type=CollectionItem.Types.COLLECTION,
            parent_collection=self.collection,
            created_by_user=self.user,
        )

        self.assertIn(f"Collection id: {collection.id}", str(collection_item))

    def test_str_collection_item_bare(self):
        """Stringification does not contain collection/artifact id."""
        collection_item = CollectionItem.objects.create(
            name="Name of the item",
            category="debian:source-package",
            child_type=CollectionItem.Types.BARE,
            parent_collection=self.collection,
            created_by_user=self.user,
        )

        self.assertNotIn("Collection id:", str(collection_item))
        self.assertNotIn("Artifact id:", str(collection_item))

    def test_expand_variables(self):
        """Variables are correctly expanded, with error handling."""
        variables = {
            "$package": "deb_fields.Package",
            "$version": "deb_fields.Version",
            "constant": "value",
        }
        artifact = Artifact()

        artifact.data = {
            "deb_fields": {
                "Package": "hello",
                "Version": "2.10-3",
                "Architecture": "any",
            }
        }
        self.assertEqual(
            CollectionItem.expand_variables(variables, artifact),
            {"package": "hello", "version": "2.10-3", "constant": "value"},
        )

        variables = {
            "$package": "deb_fields.*",
            "$version": "deb_fields.Version",
        }
        with self.assertRaisesRegex(ValueError, "Too many values expanding"):
            CollectionItem.expand_variables(variables, artifact)

        variables = {
            "$package": "]",
        }
        with self.assertRaisesRegex(ValueError, "Parse error"):
            CollectionItem.expand_variables(variables, artifact)

        variables = {"$package": "deb_fields.Package", "package": "constant"}
        with self.assertRaisesRegex(
            ValueError, r"Cannot set both '\$package' and 'package' variables"
        ):
            CollectionItem.expand_variables(variables, artifact)

        variables = {"$version": "deb_fields.Version"}
        artifact.data = {}
        with self.assertRaises(KeyError):
            CollectionItem.expand_variables(variables, artifact)

    def test_expand_name(self):
        """Item name is correctly expanded."""
        item_template = "{package}_{version}"
        expanded_variables = {"package": "hello", "version": "2.10-3"}

        self.assertEqual(
            CollectionItem.expand_name(item_template, expanded_variables),
            "hello_2.10-3",
        )
