#    Copyright 2015 IBM Corp.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

import copy
import datetime
import hashlib
import inspect
from typing import Any
from unittest import mock

from oslo_versionedobjects import base
from oslo_versionedobjects import exception
from oslo_versionedobjects import fields
from oslo_versionedobjects import fixture
from oslo_versionedobjects import test


class MyObject(base.VersionedObject):
    fields = {'diglett': fields.IntegerField()}

    @base.remotable
    def remotable_method(self):
        pass

    @classmethod
    @base.remotable
    def remotable_classmethod(cls):
        pass

    def non_remotable_method(self):
        pass

    @classmethod
    def non_remotable_classmethod(cls):
        pass


class MyObject2(base.VersionedObject):
    pass


class MyExtraObject(base.VersionedObject):
    pass


@base.VersionedObjectRegistry.register_if(False)
class MyListObject(base.ObjectListBase[MyObject], base.VersionedObject):
    fields = {'objects': fields.ListOfObjectsField('MyObject')}


class TestObjectComparators(test.TestCase):
    @base.VersionedObjectRegistry.register_if(False)
    class MyComparedObject(base.VersionedObject):
        fields = {'foo': fields.IntegerField(), 'bar': fields.IntegerField()}

    @base.VersionedObjectRegistry.register_if(False)
    class MyComparedObjectWithTZ(base.VersionedObject):
        fields = {'tzfield': fields.DateTimeField()}

    def test_compare_obj(self):
        mock_test = mock.Mock()
        mock_test.assertEqual = mock.Mock()
        my_obj = self.MyComparedObject(foo=1, bar=2)
        my_db_obj = {'foo': 1, 'bar': 2}

        fixture.compare_obj(mock_test, my_obj, my_db_obj)

        expected_calls = [(1, 1), (2, 2)]
        actual_calls = [c[0] for c in mock_test.assertEqual.call_args_list]
        for call in expected_calls:
            self.assertIn(call, actual_calls)

    def test_compare_obj_with_unset(self):
        # If the object has nothing set, and also the db object has the same
        # thing not set, it's OK.
        mock_test = mock.Mock()
        mock_test.assertEqual = mock.Mock()
        my_obj = self.MyComparedObject()

        fixture.compare_obj(mock_test, my_obj, {})

        self.assertFalse(
            mock_test.assertEqual.called,
            "assertEqual should not have been called, there is nothing to "
            "compare.",
        )

    def test_compare_obj_with_unset_in_obj(self):
        # If the db dict has something set, but the object doesn't, that's !=
        mock_test = mock.Mock()
        mock_test.assertEqual = mock.Mock()
        my_obj = self.MyComparedObject(foo=1)
        my_db_obj = {'foo': 1, 'bar': 2}

        self.assertRaises(
            AssertionError, fixture.compare_obj, mock_test, my_obj, my_db_obj
        )

    def test_compare_obj_with_unset_in_db_dict(self):
        # If the object has something set, but the db dict doesn't, that's !=
        mock_test = mock.Mock()
        mock_test.assertEqual = mock.Mock()
        my_obj = self.MyComparedObject(foo=1, bar=2)
        my_db_obj = {'foo': 1}

        self.assertRaises(
            AssertionError, fixture.compare_obj, mock_test, my_obj, my_db_obj
        )

    def test_compare_obj_with_unset_in_obj_ignored(self):
        # If the db dict has something set, but the object doesn't, but we
        # ignore that key, we are equal
        my_obj = self.MyComparedObject(foo=1)
        my_db_obj = {'foo': 1, 'bar': 2}
        ignore = ['bar']

        fixture.compare_obj(self, my_obj, my_db_obj, allow_missing=ignore)

    def test_compare_obj_with_unset_in_db_dict_ignored(self):
        # If the object has something set, but the db dict doesn't, but we
        # ignore that key, we are equal
        my_obj = self.MyComparedObject(foo=1, bar=2)
        my_db_obj = {'foo': 1}
        ignore = ['bar']

        fixture.compare_obj(self, my_obj, my_db_obj, allow_missing=ignore)

    def test_compare_obj_with_allow_missing_unequal(self):
        # If the tested key is in allow_missing, but both the obj and db_obj
        # have the value set, we should still check it for equality
        mock_test = mock.Mock()
        mock_test.assertEqual = mock.Mock()
        my_obj = self.MyComparedObject(foo=1, bar=2)
        my_db_obj = {'foo': 1, 'bar': 1}
        ignore = ['bar']

        fixture.compare_obj(mock_test, my_obj, my_db_obj, allow_missing=ignore)

        expected_calls = [(1, 1), (1, 2)]
        actual_calls = [c[0] for c in mock_test.assertEqual.call_args_list]
        for call in expected_calls:
            self.assertIn(call, actual_calls)

    def test_compare_obj_with_subs(self):
        mock_test = mock.Mock()
        mock_test.assertEqual = mock.Mock()
        my_obj = self.MyComparedObject(foo=1, bar=2)
        my_db_obj = {'doo': 1, 'bar': 2}
        subs = {'foo': 'doo'}

        fixture.compare_obj(mock_test, my_obj, my_db_obj, subs=subs)

        expected_calls = [(1, 1), (2, 2)]
        actual_calls = [c[0] for c in mock_test.assertEqual.call_args_list]
        for call in expected_calls:
            self.assertIn(call, actual_calls)

    def test_compare_obj_with_allow_missing(self):
        mock_test = mock.Mock()
        mock_test.assertEqual = mock.Mock()
        my_obj = self.MyComparedObject(foo=1)
        my_db_obj = {'foo': 1, 'bar': 2}
        ignores = ['bar']

        fixture.compare_obj(
            mock_test, my_obj, my_db_obj, allow_missing=ignores
        )

        mock_test.assertEqual.assert_called_once_with(1, 1)

    def test_compare_obj_with_comparators(self):
        mock_test = mock.Mock()
        mock_test.assertEqual = mock.Mock()
        comparator = mock.Mock()
        comp_dict = {'foo': comparator}
        my_obj = self.MyComparedObject(foo=1, bar=2)
        my_db_obj = {'foo': 1, 'bar': 2}

        fixture.compare_obj(
            mock_test, my_obj, my_db_obj, comparators=comp_dict
        )

        comparator.assert_called_once_with(1, 1)
        mock_test.assertEqual.assert_called_once_with(2, 2)

    def test_compare_obj_with_dt(self):
        mock_test = mock.Mock()
        mock_test.assertEqual = mock.Mock()
        dt = datetime.datetime(1955, 11, 5, tzinfo=datetime.UTC)
        replaced_dt = dt.replace(tzinfo=None)
        my_obj = self.MyComparedObjectWithTZ(tzfield=dt)
        my_db_obj = {'tzfield': replaced_dt}

        fixture.compare_obj(mock_test, my_obj, my_db_obj)

        mock_test.assertEqual.assert_called_once_with(replaced_dt, replaced_dt)


class FakeResource(base.VersionedObject):
    # Version 1.0: Initial version
    VERSION = '1.0'

    fields = {'identifier': fields.Field(fields.Integer(), default=123)}


class TestObjectVersionChecker(test.TestCase):
    def setUp(self):
        super().setUp()
        objects = [
            MyObject,
            MyObject2,
        ]
        self.obj_classes = {obj.__name__: [obj] for obj in objects}
        self.ovc = fixture.ObjectVersionChecker(obj_classes=self.obj_classes)

    def test_get_hashes(self):
        # Make sure get_hashes retrieves the fingerprint of all objects
        fp = 'ashketchum'
        with mock.patch.object(self.ovc, '_get_fingerprint') as mock_gf:
            mock_gf.return_value = fp
            actual = self.ovc.get_hashes()

        expected = self._generate_hashes(self.obj_classes, fp)
        self.assertEqual(
            expected,
            actual,
            "ObjectVersionChecker is not getting the fingerprints of all "
            "registered objects.",
        )

    def test_get_hashes_with_extra_data(self):
        # Make sure get_hashes uses the extra_data_func
        fp = 'garyoak'
        mock_func = mock.MagicMock()
        with mock.patch.object(self.ovc, '_get_fingerprint') as mock_gf:
            mock_gf.return_value = fp
            actual = self.ovc.get_hashes(extra_data_func=mock_func)

        expected = self._generate_hashes(self.obj_classes, fp)
        expected_calls = [
            ((name,), {'extra_data_func': mock_func})
            for name in self.obj_classes.keys()
        ]

        self.assertEqual(
            expected,
            actual,
            "ObjectVersionChecker is not getting the fingerprints of all "
            "registered objects.",
        )

        self.assertEqual(
            len(expected_calls),
            len(mock_gf.call_args_list),
            "get_hashes() did not call get the fingerprints of "
            "all objects in the registry.",
        )
        for call in expected_calls:
            self.assertIn(
                call,
                mock_gf.call_args_list,
                "get_hashes() did not call _get_fingerprint()correctly.",
            )

    def test_test_hashes_none_changed(self):
        # Make sure test_hashes() generates an empty dictionary when
        # there are no objects that have changed
        fp = 'pikachu'
        hashes = self._generate_hashes(self.obj_classes, fp)

        with mock.patch.object(self.ovc, 'get_hashes') as mock_gh:
            mock_gh.return_value = hashes
            # I'm so sorry, but they have to be named this way
            actual_expected, actual_actual = self.ovc.test_hashes(hashes)

        self.assertEqual(
            {},
            actual_expected,
            "There are no objects changed, so the 'expected' return value "
            "should contain no objects.",
        )
        self.assertEqual(
            {},
            actual_actual,
            "There are no objects changed, so the 'actual' return value "
            "should contain no objects.",
        )

    def test_test_hashes_class_not_added(self):
        # Make sure the expected and actual values differ when a class
        # was added to the registry, but not the static dictionary
        fp = 'gyrados'
        new_classes = copy.copy(self.obj_classes)
        self._add_class(new_classes, MyExtraObject)

        expected_hashes = self._generate_hashes(self.obj_classes, fp)
        actual_hashes = self._generate_hashes(new_classes, fp)

        with mock.patch.object(self.ovc, 'get_hashes') as mock_gh:
            mock_gh.return_value = actual_hashes
            actual_exp, actual_act = self.ovc.test_hashes(expected_hashes)

        expected_expected = {MyExtraObject.__name__: None}
        expected_actual = {MyExtraObject.__name__: fp}

        self.assertEqual(
            expected_expected,
            actual_exp,
            "Expected hashes should not contain the fingerprint of the class "
            "that has not been added to the expected hash dictionary.",
        )
        self.assertEqual(
            expected_actual,
            actual_act,
            "The actual hash should contain the class that was added to the "
            "registry.",
        )

    def test_test_hashes_new_fp_incorrect(self):
        # Make sure the expected and actual values differ when a fingerprint
        # was changed, but the static dictionary was not updated
        fp1 = 'beedrill'
        fp2 = 'snorlax'
        expected_hashes = self._generate_hashes(self.obj_classes, fp1)
        actual_hashes = copy.copy(expected_hashes)

        actual_hashes[MyObject.__name__] = fp2

        with mock.patch.object(self.ovc, 'get_hashes') as mock_gh:
            mock_gh.return_value = actual_hashes
            actual_exp, actual_act = self.ovc.test_hashes(expected_hashes)

        expected_expected = {MyObject.__name__: fp1}
        expected_actual = {MyObject.__name__: fp2}

        self.assertEqual(
            expected_expected,
            actual_exp,
            "Expected hashes should contain the updated object with the old "
            "hash.",
        )
        self.assertEqual(
            expected_actual,
            actual_act,
            "Actual hashes should contain the updated object with the new "
            "hash.",
        )

    def test_test_hashes_passes_extra_func(self):
        # Make sure that test_hashes passes the extra_func to get_hashes
        mock_extra_func = mock.Mock()

        with mock.patch.object(self.ovc, 'get_hashes') as mock_get_hashes:
            self.ovc.test_hashes({}, extra_data_func=mock_extra_func)

            mock_get_hashes.assert_called_once_with(
                extra_data_func=mock_extra_func
            )

    def test_get_dependency_tree(self):
        # Make sure get_dependency_tree() gets the dependencies of all
        # objects in the registry
        with mock.patch.object(self.ovc, '_get_dependencies') as mock_gd:
            self.ovc.get_dependency_tree()

        expected_calls: list[Any] = [(({}, MyObject),), (({}, MyObject2),)]

        self.assertEqual(
            2,
            len(mock_gd.call_args_list),
            "get_dependency_tree() tried to get the dependencies too many "
            "times.",
        )

        for call in expected_calls:
            self.assertIn(
                call,
                mock_gd.call_args_list,
                "get_dependency_tree() did not get the dependencies of the "
                "objects correctly.",
            )

    def test_test_relationships_none_changed(self):
        # Make sure test_relationships() generates an empty dictionary when
        # no relationships have been changed
        dep_tree: dict[str, Any] = {}
        # tree will be {'MyObject': {'MyObject2': '1.0'}}
        self._add_dependency(MyObject, MyObject2, dep_tree)

        with mock.patch.object(self.ovc, 'get_dependency_tree') as mock_gdt:
            mock_gdt.return_value = dep_tree
            actual_exp, actual_act = self.ovc.test_relationships(dep_tree)

        self.assertEqual(
            {},
            actual_exp,
            "There are no objects changed, so the 'expected' return value "
            "should contain no objects.",
        )
        self.assertEqual(
            {},
            actual_act,
            "There are no objects changed, so the 'actual' return value "
            "should contain no objects.",
        )

    def test_test_relationships_rel_added(self):
        # Make sure expected and actual relationships differ if a
        # relationship is added to a class
        exp_tree: dict[str, Any] = {}
        actual_tree: dict[str, Any] = {}
        self._add_dependency(MyObject, MyObject2, exp_tree)
        self._add_dependency(MyObject, MyObject2, actual_tree)
        self._add_dependency(MyObject, MyExtraObject, actual_tree)

        with mock.patch.object(self.ovc, 'get_dependency_tree') as mock_gdt:
            mock_gdt.return_value = actual_tree
            actual_exp, actual_act = self.ovc.test_relationships(exp_tree)

        expected_expected = {'MyObject': {'MyObject2': '1.0'}}
        expected_actual = {
            'MyObject': {'MyObject2': '1.0', 'MyExtraObject': '1.0'}
        }

        self.assertEqual(
            expected_expected,
            actual_exp,
            "The expected relationship tree is not being built from changes "
            "correctly.",
        )
        self.assertEqual(
            expected_actual,
            actual_act,
            "The actual relationship tree is not being built from changes "
            "correctly.",
        )

    def test_test_relationships_class_added(self):
        # Make sure expected and actual relationships differ if a new
        # class is added to the relationship tree
        exp_tree: dict[str, Any] = {}
        actual_tree: dict[str, Any] = {}
        self._add_dependency(MyObject, MyObject2, exp_tree)
        self._add_dependency(MyObject, MyObject2, actual_tree)
        self._add_dependency(MyObject2, MyExtraObject, actual_tree)

        with mock.patch.object(self.ovc, 'get_dependency_tree') as mock_gdt:
            mock_gdt.return_value = actual_tree
            actual_exp, actual_act = self.ovc.test_relationships(exp_tree)

        expected_expected = {'MyObject2': None}
        expected_actual = {'MyObject2': {'MyExtraObject': '1.0'}}

        self.assertEqual(
            expected_expected,
            actual_exp,
            "The expected relationship tree is not being built from changes "
            "correctly.",
        )
        self.assertEqual(
            expected_actual,
            actual_act,
            "The actual relationship tree is not being built from changes "
            "correctly.",
        )

    def test_test_compatibility_routines(self):
        # Make sure test_compatibility_routines() checks the object
        # compatibility of all objects in the registry
        del self.ovc.obj_classes[MyObject2.__name__]

        with mock.patch.object(self.ovc, '_test_object_compatibility') as toc:
            self.ovc.test_compatibility_routines()

        toc.assert_called_once_with(
            MyObject, manifest=None, init_args=[], init_kwargs={}
        )

    def test_test_compatibility_routines_with_manifest(self):
        # Make sure test_compatibility_routines() uses the version manifest
        del self.ovc.obj_classes[MyObject2.__name__]
        man = {'who': 'cares'}

        with mock.patch.object(self.ovc, '_test_object_compatibility') as toc:
            with mock.patch(
                'oslo_versionedobjects.base.obj_tree_get_versions'
            ) as otgv:
                otgv.return_value = man
                self.ovc.test_compatibility_routines(use_manifest=True)

        otgv.assert_called_once_with(MyObject.__name__)
        toc.assert_called_once_with(
            MyObject, manifest=man, init_args=[], init_kwargs={}
        )

    def test_test_compatibility_routines_with_args_kwargs(self):
        # Make sure test_compatibility_routines() uses init args/kwargs
        del self.ovc.obj_classes[MyObject2.__name__]

        with mock.patch.object(self.ovc, '_test_object_compatibility') as toc:
            self.ovc.test_compatibility_routines(
                init_args={MyObject: [1]},
                init_kwargs={MyObject: {'foo': 'bar'}},
            )

        toc.assert_called_once_with(
            MyObject, manifest=None, init_args=[1], init_kwargs={'foo': 'bar'}
        )

    def test_test_relationships_in_order(self):
        # Make sure test_relationships_in_order() tests the relationships
        # of all objects in the registry
        with mock.patch.object(
            self.ovc, '_test_relationships_in_order'
        ) as mock_tr:
            self.ovc.test_relationships_in_order()

        expected_calls = [((MyObject,),), ((MyObject2,),)]

        self.assertEqual(
            2,
            len(mock_tr.call_args_list),
            "test_relationships_in_order() tested too many relationships.",
        )
        for call in expected_calls:
            self.assertIn(
                call,
                mock_tr.call_args_list,
                "test_relationships_in_order() did not test the "
                "relationships of the individual objects "
                "correctly.",
            )

    def test_test_relationships_in_order_positive(self):
        # Make sure a correct relationship ordering doesn't blow up
        rels = {'bellsprout': [('1.0', '1.0'), ('1.1', '1.2'), ('1.3', '1.3')]}
        MyObject.obj_relationships = rels

        self.ovc._test_relationships_in_order(MyObject)

    def test_test_relationships_in_order_negative(self):
        # Make sure an out-of-order relationship does blow up
        rels = {'rattata': [('1.0', '1.0'), ('1.1', '1.2'), ('1.3', '1.1')]}
        MyObject.obj_relationships = rels

        self.assertRaises(
            AssertionError, self.ovc._test_relationships_in_order, MyObject
        )

    def test_find_remotable_method(self):
        # Make sure we can find a remotable method on an object
        method = self.ovc._find_remotable_method(
            MyObject, MyObject.remotable_method
        )

        self.assertEqual(
            getattr(MyObject.remotable_method, 'original_fn'),
            method,
            "_find_remotable_method() did not find the remotable "
            "method of MyObject.",
        )

    def test_find_remotable_method_classmethod(self):
        # Make sure we can find a remotable classmethod on an object
        rcm = MyObject.remotable_classmethod
        method = self.ovc._find_remotable_method(MyObject, rcm)

        expected = rcm.__get__(None, MyObject).original_fn
        self.assertEqual(
            expected,
            method,
            "_find_remotable_method() did not find the remotable classmethod.",
        )

    def test_find_remotable_method_non_remotable_method(self):
        # Make sure nothing is found when we have only a non-remotable method
        nrm = MyObject.non_remotable_method
        method = self.ovc._find_remotable_method(MyObject, nrm)

        self.assertIsNone(
            method,
            "_find_remotable_method() found a method that isn't remotable.",
        )

    def test_find_remotable_method_non_remotable_classmethod(self):
        # Make sure we don't find a non-remotable classmethod
        nrcm = MyObject.non_remotable_classmethod
        method = self.ovc._find_remotable_method(MyObject, nrcm)

        self.assertIsNone(
            method,
            "_find_remotable_method() found a method that isn't remotable.",
        )

    def test_get_fingerprint(self):
        # Make sure _get_fingerprint() generates a consistent fingerprint
        MyObject.VERSION = '1.1'
        argspec = 'vulpix'

        with mock.patch.object(fixture, 'get_method_spec') as mock_gas:
            mock_gas.return_value = argspec
            fp = self.ovc._get_fingerprint(MyObject.__name__)

        exp_fields = sorted(list(MyObject.fields.items()))
        exp_methods = sorted(
            [('remotable_method', argspec), ('remotable_classmethod', argspec)]
        )
        expected_relevant_data = (exp_fields, exp_methods)
        expected_hash = hashlib.md5(
            bytes(repr(expected_relevant_data).encode()), usedforsecurity=False
        ).hexdigest()
        expected_fp = f'{MyObject.VERSION}-{expected_hash}'

        self.assertEqual(
            expected_fp,
            fp,
            "_get_fingerprint() did not generate a correct fingerprint.",
        )

    def test_get_fingerprint_with_child_versions(self):
        # Make sure _get_fingerprint() generates a consistent fingerprint
        # when child_versions are present
        child_versions = {'1.0': '1.0', '1.1': '1.1'}
        MyObject.child_versions = child_versions  # type: ignore[attr-defined]
        argspec = 'onix'

        with mock.patch.object(fixture, 'get_method_spec') as mock_gas:
            mock_gas.return_value = argspec
            fp = self.ovc._get_fingerprint(MyObject.__name__)

        exp_fields = sorted(list(MyObject.fields.items()))
        exp_methods = sorted(
            [('remotable_method', argspec), ('remotable_classmethod', argspec)]
        )
        exp_child_versions = fixture.OsloOrderedDict(
            sorted(child_versions.items())
        )
        exp_relevant_data = (exp_fields, exp_methods, exp_child_versions)

        expected_hash = hashlib.md5(
            bytes(repr(exp_relevant_data).encode()), usedforsecurity=False
        ).hexdigest()
        expected_fp = f'{MyObject.VERSION}-{expected_hash}'

        self.assertEqual(
            expected_fp,
            fp,
            "_get_fingerprint() did not generate a correct fingerprint.",
        )

    def test_get_fingerprint_with_extra_data(self):
        # Make sure _get_fingerprint() uses extra_data_func when it is
        # supplied
        class ExtraDataObj(base.VersionedObject):
            pass

        def get_data(obj_class):
            return (obj_class,)

        ExtraDataObj.VERSION = '1.1'
        argspec = 'cubone'
        self._add_class(self.obj_classes, ExtraDataObj)

        with mock.patch.object(fixture, 'get_method_spec') as mock_gas:
            mock_gas.return_value = argspec
            fp = self.ovc._get_fingerprint(
                ExtraDataObj.__name__, extra_data_func=get_data
            )

        expected_hash = hashlib.md5(
            bytes(repr(([], [], ExtraDataObj)).encode()),
            usedforsecurity=False,
        ).hexdigest()
        expected_fp = f'{ExtraDataObj.VERSION}-{expected_hash}'

        self.assertEqual(
            expected_fp,
            fp,
            "_get_fingerprint() did not generate a correct fingerprint.",
        )

    def test_get_fingerprint_with_defaulted_set(self):
        class ClassWithDefaultedSetField(base.VersionedObject):
            VERSION = '1.0'
            fields = {
                'empty_default': fields.SetOfIntegersField(default=set()),
                'non_empty_default': fields.SetOfIntegersField(default={1, 2}),
            }

        self._add_class(self.obj_classes, ClassWithDefaultedSetField)

        # it is expected that this hash is stable across python versions
        expected = '1.0-bcc44920f2f727eca463c6eb4fe8445b'
        actual = self.ovc._get_fingerprint(ClassWithDefaultedSetField.__name__)
        self.assertEqual(expected, actual)

    def test_get_dependencies(self):
        # Make sure _get_dependencies() generates a correct tree when parsing
        # an object
        self._add_class(self.obj_classes, MyExtraObject)
        MyObject.fields['subob'] = fields.ObjectField('MyExtraObject')
        MyExtraObject.VERSION = '1.0'
        tree: dict[str, dict[str, str]] = {}

        self.ovc._get_dependencies(tree, MyObject)

        expected_tree = {'MyObject': {'MyExtraObject': '1.0'}}

        self.assertEqual(
            expected_tree,
            tree,
            "_get_dependencies() did not generate a correct dependency tree.",
        )

    def test_test_object_compatibility(self):
        # Make sure _test_object_compatibility() tests obj_to_primitive()
        # on each prior version to the current version
        to_prim = mock.MagicMock(spec=callable)
        MyObject.VERSION = '1.1'
        MyObject.obj_to_primitive = to_prim  # type: ignore[method-assign]

        self.ovc._test_object_compatibility(MyObject)

        expected_calls = [
            ((), {'target_version': '1.0'}),
            ((), {'target_version': '1.1'}),
        ]

        self.assertEqual(
            expected_calls,
            to_prim.call_args_list,
            "_test_object_compatibility() did not test obj_to_primitive() on "
            "the correct target versions",
        )

    def test_test_object_compatibility_args_kwargs(self):
        # Make sure _test_object_compatibility() tests obj_to_primitive()
        # with the correct args and kwargs to init
        to_prim = mock.MagicMock(spec=callable)
        MyObject.obj_to_primitive = to_prim  # type: ignore[method-assign]
        MyObject.VERSION = '1.1'
        args = [1]
        kwargs = {'foo': 'bar'}

        with mock.patch.object(
            MyObject, '__init__', return_value=None
        ) as mock_init:
            self.ovc._test_object_compatibility(
                MyObject, init_args=args, init_kwargs=kwargs
            )

        expected_init = ((1,), {'foo': 'bar'})
        expected_init_calls = [expected_init, expected_init]
        self.assertEqual(
            expected_init_calls,
            mock_init.call_args_list,
            "_test_object_compatibility() did not call __init__() properly "
            "on the object",
        )

        expected_to_prim = [
            ((), {'target_version': '1.0'}),
            ((), {'target_version': '1.1'}),
        ]
        self.assertEqual(
            expected_to_prim,
            to_prim.call_args_list,
            "_test_object_compatibility() did not test obj_to_primitive() "
            "on the correct target versions",
        )

    def _add_class(self, obj_classes, cls):
        obj_classes[cls.__name__] = [cls]

    def _generate_hashes(self, classes, fp):
        # Generate hashes for classes, giving fp as the fingerprint
        # for all classes
        return {cls: fp for cls in classes.keys()}

    def _add_dependency(self, parent_cls, child_cls, tree):
        # Add a dependency to the tree with the parent class holding
        # version 1.0 of the given child class
        deps = tree.get(parent_cls.__name__, {})
        deps[child_cls.__name__] = '1.0'
        tree[parent_cls.__name__] = deps


class TestVersionedObjectRegistryFixture(test.TestCase):
    primitive = {
        'versioned_object.name': 'FakeResource',
        'versioned_object.namespace': 'versionedobjects',
        'versioned_object.version': '1.0',
        'versioned_object.data': {'identifier': 123},
    }

    def test_object_registered_temporarily(self):
        # Test object that has not been registered
        self.assertRaises(
            exception.UnsupportedObjectError,
            FakeResource.obj_from_primitive,
            self.primitive,
        )

        with fixture.VersionedObjectRegistryFixture() as obj_registry:
            # Register object locally
            obj_registry.setUp()
            obj_registry.register(FakeResource)

            # Test object has now been registered
            obj = FakeResource.obj_from_primitive(self.primitive)
            self.assertEqual(obj.identifier, 123)
            self.assertEqual('1.0', obj.VERSION)

        # Test object that is no longer registered
        self.assertRaises(
            exception.UnsupportedObjectError,
            FakeResource.obj_from_primitive,
            self.primitive,
        )


class TestStableObjectJsonFixture(test.TestCase):
    def test_changes_sort(self):
        @base.VersionedObjectRegistry.register_if(False)
        class TestObject(base.VersionedObject):
            fields = {'z': fields.StringField(), 'a': fields.StringField()}

            def obj_what_changed(self):
                return ['z', 'a']

        obj = TestObject(a='foo', z='bar')
        self.assertEqual(
            ['z', 'a'], obj.obj_to_primitive()['versioned_object.changes']
        )
        with fixture.StableObjectJsonFixture():
            self.assertEqual(
                ['a', 'z'], obj.obj_to_primitive()['versioned_object.changes']
            )


class TestMethodSpec(test.TestCase):
    def setUp(self):
        super().setUp()

        def test_method1(a, b, kw1=123, **kwargs):
            pass

        def test_method2(a, b, *args):
            pass

        def test_method3(a, b, *args, kw1=123, **kwargs):
            pass

        self._test_method1 = test_method1
        self._test_method2 = test_method2
        self._test_method3 = test_method3

    def test_method_spec_compat(self):
        self.assertEqual(
            fixture.ArgSpec(
                args=['a', 'b', 'kw1'],
                varargs=None,
                keywords='kwargs',
                defaults=(123,),
            ),
            fixture.get_method_spec(self._test_method1),
        )
        self.assertEqual(
            fixture.ArgSpec(
                args=['a', 'b'], varargs='args', keywords=None, defaults=None
            ),
            fixture.get_method_spec(self._test_method2),
        )
        self.assertEqual(
            inspect.getfullargspec(self._test_method3),
            fixture.get_method_spec(self._test_method3),
        )
