# 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 Optional, 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

from debusine.artifacts.local_artifact import WorkRequestDebugLogs
from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    Collection,
    CollectionItem,
    File,
    FileInArtifact,
    FileInStore,
    FileStore,
    FileUpload,
    Identity,
    NotificationChannel,
    Token,
    WorkRequest,
    Worker,
    WorkerManager,
    Workspace,
    default_workspace,
)
from debusine.db.tests.utils import _calculate_hash_from_data
from debusine.test import TestHelpersMixin
from debusine.test.django import ChannelsHelpersMixin
from debusine.test.utils import data_generator


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

    @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.key)

    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 = Token.objects.create(key='some_key')

        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.key)

        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."""

    @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."""

    @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 WorkRequestManagerTests(TestHelpersMixin, TestCase):
    """Tests for WorkRequestManager."""

    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]
        )

    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])


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(ChannelsHelpersMixin, TestHelpersMixin, TestCase):
    """Tests for the WorkRequest class."""

    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())

    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.key)

        # 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)


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.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_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: Optional[File] = None,
        artifact: Optional[Artifact] = None,
        path: Optional[str] = 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_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 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:environment",
            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,
        )
        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_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))
