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

"""Unit tests for the migrations."""

import datetime as dt
import hashlib
from typing import Any, ClassVar, cast, override

from django.conf import settings
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
from django.db.migrations.state import StateApps
from django.test import TestCase
from django.utils import timezone

from debusine.artifacts.models import (
    ArtifactCategory,
    CollectionCategory,
    TaskTypes,
)
from debusine.assets import AssetCategory
from debusine.client.models import RuntimeParameter, RuntimeParameters
from debusine.db.models import (
    Collection,
    CollectionItem,
    DEFAULT_FILE_STORE_NAME,
    FileStore,
    Scope,
    User,
    WorkRequest,
    Workspace,
)
from debusine.db.models.auth import _TokenTypes
from debusine.db.models.collections import CollectionItemMatchConstraint
from debusine.tasks.models import WorkerType


def _migrate_check_constraints(action: str, *args: Any, **kwargs: Any) -> None:
    """
    Check constraints before and after (un)applying each migration.

    When applying migrations in tests, each migration only ends with a
    savepoint rather than a full commit, so deferred constraints aren't
    immediately checked.  As a result, we need to explicitly check
    constraints to avoid "cannot ALTER TABLE ... because it has pending
    trigger events" errors in some cases.
    """
    if action in {
        "apply_start",
        "apply_success",
        "unapply_start",
        "unapply_success",
    }:
        connection.check_constraints()


LATEST_IRREVERSIBLE_MIGRATION = [("db", "0001_squashed_0_11_0")]


class MigrationTestCase(TestCase):
    """Base test case for testing migrations."""

    BASE_MIGRATION: list[tuple[str, str]] = LATEST_IRREVERSIBLE_MIGRATION

    @override
    @classmethod
    def setUpClass(cls) -> None:
        super().setUpClass()
        # It seems to be faster to apply most of the migrations forwards
        # rather than backwards.  Migrate back as far as we can before
        # running this test suite.
        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate(cls.BASE_MIGRATION)

    @override
    @classmethod
    def tearDownClass(cls) -> None:
        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate(executor.loader.graph.leaf_nodes())
        super().tearDownClass()

    @staticmethod
    def get_test_user_for_apps(apps: StateApps) -> User:
        """
        Return a test user.

        We may be testing a migration, so use the provided application state.
        """
        user_model = apps.get_model("db", "User")
        try:
            user = user_model.objects.get(username="usertest")
        except user_model.DoesNotExist:
            user = user_model.objects.create_user(
                username="usertest",
                password="userpassword",
                email="usertest@example.org",
            )
        return cast(User, user)


class MigrationTests(MigrationTestCase):
    """Test migrations."""

    def test_default_store_created(self) -> None:
        """Assert Default FileStore has been created."""
        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate(LATEST_IRREVERSIBLE_MIGRATION)
        apps = executor.loader.project_state(LATEST_IRREVERSIBLE_MIGRATION).apps

        default_file_store = apps.get_model("db", "FileStore").objects.get(
            name=DEFAULT_FILE_STORE_NAME
        )

        self.assertEqual(
            default_file_store.backend, FileStore.BackendChoices.LOCAL
        )

        self.assertEqual(default_file_store.configuration, {})

    def test_system_workspace_created(self) -> None:
        """Assert System Workspace has been created."""
        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate(LATEST_IRREVERSIBLE_MIGRATION)
        apps = executor.loader.project_state(LATEST_IRREVERSIBLE_MIGRATION).apps

        workspace = apps.get_model("db", "Workspace").objects.get(
            scope__name=settings.DEBUSINE_DEFAULT_SCOPE,
            name=settings.DEBUSINE_DEFAULT_WORKSPACE,
        )
        default_file_store = apps.get_model("db", "FileStore").objects.get(
            name=DEFAULT_FILE_STORE_NAME
        )
        self.assertQuerySetEqual(
            workspace.scope.file_stores.all(), [default_file_store]
        )

    def assert_work_request_task_data_renamed(
        self,
        migrate_from: str,
        migrate_to: str,
        old_work_requests: list[tuple[TaskTypes, str, dict[str, Any]]],
        new_work_requests: list[tuple[TaskTypes, str, dict[str, Any]]],
    ) -> None:
        """Assert that migrations rename task data in work requests."""
        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate([("db", migrate_from)])
        old_apps = executor.loader.project_state([("db", migrate_from)]).apps
        workspace = old_apps.get_model("db", "Workspace").objects.get(
            name=settings.DEBUSINE_DEFAULT_WORKSPACE
        )
        for task_type, task_name, task_data in old_work_requests:
            old_apps.get_model("db", "WorkRequest").objects.create(
                created_by_id=self.get_test_user_for_apps(old_apps).id,
                task_type=task_type,
                task_name=task_name,
                workspace=workspace,
                task_data=task_data,
            )

        executor.loader.build_graph()
        executor.migrate([("db", migrate_to)])
        new_apps = executor.loader.project_state([("db", migrate_to)]).apps

        self.assertCountEqual(
            new_apps.get_model("db", "WorkRequest").objects.values_list(
                "task_type", "task_name", "task_data"
            ),
            new_work_requests,
        )

        executor.loader.build_graph()
        executor.migrate([("db", migrate_from)])

        self.assertCountEqual(
            old_apps.get_model("db", "WorkRequest").objects.values_list(
                "task_type", "task_name", "task_data"
            ),
            old_work_requests,
        )

    def assert_work_request_all_task_data_renamed(
        self,
        migrate_from: str,
        migrate_to: str,
        old_work_requests: list[
            tuple[
                TaskTypes,
                str,
                dict[str, Any],
                dict[str, Any],
                dict[str, Any] | None,
            ]
        ],
        new_work_requests: list[
            tuple[
                TaskTypes,
                str,
                dict[str, Any],
                dict[str, Any],
                dict[str, Any] | None,
            ]
        ],
    ) -> None:
        """Assert that migrations rename all task data in work requests."""
        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate([("db", migrate_from)])
        old_apps = executor.loader.project_state([("db", migrate_from)]).apps
        workspace = old_apps.get_model("db", "Workspace").objects.get(
            name=settings.DEBUSINE_DEFAULT_WORKSPACE
        )
        for (
            task_type,
            task_name,
            task_data,
            dynamic_task_data,
            configured_task_data,
        ) in old_work_requests:
            old_apps.get_model("db", "WorkRequest").objects.create(
                created_by_id=self.get_test_user_for_apps(old_apps).id,
                task_type=task_type,
                task_name=task_name,
                workspace=workspace,
                task_data=task_data,
                dynamic_task_data=dynamic_task_data,
                configured_task_data=configured_task_data,
            )

        executor.loader.build_graph()
        executor.migrate([("db", migrate_to)])
        new_apps = executor.loader.project_state([("db", migrate_to)]).apps

        self.assertCountEqual(
            new_apps.get_model("db", "WorkRequest").objects.values_list(
                "task_type",
                "task_name",
                "task_data",
                "dynamic_task_data",
                "configured_task_data",
            ),
            new_work_requests,
        )

        executor.loader.build_graph()
        executor.migrate([("db", migrate_from)])

        self.assertCountEqual(
            old_apps.get_model("db", "WorkRequest").objects.values_list(
                "task_type",
                "task_name",
                "task_data",
                "dynamic_task_data",
                "configured_task_data",
            ),
            old_work_requests,
        )

    def assert_workflow_template_task_data_renamed(
        self,
        migrate_from: str,
        migrate_to: str,
        old_workflow_templates: list[tuple[str, dict[str, Any]]],
        new_workflow_templates: list[tuple[str, dict[str, Any]]],
    ) -> None:
        """Assert that migrations rename task data in workflow templates."""
        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate([("db", migrate_from)])
        old_apps = executor.loader.project_state([("db", migrate_from)]).apps
        workspace = old_apps.get_model("db", "Workspace").objects.get(
            name=settings.DEBUSINE_DEFAULT_WORKSPACE
        )
        for i, (task_name, task_data) in enumerate(old_workflow_templates):
            old_apps.get_model("db", "WorkflowTemplate").objects.create(
                name=f"rename-test-{i}",
                workspace=workspace,
                task_name=task_name,
                task_data=task_data,
            )

        executor.loader.build_graph()
        executor.migrate([("db", migrate_to)])
        new_apps = executor.loader.project_state([("db", migrate_to)]).apps

        self.assertCountEqual(
            new_apps.get_model("db", "WorkflowTemplate").objects.values_list(
                "name", "task_name", "task_data"
            ),
            [
                (f"rename-test-{i}", task_name, task_data)
                for i, (task_name, task_data) in enumerate(
                    new_workflow_templates
                )
            ],
        )

        executor.loader.build_graph()
        executor.migrate([("db", migrate_from)])

        self.assertCountEqual(
            old_apps.get_model("db", "WorkflowTemplate").objects.values_list(
                "name", "task_name", "task_data"
            ),
            [
                (f"rename-test-{i}", task_name, task_data)
                for i, (task_name, task_data) in enumerate(
                    old_workflow_templates
                )
            ],
        )

    def assert_workflow_template_parameters_renamed(
        self,
        migrate_from: str,
        migrate_to: str,
        old_workflow_templates: list[
            tuple[str, dict[str, Any], RuntimeParameters]
        ],
        new_workflow_templates: list[
            tuple[str, dict[str, Any], RuntimeParameters]
        ],
    ) -> None:
        """Assert that migrations rename parameters in workflow templates."""
        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate([("db", migrate_from)])
        old_apps = executor.loader.project_state([("db", migrate_from)]).apps
        workspace = old_apps.get_model("db", "Workspace").objects.get(
            name=settings.DEBUSINE_DEFAULT_WORKSPACE
        )
        for i, (task_name, static_parameters, runtime_parameters) in enumerate(
            old_workflow_templates
        ):
            old_apps.get_model("db", "WorkflowTemplate").objects.create(
                name=f"rename-test-{i}",
                workspace=workspace,
                task_name=task_name,
                static_parameters=static_parameters,
                runtime_parameters=runtime_parameters,
            )

        executor.loader.build_graph()
        executor.migrate([("db", migrate_to)])
        new_apps = executor.loader.project_state([("db", migrate_to)]).apps

        self.assertCountEqual(
            new_apps.get_model("db", "WorkflowTemplate").objects.values_list(
                "name", "task_name", "static_parameters", "runtime_parameters"
            ),
            [
                (
                    f"rename-test-{i}",
                    task_name,
                    static_parameters,
                    runtime_parameters,
                )
                for i, (
                    task_name,
                    static_parameters,
                    runtime_parameters,
                ) in enumerate(new_workflow_templates)
            ],
        )

        executor.loader.build_graph()
        executor.migrate([("db", migrate_from)])

        self.assertCountEqual(
            old_apps.get_model("db", "WorkflowTemplate").objects.values_list(
                "name", "task_name", "static_parameters", "runtime_parameters"
            ),
            [
                (
                    f"rename-test-{i}",
                    task_name,
                    static_parameters,
                    runtime_parameters,
                )
                for i, (
                    task_name,
                    static_parameters,
                    runtime_parameters,
                ) in enumerate(old_workflow_templates)
            ],
        )

    def test_artifact_system_components(self) -> None:
        """Test adding components to debian:system-tarball artifacts."""
        migrate_from = [("db", "0003_add_workspace_role_viewer")]
        migrate_to = [("db", "0004_system_image_components")]

        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate(migrate_from)
        old_apps = executor.loader.project_state(migrate_from).apps
        workspace = old_apps.get_model("db", "Workspace").objects.get(
            name=settings.DEBUSINE_DEFAULT_WORKSPACE
        )
        OldArtifact = old_apps.get_model("db", "Artifact")
        OldWorkRequest = old_apps.get_model("db", "WorkRequest")
        wr_unrelated = OldWorkRequest.objects.create(
            created_by_id=self.get_test_user_for_apps(old_apps).id,
            task_name="noop",
            workspace=workspace,
            task_data={},
        )
        wr_without_components = OldWorkRequest.objects.create(
            created_by_id=self.get_test_user_for_apps(old_apps).id,
            task_name="mmdebstrap",
            workspace=workspace,
            task_data={
                "bootstrap_repositories": [
                    {
                        "mirror": "http://deb.debian.org/debian",
                        "suite": "unstable",
                    },
                ],
            },
        )
        wr_with_components = OldWorkRequest.objects.create(
            created_by_id=self.get_test_user_for_apps(old_apps).id,
            task_name="mmdebstrap",
            workspace=workspace,
            task_data={
                "bootstrap_repositories": [
                    {
                        "mirror": "http://deb.debian.org/debian",
                        "suite": "unstable",
                        "components": ["main", "contrib"],
                    },
                ],
            },
        )

        for data, created_by_work_request in (
            ({"components": ["foo"]}, None),
            ({}, None),
            ({}, wr_unrelated),
            ({}, wr_without_components),
            ({}, wr_with_components),
        ):
            OldArtifact.objects.create(
                category=ArtifactCategory.SYSTEM_TARBALL,
                data=data,
                created_by_work_request=created_by_work_request,
                workspace=workspace,
            )

        executor.loader.build_graph()
        executor.migrate(migrate_to)

        new_apps = executor.loader.project_state(migrate_to).apps
        NewArtifact = new_apps.get_model("db", "Artifact")

        self.assertQuerySetEqual(
            NewArtifact.objects.order_by("id").values_list(
                "data__components", flat=True
            ),
            [
                ["foo"],  # already specified
                [],
                [],
                [],
                ["main", "contrib"],  # looked up from components
            ],
        )

        executor.loader.build_graph()
        executor.migrate(migrate_from)

        self.assertFalse(
            OldArtifact.objects.filter(data__components__isnull=False).exists()
        )

    def test_create_archive(self) -> None:
        """``debian:archive`` is created in the default workspace."""
        migrate_from = [("db", "0009_add_archive_singleton_collection")]
        migrate_to = [("db", "0010_add_archive_singleton_collection_data")]

        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate(migrate_from)
        old_apps = executor.loader.project_state(migrate_from).apps
        OldWorkspace = old_apps.get_model("db", "Workspace")
        OldCollection = old_apps.get_model("db", "Collection")
        self.assertQuerySetEqual(
            OldCollection.objects.filter(
                name="_",
                category=CollectionCategory.ARCHIVE,
                workspace__scope__name=settings.DEBUSINE_DEFAULT_SCOPE,
                workspace__name=settings.DEBUSINE_DEFAULT_WORKSPACE,
            ),
            [],
        )

        default_workspace = OldWorkspace.objects.get(
            name=settings.DEBUSINE_DEFAULT_WORKSPACE
        )
        other_workspace = OldWorkspace.objects.create(
            scope=default_workspace.scope, name="other"
        )
        OldCollection.objects.create(
            name="bookworm",
            category=CollectionCategory.SUITE,
            workspace=default_workspace,
        )
        OldCollection.objects.create(
            name="other",
            category=CollectionCategory.SUITE,
            workspace=other_workspace,
        )

        executor.loader.build_graph()
        executor.migrate(migrate_to)
        new_apps = executor.loader.project_state(migrate_to).apps
        new_archive = new_apps.get_model("db", "Collection").objects.get(
            name="_",
            category=CollectionCategory.ARCHIVE,
            workspace__scope__name=settings.DEBUSINE_DEFAULT_SCOPE,
            workspace__name=settings.DEBUSINE_DEFAULT_WORKSPACE,
        )
        self.assertQuerySetEqual(
            new_archive.child_items.values_list(
                "name", "parent_category", "category", "collection__name"
            ),
            [
                (
                    "bookworm",
                    CollectionCategory.ARCHIVE,
                    CollectionCategory.SUITE,
                    "bookworm",
                )
            ],
        )

        executor.loader.build_graph()
        executor.migrate(migrate_from)

        self.assertQuerySetEqual(
            OldCollection.objects.filter(
                name="_",
                category=CollectionCategory.ARCHIVE,
                workspace__scope__name=settings.DEBUSINE_DEFAULT_SCOPE,
                workspace__name=settings.DEBUSINE_DEFAULT_WORKSPACE,
            ),
            [],
        )

    def test_qa_suite_rename(self) -> None:
        """Various workflow data fields are renamed to ``qa_suite``."""
        self.assert_work_request_task_data_renamed(
            migrate_from=(
                "0012_alter_collectionitem_created_by_workflow_and_more"
            ),
            migrate_to="0013_qa_suite",
            old_work_requests=[
                (
                    TaskTypes.WORKFLOW,
                    "foo",
                    {
                        "suite_collection": "bookworm@debian:suite",
                        "reverse_dependencies_autopkgtest_suite": (
                            "bookworm@debian:suite"
                        ),
                    },
                ),
                (
                    TaskTypes.WORKFLOW,
                    "reverse_dependencies_autopkgtest",
                    {"suite_collection": "bookworm@debian:suite"},
                ),
                (
                    TaskTypes.WORKFLOW,
                    "qa",
                    {
                        "reverse_dependencies_autopkgtest_suite": (
                            "bookworm@debian:suite"
                        )
                    },
                ),
                (
                    TaskTypes.WORKFLOW,
                    "debian_pipeline",
                    {
                        "reverse_dependencies_autopkgtest_suite": (
                            "bookworm@debian:suite"
                        )
                    },
                ),
            ],
            new_work_requests=[
                (
                    TaskTypes.WORKFLOW,
                    "foo",
                    {
                        "suite_collection": "bookworm@debian:suite",
                        "reverse_dependencies_autopkgtest_suite": (
                            "bookworm@debian:suite"
                        ),
                    },
                ),
                (
                    TaskTypes.WORKFLOW,
                    "reverse_dependencies_autopkgtest",
                    {"qa_suite": "bookworm@debian:suite"},
                ),
                (
                    TaskTypes.WORKFLOW,
                    "qa",
                    {"qa_suite": "bookworm@debian:suite"},
                ),
                (
                    TaskTypes.WORKFLOW,
                    "debian_pipeline",
                    {"qa_suite": "bookworm@debian:suite"},
                ),
            ],
        )
        self.assert_workflow_template_task_data_renamed(
            migrate_from=(
                "0012_alter_collectionitem_created_by_workflow_and_more"
            ),
            migrate_to="0013_qa_suite",
            old_workflow_templates=[
                (
                    "foo",
                    {
                        "suite_collection": "bookworm@debian:suite",
                        "reverse_dependencies_autopkgtest_suite": (
                            "bookworm@debian:suite"
                        ),
                    },
                ),
                (
                    "reverse_dependencies_autopkgtest",
                    {"suite_collection": "bookworm@debian:suite"},
                ),
                (
                    "qa",
                    {
                        "reverse_dependencies_autopkgtest_suite": (
                            "bookworm@debian:suite"
                        )
                    },
                ),
                (
                    "debian_pipeline",
                    {
                        "reverse_dependencies_autopkgtest_suite": (
                            "bookworm@debian:suite"
                        )
                    },
                ),
            ],
            new_workflow_templates=[
                (
                    "foo",
                    {
                        "suite_collection": "bookworm@debian:suite",
                        "reverse_dependencies_autopkgtest_suite": (
                            "bookworm@debian:suite"
                        ),
                    },
                ),
                (
                    "reverse_dependencies_autopkgtest",
                    {"qa_suite": "bookworm@debian:suite"},
                ),
                ("qa", {"qa_suite": "bookworm@debian:suite"}),
                ("debian_pipeline", {"qa_suite": "bookworm@debian:suite"}),
            ],
        )

    def test_moving_task_configuration_id(self) -> None:
        """Test moving task_configuration_id from dynamic to configured data."""
        migrate_from = [
            ("db", "0019_workflowtemplate_db_workflowtemplate_update_suites")
        ]
        migrate_to = [("db", "0020_move_task_configuration_id")]

        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate(migrate_from)

        old_apps = executor.loader.project_state(migrate_from).apps
        workspace = old_apps.get_model("db", "Workspace").objects.get(
            name=settings.DEBUSINE_DEFAULT_WORKSPACE
        )
        OldWorkRequest = old_apps.get_model("db", "WorkRequest")

        common_kwargs = {
            "created_by_id": self.get_test_user_for_apps(old_apps).id,
            "task_name": "noop",
            "workspace": workspace,
            "task_data": {},
        }

        pre: dict[str, WorkRequest] = {}

        pre["no_id1"] = OldWorkRequest.objects.create(**common_kwargs)
        pre["no_id2"] = OldWorkRequest.objects.create(
            dynamic_task_data={}, **common_kwargs
        )
        pre["no_id3"] = OldWorkRequest.objects.create(
            configured_task_data={}, **common_kwargs
        )
        pre["no_id4"] = OldWorkRequest.objects.create(
            dynamic_task_data={}, configured_task_data={}, **common_kwargs
        )
        pre["no_id5"] = OldWorkRequest.objects.create(
            dynamic_task_data={"test": 1},
            configured_task_data={"test": 2},
            **common_kwargs,
        )

        pre["null_id1"] = OldWorkRequest.objects.create(
            dynamic_task_data={"task_configuration_id": None}, **common_kwargs
        )
        pre["null_id2"] = OldWorkRequest.objects.create(
            dynamic_task_data={"task_configuration_id": None},
            configured_task_data={},
            **common_kwargs,
        )
        pre["null_id3"] = OldWorkRequest.objects.create(
            dynamic_task_data={"task_configuration_id": None},
            configured_task_data={"task_configuration": None},
            **common_kwargs,
        )
        pre["null_id4"] = OldWorkRequest.objects.create(
            dynamic_task_data={"task_configuration_id": None},
            configured_task_data={"task_configuration": 3},
            **common_kwargs,
        )
        pre["null_id5"] = OldWorkRequest.objects.create(
            dynamic_task_data={"task_configuration_id": None, "test": 1},
            configured_task_data={"task_configuration": 3, "test": 2},
            **common_kwargs,
        )

        pre["valid_id1"] = OldWorkRequest.objects.create(
            dynamic_task_data={"task_configuration_id": 3}, **common_kwargs
        )
        pre["valid_id2"] = OldWorkRequest.objects.create(
            dynamic_task_data={"task_configuration_id": 3},
            configured_task_data={},
            **common_kwargs,
        )
        pre["valid_id3"] = OldWorkRequest.objects.create(
            dynamic_task_data={"task_configuration_id": 3},
            configured_task_data={"task_configuration": None},
            **common_kwargs,
        )
        pre["valid_id4"] = OldWorkRequest.objects.create(
            dynamic_task_data={"task_configuration_id": 3},
            configured_task_data={"task_configuration": 1},
            **common_kwargs,
        )
        pre["valid_id5"] = OldWorkRequest.objects.create(
            dynamic_task_data={"task_configuration_id": 3, "test": 1},
            configured_task_data={"task_configuration": 1, "test": 2},
            **common_kwargs,
        )

        executor.loader.build_graph()
        executor.migrate(migrate_to)

        new_apps = executor.loader.project_state(migrate_to).apps
        NewWorkRequest = new_apps.get_model("db", "WorkRequest")

        for name, expected_configured in (
            ("no_id1", None),
            ("no_id2", None),
            ("no_id3", {}),
            ("no_id4", {}),
            ("no_id5", {"test": 2}),
            ("null_id1", None),
            ("null_id2", {}),
            ("null_id3", {"task_configuration": None}),
            ("null_id4", {"task_configuration": 3}),
            ("null_id5", {"task_configuration": 3, "test": 2}),
            ("valid_id1", {"task_configuration": 3}),
            ("valid_id2", {"task_configuration": 3}),
            ("valid_id3", {"task_configuration": 3}),
            ("valid_id4", {"task_configuration": 1}),
            ("valid_id5", {"task_configuration": 1, "test": 2}),
        ):
            with self.subTest(name=name):
                old = pre[name]
                new = NewWorkRequest.objects.get(pk=old.pk)
                if new.dynamic_task_data is not None:
                    self.assertNotIn(
                        "task_configuration_id", new.dynamic_task_data
                    )
                    old_dynamic = (old.dynamic_task_data or {}).copy()
                    old_dynamic.pop("task_configuration_id", None)
                    self.assertEqual(new.dynamic_task_data, old_dynamic)
                self.assertEqual(new.configured_task_data, expected_configured)

        executor.loader.build_graph()
        executor.migrate(migrate_from)

        for name, expected_dynamic in (
            ("no_id1", None),
            ("no_id2", {}),
            ("no_id3", None),
            ("no_id4", {}),
            ("no_id5", {"test": 1}),
            ("null_id1", {}),
            ("null_id2", {}),
            ("null_id3", {}),
            ("null_id4", {"task_configuration_id": 3}),
            ("null_id5", {"task_configuration_id": 3, "test": 1}),
            ("valid_id1", {"task_configuration_id": 3}),
            ("valid_id2", {"task_configuration_id": 3}),
            ("valid_id3", {"task_configuration_id": 3}),
            ("valid_id4", {"task_configuration_id": 1}),
            ("valid_id5", {"task_configuration_id": 1, "test": 1}),
        ):
            with self.subTest(name=name):
                old = pre[name]
                newold = OldWorkRequest.objects.get(pk=old.pk)
                if newold.configured_task_data is not None:
                    self.assertNotIn(
                        "task_configuration", newold.configured_task_data
                    )
                    old_configured = (old.configured_task_data or {}).copy()
                    old_configured.pop("task_configuration", None)
                    self.assertEqual(
                        newold.configured_task_data, old_configured
                    )
                self.assertEqual(newold.dynamic_task_data, expected_dynamic)

    def test_work_request_set_workflow_root(self) -> None:
        migrate_from = [("db", "0022_workrequest_workflow_root")]
        migrate_to = [("db", "0023_workrequest_workflow_root_data")]

        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate(migrate_from)

        old_apps = executor.loader.project_state(migrate_from).apps
        workspace = old_apps.get_model("db", "Workspace").objects.get(
            name=settings.DEBUSINE_DEFAULT_WORKSPACE
        )
        OldWorkRequest = old_apps.get_model("db", "WorkRequest")

        common_kwargs = {
            "created_by_id": self.get_test_user_for_apps(old_apps).id,
            "task_name": "noop",
            "workspace": workspace,
            "task_data": {},
        }

        pre: dict[str, WorkRequest] = {}
        expected_root_pks: dict[str, int | None] = {}

        for path, is_workflow, expected_root_path in (
            ("1", True, "1"),
            ("1-1", True, "1"),
            ("1-1-1", False, "1"),
            ("1-1-2", True, "1"),
            ("1-1-2-1", False, "1"),
            ("1-1-3", False, "1"),
            ("1-2", True, "1"),
            ("1-2-1", True, "1"),
            ("1-2-1-1", False, "1"),
            ("2", True, "2"),
            ("2-1", True, "2"),
            ("2-1-1", True, "2"),
            ("2-1-1-1", False, "2"),
            ("2-2", True, "2"),
            ("3", True, "3"),
            ("3-1", False, "3"),
            ("4", True, "4"),
            ("5", False, None),
        ):
            parent = pre.get("-".join(path.split("-")[:-1]))
            if parent is not None:
                self.assertEqual(parent.task_type, TaskTypes.WORKFLOW)
            pre[path] = OldWorkRequest.objects.create(
                task_type=(
                    TaskTypes.WORKFLOW if is_workflow else TaskTypes.WORKER
                ),
                parent=pre.get("-".join(path.split("-")[:-1])),
                **common_kwargs,
            )
            expected_root_pks[path] = (
                None
                if expected_root_path is None
                else pre[expected_root_path].pk
            )

        executor.loader.build_graph()
        executor.migrate(migrate_to)

        new_apps = executor.loader.project_state(migrate_to).apps
        NewWorkRequest = new_apps.get_model("db", "WorkRequest")

        for path, expected_root_pk in expected_root_pks.items():
            with self.subTest(path=path, state="after"):
                self.assertEqual(
                    NewWorkRequest.objects.get(
                        pk=pre[path].pk
                    ).workflow_root_id,
                    expected_root_pk,
                )

        executor.loader.build_graph()
        executor.migrate(migrate_from)

        for path in pre:
            with self.subTest(path=path, state="before"):
                self.assertIsNone(
                    OldWorkRequest.objects.get(pk=pre[path].pk).workflow_root_id
                )

    def test_rename_publish_target_to_suite(self) -> None:
        self.assert_work_request_all_task_data_renamed(
            migrate_from="0025_base_manager_name",
            migrate_to="0031_rename_more_arch_all_build_architecture_and_suite",
            old_work_requests=[
                (
                    TaskTypes.WORKFLOW,
                    "foo",
                    {"publish_target": "bookworm@debian:suite"},
                    {},
                    {"publish_target": "bookworm@debian:suite"},
                ),
                (
                    TaskTypes.WORKFLOW,
                    "debian_pipeline",
                    {"publish_target": "bookworm@debian:suite"},
                    {},
                    {"publish_target": "bookworm@debian:suite"},
                ),
                (
                    TaskTypes.WORKFLOW,
                    "debian_pipeline",
                    {"vendor": "debian"},
                    {},
                    {"vendor": "debian"},
                ),
            ],
            new_work_requests=[
                (
                    TaskTypes.WORKFLOW,
                    "foo",
                    {"publish_target": "bookworm@debian:suite"},
                    {},
                    {"publish_target": "bookworm@debian:suite"},
                ),
                (
                    TaskTypes.WORKFLOW,
                    "debian_pipeline",
                    {"suite": "bookworm@debian:suite"},
                    {},
                    {"suite": "bookworm@debian:suite"},
                ),
                (
                    TaskTypes.WORKFLOW,
                    "debian_pipeline",
                    {"vendor": "debian"},
                    {},
                    {"vendor": "debian"},
                ),
            ],
        )
        self.assert_workflow_template_task_data_renamed(
            migrate_from="0025_base_manager_name",
            migrate_to="0026_rename_publish_target_to_suite",
            old_workflow_templates=[
                ("foo", {"publish_target": "bookworm@debian:suite"}),
                (
                    "debian_pipeline",
                    {"publish_target": "bookworm@debian:suite"},
                ),
                ("debian_pipeline", {"vendor": "debian"}),
            ],
            new_workflow_templates=[
                ("foo", {"publish_target": "bookworm@debian:suite"}),
                ("debian_pipeline", {"suite": "bookworm@debian:suite"}),
                ("debian_pipeline", {"vendor": "debian"}),
            ],
        )

    def test_rename_worker_static_native_architecture(self) -> None:
        migrate_from = [("db", "0026_rename_publish_target_to_suite")]
        migrate_to = [("db", "0027_rename_worker_static_native_architecture")]
        old_workers = [
            ("worker-1", {}),
            (
                "worker-2",
                {
                    "system:host_architecture": "amd64",
                    "system:architectures": ["amd64", "i386"],
                },
            ),
        ]
        new_workers = [
            ("worker-1", {}),
            (
                "worker-2",
                {
                    "system:native_architecture": "amd64",
                    "system:architectures": ["amd64", "i386"],
                },
            ),
        ]

        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate(migrate_from)
        old_apps = executor.loader.project_state(migrate_from).apps
        for name, static_metadata in old_workers:
            old_apps.get_model("db", "Worker").objects.create(
                name=name,
                registered_at=timezone.now(),
                token=old_apps.get_model("db", "Token").objects.create(
                    hash=hashlib.sha256(name.encode()).hexdigest()
                ),
                static_metadata=static_metadata,
            )

        executor.loader.build_graph()
        executor.migrate(migrate_to)
        new_apps = executor.loader.project_state(migrate_to).apps

        self.assertCountEqual(
            new_apps.get_model("db", "Worker").objects.values_list(
                "name", "static_metadata"
            ),
            new_workers,
        )

        executor.loader.build_graph()
        executor.migrate(migrate_from)

        self.assertCountEqual(
            old_apps.get_model("db", "Worker").objects.values_list(
                "name", "static_metadata"
            ),
            old_workers,
        )

    def test_rename_arch_all_build_architecture(self) -> None:
        affected_workflows = {
            "autopkgtest",
            "blhc",
            "debdiff",
            "debian_pipeline",
            "lintian",
            "package_upload",
            "piuparts",
            "qa",
            "reverse_dependencies_autopkgtest",
            "sbuild",
        }
        self.assert_work_request_all_task_data_renamed(
            migrate_from="0027_rename_worker_static_native_architecture",
            migrate_to="0031_rename_more_arch_all_build_architecture_and_suite",
            old_work_requests=[
                (
                    TaskTypes.WORKFLOW,
                    "foo",
                    {"arch_all_host_architecture": "amd64"},
                    {},
                    {"arch_all_host_architecture": "amd64"},
                ),
                *(
                    (
                        TaskTypes.WORKFLOW,
                        workflow,
                        {
                            "vendor": "debian",
                            "arch_all_host_architecture": "amd64",
                        },
                        {},
                        {
                            "vendor": "debian",
                            "arch_all_host_architecture": "amd64",
                        },
                    )
                    for workflow in affected_workflows
                ),
            ],
            new_work_requests=[
                (
                    TaskTypes.WORKFLOW,
                    "foo",
                    {"arch_all_host_architecture": "amd64"},
                    {},
                    {"arch_all_host_architecture": "amd64"},
                ),
                *(
                    (
                        TaskTypes.WORKFLOW,
                        workflow,
                        {
                            "vendor": "debian",
                            "arch_all_build_architecture": "amd64",
                        },
                        {},
                        {
                            "vendor": "debian",
                            "arch_all_build_architecture": "amd64",
                        },
                    )
                    for workflow in affected_workflows
                ),
            ],
        )
        self.assert_workflow_template_task_data_renamed(
            migrate_from="0027_rename_worker_static_native_architecture",
            migrate_to="0028_rename_arch_all_build_architecture",
            old_workflow_templates=[
                ("foo", {"arch_all_host_architecture": "amd64"}),
                *(
                    (
                        workflow,
                        {
                            "vendor": "debian",
                            "arch_all_host_architecture": "amd64",
                        },
                    )
                    for workflow in affected_workflows
                ),
            ],
            new_workflow_templates=[
                ("foo", {"arch_all_host_architecture": "amd64"}),
                *(
                    (
                        workflow,
                        {
                            "vendor": "debian",
                            "arch_all_build_architecture": "amd64",
                        },
                    )
                    for workflow in affected_workflows
                ),
            ],
        )

    def test_rename_build_architecture(self) -> None:
        affected_tasks = {
            "autopkgtest",
            "lintian",
            "blhc",
            "debdiff",
            "piuparts",
            "sbuild",
            "extractforsigning",
            "assemblesignedsource",
            "makesourcepackageupload",
        }
        self.assert_work_request_all_task_data_renamed(
            migrate_from="0028_rename_arch_all_build_architecture",
            migrate_to="0030_rename_more_build_architecture",
            old_work_requests=[
                (
                    TaskTypes.WORKER,
                    "foo",
                    {"host_architecture": "amd64"},
                    {"host_architecture": "amd64"},
                    {"host_architecture": "amd64"},
                ),
                *(
                    (
                        TaskTypes.WORKER,
                        task,
                        {"backend": "unshare", "host_architecture": "amd64"},
                        {"backend": "unshare", "host_architecture": "amd64"},
                        {"backend": "unshare", "host_architecture": "amd64"},
                    )
                    for task in affected_tasks
                ),
            ],
            new_work_requests=[
                (
                    TaskTypes.WORKER,
                    "foo",
                    {"host_architecture": "amd64"},
                    {"host_architecture": "amd64"},
                    {"host_architecture": "amd64"},
                ),
                *(
                    (
                        TaskTypes.WORKER,
                        task,
                        {"backend": "unshare", "build_architecture": "amd64"},
                        {"backend": "unshare", "build_architecture": "amd64"},
                        {"backend": "unshare", "build_architecture": "amd64"},
                    )
                    for task in affected_tasks
                ),
            ],
        )

    def test_add_runtime_parameters(self) -> None:
        """Runtime parameters are generated on workflow templates."""
        migrate_from = [
            ("db", "0031_rename_more_arch_all_build_architecture_and_suite")
        ]
        migrate_to = [
            ("db", "0034_workflow_template_configure_runtime_parameters")
        ]

        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate(migrate_from)

        old_apps = executor.loader.project_state(migrate_from).apps
        workspace = old_apps.get_model("db", "Workspace").objects.get(
            name=settings.DEBUSINE_DEFAULT_WORKSPACE
        )
        OldWorkflowTemplate = old_apps.get_model("db", "WorkflowTemplate")

        old_template = OldWorkflowTemplate.objects.create(
            name="test",
            workspace=workspace,
            task_name="create_experiment_workspace",
            task_data={
                "public": True,
                "workflow_template_names": ["upload-to-foo"],
                "expiration_delay": 60,
            },
            priority=0,
        )

        executor.loader.build_graph()
        executor.migrate(migrate_to)

        new_apps = executor.loader.project_state(migrate_to).apps
        NewWorkflowTemplate = new_apps.get_model("db", "WorkflowTemplate")

        new_template = NewWorkflowTemplate.objects.get(pk=old_template.pk)
        self.assertEqual(new_template.static_parameters, old_template.task_data)
        self.assertEqual(
            new_template.runtime_parameters,
            {
                "experiment_name": "any",
                "owner_group": "any",
                "task_configuration": "any",
            },
        )

        executor.loader.build_graph()
        executor.migrate(migrate_from)

        new_old_template = OldWorkflowTemplate.objects.get(pk=old_template.pk)
        self.assertEqual(new_old_template.task_data, old_template.task_data)

    def test_rename_create_child_workspace(self) -> None:
        self.assert_work_request_all_task_data_renamed(
            migrate_from="0035_alter_workrequest_status",
            migrate_to="0036_rename_create_child_workspace",
            old_work_requests=[
                (
                    TaskTypes.SERVER,
                    "create_experiment_workspace",
                    {"experiment_name": "test"},
                    {},
                    {"experiment_name": "test"},
                ),
                (
                    TaskTypes.SERVER,
                    "create_experiment_workspace",
                    {"experiment_name": "test2"},
                    {},
                    None,
                ),
                (TaskTypes.SERVER, "servernoop", {}, {}, {}),
                (
                    TaskTypes.WORKFLOW,
                    "create_experiment_workspace",
                    {"experiment_name": "test"},
                    {},
                    {"experiment_name": "test"},
                ),
                (
                    TaskTypes.WORKFLOW,
                    "create_experiment_workspace",
                    {"experiment_name": "test2"},
                    {},
                    None,
                ),
                (TaskTypes.WORKFLOW, "noop", {}, {}, {}),
            ],
            new_work_requests=[
                (
                    TaskTypes.SERVER,
                    "create_child_workspace",
                    {
                        "prefix": settings.DEBUSINE_DEFAULT_WORKSPACE,
                        "suffix": "test",
                    },
                    {},
                    {
                        "prefix": settings.DEBUSINE_DEFAULT_WORKSPACE,
                        "suffix": "test",
                    },
                ),
                (
                    TaskTypes.SERVER,
                    "create_child_workspace",
                    {
                        "prefix": settings.DEBUSINE_DEFAULT_WORKSPACE,
                        "suffix": "test2",
                    },
                    {},
                    None,
                ),
                (TaskTypes.SERVER, "servernoop", {}, {}, {}),
                (
                    TaskTypes.WORKFLOW,
                    "create_child_workspace",
                    {"suffix": "test"},
                    {},
                    {"suffix": "test"},
                ),
                (
                    TaskTypes.WORKFLOW,
                    "create_child_workspace",
                    {"suffix": "test2"},
                    {},
                    None,
                ),
                (TaskTypes.WORKFLOW, "noop", {}, {}, {}),
            ],
        )
        self.assert_workflow_template_parameters_renamed(
            migrate_from="0035_alter_workrequest_status",
            migrate_to="0036_rename_create_child_workspace",
            old_workflow_templates=[
                ("foo", {}, {"experiment_name": RuntimeParameter.ANY}),
                (
                    "create_experiment_workspace",
                    {},
                    {"experiment_name": RuntimeParameter.ANY},
                ),
                (
                    "create_experiment_workspace",
                    {"experiment_name": "test"},
                    {},
                ),
            ],
            new_workflow_templates=[
                ("foo", {}, {"experiment_name": RuntimeParameter.ANY}),
                (
                    "create_child_workspace",
                    {},
                    {"suffix": RuntimeParameter.ANY},
                ),
                ("create_child_workspace", {"suffix": "test"}, {}),
            ],
        )

    def test_copy_build_arch_to_tag(self) -> None:
        BUILD_ARCH_PREFIX = "worker:build-arch:"

        migrate_from = [("db", "0045_alter_workrequest_unblock_strategy")]
        migrate_to = [("db", "0046_copy_build_arch_to_tag")]

        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate(migrate_from)

        old_apps = executor.loader.project_state(migrate_from).apps
        OldWorkspace = old_apps.get_model("db", "Workspace")
        old_workspace = OldWorkspace.objects.get(
            name=settings.DEBUSINE_DEFAULT_WORKSPACE
        )
        old_user = self.get_test_user_for_apps(old_apps)
        OldWorkRequest = old_apps.get_model("db", "WorkRequest")

        old = {
            # A task without build architecture (like noop)
            "noop": OldWorkRequest.objects.create(
                workspace=old_workspace,
                created_by=old_user,
                task_type=TaskTypes.WORKER,
                task_name="noop",
                task_data={},
            ),
            # A task that uses build_architecture (like sbuild)
            "sbuild": OldWorkRequest.objects.create(
                workspace=old_workspace,
                created_by=old_user,
                task_type=TaskTypes.WORKER,
                task_name="sbuild",
                task_data={"build_architecture": "risc64"},
            ),
            # A sbuild-like task with an undefined build_architecture
            "sbuild_none": OldWorkRequest.objects.create(
                workspace=old_workspace,
                created_by=old_user,
                task_type=TaskTypes.WORKER,
                task_name="sbuild",
                task_data={"build_architecture": None},
            ),
            # A task that uses bootstrap_options.architecture (like mmdebstrap)
            "mmdebstrap": OldWorkRequest.objects.create(
                workspace=old_workspace,
                created_by=old_user,
                task_type=TaskTypes.WORKER,
                task_name="mmdebstrap",
                task_data={"bootstrap_options": {"architecture": "arm64"}},
            ),
            # A mmdebstrap-like task with an undefined build architecture
            "mmdebstrap_none": OldWorkRequest.objects.create(
                workspace=old_workspace,
                created_by=old_user,
                task_type=TaskTypes.WORKER,
                task_name="mmdebstrap",
                task_data={"bootstrap_options": {"architecture": None}},
            ),
            # A task that already has some task-required tags
            "sbuild_with_tags": OldWorkRequest.objects.create(
                workspace=old_workspace,
                created_by=old_user,
                task_type=TaskTypes.WORKER,
                task_name="sbuild",
                task_data={"build_architecture": "i386"},
                scheduler_tags_required=["existing_tag"],
            ),
            # A task that already has some task-required tags
            "sbuild_with_arch_tags": OldWorkRequest.objects.create(
                workspace=old_workspace,
                created_by=old_user,
                task_type=TaskTypes.WORKER,
                task_name="sbuild",
                task_data={"build_architecture": "ppc64el"},
                scheduler_tags_required=[
                    "existing_tag",
                    BUILD_ARCH_PREFIX + "ppc64el",
                ],
            ),
            # A task that already has some task-required tags
            "mmdebstrap_with_arch_tags": OldWorkRequest.objects.create(
                workspace=old_workspace,
                created_by=old_user,
                task_type=TaskTypes.WORKER,
                task_name="sbuild",
                task_data={"bootstrap_options": {"architecture": "ppc64el"}},
                scheduler_tags_required=[
                    "existing_tag",
                    BUILD_ARCH_PREFIX + "ppc64el",
                ],
            ),
        }

        expected_tags = {
            "noop": [],
            "sbuild": [BUILD_ARCH_PREFIX + "risc64"],
            "sbuild_none": [],
            "mmdebstrap": [BUILD_ARCH_PREFIX + "arm64"],
            "mmdebstrap_none": [],
            "sbuild_with_tags": ["existing_tag", BUILD_ARCH_PREFIX + "i386"],
            "sbuild_with_arch_tags": [
                "existing_tag",
                BUILD_ARCH_PREFIX + "ppc64el",
            ],
            "mmdebstrap_with_arch_tags": [
                "existing_tag",
                BUILD_ARCH_PREFIX + "ppc64el",
            ],
        }

        executor.loader.build_graph()
        executor.migrate(migrate_to)

        new_apps = executor.loader.project_state(migrate_to).apps
        NewWorkRequest = new_apps.get_model("db", "WorkRequest")

        for name in old:
            with self.subTest(name=name):
                new = NewWorkRequest.objects.get(pk=old[name].pk)
                self.assertEqual(
                    new.scheduler_tags_required, expected_tags[name]
                )
        executor.loader.build_graph()
        executor.migrate(migrate_from)

        # Going backwards does not remove tags, as it may remove tags that were
        # preexisting before the migration
        for name in old:
            with self.subTest(name=name):
                new_old = OldWorkRequest.objects.get(pk=old[name].pk)
                self.assertEqual(
                    new_old.scheduler_tags_required, expected_tags[name]
                )

    def test_set_token_workers(self) -> None:
        migrate_from = [("db", "0048_mirror_worker_token_ids")]
        migrate_to = [("db", "0049_token_worker_data")]

        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate(migrate_from)

        old_apps = executor.loader.project_state(migrate_from).apps
        OldToken = old_apps.get_model("db", "Token")
        OldWorker = old_apps.get_model("db", "Worker")

        old_tokens = [
            OldToken.objects.create(
                hash=hashlib.sha256(str(i).encode()).hexdigest(), enabled=True
            )
            for i in range(6)
        ]
        old_workers = {
            worker.name: worker
            for worker in (
                OldWorker.objects.create(
                    name="celery-worker-without-token",
                    registered_at=timezone.now(),
                    worker_type=WorkerType.CELERY,
                ),
                OldWorker.objects.create(
                    name="worker-with-activation-token-1",
                    registered_at=timezone.now(),
                    activation_token=old_tokens[0],
                ),
                OldWorker.objects.create(
                    name="worker-with-activation-token-2",
                    registered_at=timezone.now(),
                    activation_token=old_tokens[1],
                ),
                OldWorker.objects.create(
                    name="worker-with-token-1",
                    registered_at=timezone.now(),
                    token=old_tokens[2],
                ),
                OldWorker.objects.create(
                    name="worker-with-token-2",
                    registered_at=timezone.now(),
                    token=old_tokens[3],
                ),
            )
        }
        old_user = self.get_test_user_for_apps(old_apps)
        old_tokens[4].user = old_user
        old_tokens[4].save()

        expected_tokens = [
            (
                old_tokens[0].id,
                _TokenTypes.WORKER_ACTIVATION,
                old_workers["worker-with-activation-token-1"].id,
                None,
            ),
            (
                old_tokens[1].id,
                _TokenTypes.WORKER_ACTIVATION,
                old_workers["worker-with-activation-token-2"].id,
                None,
            ),
            (
                old_tokens[2].id,
                _TokenTypes.WORKER,
                None,
                old_workers["worker-with-token-1"].id,
            ),
            (
                old_tokens[3].id,
                _TokenTypes.WORKER,
                None,
                old_workers["worker-with-token-2"].id,
            ),
            (old_tokens[4].id, _TokenTypes.USER, None, None),
        ]

        executor.loader.build_graph()
        executor.migrate(migrate_to)

        new_apps = executor.loader.project_state(migrate_to).apps
        NewToken = new_apps.get_model("db", "Token")

        self.assertQuerySetEqual(
            (
                NewToken.objects.filter(
                    id__in={token.id for token in old_tokens}
                )
                .order_by("id")
                .values_list(
                    "id", "token_type", "_activating_worker_id", "_worker_id"
                )
            ),
            expected_tokens,
        )
        self.assertFalse(NewToken.objects.filter(id=old_tokens[5].id).exists())

        executor.loader.build_graph()
        executor.migrate(migrate_from)

        self.assertQuerySetEqual(
            (
                OldToken.objects.filter(
                    id__in={token.id for token in old_tokens}
                )
                .order_by("id")
                .values_list(
                    "id", "token_type", "_activating_worker_id", "_worker_id"
                )
            ),
            [(token.id, "", None, None) for token in old_tokens[:-1]],
        )

    def test_copy_tags_from_most_recent_worker(self) -> None:
        migrate_from = [("db", "0063_workflowtemplate_restricted")]
        migrate_to = [("db", "0064_workerpool_replace_tags_with_metadata")]

        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate(migrate_from)

        old_apps = executor.loader.project_state(migrate_from).apps
        OldAsset = old_apps.get_model("db", "Asset")
        OldWorker = old_apps.get_model("db", "Worker")
        OldWorkerPool = old_apps.get_model("db", "WorkerPool")

        old_asset = OldAsset.objects.create(
            category=AssetCategory.CLOUD_PROVIDER_ACCOUNT,
            data={},
        )

        old_worker_pools = [
            OldWorkerPool.objects.create(
                name="empty",
                provider_account=old_asset,
                registered_at=timezone.now(),
            ),
            OldWorkerPool.objects.create(
                name="used",
                provider_account=old_asset,
                registered_at=timezone.now(),
            ),
        ]

        OldWorker.objects.create(
            name="used-1",
            worker_type=WorkerType.EXTERNAL,
            worker_pool=old_worker_pools[1],
            dynamic_metadata={"foo": "bar"},
            dynamic_metadata_updated_at=timezone.now() - dt.timedelta(days=7),
            registered_at=timezone.now(),
        )
        OldWorker.objects.create(
            name="used-2",
            worker_type=WorkerType.EXTERNAL,
            worker_pool=old_worker_pools[1],
            dynamic_metadata={"foo": "baz"},
            dynamic_metadata_updated_at=timezone.now(),
            registered_at=timezone.now(),
        )

        expected_dynamic_metadata = [
            (old_worker_pools[0].id, {}),
            (old_worker_pools[1].id, {"foo": "baz"}),
        ]

        executor.loader.build_graph()
        executor.migrate(migrate_to)

        new_apps = executor.loader.project_state(migrate_to).apps
        NewWorkerPool = new_apps.get_model("db", "WorkerPool")

        self.assertQuerySetEqual(
            (
                NewWorkerPool.objects.order_by("id").values_list(
                    "id", "dynamic_metadata"
                )
            ),
            expected_dynamic_metadata,
        )

        executor.loader.build_graph()
        executor.migrate(migrate_from)

    def test_remove_suites_from_archives(self) -> None:
        migrate_from = [("db", "0067_collectionitem_db_ci_suite_dbgsym_id_idx")]
        migrate_to = [("db", "0068_remove_suites_from_archives")]

        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        executor.migrate(migrate_from)

        old_apps = executor.loader.project_state(migrate_from).apps
        OldWorkspace = old_apps.get_model("db", "Workspace")
        OldCollection = old_apps.get_model("db", "Collection")
        OldCollectionItem = old_apps.get_model("db", "CollectionItem")

        workspace = OldWorkspace.objects.get(
            scope__name=settings.DEBUSINE_DEFAULT_SCOPE,
            name=settings.DEBUSINE_DEFAULT_WORKSPACE,
        )

        old_archive = workspace.collections.get(
            category=CollectionCategory.ARCHIVE
        )
        old_suite = OldCollection.objects.create(
            workspace=workspace, category=CollectionCategory.SUITE, name="suite"
        )
        self.assertFalse(OldCollectionItem.objects.exists())
        OldCollectionItem.objects.create(
            name="suite",
            child_type=CollectionItem.Types.COLLECTION,
            collection=old_suite,
            category=old_suite.category,
            parent_collection=old_archive,
            parent_category=old_archive.category,
            created_by_user=self.get_test_user_for_apps(old_apps),
        )

        executor.loader.build_graph()
        executor.migrate(migrate_to)

        new_apps = executor.loader.project_state(migrate_to).apps
        NewCollectionItem = new_apps.get_model("db", "CollectionItem")
        self.assertFalse(NewCollectionItem.objects.exists())


class AddArchiveContraintsMigrationTests(MigrationTestCase):
    """Test 0067_add_archive_constraints migration."""

    BASE_MIGRATION = [("db", "0068_remove_suites_from_archives")]
    TARGET_MIGRATION = [("db", "0069_add_archive_constraints")]

    old_apps: ClassVar[StateApps]
    OldScope: ClassVar[type[Scope]]
    OldWorkspace: ClassVar[type[Workspace]]
    OldCollection: ClassVar[type[Collection]]
    OldCollectionItemMatchConstraint: ClassVar[
        type[CollectionItemMatchConstraint]
    ]

    new_apps: ClassVar[StateApps]
    NewScope: ClassVar[type[Scope]]
    NewWorkspace: ClassVar[type[Workspace]]
    NewCollection: ClassVar[type[Collection]]
    NewCollectionItemMatchConstraint: ClassVar[
        type[CollectionItemMatchConstraint]
    ]

    oldws: ClassVar[Workspace]
    oldarchive: ClassVar[Collection]
    oldsuite1: ClassVar[Collection]
    oldsuite2: ClassVar[Collection]

    newws: ClassVar[Workspace]
    newarchive: ClassVar[Collection]
    newsuite1: ClassVar[Collection]
    newsuite2: ClassVar[Collection]

    @override
    @classmethod
    def setUpClass(cls) -> None:
        executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )
        cls.old_apps = executor.loader.project_state(cls.BASE_MIGRATION).apps
        cls.OldScope = cls.old_apps.get_model("db", "Scope")
        cls.OldWorkspace = cls.old_apps.get_model("db", "Workspace")
        cls.OldCollection = cls.old_apps.get_model("db", "Collection")
        cls.OldCollectionItemMatchConstraint = cls.old_apps.get_model(
            "db", "CollectionItemMatchConstraint"
        )

        cls.new_apps = executor.loader.project_state(cls.TARGET_MIGRATION).apps
        cls.NewScope = cls.new_apps.get_model("db", "Scope")
        cls.NewWorkspace = cls.new_apps.get_model("db", "Workspace")
        cls.NewCollection = cls.new_apps.get_model("db", "Collection")
        cls.NewCollectionItemMatchConstraint = cls.new_apps.get_model(
            "db", "CollectionItemMatchConstraint"
        )
        # setUpClass runs setUpTestData, so run it after we instantiated model
        # classes
        super().setUpClass()

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.oldws = cls.OldWorkspace.objects.create(
            name="test", scope=cls.OldScope.objects.create(name="test")
        )
        cls.oldarchive = cls.OldCollection.objects.create(
            workspace=cls.oldws, name="_", category=CollectionCategory.ARCHIVE
        )
        cls.oldsuite1 = cls.OldCollection.objects.create(
            workspace=cls.oldws,
            name="suite1",
            category=CollectionCategory.SUITE,
        )
        cls.oldsuite2 = cls.OldCollection.objects.create(
            workspace=cls.oldws,
            name="suite2",
            category=CollectionCategory.SUITE,
        )

        cls.newws = cls.OldWorkspace.objects.get(id=cls.oldws.id)
        cls.newarchive = cls.OldCollection.objects.get(id=cls.oldarchive.id)
        cls.newsuite1 = cls.OldCollection.objects.get(id=cls.oldsuite1.id)
        cls.newsuite2 = cls.OldCollection.objects.get(id=cls.oldsuite2.id)

    @override
    def setUp(self) -> None:
        super().setUp()
        self.executor = MigrationExecutor(
            connection, progress_callback=_migrate_check_constraints
        )

    def migrate_forwards(self) -> None:
        """Run a forward migration."""
        self.executor.loader.build_graph()
        self.executor.migrate(self.TARGET_MIGRATION)

    def test_no_archive(self) -> None:
        constraint_name = "pool-file"
        # Two conflicting constraints on separate suite, with no common archive
        self.oldarchive.delete()
        c1 = self.OldCollectionItemMatchConstraint.objects.create(
            collection_id=self.oldsuite1.id,
            collection_item_id=1,
            constraint_name=constraint_name,
            key="README.md",
            value="0" * 40,
        )
        c2 = self.OldCollectionItemMatchConstraint.objects.create(
            collection_id=self.oldsuite2.id,
            collection_item_id=2,
            constraint_name=constraint_name,
            key="README.md",
            value="1" * 40,
        )
        with self.assertNoLogs():
            self.migrate_forwards()

        self.assertEqual(
            list(
                self.NewCollectionItemMatchConstraint.objects.values(
                    "collection",
                    "collection_item_id",
                    "constraint_name",
                    "key",
                    "value",
                ).order_by("id")
            ),
            [
                {
                    "collection": self.newsuite1.id,
                    "collection_item_id": 1,
                    "constraint_name": constraint_name,
                    "key": "README.md",
                    "value": c1.value,
                },
                {
                    "collection": self.newsuite2.id,
                    "collection_item_id": 2,
                    "constraint_name": constraint_name,
                    "key": "README.md",
                    "value": c2.value,
                },
            ],
        )

    def test_empty_archive_no_conflicts(self) -> None:
        constraint_name = "build-id"
        c1 = self.OldCollectionItemMatchConstraint.objects.create(
            collection_id=self.oldsuite1.id,
            collection_item_id=1,
            constraint_name=constraint_name,
            key="README1.md",
            value="0" * 40,
        )
        c2 = self.OldCollectionItemMatchConstraint.objects.create(
            collection_id=self.oldsuite2.id,
            collection_item_id=2,
            constraint_name=constraint_name,
            key="README2.md",
            value="1" * 40,
        )
        with self.assertNoLogs():
            self.migrate_forwards()

        self.assertEqual(
            list(
                self.NewCollectionItemMatchConstraint.objects.values(
                    "collection",
                    "collection_item_id",
                    "constraint_name",
                    "key",
                    "value",
                ).order_by("collection_id", "id")
            ),
            [
                {
                    "collection": self.newarchive.id,
                    "collection_item_id": 1,
                    "constraint_name": constraint_name,
                    "key": "README1.md",
                    "value": c1.value,
                },
                {
                    "collection": self.newarchive.id,
                    "collection_item_id": 2,
                    "constraint_name": constraint_name,
                    "key": "README2.md",
                    "value": c2.value,
                },
                {
                    "collection": self.newsuite1.id,
                    "collection_item_id": 1,
                    "constraint_name": constraint_name,
                    "key": "README1.md",
                    "value": c1.value,
                },
                {
                    "collection": self.newsuite2.id,
                    "collection_item_id": 2,
                    "constraint_name": constraint_name,
                    "key": "README2.md",
                    "value": c2.value,
                },
            ],
        )

    def test_empty_archive_conflicts(self) -> None:
        constraint_name = "pool-file"
        c1 = self.OldCollectionItemMatchConstraint.objects.create(
            collection_id=self.oldsuite1.id,
            collection_item_id=1,
            constraint_name=constraint_name,
            key="README.md",
            value="0" * 40,
        )
        c2 = self.OldCollectionItemMatchConstraint.objects.create(
            collection_id=self.oldsuite2.id,
            collection_item_id=2,
            constraint_name=constraint_name,
            key="README.md",
            value="1" * 40,
        )
        with self.assertLogs() as log:
            self.migrate_forwards()

        self.assertEqual(
            list(
                self.NewCollectionItemMatchConstraint.objects.values(
                    "collection",
                    "collection_item_id",
                    "constraint_name",
                    "key",
                    "value",
                ).order_by("collection_id", "id")
            ),
            [
                {
                    "collection": self.newarchive.id,
                    "collection_item_id": 2,
                    "constraint_name": constraint_name,
                    "key": "README.md",
                    "value": c2.value,
                },
                {
                    "collection": self.newsuite1.id,
                    "collection_item_id": 1,
                    "constraint_name": constraint_name,
                    "key": "README.md",
                    "value": c1.value,
                },
                {
                    "collection": self.newsuite2.id,
                    "collection_item_id": 2,
                    "constraint_name": constraint_name,
                    "key": "README.md",
                    "value": c2.value,
                },
            ],
        )

        self.assertEqual(
            log.output,
            [
                f"WARNING:root:Workspace object ({self.oldws.id}):"
                " README.md: same file found in different suites"
                " with different contents:"
                " manual intervention is needed to fix the inconsistency"
            ],
        )

    def test_archive_pool_entries_replaced(self) -> None:
        constraint_name = "pool-file"
        # An unrelated pool-file entry, will disappear
        self.OldCollectionItemMatchConstraint.objects.create(
            collection_id=self.oldarchive.id,
            collection_item_id=3,
            constraint_name=constraint_name,
            key="LICENSE",
            value="2" * 40,
        )
        # An unrelated entry of a different kind of constraint, will be kept
        self.OldCollectionItemMatchConstraint.objects.create(
            collection_id=self.oldarchive.id,
            collection_item_id=4,
            constraint_name="other",
            key="key",
            value="value",
        )

        c1 = self.OldCollectionItemMatchConstraint.objects.create(
            collection_id=self.oldsuite1.id,
            collection_item_id=1,
            constraint_name=constraint_name,
            key="README1.md",
            value="0" * 40,
        )
        c2 = self.OldCollectionItemMatchConstraint.objects.create(
            collection_id=self.oldsuite2.id,
            collection_item_id=2,
            constraint_name=constraint_name,
            key="README2.md",
            value="1" * 40,
        )
        with self.assertNoLogs():
            self.migrate_forwards()

        self.assertEqual(
            list(
                self.NewCollectionItemMatchConstraint.objects.values(
                    "collection",
                    "collection_item_id",
                    "constraint_name",
                    "key",
                    "value",
                ).order_by("collection_id", "id")
            ),
            [
                {
                    "collection": self.newarchive.id,
                    "collection_item_id": 4,
                    "constraint_name": "other",
                    "key": "key",
                    "value": "value",
                },
                {
                    "collection": self.newarchive.id,
                    "collection_item_id": 1,
                    "constraint_name": constraint_name,
                    "key": "README1.md",
                    "value": c1.value,
                },
                {
                    "collection": self.newarchive.id,
                    "collection_item_id": 2,
                    "constraint_name": constraint_name,
                    "key": "README2.md",
                    "value": c2.value,
                },
                {
                    "collection": self.newsuite1.id,
                    "collection_item_id": 1,
                    "constraint_name": constraint_name,
                    "key": "README1.md",
                    "value": c1.value,
                },
                {
                    "collection": self.newsuite2.id,
                    "collection_item_id": 2,
                    "constraint_name": constraint_name,
                    "key": "README2.md",
                    "value": c2.value,
                },
            ],
        )
