Skip to content

Commit c0ee2ad

Browse files
authored
Merge pull request #118 from netboxlabs/unit-test-suite
Unit test suite (1st pass)
2 parents 11ce9f5 + 573cc7f commit c0ee2ad

File tree

7 files changed

+916
-16
lines changed

7 files changed

+916
-16
lines changed

.github/workflows/lint-tests.yaml

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: Lint and tests
2+
on:
3+
workflow_dispatch:
4+
pull_request:
5+
push:
6+
7+
concurrency:
8+
group: ${{ github.workflow }}
9+
cancel-in-progress: false
10+
11+
permissions:
12+
contents: write
13+
checks: write
14+
pull-requests: write
15+
16+
jobs:
17+
lint:
18+
runs-on: ubuntu-latest
19+
timeout-minutes: 10
20+
steps:
21+
- name: Checkout
22+
uses: actions/checkout@v4
23+
- name: Setup Python
24+
uses: actions/setup-python@v5
25+
with:
26+
python-version: "3.10"
27+
- name: Install dependencies
28+
run: |
29+
python -m pip install --upgrade pip
30+
pip install .
31+
pip install .[dev]
32+
pip install .[test]
33+
- name: Run ruff
34+
run: ruff check
35+
tests:
36+
runs-on: ubuntu-latest
37+
timeout-minutes: 10
38+
strategy:
39+
matrix:
40+
python-version: [ "3.10", "3.11", "3.12" ]
41+
services:
42+
redis:
43+
image: redis
44+
ports:
45+
- 6379:6379
46+
postgres:
47+
image: postgres
48+
env:
49+
POSTGRES_USER: netbox
50+
POSTGRES_PASSWORD: netbox
51+
options: >-
52+
--health-cmd pg_isready
53+
--health-interval 10s
54+
--health-timeout 5s
55+
--health-retries 5
56+
ports:
57+
- 5432:5432
58+
steps:
59+
- name: Checkout netbox-custom-objects
60+
uses: actions/checkout@v4
61+
with:
62+
path: netbox-custom-objects
63+
- name: Setup Python ${{ matrix.python-version }}
64+
uses: actions/setup-python@v5
65+
with:
66+
python-version: ${{ matrix.python-version }}
67+
- name: Checkout netbox
68+
uses: actions/checkout@v4
69+
with:
70+
repository: "netbox-community/netbox"
71+
path: netbox
72+
- name: Install netbox-custom-objects
73+
working-directory: netbox-custom-objects
74+
run: |
75+
# Include tests directory for test
76+
sed -i 's/exclude-package-data/#exclude-package-data/g' pyproject.toml
77+
python -m pip install --upgrade pip
78+
pip install .
79+
pip install .[test]
80+
- name: Install dependencies & configure plugin
81+
working-directory: netbox
82+
run: |
83+
ln -s $(pwd)/../netbox-custom-objects/testing/configuration.py netbox/netbox/configuration.py
84+
85+
python -m pip install --upgrade pip
86+
pip install -r requirements.txt -U
87+
- name: Run tests
88+
working-directory: netbox
89+
run: |
90+
python netbox/manage.py test netbox_custom_objects.tests --keepdb

netbox_custom_objects/models.py

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,12 @@ def clone_fields(self):
9898
"""
9999
if not hasattr(self, 'custom_object_type_id'):
100100
return ()
101-
101+
102102
# Get all field names where is_cloneable=True for this custom object type
103103
cloneable_fields = self.custom_object_type.fields.filter(
104104
is_cloneable=True
105105
).values_list('name', flat=True)
106-
106+
107107
return tuple(cloneable_fields)
108108

109109
def get_absolute_url(self):
@@ -126,7 +126,6 @@ class CustomObjectType(NetBoxModel):
126126
# Class-level cache for generated models
127127
_model_cache = {}
128128
_through_model_cache = {} # Now stores {custom_object_type_id: {through_model_name: through_model}}
129-
130129
name = models.CharField(max_length=100, unique=True)
131130
description = models.TextField(blank=True)
132131
schema = models.JSONField(blank=True, default=dict)
@@ -441,7 +440,6 @@ def get_model(
441440
if self.id not in self._through_model_cache:
442441
self._through_model_cache[self.id] = {}
443442
self._through_model_cache[self.id][through_model_name] = through_model
444-
445443
return model
446444

447445
def create_model(self):
@@ -1138,19 +1136,19 @@ def save(self, *args, **kwargs):
11381136
else:
11391137
old_field = field_type.get_model_field(self.original)
11401138
old_field.contribute_to_class(model, self._original_name)
1141-
1139+
11421140
# Special handling for MultiObject fields when the name changes
1143-
if (self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT and
1141+
if (self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT and
11441142
self.name != self._original_name):
11451143
# For renamed MultiObject fields, we just need to rename the through table
11461144
old_through_table_name = self.original.through_table_name
11471145
new_through_table_name = self.through_table_name
1148-
1146+
11491147
# Check if old through table exists
11501148
with connection.cursor() as cursor:
11511149
tables = connection.introspection.table_names(cursor)
11521150
old_table_exists = old_through_table_name in tables
1153-
1151+
11541152
if old_table_exists:
11551153
# Create temporary models to represent the old and new through table states
11561154
old_through_meta = type(
@@ -1170,16 +1168,16 @@ def save(self, *args, **kwargs):
11701168
"Meta": old_through_meta,
11711169
"id": models.AutoField(primary_key=True),
11721170
"source": models.ForeignKey(
1173-
model, on_delete=models.CASCADE,
1171+
model, on_delete=models.CASCADE,
11741172
db_column="source_id", related_name="+"
11751173
),
11761174
"target": models.ForeignKey(
1177-
model, on_delete=models.CASCADE,
1175+
model, on_delete=models.CASCADE,
11781176
db_column="target_id", related_name="+"
11791177
),
11801178
},
11811179
)
1182-
1180+
11831181
new_through_meta = type(
11841182
"Meta",
11851183
(),
@@ -1197,22 +1195,23 @@ def save(self, *args, **kwargs):
11971195
"Meta": new_through_meta,
11981196
"id": models.AutoField(primary_key=True),
11991197
"source": models.ForeignKey(
1200-
model, on_delete=models.CASCADE,
1198+
model, on_delete=models.CASCADE,
12011199
db_column="source_id", related_name="+"
12021200
),
12031201
"target": models.ForeignKey(
1204-
model, on_delete=models.CASCADE,
1202+
model, on_delete=models.CASCADE,
12051203
db_column="target_id", related_name="+"
12061204
),
12071205
},
12081206
)
1209-
1207+
new_through_model # To silence ruff error
1208+
12101209
# Rename the table using Django's schema editor
12111210
schema_editor.alter_db_table(old_through_model, old_through_table_name, new_through_table_name)
12121211
else:
12131212
# No old table exists, create the new through table
12141213
field_type.create_m2m_table(self, model, self.name)
1215-
1214+
12161215
# Alter the field normally (this updates the field definition)
12171216
schema_editor.alter_field(model, old_field, model_field)
12181217
else:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Tests package for netbox_custom_objects plugin

netbox_custom_objects/tests/base.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
# Test utilities for netbox_custom_objects plugin
2+
from django.contrib.contenttypes.models import ContentType
3+
from django.test import Client
4+
from extras.models import CustomFieldChoiceSet
5+
from utilities.testing import create_test_user
6+
7+
from netbox_custom_objects.models import CustomObjectType, CustomObjectTypeField
8+
9+
10+
class CustomObjectsTestCase:
11+
"""
12+
Base test case for custom objects tests.
13+
"""
14+
15+
@classmethod
16+
def setUpTestData(cls):
17+
"""Set up test data that should be created once for the entire test class."""
18+
pass
19+
20+
def setUp(self):
21+
"""Set up test data."""
22+
self.user = create_test_user('testuser')
23+
self.client = Client()
24+
self.client.force_login(self.user)
25+
26+
@classmethod
27+
def create_custom_object_type(cls, **kwargs):
28+
"""Helper method to create a custom object type."""
29+
defaults = {
30+
'name': 'TestObject',
31+
'description': 'A test custom object type',
32+
'verbose_name_plural': 'Test Objects'
33+
}
34+
defaults.update(kwargs)
35+
return CustomObjectType.objects.create(**defaults)
36+
37+
@classmethod
38+
def create_custom_object_type_field(cls, custom_object_type, **kwargs):
39+
"""Helper method to create a custom object type field."""
40+
defaults = {
41+
'custom_object_type': custom_object_type,
42+
'name': 'test_field',
43+
'label': 'Test Field',
44+
'type': 'text'
45+
}
46+
defaults.update(kwargs)
47+
return CustomObjectTypeField.objects.create(**defaults)
48+
49+
@classmethod
50+
def create_choice_set(cls, **kwargs):
51+
"""Helper method to create a choice set."""
52+
defaults = {
53+
'name': 'Test Choice Set',
54+
'extra_choices': [
55+
['choice1', 'Choice 1'],
56+
['choice2', 'Choice 2'],
57+
['choice3', 'Choice 3'],
58+
]
59+
}
60+
defaults.update(kwargs)
61+
return CustomFieldChoiceSet.objects.create(**defaults)
62+
63+
@classmethod
64+
def get_device_content_type(cls):
65+
"""Get the device content type for object field testing."""
66+
return ContentType.objects.get(app_label='dcim', model='device')
67+
68+
@classmethod
69+
def get_site_content_type(cls):
70+
"""Get the site content type for object field testing."""
71+
return ContentType.objects.get(app_label='dcim', model='site')
72+
73+
def create_simple_custom_object_type(self, **kwargs):
74+
"""Create a simple custom object type with basic fields."""
75+
custom_object_type = CustomObjectsTestCase.create_custom_object_type(**kwargs)
76+
77+
# Add a text field as primary
78+
CustomObjectsTestCase.create_custom_object_type_field(
79+
custom_object_type,
80+
name="name",
81+
label="Name",
82+
type="text",
83+
primary=True,
84+
required=True
85+
)
86+
87+
# Add a description field
88+
CustomObjectsTestCase.create_custom_object_type_field(
89+
custom_object_type,
90+
name="description",
91+
label="Description",
92+
type="text",
93+
required=False
94+
)
95+
96+
return custom_object_type
97+
98+
def create_complex_custom_object_type(self, **kwargs):
99+
"""Create a complex custom object type with various field types."""
100+
custom_object_type = CustomObjectsTestCase.create_custom_object_type(**kwargs)
101+
choice_set = CustomObjectsTestCase.create_choice_set()
102+
device_content_type = CustomObjectsTestCase.get_device_content_type()
103+
104+
# Primary text field
105+
CustomObjectsTestCase.create_custom_object_type_field(
106+
custom_object_type,
107+
name="name",
108+
label="Name",
109+
type="text",
110+
primary=True,
111+
required=True
112+
)
113+
114+
# Integer field
115+
CustomObjectsTestCase.create_custom_object_type_field(
116+
custom_object_type,
117+
name="count",
118+
label="Count",
119+
type="integer",
120+
validation_minimum=0,
121+
validation_maximum=100
122+
)
123+
124+
# Boolean field
125+
CustomObjectsTestCase.create_custom_object_type_field(
126+
custom_object_type,
127+
name="active",
128+
label="Active",
129+
type="boolean",
130+
default=True
131+
)
132+
133+
# Select field
134+
CustomObjectsTestCase.create_custom_object_type_field(
135+
custom_object_type,
136+
name="status",
137+
label="Status",
138+
type="select",
139+
choice_set=choice_set
140+
)
141+
142+
# Object field (device)
143+
CustomObjectsTestCase.create_custom_object_type_field(
144+
custom_object_type,
145+
name="device",
146+
label="Device",
147+
type="object",
148+
related_object_type=device_content_type
149+
)
150+
151+
return custom_object_type
152+
153+
def create_self_referential_custom_object_type(self, **kwargs):
154+
"""Create a custom object type that can reference itself."""
155+
custom_object_type = CustomObjectsTestCase.create_custom_object_type(**kwargs)
156+
157+
# Primary text field
158+
CustomObjectsTestCase.create_custom_object_type_field(
159+
custom_object_type,
160+
name="name",
161+
label="Name",
162+
type="text",
163+
primary=True,
164+
required=True
165+
)
166+
167+
return custom_object_type
168+
169+
def create_multi_object_custom_object_type(self, **kwargs):
170+
"""Create a custom object type with multi-object fields."""
171+
custom_object_type = CustomObjectsTestCase.create_custom_object_type(**kwargs)
172+
device_content_type = CustomObjectsTestCase.get_device_content_type()
173+
site_content_type = CustomObjectsTestCase.get_site_content_type()
174+
175+
# Primary text field
176+
CustomObjectsTestCase.create_custom_object_type_field(
177+
custom_object_type,
178+
name="name",
179+
label="Name",
180+
type="text",
181+
primary=True,
182+
required=True
183+
)
184+
185+
# Multi-object field (devices)
186+
CustomObjectsTestCase.create_custom_object_type_field(
187+
custom_object_type,
188+
name="devices",
189+
label="Devices",
190+
type="multiobject",
191+
related_object_type=device_content_type
192+
)
193+
194+
# Multi-object field (sites)
195+
CustomObjectsTestCase.create_custom_object_type_field(
196+
custom_object_type,
197+
name="sites",
198+
label="Sites",
199+
type="multiobject",
200+
related_object_type=site_content_type
201+
)
202+
203+
return custom_object_type

0 commit comments

Comments
 (0)