Skip to content

Commit

Permalink
Merge pull request #148 from nautobot/tests
Browse files Browse the repository at this point in the history
Tests
  • Loading branch information
abates authored May 20, 2024
2 parents 61a72dc + 432460e commit c86d637
Show file tree
Hide file tree
Showing 23 changed files with 680 additions and 161 deletions.
9 changes: 4 additions & 5 deletions nautobot_design_builder/contrib/tests/test_ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@

import os

from django.test import TestCase
from nautobot_design_builder.tests.test_builder import BuilderTestCase

from nautobot_design_builder.tests.test_builder import builder_test_case


@builder_test_case(os.path.join(os.path.dirname(__file__), "testdata"))
class TestAgnosticExtensions(TestCase):
class TestAgnosticExtensions(BuilderTestCase):
"""Test contrib extensions against any version of Nautobot."""

data_dir = os.path.join(os.path.dirname(__file__), "testdata")
59 changes: 51 additions & 8 deletions nautobot_design_builder/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,25 +213,49 @@ class ManyToManyField(BaseModelField, RelationshipFieldMixin): # pylint:disable

def __init__(self, field: django_models.Field): # noqa:D102
super().__init__(field)
if hasattr(field.remote_field, "through"):
through = field.remote_field.through
if not through._meta.auto_created:
self.related_model = through
self.auto_through = True
self.through_fields = field.remote_field.through_fields
through = field.remote_field.through
if not through._meta.auto_created:
self.auto_through = False
self.related_model = through
if field.remote_field.through_fields:
self.link_field = field.remote_field.through_fields[0]
else:
for f in through._meta.fields:
if f.related_model == field.model:
self.link_field = f.name

@debug_set
def __set__(self, obj: "ModelInstance", values): # noqa:D105
def setter():
items = []
for value in values:
value = self._get_instance(obj, value, getattr(obj.instance, self.field_name))
if self.auto_through:
# Only need to call `add` if the through relationship was
# auto-created. Otherwise we explicitly create the through
# object
items.append(value.instance)
else:
setattr(value.instance, self.link_field, obj.instance)
if value.metadata.created:
value.save()
items.append(value.instance)
getattr(obj.instance, self.field_name).add(*items)
else:
value.environment.journal.log(value)
if items:
getattr(obj.instance, self.field_name).add(*items)

obj.connect("POST_INSTANCE_SAVE", setter)


class ManyToManyRelField(ManyToManyField): # pylint:disable=too-few-public-methods
"""Reverse many to many relationship field."""

def __init__(self, field: django_models.Field): # noqa:D102
super().__init__(field.remote_field)


class GenericRelationField(BaseModelField, RelationshipFieldMixin): # pylint:disable=too-few-public-methods
"""Generic relationship field."""

Expand Down Expand Up @@ -259,13 +283,30 @@ def __set__(self, obj: "ModelInstance", value): # noqa:D105
setattr(obj.instance, ct_field, ContentType.objects.get_for_model(value.instance))


class TagField(ManyToManyField): # pylint:disable=too-few-public-methods
class TagField(BaseModelField, RelationshipFieldMixin): # pylint:disable=too-few-public-methods
"""Taggit field."""

def __init__(self, field: django_models.Field): # noqa:D102
super().__init__(field)
self.related_model = field.remote_field.model

def __set__(self, obj: "ModelInstance", values): # noqa:D105
# I hate that this code is almost identical to the ManyToManyField
# __set__ code, but I don't see an easy way to DRY it up at the
# moment.
def setter():
items = []
for value in values:
value = self._get_instance(obj, value, getattr(obj.instance, self.field_name))
if value.metadata.created:
value.save()
else:
value.environment.journal.log(value)
items.append(value.instance)
getattr(obj.instance, self.field_name).add(*items)

obj.connect("POST_INSTANCE_SAVE", setter)


class GenericRelField(BaseModelField, RelationshipFieldMixin): # pylint:disable=too-few-public-methods
"""Field used as part of content-types generic relation."""
Expand Down Expand Up @@ -348,8 +389,10 @@ def field_factory(arg1, arg2) -> ModelField:
field = ForeignKeyField(arg2)
elif isinstance(arg2, django_models.ManyToOneRel):
field = ManyToOneRelField(arg2)
elif isinstance(arg2, (django_models.ManyToManyField, django_models.ManyToManyRel)):
elif isinstance(arg2, django_models.ManyToManyField):
field = ManyToManyField(arg2)
elif isinstance(arg2, django_models.ManyToManyRel):
field = ManyToManyRelField(arg2)
else:
raise DesignImplementationError(f"Cannot manufacture field for {type(arg2)}, {arg2} {arg2.is_relation}")
return field
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,22 @@
class Command(BaseCommand):
"""Create a git datasource pointed to the demo designs repo."""

def add_arguments(self, parser):
"""Add the branch argument to the command."""
parser.add_argument(
"--branch",
action="store",
help="Specify which branch to use in the demo-design repository (default: main).",
default="main",
)

def handle(self, *args, **options):
"""Handle the execution of the command."""
GitRepository.objects.get_or_create(
name="Demo Designs",
defaults={
"remote_url": "https://github.com/nautobot/demo-designs.git",
"branch": "main",
"branch": options["branch"],
"provided_contents": ["extras.job"],
},
)
99 changes: 69 additions & 30 deletions nautobot_design_builder/tests/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import importlib
from operator import attrgetter
import os
from unittest.mock import Mock, patch
from unittest.mock import patch
import yaml

from django.db.models import Manager, Q
Expand Down Expand Up @@ -64,6 +64,24 @@ def check_model_not_exist(test, check, index):
values = _get_value(check)
test.assertEqual(len(values), 0, msg=f"Check {index}")

@staticmethod
def check_in(test, check, index):
"""Check that a model does not exist."""
value0 = _get_value(check[0])[0]
value1 = _get_value(check[1])
if len(value1) == 1:
value1 = value1[0]
test.assertIn(value0, value1, msg=f"Check {index}")

@staticmethod
def check_not_in(test, check, index):
"""Check that a model does not exist."""
value0 = _get_value(check[0])[0]
value1 = _get_value(check[1])
if len(value1) == 1:
value1 = value1[0]
test.assertNotIn(value0, value1, msg=f"Check {index}")


def _get_value(check_info):
if "value" in check_info:
Expand Down Expand Up @@ -105,48 +123,69 @@ def _testcases(data_dir):
yield yaml.safe_load(file), filename


def builder_test_case(data_dir):
"""Decorator to load tests into a TestCase from a data directory."""
class _BuilderTestCaseMeta(type):
def __new__(mcs, name, bases, dct):
cls = super().__new__(mcs, name, bases, dct)
data_dir = getattr(cls, "data_dir", None)
if data_dir is None:
return cls

def class_wrapper(test_class):
for testcase, filename in _testcases(data_dir):
if testcase.get("abstract", False):
continue
# Strip the .yaml extension
testcase_name = f"test_{filename[:-5]}"

# Create a new closure for testcase
def test_wrapper(testcase):
@patch("nautobot_design_builder.design.Environment.roll_back")
def test_runner(self, roll_back: Mock):
def test_runner(self: "BuilderTestCase"):
if testcase.get("skip", False):
self.skipTest("Skipping due to testcase skip=true")
extensions = []
for extension in testcase.get("extensions", []):
extensions.append(_load_class(extension))

with self.captureOnCommitCallbacks(execute=True):
for design in testcase["designs"]:
environment = Environment(extensions=extensions)
commit = design.pop("commit", True)
environment.implement_design(design=design, commit=commit)
if not commit:
roll_back.assert_called()

for index, check in enumerate(testcase.get("checks", [])):
for check_name, args in check.items():
_check_name = f"check_{check_name}"
if hasattr(BuilderChecks, _check_name):
getattr(BuilderChecks, _check_name)(self, args, index)
else:
raise ValueError(f"Unknown check {check_name} {check}")
self._run_test_case(testcase, cls.data_dir) # pylint:disable=protected-access

return test_runner

setattr(test_class, testcase_name, test_wrapper(testcase))
return test_class
setattr(cls, testcase_name, test_wrapper(testcase))
return cls


class BuilderTestCase(TestCase, metaclass=_BuilderTestCaseMeta): # pylint:disable=missing-class-docstring
def _run_checks(self, checks):
for index, check in enumerate(checks):
for check_name, args in check.items():
_check_name = f"check_{check_name}"
if hasattr(BuilderChecks, _check_name):
getattr(BuilderChecks, _check_name)(self, args, index)
else:
raise ValueError(f"Unknown check {check_name} {check}")

def _run_test_case(self, testcase, data_dir):
with patch("nautobot_design_builder.design.Environment.roll_back") as roll_back:
self._run_checks(testcase.get("pre_checks", []))

return class_wrapper
depends_on = testcase.pop("depends_on", None)
if depends_on:
depends_on_path = os.path.join(data_dir, depends_on)
depends_on_dir = os.path.dirname(depends_on_path)
with open(depends_on_path, encoding="utf-8") as file:
self._run_test_case(yaml.safe_load(file), depends_on_dir)

extensions = []
for extension in testcase.get("extensions", []):
extensions.append(_load_class(extension))

@builder_test_case(os.path.join(os.path.dirname(__file__), "testdata"))
class TestGeneralDesigns(TestCase):
with self.captureOnCommitCallbacks(execute=True):
for design in testcase["designs"]:
environment = Environment(extensions=extensions)
commit = design.pop("commit", True)
environment.implement_design(design=design, commit=commit)
if not commit:
roll_back.assert_called()

self._run_checks(testcase.get("checks", []))


class TestGeneralDesigns(BuilderTestCase):
"""Designs that should work with all versions of Nautobot."""

data_dir = os.path.join(os.path.dirname(__file__), "testdata")
19 changes: 19 additions & 0 deletions nautobot_design_builder/tests/testdata/assign_tags_by_name.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
depends_on: "base_test.yaml"
designs:
- tags:
- name: "Test Tag"
description: "Some Description"

locations:
- name: "site_1"
location_type__name: "Site"
status__name: "Active"
tags:
- {"!get:name": "Test Tag"}
checks:
- equal:
- model: "nautobot.dcim.models.Location"
query: {name: "site_1"}
attribute: "tags"
- model: "nautobot.extras.models.Tag"
20 changes: 20 additions & 0 deletions nautobot_design_builder/tests/testdata/assign_tags_by_ref.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
depends_on: "base_test.yaml"
designs:
- tags:
- name: "Test Tag"
"!ref": "test_tag"
description: "Some Description"

locations:
- name: "site_1"
location_type__name: "Site"
status__name: "Active"
tags:
- "!ref:test_tag"
checks:
- equal:
- model: "nautobot.dcim.models.Location"
query: {name: "site_1"}
attribute: "tags"
- model: "nautobot.extras.models.Tag"
42 changes: 42 additions & 0 deletions nautobot_design_builder/tests/testdata/base_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
abstract: true
designs:
- manufacturers:
- name: "manufacturer1"

device_types:
- manufacturer__name: "manufacturer1"
model: "model name"
u_height: 1

roles:
- name: "device role"
content_types:
- "!get:app_label": "dcim"
"!get:model": "device"

location_types:
- name: "Site"
content_types:
- "!get:app_label": "circuits"
"!get:model": "circuittermination"
- "!get:app_label": "dcim"
"!get:model": "device"
- "!get:app_label": "dcim"
"!get:model": "powerpanel"
- "!get:app_label": "dcim"
"!get:model": "rack"
- "!get:app_label": "dcim"
"!get:model": "rackgroup"
- "!get:app_label": "ipam"
"!get:model": "prefix"
- "!get:app_label": "ipam"
"!get:model": "vlan"
- "!get:app_label": "ipam"
"!get:model": "vlangroup"
- "!get:app_label": "virtualization"
"!get:model": "cluster"
locations:
- "name": "Site"
"location_type__name": "Site"
"status__name": "Active"
Loading

0 comments on commit c86d637

Please sign in to comment.