From d98ce48d512382ab7d2496e6788f1d0992bda962 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 1 Oct 2026 15:13:54 +0200 Subject: [PATCH 01/13] feat(triggers): do nothing while netbox-branching merges, reverts or syncs netbox-branching merges, reverts and syncs a branch: it replays the changes that NetBox logged. A replayed update is a model save (netbox_branching/utilities.py:513), and a replayed create is a raw save (models/changes.py:131). So the plugin's receivers ran for each replayed module and device. After a merge, the reapply ran at the commit with the rules of main and outside event tracking: it renamed on main with no change record. During a sync, it renamed in the branch with no change record, so a merge never carried the rename. At startup, after the version check, the plugin now wraps Branch.merge, Branch.revert and Branch.sync once. Each wrapper marks its context with a ContextVar and resets the token in a finally, so the mark ends on a return, an early return and an error. While the mark is set, before_save and after_save return at once: no previous-state read, no trigger and no plan. The netbox-branching jobs call these methods (jobs.py:132, 217, 243), and the merge strategies run inside them (models/branches.py:1135-1139, 1211-1214). Rejected alternatives: - The pre_ and post_ signals. netbox-branching sends no post_ signal on the "No changes found" return (models/branches.py:923-924, 1120-1121, 1196-1197) or on an error (988-993, 1144-1150, 1219-1225). - A reset from the branch status. It reads a status that other workers share, and it adds one query for each trigger. - Skipping raw saves. It changes a fixture load on main. A merge or revert started in a shell with the branch active no longer raises at the alias check of the trigger. netbox-branching itself still refuses some replays there: its full_clean validates a replayed create against the active branch (models/changes.py:110), and the revert of a module move fails at COMMIT on the AppliedChange foreign key (utilities.py:525). That revert fails also with the plugin's receivers disconnected. The tests start each operation on netbox-branching's page and run its job from the queue, as a worker does: - A merge of an install and a move gives main the names of the branch, and main logs only replayed changes. A revert gives back the names from before. The rule exists on main only, so a trigger would rename. Both with the iterative and the squash strategy. - A sync writes no rename into the branch. The rule exists in the branch only. - After a merge without changes, a dry run, a failed merge, and a dry run followed by another worker's merge of the branch, an install on main still gets the names of the rule. - NetBox's channel cascade (dcim/models/mixins.py:302-337) renames a kept channel again at the commit of a merge and of a sync. A revert gives back the names from before. This pins the accepted limit. - A contract test checks that each method is wrapped once and keeps its signature, name and alters_data. The branch write tests now share one reader of the interfaces of a bay, on the active branch or on main. Each mutation turns tests red: no mark (the merges, the reverts, the sync and the active-branch test), a reset without a finally (the dry run and the failed merge), the signals with the status reset (the other-worker test), the signals alone (the three exit tests), skipped raw saves (the fixture load on main), and a second wrap (the contract test). Slice 6 of #143 (ADR 0016). --- CONTEXT.md | 2 +- netbox_interface_name_rules/__init__.py | 4 +- netbox_interface_name_rules/branching.py | 44 ++- .../rename_triggers.py | 13 +- .../tests/test_branch_replay.py | 346 ++++++++++++++++++ .../tests/test_branch_writes.py | 8 +- .../tests/test_branching.py | 31 +- 7 files changed, 436 insertions(+), 12 deletions(-) create mode 100644 netbox_interface_name_rules/tests/test_branch_replay.py diff --git a/CONTEXT.md b/CONTEXT.md index f3a69378..f600d79e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -94,7 +94,7 @@ The complete path from a NetBox model save, through the committed callback, to t _Avoid_: Signal handler performance **Rename trigger**: -A saved change in NetBox after which the names a rule gives may be wrong, so the plugin must reapply its rules. The triggers are: a module is installed, a module's type changes, a module moves to another bay or device, an occupied module bay's position or name changes, and a device's virtual chassis or virtual-chassis position changes (the device joins a virtual chassis, leaves it, or gets a different position). A bay's name change is a trigger only when a template variable reads the name: a bay whose position is a template token takes its position from the trailing digits of its name. An edit of an empty bay is not a trigger. A module's type change also reaches each module nested in it whose rule changes, because a rule can be scoped to a parent module type. The triggers of one transaction cause one reapply plan, which reapplies each module and each device at most once. A device reapply also reapplies every module of its device that the plan did not already reapply for a module trigger. +A saved change in NetBox after which the names a rule gives may be wrong, so the plugin must reapply its rules. The triggers are: a module is installed, a module's type changes, a module moves to another bay or device, an occupied module bay's position or name changes, and a device's virtual chassis or virtual-chassis position changes (the device joins a virtual chassis, leaves it, or gets a different position). A bay's name change is a trigger only when a template variable reads the name: a bay whose position is a template token takes its position from the trailing digits of its name. An edit of an empty bay is not a trigger. A module's type change also reaches each module nested in it whose rule changes, because a rule can be scoped to a parent module type. A change that netbox-branching replays in a merge, a revert or a sync is not a rename trigger: the replayed changes already hold the names. The triggers of one transaction cause one reapply plan, which reapplies each module and each device at most once. A device reapply also reapplies every module of its device that the plan did not already reapply for a module trigger. _Avoid_: Signal, event **Reapply**: diff --git a/netbox_interface_name_rules/__init__.py b/netbox_interface_name_rules/__init__.py index 0012fae3..ff29869a 100644 --- a/netbox_interface_name_rules/__init__.py +++ b/netbox_interface_name_rules/__init__.py @@ -22,11 +22,11 @@ class InterfaceNameRulesConfig(PluginConfig): author_email = "marcinpsk@gmail.com" def ready(self): - """Connect signal handlers after all apps are loaded, and check netbox-branching when it is installed.""" + """Connect signal handlers after all apps are loaded, and prepare for netbox-branching when it is installed.""" super().ready() from . import branching, signals # signals registers the post_save handler - branching.check_installed_version() + branching.ready() config = InterfaceNameRulesConfig diff --git a/netbox_interface_name_rules/branching.py b/netbox_interface_name_rules/branching.py index edff5e2e..274a9a1a 100644 --- a/netbox_interface_name_rules/branching.py +++ b/netbox_interface_name_rules/branching.py @@ -2,6 +2,9 @@ # Copyright (C) 2025 Marcin Zieba """The one module that uses netbox-branching. It imports netbox-branching only in a function that needs it.""" +import functools +from contextvars import ContextVar + from django.apps import apps from django.core.exceptions import ImproperlyConfigured from packaging.version import Version @@ -9,6 +12,12 @@ APP_LABEL = "netbox_branching" SUPPORTED_RELEASE = (1, 2) SUPPORTED_SERIES = ".".join(map(str, SUPPORTED_RELEASE)) + ".x" +# The methods of netbox-branching's Branch that replay logged changes. +REPLAYING_METHODS = ("merge", "revert", "sync") +# The attribute that marks a method this module wrapped. +REPLAY_MARK = "marks_a_replay" + +_replaying = ContextVar("netbox_interface_name_rules_replay", default=False) def check_version(version: str) -> None: @@ -19,10 +28,37 @@ def check_version(version: str) -> None: ) -def check_installed_version() -> None: - """Check netbox-branching when NetBox starts, if it is installed.""" - if apps.is_installed(APP_LABEL): - check_version(apps.get_app_config(APP_LABEL).version) +def ready() -> None: + """When netbox-branching is installed, check its version and mark each of its replays in the context that runs it.""" + if not apps.is_installed(APP_LABEL): + return + check_version(apps.get_app_config(APP_LABEL).version) + from netbox_branching.models import Branch + + for name in REPLAYING_METHODS: + method = getattr(Branch, name) + if not getattr(method, REPLAY_MARK, False): + setattr(Branch, name, _marking_a_replay(method)) + + +def _marking_a_replay(method): + """Return *method*, wrapped so that the context that runs it is in a replay until it returns or raises.""" + + @functools.wraps(method) + def replay(*args, **kwargs): + token = _replaying.set(True) + try: + return method(*args, **kwargs) + finally: + _replaying.reset(token) + + setattr(replay, REPLAY_MARK, True) + return replay + + +def replay_in_progress() -> bool: + """Return whether a netbox-branching merge, revert or sync runs in this context.""" + return _replaying.get() def branch_identity() -> str | None: diff --git a/netbox_interface_name_rules/rename_triggers.py b/netbox_interface_name_rules/rename_triggers.py index 1d201d43..fcedf99d 100644 --- a/netbox_interface_name_rules/rename_triggers.py +++ b/netbox_interface_name_rules/rename_triggers.py @@ -38,6 +38,7 @@ from django.db import transaction from netbox.context import current_request +from .branching import replay_in_progress from .naming import bay_naming_values, chassis_position from .rename_outcomes import OutcomeKind, RenameOutcome, renamed_count from .rule_selection import parent_type_scopes_a_rule @@ -671,8 +672,11 @@ def _check_write_alias(using): def before_save(sender, instance, using): """Read the previous state of *instance*, saved through *using*, and hold it for its post_save. - A read error propagates, and so does a save through another alias than the write alias. + A read error propagates, and so does a save through another alias than the write alias. A save that + netbox-branching replays returns before the write-alias check: a merge writes ``default`` while a branch is active. """ + if replay_in_progress(): + return _check_write_alias(using) read, _ = _TRIGGERS[sender._meta.label] previous = None if instance.pk is None else read(instance) @@ -687,7 +691,12 @@ def forget(reference): def after_save(sender, instance, created, using): - """Add the save of *instance* through *using* to the reapply plan of that connection when it is a rename trigger.""" + """Add the save of *instance* through *using* to the reapply plan of that connection when it is a rename trigger. + + A save that netbox-branching replays returns before the write-alias check, as in ``before_save``. + """ + if replay_in_progress(): + return _check_write_alias(using) _, trigger_of = _TRIGGERS[sender._meta.label] # No entry: NetBox sent this post_save by hand, without a model save. diff --git a/netbox_interface_name_rules/tests/test_branch_replay.py b/netbox_interface_name_rules/tests/test_branch_replay.py new file mode 100644 index 00000000..4fcc4a48 --- /dev/null +++ b/netbox_interface_name_rules/tests/test_branch_replay.py @@ -0,0 +1,346 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""A netbox-branching merge, revert or sync replays logged changes, and the rename triggers do nothing then. + +A test starts the operation on netbox-branching's page of the branch, as an operator does, and runs +the job that the page enqueued from the queue, as a worker does, or it calls the operation as a shell +does. netbox-branching replays only the changes that NetBox logged, so the changes that a test +replays go through requests. +""" + +from core.choices import JobStatusChoices +from core.models import Job, ObjectChange +from dcim.models import Device, Interface, InterfaceTemplate, Module, VirtualChassis +from django.db import transaction +from django.urls import reverse +from extras.models import JournalEntry + +from netbox_interface_name_rules.models import InterfaceNameRule +from netbox_interface_name_rules.tests.helpers import ( + branch_cookie, + make_device, + make_device_type, + make_manufacturer, + make_module_bay_templates, + make_module_type, + queued_job, +) +from netbox_interface_name_rules.tests.test_branch_triggers import install_form +from netbox_interface_name_rules.tests.test_branch_writes import _BranchWriteCase, _KeptChannelCase +from netbox_interface_name_rules.tests.test_channelization import PLAIN_TYPE + +COMPLETED = JobStatusChoices.STATUS_COMPLETED +ERRORED = JobStatusChoices.STATUS_ERRORED +ITERATIVE = "iterative" +SQUASH = "squash" +POSITIONS = (0, 1, 2) +# The names of each bay after the branch installed a module in bay 0 and moved the module of bay 1 to bay 2. +BRANCH_NAMES = {0: ["a0", "b0"], 1: [], 2: ["a2", "b2"]} +NAMES_BEFORE = {0: [], 1: ["a1", "b1"], 2: []} +# The names that NetBox gives the channelized family in bay 1. +RAW_FAMILY = ["1", "1:1", "1:2", "1:3", "1:4"] + + +def merging(strategy, commit=True): + """Return the form that merges the branch with *strategy*, or only tries it when *commit* is false.""" + return {"merge_strategy": strategy, **({"commit": "on"} if commit else {})} + + +class _ReplayCase(_BranchWriteCase): + """Run netbox-branching's merge, revert and sync as an operator and a worker do.""" + + def enqueue(self, action, **form): + """Post *form* on netbox-branching's *action* page of the branch and return the job that it enqueued.""" + before = set(Job.objects.values_list("pk", flat=True)) + + response = self.client.post( + reverse(f"plugins:netbox_branching:branch_{action}", kwargs={"pk": self.branch.pk}), form + ) + + self.assertEqual(response.status_code, 302) + [job] = Job.objects.exclude(pk__in=before) + return job + + def run_queued(self, job): + """Run *job* from the queue's record as a worker does, and return it reloaded.""" + queued_job(self, job).perform() + job.refresh_from_db() + return job + + def act(self, action, **form): + """Post *form* on netbox-branching's *action* page of the branch, run the job it enqueued, and return it.""" + return self.run_queued(self.enqueue(action, **form)) + + def on_main(self): + """Send the next requests of the test client to main.""" + del self.client.cookies[branch_cookie()] + + def last_change_on_main(self): + """Return the primary key of the newest change that NetBox logged on main.""" + return ObjectChange.objects.order_by("-pk").values_list("pk", flat=True).first() or 0 + + def assert_only_replayed_changes_on_main(self, since): + """Assert that each change logged on main after *since* replays a change of the branch, and no journal entry.""" + replayed = set(ObjectChange.objects.using(self.alias).values_list("request_id", flat=True)) + logged = set(ObjectChange.objects.filter(pk__gt=since).values_list("request_id", flat=True)) + + self.assertTrue(logged) + self.assertLessEqual(logged, replayed) + self.assertFalse(JournalEntry.objects.exists()) + + +class _InstallAndMoveCase(_ReplayCase): + """A device with three module bays, and a module in bay 1 whose interfaces NetBox named ``a1`` and ``b1``.""" + + def build(self): + manufacturer = make_manufacturer(self.PREFIX) + device_type = make_device_type(manufacturer, self.PREFIX) + make_module_bay_templates(device_type, tuple(f"Bay {position}" for position in POSITIONS)) + self.device = make_device(self.PREFIX, device_type) + self.module_type = make_module_type(manufacturer, self.PREFIX) + for template in ("a{module}", "b{module}"): + InterfaceTemplate.objects.create(module_type=self.module_type, name=template, type=PLAIN_TYPE) + with transaction.atomic(): + self.module = Module.objects.create( + device=self.device, module_bay=self.bay(1), module_type=self.module_type + ) + + def add_rule(self): + return InterfaceNameRule.objects.create(module_type=self.module_type, name_template="{base}.br") + + def install_and_move(self): + """Install a module in bay 0 through the UI and move the module of bay 1 to bay 2 through the REST API.""" + response = self.client.post(reverse("dcim:module_add"), install_form(self.bay(0), self.module_type)) + self.assertEqual(response.status_code, 302) + response = self.client.patch( + reverse("dcim-api:module-detail", kwargs={"pk": self.module.pk}), + {"module_bay": self.bay(2).pk}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 200, response.content) + + def names(self): + """Return the sorted interface names of the module in each bay, on the active branch or on main.""" + return {position: sorted(name for _, name in self.interfaces_at(position)) for position in POSITIONS} + + def branch_names(self): + with self.in_branch(): + return self.names() + + +class _MergeAndRevertTests: + """The rule exists on main only, so the branch keeps NetBox's names, and a rename trigger on main would not.""" + + STRATEGY = None + + def setUp(self): + super().setUp() + self.add_rule() + self.install_and_move() + + def test_the_merge_gives_main_the_names_of_the_branch_and_only_the_replayed_changes(self): + job = self.enqueue("merge", **merging(self.STRATEGY)) + since = self.last_change_on_main() + + job = self.run_queued(job) + + self.assertEqual((job.status, job.error), (COMPLETED, "")) + self.assertEqual(self.branch_names(), BRANCH_NAMES) + self.assertEqual(self.names(), BRANCH_NAMES) + self.assert_only_replayed_changes_on_main(since) + + def test_the_revert_of_the_merge_gives_main_the_names_from_before_the_branch(self): + self.assertEqual(self.names(), NAMES_BEFORE) + self.assertEqual(self.act("merge", **merging(self.STRATEGY)).status, COMPLETED) + job = self.enqueue("revert", commit="on") + since = self.last_change_on_main() + + job = self.run_queued(job) + + self.assertEqual((job.status, job.error), (COMPLETED, "")) + self.assertEqual(self.names(), NAMES_BEFORE) + self.assert_only_replayed_changes_on_main(since) + + +class IterativeMergeTest(_MergeAndRevertTests, _InstallAndMoveCase): + PREFIX = "BrMergeIter" + STRATEGY = ITERATIVE + + +class SquashMergeTest(_MergeAndRevertTests, _InstallAndMoveCase): + PREFIX = "BrMergeSquash" + STRATEGY = SQUASH + + +class SyncTest(_InstallAndMoveCase): + """The rule exists in the branch only, so main keeps NetBox's names, and a rename trigger in the branch would not.""" + + PREFIX = "BrSync" + + def test_a_sync_writes_no_unlogged_rename_into_the_branch(self): + with self.in_branch(): + self.add_rule() + self.on_main() + self.install_and_move() + on_main = self.names() + + job = self.act("sync", commit="on") + + self.assertEqual((job.status, job.error), (COMPLETED, "")) + self.assertEqual(on_main, BRANCH_NAMES) + self.assertEqual(self.branch_names(), on_main) + + +class ReplayInTheActiveBranchTest(_ReplayCase): + """A merge and a revert started from a shell in which the branch is active. + + The branch changes the virtual-chassis position of a device, which is a rename trigger. While the + branch is active, netbox-branching itself refuses a replayed create and the revert of a module move. + """ + + PREFIX = "BrReplayActive" + + def build(self): + chassis = VirtualChassis.objects.create(name=f"{self.PREFIX} chassis") + device_type = make_device_type(make_manufacturer(self.PREFIX), self.PREFIX) + self.device = make_device(self.PREFIX, device_type, virtual_chassis=chassis, vc_position=1) + + def position_on_main(self): + return Device.objects.get(pk=self.device.pk).vc_position + + def test_a_merge_and_a_revert_started_in_the_branch_replay_a_rename_trigger_without_an_error(self): + response = self.client.patch( + reverse("dcim-api:device-detail", kwargs={"pk": self.device.pk}), + {"vc_position": 2}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 200, response.content) + self.branch.refresh_from_db() + + with self.in_branch(): + self.branch.merge(user=self.user) + merged = self.position_on_main() + with self.in_branch(): + self.branch.revert(user=self.user) + + self.assertEqual((merged, self.position_on_main()), (2, 1)) + + +class MergeExitTest(_InstallAndMoveCase): + """A merge that returns early or fails leaves the next save on main a rename trigger. + + The rule exists on main and in the branch, so an install on main gets its names only from a rename + trigger. + """ + + PREFIX = "BrMergeExit" + + def build(self): + super().build() + self.add_rule() + + def assert_an_install_on_main_gets_the_names_of_the_rule(self): + with transaction.atomic(): + Module.objects.create(device=self.device, module_bay=self.bay(0), module_type=self.module_type) + + self.assertEqual(self.names()[0], ["a0.br", "b0.br"]) + + def test_a_merge_without_a_change_leaves_the_next_save_a_rename_trigger(self): + job = self.act("merge", **merging(ITERATIVE)) + + self.assertEqual((job.status, job.error), (COMPLETED, "")) + self.assert_an_install_on_main_gets_the_names_of_the_rule() + + def test_a_dry_run_merge_leaves_the_next_save_a_rename_trigger(self): + self.install_and_move() + + job = self.act("merge", **merging(ITERATIVE, commit=False)) + + self.assertEqual((job.status, job.error), (COMPLETED, "")) + self.assertEqual(self.names(), NAMES_BEFORE) + self.assert_an_install_on_main_gets_the_names_of_the_rule() + + def test_a_failed_merge_leaves_the_next_save_a_rename_trigger(self): + self.install_and_move() + # The merge replays the creation of interface a0, and the device on main has an interface of that name now. + Interface.objects.create(device=self.device, name="a0", type=PLAIN_TYPE) + + job = self.act("merge", **merging(ITERATIVE)) + + self.assertEqual(job.status, ERRORED) + self.assertEqual(self.names(), NAMES_BEFORE) + Interface.objects.filter(device=self.device, name="a0").delete() + self.assert_an_install_on_main_gets_the_names_of_the_rule() + + def test_a_merge_of_the_branch_in_another_worker_does_not_skip_a_save_here(self): + """The replay mark belongs to the context that ran the operation, not to the branch status that workers share.""" + from netbox_branching.choices import BranchStatusChoices + from netbox_branching.models import Branch + + self.install_and_move() + self.assertEqual(self.act("merge", **merging(ITERATIVE, commit=False)).status, COMPLETED) + # netbox-branching sets this status first when another worker starts to merge the branch. + Branch.objects.filter(pk=self.branch.pk).update(status=BranchStatusChoices.MERGING) + + self.assert_an_install_on_main_gets_the_names_of_the_rule() + + +class _CascadeCase(_KeptChannelCase, _ReplayCase): + """NetBox's cascade renames a kept channel again at the commit of a replay: the documented limit. + + The rule keeps channel 2 at ``1:2`` while it renames the parent. A replay renames the parent before + its commit, so NetBox's cascade gives channel 2 the name ``et-0/0/1:2`` at that commit. + """ + + def apply_on_the_page(self): + response = self.client.post( + self.apply_url(self.rule), {"action": "apply", "interface_ids": [str(self.parent("1").pk)]} + ) + self.assertEqual(response.status_code, 302) + + def names_on(self, alias): + return sorted(Interface.objects.using(alias).filter(module=self.modules["1"]).values_list("name", flat=True)) + + +class _CascadeMergeTests: + """A merge with one strategy, and the revert of that merge.""" + + STRATEGY = None + + def test_a_merge_renames_the_kept_channel_on_main_and_a_revert_restores_the_names_from_before(self): + self.apply_on_the_page() + self.assertEqual(self.names_on(self.alias), self.kept("1")) + + merged = self.act("merge", **merging(self.STRATEGY)) + names_after_the_merge = self.names_on("default") + reverted = self.act("revert", commit="on") + + self.assertEqual((merged.status, reverted.status), (COMPLETED, COMPLETED)) + self.assertEqual(names_after_the_merge, self.cascaded("1")) + self.assertEqual(self.names_on(self.alias), self.kept("1")) + self.assertEqual(self.names_on("default"), RAW_FAMILY) + + +class IterativeCascadeTest(_CascadeMergeTests, _CascadeCase): + PREFIX = "BrCascadeIter" + STRATEGY = ITERATIVE + + +class SquashCascadeTest(_CascadeMergeTests, _CascadeCase): + PREFIX = "BrCascadeSquash" + STRATEGY = SQUASH + + +class SyncCascadeTest(_CascadeCase): + PREFIX = "BrCascadeSync" + + def test_a_sync_renames_the_kept_channel_in_the_branch(self): + self.on_main() + self.apply_on_the_page() + self.assertEqual(self.names_on("default"), self.kept("1")) + + job = self.act("sync", commit="on") + + self.assertEqual((job.status, job.error), (COMPLETED, "")) + self.assertEqual(self.names_on(self.alias), self.cascaded("1")) + self.assertEqual(self.names_on("default"), self.kept("1")) diff --git a/netbox_interface_name_rules/tests/test_branch_writes.py b/netbox_interface_name_rules/tests/test_branch_writes.py index d5df2070..70838b3d 100644 --- a/netbox_interface_name_rules/tests/test_branch_writes.py +++ b/netbox_interface_name_rules/tests/test_branch_writes.py @@ -109,11 +109,15 @@ def in_branch(self): def bay(self, position): return ModuleBay.objects.get(device=self.device, name=f"Bay {position}") + def interfaces_at(self, position): + """Return ``(pk, name)`` of each interface of the module in the bay at *position*, on the active branch or main.""" + interfaces = Interface.objects.filter(device=self.device, module__module_bay__name=f"Bay {position}") + return list(interfaces.values_list("pk", "name")) + def interfaces_in_branch(self, position): """Return ``(pk, name)`` of each interface in the branch of the module in the bay at *position*.""" with self.in_branch(): - interfaces = Interface.objects.filter(device=self.device, module__module_bay__name=f"Bay {position}") - return list(interfaces.values_list("pk", "name")) + return self.interfaces_at(position) def names_in_branch(self, position): """Return the sorted interface names in the branch of the module in the bay at *position*.""" diff --git a/netbox_interface_name_rules/tests/test_branching.py b/netbox_interface_name_rules/tests/test_branching.py index 5fa491a6..2b9887ae 100644 --- a/netbox_interface_name_rules/tests/test_branching.py +++ b/netbox_interface_name_rules/tests/test_branching.py @@ -6,6 +6,7 @@ ``EXPECT_NETBOX_BRANCHING=1``, and there a missing netbox-branching fails the guard test instead. """ +import inspect import os import subprocess import sys @@ -18,7 +19,8 @@ from django.db import connection, connections, router from django.test import SimpleTestCase, TransactionTestCase -from netbox_interface_name_rules.branching import check_version +from netbox_interface_name_rules import branching +from netbox_interface_name_rules.branching import REPLAY_MARK, REPLAYING_METHODS, check_version from netbox_interface_name_rules.tests.helpers import activate, make_device, make_device_type, make_manufacturer BRANCHING_INSTALLED = apps.is_installed("netbox_branching") @@ -72,6 +74,33 @@ def test_an_unsupported_version_stops_startup(self): self.assertIn("is 1.3.0.", completed.stderr) +@skipUnless(BRANCHING_INSTALLED, BRANCHING_SKIP_REASON) +class ReplayWrapperContractTest(SimpleTestCase): + """The plugin wraps each method of netbox-branching's Branch that replays changes, once, as it was reviewed.""" + + def replaying_methods(self): + from netbox_branching.models import Branch + + return {name: getattr(Branch, name) for name in REPLAYING_METHODS} + + def test_each_replaying_method_is_wrapped_once_and_keeps_its_signature_and_attributes(self): + for name, method in self.replaying_methods().items(): + with self.subTest(method=name): + original = method.__wrapped__ + + self.assertFalse(getattr(original, REPLAY_MARK, False)) + self.assertEqual(str(inspect.signature(original)), "(self, user, commit=True)") + self.assertEqual(inspect.signature(method), inspect.signature(original)) + self.assertEqual((method.__name__, method.alters_data), (name, True)) + + def test_a_second_start_wraps_nothing_again(self): + wrapped = self.replaying_methods() + + branching.ready() + + self.assertEqual(self.replaying_methods(), wrapped) + + def remove_branch(branch): """Close the connection of *branch*, then drop its schema.""" connections[branch.connection_name].close() From 148fa2379b3288853bd5cfc3912193fcfa961e3d Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 1 Oct 2026 15:14:16 +0200 Subject: [PATCH 02/13] docs(configuration): describe netbox-branching support and its limits The configuration guide now has a netbox-branching section. It states the supported release, 1.2.x on NetBox 4.7, and that NetBox does not start with another release. It describes what the plugin does in a branch: the rename triggers, Apply Rules, the conversion and the jobs run in the branch. A script installs a module in a transaction on the interface write connection, as the Apply Rules section already says. In a branch, each plugin operation sets a lock_timeout of 10 seconds on both connections. An engine function that a script calls inside its own transaction leaves the lock waits of the commit callbacks to the script. The section describes merge, revert and sync. The rename triggers do nothing while netbox-branching replays changes. A merge gives main the names of the branch, a revert gives back the names from before, and a sync writes no rename without a change record. It lists the accepted limits of the design record: - NetBox's channel cascade runs again at the commit of a replay (dcim/models/mixins.py:302-337). - A bulk REST request with background=true runs on main, because the background job does not keep the cookies of the request (netbox/api/viewsets/mixins.py:307, 345-347). The rule endpoints refuse it in a branch; the other endpoints keep the gap. - NetBox ignores a failed branch activation (utilities/request.py:132-141). - There is no atomicity across the two connections. - PgBouncer in transaction mode is not supported. The installation guide lists netbox-branching as an optional requirement. Its upgrade note says to let the queued plugin jobs finish before the upgrade, because a job that an earlier release queued fails: the jobs now store their branch. The engine example states the lock timeout of a call inside a transaction of the caller, and tells the script to set lock_timeout for the commit callbacks. The README and the index list the feature. They and the merge and sync statements point at the limit of a kept channel. Documentation tests pin the supported series in the configuration guide, the installation guide, the README and the index to the constant of the version gate. They also pin the replay statement with the cascade limit, and the upgrade note. A helper reads a whole page with its whitespace collapsed, at each site in the documentation tests. Slice 6 of #143 (ADR 0016). --- README.md | 1 + docs/configuration.md | 87 +++++++++++++++++++ docs/examples.md | 4 + docs/index.md | 1 + docs/installation.md | 16 ++++ .../tests/test_documentation.py | 50 +++++++++-- 6 files changed, 154 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d171bf35..32557134 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ automatically apply renaming rules based on configurable templates. - **Breakout support**: create multiple channel interfaces from a single port (e.g., QSFP+ 4x10G) - **Scoping**: rules can be scoped to specific device types, parent module types, or be universal - **Bulk import/export**: YAML-based rule management via the UI or API +- **netbox-branching**: renames run in the active branch, and a merge, revert or sync keeps the names that it replays (netbox-branching 1.2.x on NetBox 4.7). A channel that the plugin kept at its old name is the exception: see [Limits in a branch](https://marcinpsk.github.io/netbox-InterfaceNameRules-plugin/configuration/#limits-in-a-branch) ## Supported scenarios diff --git a/docs/configuration.md b/docs/configuration.md index 3a2c52e1..6dc0754a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -296,6 +296,93 @@ The **Applicable** column shows ✓ only when at least one currently-installed interface **would actually change name** if the rule were applied. Rules where all matching interfaces are already correctly named show `—`. +## netbox-branching + +The plugin supports [netbox-branching](https://github.com/netboxlabs/netbox-branching) +1.2.x on NetBox 4.7. When netbox-branching is installed, NetBox does not start +with another release of it, and the error names the installed version. Without +netbox-branching, the plugin supports NetBox 4.3 to 4.7 and works as this guide +describes. + +### In a branch + +While a branch is active, the plugin reads and writes in that branch only: + +- A rename trigger in the branch renames the interfaces in the branch, after + NetBox commits the change. Each rename has a change record in the branch, so a + merge applies it to main. +- **Apply Rules** and the flat-to-channelized conversion change the interfaces + of the branch. A rule that exists only in the branch applies only there. +- **Run as Background Job** and **Convert as Background Job** run in the branch + that was active when you started the job. When that branch is not ready when + the job starts, for example because it was merged, the job fails and changes + nothing. +- A script or the shell must install a module in a transaction on the interface + write connection, as [Apply Rules and the Applicable Column](#apply-rules-and-the-applicable-column) + describes. + +In a branch, each plugin operation sets the PostgreSQL `lock_timeout` to 10 +seconds on the connection of the branch and on the connection of main. When the +operation ends, the plugin sets the earlier values again. A request in a branch +holds two PostgreSQL sessions, and PostgreSQL does not find a lock cycle through +the two sessions of one request. An operation that waits longer for a lock stops +with an error. A script can call an engine function, such as +`apply_device_interface_rules`, inside a transaction that the script holds. The +commit callbacks of the plugin then run when that transaction commits, with the +earlier `lock_timeout` of the session. Set `lock_timeout` for these callbacks in +your script. + +### Merge, revert and sync + +netbox-branching merges, reverts and syncs a branch: it replays the changes that +NetBox logged. The replayed changes already hold the interface names, so the +rename triggers do nothing while netbox-branching replays them. + +- A merge gives main the interface names of the branch, except a kept channel + (see [Limits in a branch](#limits-in-a-branch)). On main, it writes only the + replayed changes. +- A revert of the merge gives main the names from before the merge. +- A sync gives the branch the names of main, except a kept channel, and writes no + rename without a change record. A rule that exists only in the branch does not rename the interfaces + that the sync brought. Run **Apply Rules** in the branch after the sync to + apply it. + +After a merge, a revert or a sync that fails or stops early, for example a dry +run or a merge of a branch without changes, the next change is a rename trigger +again. + +### Limits in a branch + +- **A replay can rename a kept channel.** When the name that a rule gives a + channel subinterface is in use, the plugin keeps the old name of the channel + and renames its parent. NetBox renames the channels of a renamed parent when + the change commits, and the plugin then gives the kept channel its old name + again. A merge, a revert or a sync replays the rename of the parent, so + NetBox renames the channels again when the replay commits, and the plugin does + not act. After a merge, the kept channel on main then has the name from NetBox, + for example `et-0/0/1:2`, while the channel in the branch keeps `1:2`. After a + sync, the kept channel in the branch has the name from NetBox. A revert of the + merge gives the channel its name from before the merge. Rename such a channel + by hand when you want the name from the other side. +- **A background REST request runs on main.** NetBox runs a bulk REST request + with `background=true` as a background job, and that job does not keep the + active branch. The REST API of the rules refuses such a request while a branch + is active, before it writes. The other NetBox endpoints run it on main. For + example, modules that you install with such a request are installed on main, + and the plugin renames their interfaces on main. +- **A failed branch activation runs the request on main.** When NetBox cannot + activate the branch of a request, it continues the request on main. This + applies to all changes of the request, not only to the plugin. +- **No atomicity across the two connections.** netbox-branching records each + change of a branch on the connection of main. The plugin commits the connection + of the branch first and the connection of main second, as NetBox scripts and + netbox-branching do. When the second commit fails, or a NetBox callback fails + after the first commit, the branch keeps the renames, but the list of branch + changes in netbox-branching does not show them. A merge still applies them. +- **Connection pooling in transaction mode is not supported**, for example + PgBouncer with `pool_mode = transaction`. A session setting such as + `lock_timeout` does not stay with the session of such a pool. + ## Bulk Import Export existing rules or import new ones via **Interface Name Rules → Import**. diff --git a/docs/examples.md b/docs/examples.md index 46c15540..45a620aa 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -410,6 +410,10 @@ Arista modular/multi-chassis naming uses `Ethernet{slot}/{port}`. The device typ for dev in Device.objects.filter(virtual_chassis__isnull=False): apply_device_interface_rules(dev) ``` + In a netbox-branching branch, the function sets a `lock_timeout` of 10 seconds + while it runs. When you call it inside a transaction that you hold, its commit + callbacks run when your transaction commits, with the earlier `lock_timeout` of + the session. Set `lock_timeout` for these callbacks in your script. --- diff --git a/docs/index.md b/docs/index.md index 1597897f..ae4826fd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,6 +23,7 @@ automatically apply renaming rules based on configurable templates. - **Scoping**: rules can target specific device types, parent module types, platforms, or be universal - **Build Rule tester**: preview module and device-interface names before saving. Module rules also preview matching installed interfaces. - **Apply Rules**: batch rename existing interfaces with live preview and background job support +- **netbox-branching**: renames run in the active branch, and a merge, revert or sync keeps the names that it replays (netbox-branching 1.2.x on NetBox 4.7). A channel that the plugin kept at its old name is the exception: see [Limits in a branch](configuration.md#limits-in-a-branch) ## Supported Scenarios diff --git a/docs/installation.md b/docs/installation.md index 2bffee74..687f95cf 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -4,6 +4,8 @@ - NetBox ≥ 4.3.0 - Python ≥ 3.12 +- Optional: netbox-branching 1.2.x, on NetBox 4.7. See + [netbox-branching](configuration.md#netbox-branching). ## Install from PyPI @@ -19,6 +21,20 @@ Add to your NetBox `configuration.py`: PLUGINS = ["netbox_interface_name_rules"] ``` +## Before You Upgrade + +Let the queued **Run as Background Job** and **Convert as Background Job** jobs +finish before you upgrade the plugin. A release can change the data that a job +stores, and a job that an earlier release queued then fails after the upgrade. +The release that adds netbox-branching support stores the branch of each job. +Start a failed job again after the upgrade. + +With netbox-branching, a script can call an engine function, such as +`apply_device_interface_rules`, inside a transaction that the script holds. The +commit callbacks of the plugin then run when that transaction commits, with the +earlier `lock_timeout` of the session. Set `lock_timeout` for these callbacks in +your script. See [netbox-branching](configuration.md#netbox-branching). + ## Run Database Migrations The migration audits every existing nonempty **Module Type Pattern** used by a diff --git a/netbox_interface_name_rules/tests/test_documentation.py b/netbox_interface_name_rules/tests/test_documentation.py index ff58abe4..de00b7e7 100644 --- a/netbox_interface_name_rules/tests/test_documentation.py +++ b/netbox_interface_name_rules/tests/test_documentation.py @@ -22,6 +22,7 @@ from django.core.management.base import CommandError from netbox_interface_name_rules import template_variable_reference +from netbox_interface_name_rules.branching import SUPPORTED_SERIES from netbox_interface_name_rules.models import InterfaceNameRule from netbox_interface_name_rules.name_template import ( TEMPLATE_VARIABLES, @@ -44,6 +45,12 @@ _PROJECT_ROOT = Path(__file__).resolve().parents[2] + +def _normalised_doc(*path): + """Return the text of the project file at *path* with each run of whitespace collapsed to one space.""" + return " ".join(_PROJECT_ROOT.joinpath(*path).read_text(encoding="utf-8").split()) + + _RE2_AUDIT = importlib.import_module("netbox_interface_name_rules.migrations.0014_validate_re2_patterns") _CONVERTER_OFFSET_SCENARIO = re.compile( @@ -318,7 +325,7 @@ class BaseVariableDocumentationTest(unittest.TestCase): """Keep each module-family base path explicit outside the generated reference.""" def test_each_module_family_base_path_is_documented(self): - guide = (_PROJECT_ROOT / "docs" / "template-variables.md").read_text(encoding="utf-8") + guide = _normalised_doc("docs", "template-variables.md") for statement in ( "In a module rule, `{base}` is the raw template name of the interface the rule renames:", @@ -344,7 +351,7 @@ def test_each_module_family_base_path_is_documented(self): "In a device interface rule, `{base}` is the interface's current name.", ): with self.subTest(statement=statement): - self.assertIn(statement, " ".join(guide.split())) + self.assertIn(statement, guide) class PerformanceDocumentationTest(unittest.TestCase): @@ -415,7 +422,7 @@ def test_rule_priority_lists_every_specificity_score(self): ) def test_configuration_states_that_a_save_outside_a_request_has_no_journal_author(self): - guide = " ".join((_PROJECT_ROOT / "docs" / "configuration.md").read_text(encoding="utf-8").split()) + guide = _normalised_doc("docs", "configuration.md") self.assertIn( "The author is the user of the request that saved the change. " @@ -424,13 +431,13 @@ def test_configuration_states_that_a_save_outside_a_request_has_no_journal_autho ) def test_configuration_puts_a_script_install_in_a_transaction_on_the_interface_write_alias(self): - guide = " ".join((_PROJECT_ROOT / "docs" / "configuration.md").read_text(encoding="utf-8").split()) + guide = _normalised_doc("docs", "configuration.md") self.assertIn("wrap the install in `transaction.atomic(using=router.db_for_write(Interface))`.", guide) self.assertNotIn("wrap the install in `transaction.atomic()`", guide) def test_configuration_states_whom_the_change_log_of_a_job_names(self): - guide = " ".join((_PROJECT_ROOT / "docs" / "configuration.md").read_text(encoding="utf-8").split()) + guide = _normalised_doc("docs", "configuration.md") self.assertIn( "**Run as Background Job** and **Convert as Background Job** record each change as the user who " @@ -438,6 +445,39 @@ def test_configuration_states_whom_the_change_log_of_a_job_names(self): guide, ) + def test_the_guides_name_the_netbox_branching_series_that_the_version_gate_accepts(self): + statements = { + ("docs", "configuration.md"): ( + f"(https://github.com/netboxlabs/netbox-branching) {SUPPORTED_SERIES} on NetBox 4.7." + ), + ("docs", "installation.md"): f"Optional: netbox-branching {SUPPORTED_SERIES}, on NetBox 4.7.", + ("README.md",): f"(netbox-branching {SUPPORTED_SERIES} on NetBox 4.7).", + ("docs", "index.md"): f"(netbox-branching {SUPPORTED_SERIES} on NetBox 4.7).", + } + + for path, statement in statements.items(): + with self.subTest(path="/".join(path)): + self.assertIn(statement, _normalised_doc(*path)) + + def test_configuration_states_that_a_replay_is_not_a_rename_trigger_and_can_rename_a_kept_channel(self): + guide = _normalised_doc("docs", "configuration.md") + + self.assertIn("so the rename triggers do nothing while netbox-branching replays them.", guide) + self.assertIn( + "A merge, a revert or a sync replays the rename of the parent, so NetBox renames the channels again " + "when the replay commits, and the plugin does not act.", + guide, + ) + + def test_upgrade_guide_says_to_let_the_queued_plugin_jobs_finish(self): + guide = _normalised_doc("docs", "installation.md") + + self.assertIn( + "Let the queued **Run as Background Job** and **Convert as Background Job** jobs finish before you " + "upgrade the plugin.", + guide, + ) + def test_transaction_adr_states_unrelated_failure_behavior(self): adr = (_PROJECT_ROOT / "docs" / "adr" / "0005-execute-each-family-in-its-own-transaction.md").read_text( encoding="utf-8" From a22ef3d7938fcafc2004a6f0a23fbfdec4bab246 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 1 Oct 2026 15:14:25 +0200 Subject: [PATCH 03/13] docs: name the transaction and branching modules in the agent instructions The architecture list named no owner of the database connections and no module for netbox-branching. It now names transactions.py, the one owner of connections and transaction state, and branching.py, the one module that imports netbox-branching: the version check, the branch of a job, and the mark that stops the rename triggers during a merge, revert or sync. Part of #143 (ADR 0016). --- .github/copilot-instructions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e9838746..15145c72 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,6 +13,8 @@ This follows the standard [NetBox plugin pattern](https://netboxlabs.com/docs/ne - **`models.py`**: Defines `InterfaceNameRule`. A rule can select an exact module type or a regex pattern, add parent, device, and platform scopes, and describe flat or channelized breakout output. - **`signals.py`**: Connects `pre_save` and `post_save` for `dcim.Module`, `dcim.ModuleBay` and `dcim.Device` and passes each save to `rename_triggers.py` with the alias of the save, which must be the write alias. The receivers hold no state and make no decision. It intentionally does not connect to `dcim.Interface` because NetBox creates module interfaces with `bulk_create()`. It also connects the optional LibreNMS prediction signal when that plugin is installed. - **`rename_triggers.py`**: Owns the rename-trigger lifecycle. It reads the previous state before a save and lets a read error fail the save. After the save it decides whether the save is a rename trigger and schedules one reapply per module or device per transaction with `transaction.on_commit()` on the connection of the save, so the plan runs after that connection commits. The reapply compares the earliest previous state with the committed row, and it catches and logs failures at that boundary. +- **`transactions.py`**: The one owner of database connections and transaction state. Each plugin write runs in a write scope on `default`, and in a netbox-branching branch also on the branch connection. +- **`branching.py`**: The one module that imports netbox-branching. It checks its version at startup, gives a job the identity of its branch, and marks each merge, revert and sync so that the rename triggers do nothing while it replays changes. - **`rule_selection.py`**: Loads and fingerprints enabled rules, separates exact and regex candidates, applies scope priority, and pins one cached snapshot across batch work. - **`name_template.py`**: Owns the name-template language, its template-variable catalogue, and evaluation. - **`naming.py`**: Builds template-variable values from the module-bay hierarchy. From 204eb91137cba24160c6b2c56e79b647761054ca Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 1 Oct 2026 16:19:21 +0200 Subject: [PATCH 04/13] test(branching): pin each replay call site of netbox-branching The plugin marks a replay only inside Branch.merge, Branch.revert and Branch.sync. The version gate accepts any 1.2.x release, so a later release could add a replay path outside those methods, and the rename triggers would then act during that replay. The test parses the source of the installed netbox-branching with ast. It finds each call of a replay primitive and the function that holds the call, and compares the set with an allow-list. Each entry names the wrapped method that reaches it. - The calls that save a replayed object: ObjectChange.apply and .undo (merge_strategies/iterative.py:34, 53; merge_strategies/squash.py:223, 271; models/branches.py:743-759, 877), and update_object and deserialize_object inside them (models/changes.py:107-140, 182-186). - The calls that start a replay: the strategy in Branch.merge and Branch.revert (models/branches.py:1139, 1214), the two sync helpers in Branch.sync (966, 975), and the jobs that call the wrapped methods (jobs.py:217, 243). The scan does not pin save() and delete(): netbox-branching calls them for its own Branch and ChangeDiff rows, and each save of a replayed object runs inside ObjectChange.apply, ObjectChange.undo or update_object. A copy of the installed package with one added function that calls change.apply() turns the first test red and names the new call site. A copy with an added caller of strategy.merge() turns the second test red. The installed 1.2.1 tree passes. A third test checks the scan on a constructed package on every leg. Follow-up of #143 (ADR 0016). --- .../tests/test_branching.py | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/netbox_interface_name_rules/tests/test_branching.py b/netbox_interface_name_rules/tests/test_branching.py index 2b9887ae..a0c4934f 100644 --- a/netbox_interface_name_rules/tests/test_branching.py +++ b/netbox_interface_name_rules/tests/test_branching.py @@ -6,10 +6,13 @@ ``EXPECT_NETBOX_BRANCHING=1``, and there a missing netbox-branching fails the guard test instead. """ +import ast import inspect import os import subprocess import sys +import tempfile +from pathlib import Path from unittest import skipUnless from dcim.models import Interface @@ -101,6 +104,100 @@ def test_a_second_start_wraps_nothing_again(self): self.assertEqual(self.replaying_methods(), wrapped) +# The calls that save a replayed object, each with the wrapped Branch method that reaches it (netbox-branching 1.2.1). +REPLAY_SAVES = { + ("apply", "merge_strategies/iterative.py", "IterativeMergeStrategy.merge"): "Branch.merge", + ("undo", "merge_strategies/iterative.py", "IterativeMergeStrategy.revert"): "Branch.revert", + ("apply", "merge_strategies/squash.py", "SquashMergeStrategy.merge"): "Branch.merge", + ("undo", "merge_strategies/squash.py", "SquashMergeStrategy.revert"): "Branch.revert", + ("apply", "models/branches.py", "Branch._apply_sync_update"): "Branch.sync", + ("apply", "models/branches.py", "Branch._handle_sync_delete"): "Branch.sync", + ("deserialize_object", "models/changes.py", "ObjectChange.apply"): "each apply above", + ("update_object", "models/changes.py", "ObjectChange.apply"): "each apply above", + ("deserialize_object", "models/changes.py", "ObjectChange.undo"): "each undo above", + ("update_object", "models/changes.py", "ObjectChange.undo"): "each undo above", +} +# The calls that start a replay: a merge strategy or a sync helper, or a job that calls a wrapped method. +REPLAY_ENTRIES = { + ("merge", "models/branches.py", "Branch.merge"): "the strategy, inside the wrapped Branch.merge", + ("revert", "models/branches.py", "Branch.revert"): "the strategy, inside the wrapped Branch.revert", + ("_apply_sync_update", "models/branches.py", "Branch.sync"): "inside the wrapped Branch.sync", + ("_handle_sync_delete", "models/branches.py", "Branch.sync"): "inside the wrapped Branch.sync", + ("merge", "jobs.py", "MergeBranchJob.run"): "the wrapped Branch.merge", + ("revert", "jobs.py", "RevertBranchJob.run"): "the wrapped Branch.revert", +} + + +def replay_call_sites(package, names): + """Return ``(callee, module, enclosing qualname)`` of each call of one of *names* in *package*, its tests excluded.""" + sites = set() + + def visit(node, scope, module): + for child in ast.iter_child_nodes(node): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + visit(child, (*scope, child.name), module) + continue + if isinstance(child, ast.Call): + callee = getattr(child.func, "attr", getattr(child.func, "id", None)) + if callee in names: + sites.add((callee, module, ".".join(scope) or "")) + visit(child, scope, module) + + for path in sorted(package.rglob("*.py")): + module = path.relative_to(package).as_posix() + if not module.startswith("tests/"): + visit(ast.parse(path.read_text(encoding="utf-8"), filename=str(path)), (), module) + return sites + + +def replay_names(sites): + """Return the callee of each of the ``(callee, module, qualname)`` *sites*.""" + return {callee for callee, _, _ in sites} + + +@skipUnless(BRANCHING_INSTALLED, BRANCHING_SKIP_REASON) +class ReplayCallSiteContractTest(SimpleTestCase): + """Each replay of the installed netbox-branching runs inside a wrapped method, as in the reviewed release.""" + + def installed_sites(self, allowed): + import netbox_branching + + return replay_call_sites(Path(netbox_branching.__file__).parent, replay_names(allowed)) + + def test_each_save_of_a_replayed_object_is_a_reviewed_call_site(self): + self.assertEqual(self.installed_sites(REPLAY_SAVES), set(REPLAY_SAVES)) + + def test_each_replay_starts_in_a_wrapped_method(self): + self.assertEqual(self.installed_sites(REPLAY_ENTRIES), set(REPLAY_ENTRIES)) + + +class ReplayCallSiteScanTest(SimpleTestCase): + """The scan finds a replay call wherever a release adds one.""" + + def test_the_scan_reports_each_call_with_its_enclosing_function(self): + source = ( + "class Strategy:\n def merge(self, change):\n change.apply(self)\n" + "def helper(instance, data):\n def nested():\n update_object(instance, data, using=None)\n" + "change.undo(None)\n" + ) + with tempfile.TemporaryDirectory() as directory: + package = Path(directory) + (package / "added.py").write_text(source, encoding="utf-8") + (package / "tests").mkdir() + (package / "tests" / "test_added.py").write_text("change.apply(None)\n", encoding="utf-8") + + sites = replay_call_sites(package, replay_names(REPLAY_SAVES)) + + self.assertEqual( + sites, + { + ("apply", "added.py", "Strategy.merge"), + ("update_object", "added.py", "helper.nested"), + ("undo", "added.py", ""), + }, + ) + + def remove_branch(branch): """Close the connection of *branch*, then drop its schema.""" connections[branch.connection_name].close() From c12a9e44d855c40612e263587c3a0f796024cae1 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 1 Oct 2026 16:33:50 +0200 Subject: [PATCH 05/13] refactor(tests): define the shared test fixtures once, outside the test modules Test modules imported constants, builders and test case bases from other test modules, and several modules kept their own copy of a shared constant: PLAIN_TYPE had six definitions. Each name that a test module imported from another test module now lives in one shared module, under a public name: - helpers.py: the interface types, the breakout modes, TEST_PASSWORD, PLUGIN_LOGGER, the skip reasons, the builders of devices and module types, install_form, names_of, request_context, WriteInterfacesTo, ChannelizationTestCase and VcDriftTestCase. - trigger_cases.py: the move fixture, ModuleMoveTestCase, BayEditTestCase, flat_rule, the journal reasons, and the probes that count reapplies, read the journal and inject database failures. - branch_cases.py: BranchTestCase, the branch write cases, the branch values of lock_timeout and FLAT_NAMES. Like helpers.py, it imports netbox-branching only inside functions. The copies go: PLAIN_TYPE, CHANNEL_TYPE, PARENT_TYPE, PLUGIN_LOGGER, TEST_PASSWORD, FLAT, CHANNELIZED, REQUIRES_CHANNELIZATION, REQUIRES_NO_CHANNELIZATION, _journal, _module_reapplies and _reject_interface_updates. The breakout modes come from BreakoutModeChoices. Two skip reasons that lacked "(4.7+)" now have it. A local variable that would hide module_reapplies is renamed, and a moved two-line comment is one line. The shared modules do not match test_*.py, so pytest collects nothing from them. A base that a test module imports defines no test, so it adds no item. On the branch stack, --co collects the same 1695 test IDs before and after. No guard test or allow-list needed a change. Follow-up of #143. --- .../tests/branch_cases.py | 236 ++++++++++ netbox_interface_name_rules/tests/helpers.py | 189 +++++++- .../tests/test_bay_edit_trigger.py | 206 ++++----- .../tests/test_branch_jobs.py | 19 +- .../tests/test_branch_replay.py | 10 +- .../tests/test_branch_transactions.py | 9 +- .../tests/test_branch_triggers.py | 28 +- .../tests/test_branch_writes.py | 221 +-------- .../tests/test_branching.py | 30 +- .../tests/test_breakout_mode.py | 92 ++-- .../tests/test_bulk_families.py | 15 +- .../tests/test_change_log.py | 2 +- .../tests/test_channelization.py | 164 ++----- .../tests/test_channelized_mode.py | 68 ++- .../tests/test_conversion.py | 77 ++-- .../tests/test_installed_families.py | 13 +- .../tests/test_module_move_trigger.py | 425 +++++------------- .../tests/test_naming_point_sequences.py | 10 +- .../tests/test_prospective_families.py | 68 +-- .../tests/test_raw_base.py | 66 +-- .../tests/test_rename_triggers.py | 174 +++---- .../tests/test_structural_families.py | 18 +- .../tests/test_transactions.py | 33 +- .../tests/test_type_change_trigger.py | 78 ++-- .../tests/test_vc_drift.py | 133 ++---- .../tests/test_views.py | 3 +- .../tests/trigger_cases.py | 292 ++++++++++++ 27 files changed, 1371 insertions(+), 1308 deletions(-) create mode 100644 netbox_interface_name_rules/tests/branch_cases.py create mode 100644 netbox_interface_name_rules/tests/trigger_cases.py diff --git a/netbox_interface_name_rules/tests/branch_cases.py b/netbox_interface_name_rules/tests/branch_cases.py new file mode 100644 index 00000000..7a596367 --- /dev/null +++ b/netbox_interface_name_rules/tests/branch_cases.py @@ -0,0 +1,236 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Test cases that provision a real netbox-branching branch, and the rows and values that the branch tests share. + +The cases skip when netbox-branching is not installed. This module imports netbox-branching only inside +functions, so it imports where netbox-branching is not installed. +""" + +from unittest import skipUnless + +from core.models import ObjectChange +from dcim.models import Interface, InterfaceTemplate, Module, ModuleBay +from django.apps import apps +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.db import connections, transaction +from django.test import TransactionTestCase +from django.urls import reverse + +from netbox_interface_name_rules.engine import apply_rule_to_existing, supports_channelization +from netbox_interface_name_rules.models import InterfaceNameRule +from netbox_interface_name_rules.tests.helpers import ( + CHANNELIZED, + FLAT, + PLAIN_TYPE, + REQUIRES_CHANNELIZATION, + activate, + branch_cookie, + channelized_module_type, + make_device, + make_device_type, + make_manufacturer, + make_module_bay_templates, + make_module_type, +) + +User = get_user_model() +BRANCHING_INSTALLED = apps.is_installed("netbox_branching") +BRANCHING_SKIP_REASON = "netbox-branching is not installed" +# Each connection starts from its own value, so a test can tell which value came back where. +DEFAULT_BEFORE = "3s" +BRANCH_BEFORE = "4s" +# The value that PostgreSQL gives a new session. +SERVER_DEFAULT = "0" +# The names of the flat family that each ConversionCase builds in bay 3. +FLAT_NAMES = ("xe-0/0/3:0", "xe-0/0/3:1", "xe-0/0/3:2", "xe-0/0/3:3") + + +def remove_branch(branch): + """Close the connection of *branch*, then drop its schema.""" + connections[branch.connection_name].close() + branch.deprovision() + + +@skipUnless(BRANCHING_INSTALLED, BRANCHING_SKIP_REASON) +class BranchTestCase(TransactionTestCase): + """Provision real branches. Each branch is removed when its test ends, because ``--reuse-db`` keeps schemas.""" + + def provision_branch(self, name, user): + """Return a new branch named *name*, provisioned by *user* as netbox-branching's own tests do.""" + from netbox_branching.models import Branch + + branch = Branch(name=name) + branch.save(provision=False) + self.addCleanup(remove_branch, branch) + branch.provision(user=user) + # provision() writes the status with a queryset update, which the instance does not see. + branch.refresh_from_db() + return branch + + +class BranchWriteCase(BranchTestCase): + """A superuser logged in with netbox-branching's cookie of a branch provisioned from the rows ``build`` made.""" + + PREFIX = "" + + def setUp(self): + self.user = User.objects.create_superuser(username=f"{self.PREFIX.lower()}-operator") + self.build() + self.branch = self.provision_branch(self.PREFIX, self.user) + self.alias = self.branch.connection_name + self.client.force_login(self.user) + self.client.cookies[branch_cookie()] = self.branch.schema_id + + def build(self): + """Create the rows on main that the branch copies.""" + raise NotImplementedError + + def in_branch(self): + return activate(self.branch) + + def bay(self, position): + return ModuleBay.objects.get(device=self.device, name=f"Bay {position}") + + def interfaces_at(self, position): + """Return ``(pk, name)`` of each interface of the module in the bay at *position*, on the active branch or main.""" + interfaces = Interface.objects.filter(device=self.device, module__module_bay__name=f"Bay {position}") + return list(interfaces.values_list("pk", "name")) + + def interfaces_in_branch(self, position): + """Return ``(pk, name)`` of each interface in the branch of the module in the bay at *position*.""" + with self.in_branch(): + return self.interfaces_at(position) + + def names_in_branch(self, position): + """Return the sorted interface names in the branch of the module in the bay at *position*.""" + return sorted(name for _, name in self.interfaces_in_branch(position)) + + def apply_url(self, rule): + return reverse("plugins:netbox_interface_name_rules:interfacenamerule_apply_detail", kwargs={"pk": rule.pk}) + + def change_diffs(self): + """Return the ChangeDiff rows of the branch, which netbox-branching keeps on ``default``.""" + from netbox_branching.models import ChangeDiff + + return ChangeDiff.objects.filter(branch=self.branch) + + def branch_updates_of(self, instance): + """Return ``(name before, name after)`` for each update record of *instance* in the branch.""" + changes = ObjectChange.objects.using(self.alias).filter( + changed_object_type=ContentType.objects.get_for_model(instance), changed_object_id=instance.pk + ) + return [(change.prechange_data["name"], change.postchange_data["name"]) for change in changes] + + +class PlainModuleCase(BranchWriteCase): + """One device with one module bay, and a module whose one interface NetBox named ``0``.""" + + def build(self): + manufacturer = make_manufacturer(self.PREFIX) + device_type = make_device_type(manufacturer, self.PREFIX) + make_module_bay_templates(device_type, ("Bay 0",)) + self.device = make_device(self.PREFIX, device_type) + self.device_type = device_type + self.module_type = make_module_type(manufacturer, self.PREFIX) + InterfaceTemplate.objects.create(module_type=self.module_type, name="{module}", type=PLAIN_TYPE) + # No rule exists yet, so the interface keeps NetBox's raw name. + self.module = Module.objects.create(device=self.device, module_bay=self.bay(0), module_type=self.module_type) + self.interface = Interface.objects.get(module=self.module) + + +@skipUnless(supports_channelization(), REQUIRES_CHANNELIZATION) +class ConversionCase(BranchWriteCase): + """A flat family built on main by a flat rule, and the rule then switched to the channelized topology.""" + + def build(self): + manufacturer = make_manufacturer(self.PREFIX) + device_type = make_device_type(manufacturer, self.PREFIX) + make_module_bay_templates(device_type, ("Bay 0", "Bay 1", "Bay 2", "Bay 3")) + self.device = make_device(self.PREFIX, device_type) + module_type = make_module_type(manufacturer, self.PREFIX) + InterfaceTemplate.objects.create(module_type=module_type, name="{module}", type=PLAIN_TYPE) + self.rule = InterfaceNameRule.objects.create( + module_type=module_type, + name_template="xe-0/0/{bay_position}:{channel}", + breakout_mode=FLAT, + channel_count=4, + channel_start=0, + ) + # The rename trigger builds the family when the install commits. + with transaction.atomic(): + self.module = Module.objects.create(device=self.device, module_bay=self.bay(3), module_type=module_type) + self.rule.snapshot() + self.rule.breakout_mode = CHANNELIZED + self.rule.parent_name_template = "et-0/0/{bay_position}" + self.rule.save() + self.base = Interface.objects.get(module=self.module, name="xe-0/0/3:0") + + +@skipUnless(supports_channelization(), REQUIRES_CHANNELIZATION) +class ChannelCase(BranchWriteCase): + """Empty module bays, a channelized module type, and an interface on the target of channel 2 at each position. + + NetBox names a family ```` and ``:``. The rule that ``add_rule`` creates + keeps channel 2 at its old name while it renames the parent. NetBox's cascade then renames the kept + channel after the parent, and the plugin's reconciliation gives it back the name it kept. + """ + + POSITIONS = ("1",) + + def build(self): + manufacturer = make_manufacturer(self.PREFIX) + device_type = make_device_type(manufacturer, self.PREFIX) + make_module_bay_templates(device_type, [f"Bay {index}" for index in range(max(map(int, self.POSITIONS)) + 1)]) + self.device = make_device(self.PREFIX, device_type) + self.module_type = channelized_module_type(manufacturer, f"{self.PREFIX}-QSFP") + for position in self.POSITIONS: + Interface.objects.create(device=self.device, name=f"xe-0/0/{position}:1", type=PLAIN_TYPE) + + def add_rule(self): + self.rule = InterfaceNameRule.objects.create( + module_type=self.module_type, + name_template="xe-0/0/{bay_position}:{channel}", + parent_name_template="et-0/0/{bay_position}", + breakout_mode=CHANNELIZED, + channel_count=4, + channel_start=0, + ) + + @staticmethod + def kept(position): + """Return the names of the family at *position* after the reconciliation gave channel 2 its kept name.""" + return [ + f"{position}:2", + f"et-0/0/{position}", + f"xe-0/0/{position}:0", + f"xe-0/0/{position}:2", + f"xe-0/0/{position}:3", + ] + + @staticmethod + def cascaded(position): + """Return the names of the family at *position* when channel 2 carries the name of NetBox's cascade.""" + return sorted([f"et-0/0/{position}:2", f"et-0/0/{position}", *(f"xe-0/0/{position}:{c}" for c in (0, 2, 3))]) + + +class KeptChannelCase(ChannelCase): + """The families installed on main before the rule exists, so they keep NetBox's raw names, and the rule.""" + + def build(self): + super().build() + self.modules = { + position: Module.objects.create( + device=self.device, module_bay=self.bay(position), module_type=self.module_type + ) + for position in self.POSITIONS + } + self.add_rule() + + def parent(self, position): + return Interface.objects.get(module=self.modules[position], channels__isnull=False) + + def apply(self, position): + """Apply the rule in the branch to the family at *position*.""" + with self.in_branch(): + return apply_rule_to_existing(self.rule, interface_ids=[self.parent(position).pk]) diff --git a/netbox_interface_name_rules/tests/helpers.py b/netbox_interface_name_rules/tests/helpers.py index 168deb40..cc463f13 100644 --- a/netbox_interface_name_rules/tests/helpers.py +++ b/netbox_interface_name_rules/tests/helpers.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (C) 2025 Marcin Zieba -"""Builders for the objects the tests need, a runner for the background jobs, and a reader of their webhooks. +"""Builders, the values and test cases that several test modules share, a job runner and a webhook reader. Every builder takes a *prefix* and derives names and slugs from it. Test classes share one database per worker, so a class that names its objects after itself cannot collide with another class, and a @@ -14,26 +14,47 @@ import django_rq from core.events import OBJECT_CREATED, OBJECT_DELETED, OBJECT_UPDATED from core.models import Job, ObjectType +from dcim.choices import InterfaceTypeChoices from dcim.models import ( Device, DeviceRole, DeviceType, Interface, + InterfaceTemplate, Manufacturer, + Module, + ModuleBay, ModuleBayTemplate, ModuleType, Site, ) from django.contrib.auth import get_user_model from django.db import connections +from django.http import HttpRequest +from django.test import TestCase from extras.choices import EventRuleActionChoices from extras.models import EventRule, Webhook +from netbox.context import current_request, events_queue from rq import Worker from rq.job import Job as RQJob +from netbox_interface_name_rules.choices import BreakoutModeChoices from netbox_interface_name_rules.models import InterfaceNameRule from netbox_interface_name_rules.transactions import READ_LOCK_TIMEOUT, SET_LOCK_TIMEOUT +# Resolved defensively so this module still imports on NetBox releases without channelization. +CHANNEL_TYPE = getattr(InterfaceTypeChoices, "TYPE_CHANNEL", "channel") +PARENT_TYPE = InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS +PLAIN_TYPE = InterfaceTypeChoices.TYPE_10GE_SFP_PLUS +FLAT = BreakoutModeChoices.FLAT +CHANNELIZED = BreakoutModeChoices.CHANNELIZED +TEST_PASSWORD = "testpass123" # noqa: S105 - Test credential only. +PLUGIN_LOGGER = "netbox_interface_name_rules" +REQUIRES_CHANNELIZATION = "requires a NetBox that models channelized interfaces (4.7+)" +REQUIRES_NO_CHANNELIZATION = "requires a NetBox that cannot model channelized interfaces (4.6 and older)" +# On NetBox 4.5 and older the token stays literal in the interface name. +REQUIRES_VC_POSITION_TOKEN = "requires a NetBox that resolves {vc_position} in template names (4.6+)" # noqa: S105 - Skip reason, not a credential. + def slug_for(prefix: str, suffix: str = "") -> str: """Return a slug built from *prefix*, safe to use as a NetBox slug.""" @@ -230,3 +251,169 @@ def interface_signal(signal, receiver): yield finally: signal.disconnect(receiver, sender=Interface) + + +def build_device(prefix, bay_positions=(), **device_kwargs): + """Create a manufacturer, a device type with module bays at *bay_positions*, and one device.""" + slug = prefix.lower() + manufacturer = Manufacturer.objects.create(name=f"{prefix}Mfg", slug=f"{slug}-mfg") + device_type = DeviceType.objects.create(manufacturer=manufacturer, model=f"{prefix}-Dev", slug=f"{slug}-dev") + for position in bay_positions: + ModuleBayTemplate.objects.create(device_type=device_type, name=f"Bay {position}", position=position) + role = DeviceRole.objects.create(name=f"{prefix}Role", slug=f"{slug}-role") + site = Site.objects.create(name=f"{prefix}Site", slug=f"{slug}-site") + device = Device.objects.create(name=f"{slug}-sw1", device_type=device_type, role=role, site=site, **device_kwargs) + return manufacturer, device + + +def channelized_family(module_type, parent_name, child_names, channels=4): + """Add one channelized parent template plus its channel templates to *module_type*. + + *child_names* maps a channel_id to the template name that channel takes. + """ + parent = InterfaceTemplate.objects.create( + module_type=module_type, name=parent_name, type=PARENT_TYPE, channels=channels + ) + for channel_id, name in child_names.items(): + InterfaceTemplate.objects.create( + module_type=module_type, + name=name, + type=CHANNEL_TYPE, + parent=parent, + channel_id=channel_id, + ) + return parent + + +def channelized_module_type(manufacturer, model, channels=4, child_channel_ids=(1, 2, 3, 4), child_names=None): + """Create a ModuleType whose interface templates form a channelized family. + + The parent template is ``{module}`` with *channels* set; each entry in *child_channel_ids* adds a + channel-type template bound to it. *child_names* maps a channel_id to a template name, defaulting + to the upstream ``:`` convention. + """ + module_type = ModuleType.objects.create(manufacturer=manufacturer, model=model, part_number=model) + names = child_names or {channel_id: f"{{module}}:{channel_id}" for channel_id in child_channel_ids} + channelized_family( + module_type, "{module}", {channel_id: names[channel_id] for channel_id in child_channel_ids}, channels=channels + ) + return module_type + + +def plain_module_type(manufacturer, model, iface_type=PARENT_TYPE): + """Create a ModuleType with a single plain (non-channelized) port template.""" + module_type = ModuleType.objects.create(manufacturer=manufacturer, model=model, part_number=model) + InterfaceTemplate.objects.create(module_type=module_type, name="{module}", type=iface_type) + return module_type + + +def token_module_type(manufacturer, model, *template_names, iface_type=PLAIN_TYPE): + """Create a ModuleType whose interface templates are named *template_names*, in order.""" + module_type = ModuleType.objects.create(manufacturer=manufacturer, model=model, part_number=model) + for name in template_names: + InterfaceTemplate.objects.create(module_type=module_type, name=name, type=iface_type) + return module_type + + +def install_form(bay, module_type): + """Return the form data of NetBox's module edit view that installs *module_type* in *bay*.""" + return { + "device": bay.device_id, + "module_bay": bay.pk, + "module_type": module_type.pk, + "status": "active", + "replicate_components": "on", + } + + +def names_of(module): + """Return the sorted interface names of *module* on the active branch.""" + return sorted(Interface.objects.filter(module=module).values_list("name", flat=True)) + + +@contextlib.contextmanager +def request_context(user): + """Set the request and the event queue as NetBox's event_tracking does, without its flush.""" + request = HttpRequest() + request.user = user + request.id = uuid.uuid4() + request_token = current_request.set(request) + queue_token = events_queue.set({}) + try: + yield + finally: + events_queue.reset(queue_token) + current_request.reset(request_token) + + +class WriteInterfacesTo: + """A database router that sends each write of an interface to one alias.""" + + def __init__(self, alias): + self.alias = alias + + def db_for_write(self, model, **hints): + return self.alias if model is Interface else None + + +class ChannelizationTestCase(TestCase): + """Install helpers shared by the channelized module-install test cases.""" + + def _install(self, module_type, position, run_rules=True): + """Install a module into the bay at *position*; run the post-commit rename unless told not to. + + ``run_rules=False`` leaves the freshly instantiated (raw-named) family in place so a test can + call the engine directly and assert its return value. + """ + bay = ModuleBay.objects.get(device=self.device, name=f"Bay {position}") + if run_rules: + with self.captureOnCommitCallbacks(execute=True): + module = Module.objects.create(device=self.device, module_bay=bay, module_type=module_type) + else: + module = Module.objects.create(device=self.device, module_bay=bay, module_type=module_type) + return module, bay + + @staticmethod + def _names(module): + """Return the sorted interface names of *module*.""" + return sorted(Interface.objects.filter(module=module).values_list("name", flat=True)) + + @staticmethod + def _parent(module): + """Return the channelized parent interface of *module*.""" + return Interface.objects.get(module=module, channels__isnull=False) + + @staticmethod + def _child(module, channel_id): + """Return the channel subinterface of *module* bound to *channel_id*.""" + return Interface.objects.get(module=module, channel_id=channel_id) + + +class VcDriftTestCase(ChannelizationTestCase): + """VC transitions go through a real ``Device.save()`` so the plugin's signals do the scheduling.""" + + def _save_vc_state(self, device, virtual_chassis, position): + with self.captureOnCommitCallbacks(execute=True): + device.virtual_chassis = virtual_chassis + device.vc_position = position + device.save() + + def _join(self, vc, position, device=None): + """Add *device* to *vc* at *position* — the join direction (fallback → position).""" + self._save_vc_state(device or self.device, vc, position) + + def _renumber(self, position, device=None): + """Move *device* to another position inside its VC — the renumber direction (P → Q).""" + device = device or self.device + self._save_vc_state(device, device.virtual_chassis, position) + + def _leave(self, device=None): + """Remove *device* from its VC — the leave direction (position → fallback).""" + self._save_vc_state(device or self.device, None, None) + + def _install_on(self, device, module_type, position): + """Install a module of *module_type* into *device*'s bay at *position*, rules and all.""" + bay = ModuleBay.objects.get(device=device, name=f"Bay {position}") + with self.captureOnCommitCallbacks(execute=True): + module = Module.objects.create(device=device, module_bay=bay, module_type=module_type) + return module, bay diff --git a/netbox_interface_name_rules/tests/test_bay_edit_trigger.py b/netbox_interface_name_rules/tests/test_bay_edit_trigger.py index 7862ecbf..d6344611 100644 --- a/netbox_interface_name_rules/tests/test_bay_edit_trigger.py +++ b/netbox_interface_name_rules/tests/test_bay_edit_trigger.py @@ -20,64 +20,32 @@ from rest_framework import status from utilities.testing import APITestCase -from netbox_interface_name_rules.choices import BreakoutModeChoices from netbox_interface_name_rules.engine import supports_vc_position_token from netbox_interface_name_rules.family import supports_module_moves from netbox_interface_name_rules.models import InterfaceNameRule from netbox_interface_name_rules.rename_triggers import ModuleTrigger, PlanRunner +from netbox_interface_name_rules.tests.helpers import PLAIN_TYPE, REQUIRES_VC_POSITION_TOKEN from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band -from netbox_interface_name_rules.tests.test_module_move_trigger import ( +from netbox_interface_name_rules.tests.trigger_cases import ( CHASSIS_RULES, - PLAIN_TYPE, REQUIRES_SUBTREE_MOVES, TAKEN, UNAVAILABLE, - ModuleMoveTestCase, - _fail_the_naming_read, - _journal, - _module_reapplies, - _MoveFixture, - _naming_reads, - _reapplied, - _reject_interface_updates, + BayEditTestCase, + MoveFixture, + fail_the_naming_read, + flat_rule, + journal, + module_reapplies, + naming_reads, + previous_state_read_fails, + reapplied, + reject_interface_updates, ) -from netbox_interface_name_rules.tests.test_rename_triggers import _previous_state_read_fails -from netbox_interface_name_rules.tests.test_vc_drift import REQUIRES_VC_POSITION_TOKEN BAY_STATE_READ = re.compile(r'SELECT "dcim_modulebay"\."position".* FROM "dcim_modulebay"') -def _flat_rule(module_type, name_template, **scope): - return InterfaceNameRule.objects.create( - module_type=module_type, - name_template=name_template, - breakout_mode=BreakoutModeChoices.FLAT, - channel_count=2, - channel_start=0, - **scope, - ) - - -class BayEditTestCase(ModuleMoveTestCase): - """Install modules and edit their bays through real saves, with the committed callbacks run.""" - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - cls.plain_type = cls._module_type("Plain", "{module}") - InterfaceNameRule.objects.create(module_type=cls.plain_type, name_template="et-{vc_position}/0/{bay_position}") - - @staticmethod - def _save_edit(bay, **values): - for field, value in values.items(): - setattr(bay, field, value) - bay.save() - - def _edit(self, bay, **values): - with self.captureOnCommitCallbacks(execute=True): - self._save_edit(bay, **values) - - class BayEditTest(BayEditTestCase): """The module in an edited bay gets the names its rule gives for the bay's new position or name.""" @@ -101,7 +69,7 @@ def test_a_raw_name_whose_bay_position_brought_the_chassis_token_is_recognised_a self._edit(bay, position="2") self.assertEqual(self._names(module), ["p1/2"]) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) @skipUnless(supports_vc_position_token(), REQUIRES_VC_POSITION_TOKEN) def test_a_raw_name_whose_fallback_reads_the_bay_is_recognised_after_a_bay_edit_off_a_chassis(self): @@ -116,7 +84,7 @@ def test_a_raw_name_whose_fallback_reads_the_bay_is_recognised_after_a_bay_edit_ self._edit(bay, position="4") self.assertEqual(self._names(module), ["pxe-4"]) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) def test_a_position_edit_renames_the_module_for_the_new_position(self): bay = self._bay(self.device) @@ -126,7 +94,7 @@ def test_a_position_edit_renames_the_module_for_the_new_position(self): self._edit(bay, position="5") self.assertEqual(self._names(module), ["et-1/0/5"]) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) def test_a_base_rule_is_renamed_from_the_raw_name_the_old_position_gave(self): bay = self._bay(self.device) @@ -145,7 +113,7 @@ def test_a_name_edit_renames_the_module_when_its_position_takes_the_number_from_ self._edit(bay, name="Bay 7") self.assertEqual(self._names(module), ["ge-1/0/7"]) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) def test_a_name_edit_that_no_variable_reads_changes_nothing(self): numbered = self._install(self.plain_type, self._bay(self.device)) @@ -153,7 +121,7 @@ def test_a_name_edit_that_no_variable_reads_changes_nothing(self): named = self._install(self.fixed_type, self._bay(self.device, "Bay 3")) control = self._install(self.plain_type, self._bay(self.device, "Bay 1")) - with _naming_reads() as reads, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with naming_reads() as reads, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): self._save_edit(self._bay(self.device), name="Uplink 0") self._save_edit(self._bay(self.device, "Bay 3"), name="Slot 3") self._save_edit(self._bay(self.device, "Bay 1"), position="4") @@ -163,7 +131,7 @@ def test_a_name_edit_that_no_variable_reads_changes_nothing(self): (self._names(numbered), self._names(named), self._names(control)), (["et-1/0/0", "operator-name"], ["ge-1/0/3"], ["et-1/0/4"]), ) - self.assertEqual((_journal(numbered), _journal(named)), ([], [])) + self.assertEqual((journal(numbered), journal(named)), ([], [])) def test_only_an_occupied_bay_whose_position_or_name_changes_reads_the_naming(self): occupied = self._bay(self.device) @@ -171,8 +139,8 @@ def test_only_an_occupied_bay_whose_position_or_name_changes_reads_the_naming(se control = self._install(self.plain_type, self._bay(self.device, "Bay 1")) with ( - _naming_reads() as reads, - _module_reapplies() as reapplies, + naming_reads() as reads, + module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(), ): @@ -200,7 +168,7 @@ def test_a_subinterface_does_not_stop_the_rename(self): self._edit(bay, position="5") self.assertEqual(self._names(module), ["et-1/0/0.100", "et-1/0/5"]) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) class NestedBayEditTest(BayEditTestCase): @@ -226,7 +194,7 @@ def test_the_module_in_the_bay_and_the_modules_nested_in_it_are_renamed_for_the_ self._edit(bay, position="2") self.assertEqual((self._names(card), self._names(optic)), (["p2-1"], ["et-1/2/1"])) - self.assertEqual(_journal(card), []) + self.assertEqual(journal(card), []) def test_the_subtree_reports_in_one_journal_entry_on_the_module_in_the_edited_bay(self): card_type = self._card_type("Two Port Card", "1") @@ -242,16 +210,16 @@ def test_the_subtree_reports_in_one_journal_entry_on_the_module_in_the_edited_ba (runner,) = [callback for callback in callbacks if isinstance(callback, PlanRunner)] self.assertEqual([trigger.pk for trigger in runner.plan.triggers], [card.pk]) - with connection.execute_wrapper(_reject_interface_updates), self.assertLogs("netbox_interface_name_rules"): + with connection.execute_wrapper(reject_interface_updates), self.assertLogs("netbox_interface_name_rules"): for callback in callbacks: callback() - (entry,) = _journal(card) + (entry,) = journal(card) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_DANGER) self.assertIn(f"`et-1/0/1` to `et-1/2/1`: {TAKEN}", entry.comments) self.assertIn("injected reapply failure", entry.comments) self.assertEqual((self._names(blocked), self._names(failed)), (["et-1/0/1"], ["et-1/0/2"])) - self.assertEqual((_journal(blocked), _journal(failed)), ([], [])) + self.assertEqual((journal(blocked), journal(failed)), ([], [])) def test_a_type_change_and_a_bay_edit_rename_the_nested_modules_from_the_naming_before_the_edit(self): bay = self._bay(self.device) @@ -283,7 +251,7 @@ def test_an_outer_edit_undone_around_a_nested_edit_renames_the_nested_module_for self._save_edit(bay, position="0") self.assertEqual(self._names(optic), ["et-1/0/3"]) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) def test_a_nested_edit_before_an_outer_edit_renames_the_nested_module_without_a_report(self): bay, card, port, optic = self._card_with_optic() @@ -293,7 +261,7 @@ def test_a_nested_edit_before_an_outer_edit_renames_the_nested_module_without_a_ self._save_edit(bay, position="2") self.assertEqual(self._names(optic), ["et-1/2/3"]) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) def test_an_outer_reapply_that_runs_first_renames_the_nested_module_from_its_earliest_naming(self): bay, card, port, optic = self._card_with_optic() @@ -306,11 +274,11 @@ def test_an_outer_reapply_that_runs_first_renames_the_nested_module_from_its_ear self._save_edit(bay, position="2") self.assertEqual(self._names(optic), ["et-1/2/3"]) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) def test_a_card_and_an_optic_installed_before_an_edit_of_the_card_bay_build_the_optic_family(self): flat_optic_type = self._module_type("Flat Optic", "{module}") - _flat_rule(flat_optic_type, "x-{slot}/{bay_position}:{channel}") + flat_rule(flat_optic_type, "x-{slot}/{bay_position}:{channel}") bay = self._bay(self.device) with self.captureOnCommitCallbacks(execute=True), transaction.atomic(): @@ -320,11 +288,11 @@ def test_a_card_and_an_optic_installed_before_an_edit_of_the_card_bay_build_the_ self._save_edit(bay, position="2") self.assertEqual(self._names(optic), ["x-2/1:0", "x-2/1:1"]) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) def test_an_optic_installed_in_a_card_before_an_edit_of_the_card_bay_builds_its_family_once(self): flat_optic_type = self._module_type("Flat Optic", "{module}") - _flat_rule(flat_optic_type, "x-{slot}/{bay_position}:{channel}") + flat_rule(flat_optic_type, "x-{slot}/{bay_position}:{channel}") bay = self._bay(self.device) card, port = self._install_card(self._card_type("Card", "1"), bay) @@ -333,11 +301,11 @@ def test_an_optic_installed_in_a_card_before_an_edit_of_the_card_bay_builds_its_ self._save_edit(bay, position="2") self.assertEqual(self._names(optic), ["x-2/1:0", "x-2/1:1"]) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) def test_an_optic_installed_before_edits_of_the_card_bay_and_its_own_bay_builds_its_family_once(self): flat_optic_type = self._module_type("Flat Optic", "{module}") - _flat_rule(flat_optic_type, "x-{slot}/{bay_position}:{channel}") + flat_rule(flat_optic_type, "x-{slot}/{bay_position}:{channel}") bay = self._bay(self.device) card, port = self._install_card(self._card_type("Card", "1"), bay) @@ -347,13 +315,13 @@ def test_an_optic_installed_before_edits_of_the_card_bay_and_its_own_bay_builds_ self._save_edit(port, position="3") self.assertEqual(self._names(optic), ["x-2/3:0", "x-2/3:1"]) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) def test_an_optic_moved_into_a_card_before_an_edit_of_the_card_bay_builds_its_family_once(self): card_type = self._card_type("Card", "1") optic_type = self._module_type("Scoped Optic", "{module}") InterfaceNameRule.objects.create(module_type=optic_type, name_template="p{bay_position}") - _flat_rule(optic_type, "x-{slot}/{bay_position}:{channel}", parent_module_type=card_type) + flat_rule(optic_type, "x-{slot}/{bay_position}:{channel}", parent_module_type=card_type) bay = self._bay(self.device) card, port = self._install_card(card_type, bay) optic = self._install(optic_type, self._bay(self.device, "Bay 1")) @@ -364,7 +332,7 @@ def test_an_optic_moved_into_a_card_before_an_edit_of_the_card_bay_builds_its_fa self._save_edit(bay, position="2") self.assertEqual(self._names(optic), ["x-2/1:0", "x-2/1:1"]) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) def test_an_optic_whose_raw_name_reads_the_card_bay_is_renamed_after_an_edit_of_that_bay(self): chained_optic_type = self._module_type("Chained Optic", "{module}/{module}") @@ -379,7 +347,7 @@ def test_an_optic_whose_raw_name_reads_the_card_bay_is_renamed_after_an_edit_of_ self._save_edit(bay, position="2") self.assertEqual(self._names(optic), ["et-1/2/1"]) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) @skipUnless(supports_module_moves(), REQUIRES_SUBTREE_MOVES) def test_the_bay_post_saves_netbox_sends_in_a_move_are_not_bay_triggers(self): @@ -387,7 +355,7 @@ def test_the_bay_post_saves_netbox_sends_in_a_move_are_not_bay_triggers(self): optic = self._install(self.optic_type, port) self.assertEqual(self._names(optic), ["et-1/0/0"]) - with _naming_reads() as reads, self.captureOnCommitCallbacks() as callbacks: + with naming_reads() as reads, self.captureOnCommitCallbacks() as callbacks: self._save_move(card, self._bay(self.device, "Bay 2")) (runner,) = [callback for callback in callbacks if isinstance(callback, PlanRunner)] for callback in callbacks: @@ -427,14 +395,14 @@ def test_a_nested_module_with_its_own_trigger_reports_on_the_module_in_the_outer bay, card, port, optic = self._card_with_optic() Interface.objects.create(device=self.device, name="et-1/2/3", type=PLAIN_TYPE) - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): self._save_edit(bay, position="2") self._save_edit(port, position="3") self.assertEqual((self._names(optic), reapplies.call_count), (["et-1/0/1"], 2)) - (entry,) = _journal(card) + (entry,) = journal(card) self.assertIn(f"`et-1/0/1` to `et-1/2/3`: {TAKEN}", entry.comments) - self.assertEqual(_journal(optic), []) + self.assertEqual(journal(optic), []) def test_a_nested_type_change_and_an_outer_edit_reapply_the_nested_module_once_as_a_type_change(self): bay, card, _port, optic = self._card_with_optic() @@ -444,20 +412,20 @@ def test_a_nested_type_change_and_an_outer_edit_reapply_the_nested_module_once_a ) Interface.objects.create(device=self.device, name="ge-1/2/1", type=PLAIN_TYPE) - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): optic.module_type = other_optic_type optic.save() self._save_edit(bay, position="2") self.assertEqual((self._names(optic), reapplies.call_count), (["et-1/0/1"], 2)) - (entry,) = _journal(card) + (entry,) = journal(card) self.assertIn(f"`et-1/0/1` to `ge-1/2/1`: {TAKEN}", entry.comments) - self.assertEqual(_journal(optic), []) + self.assertEqual(journal(optic), []) def test_an_outer_edit_and_a_move_of_the_nested_module_out_reapply_it_once(self): bay, _card, _port, optic = self._card_with_optic() - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): self._save_edit(bay, position="2") self._save_move(optic, self._bay(self.device, "Bay 1")) @@ -467,25 +435,25 @@ def test_an_outer_edit_and_a_move_of_the_nested_module_out_reapply_it_once(self) def test_an_outer_move_and_a_nested_bay_edit_reapply_each_module_once(self): _bay, card, port, optic = self._card_with_optic() - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): self._save_move(card, self._bay(self.device, "Bay 2")) port.refresh_from_db() self._save_edit(port, position="3") self.assertEqual((self._names(optic), reapplies.call_count), (["et-1/2/3"], 2)) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) @skipUnless(supports_module_moves(), REQUIRES_SUBTREE_MOVES) def test_a_nested_bay_edit_and_an_outer_move_reapply_each_module_once(self): _bay, card, port, optic = self._card_with_optic() - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): self._save_edit(port, position="3") self._save_move(card, self._bay(self.device, "Bay 2")) port.refresh_from_db() self.assertEqual((self._names(optic), reapplies.call_count), ([f"et-1/2/{port.position}"], 2)) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) def test_an_optic_installed_while_the_card_bay_is_edited_and_restored_is_named_from_its_raw_name(self): chained_optic_type = self._module_type("Chained Optic", "{module}/{module}") @@ -501,7 +469,7 @@ def test_an_optic_installed_while_the_card_bay_is_edited_and_restored_is_named_f self._save_edit(bay, position="0") self.assertEqual(self._names(optic), ["et-1/0/1"]) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) def test_a_rolled_back_nested_edit_leaves_the_outer_edit_to_rename_the_nested_module(self): bay, card, port, optic = self._card_with_optic() @@ -513,7 +481,7 @@ def test_a_rolled_back_nested_edit_leaves_the_outer_edit_to_rename_the_nested_mo raise RuntimeError("roll back the savepoint") self.assertEqual(self._names(optic), ["et-1/2/1"]) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) def test_a_rolled_back_outer_edit_leaves_the_nested_edit_to_rename_its_module(self): bay, card, port, optic = self._card_with_optic() @@ -525,7 +493,7 @@ def test_a_rolled_back_outer_edit_leaves_the_nested_edit_to_rename_its_module(se raise RuntimeError("roll back the savepoint") self.assertEqual(self._names(optic), ["et-1/0/3"]) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) def test_a_naming_read_in_a_rolled_back_savepoint_is_not_used(self): other_card_type = self._card_type("Other Card", "1") @@ -554,7 +522,7 @@ def test_a_naming_read_in_a_rolled_back_savepoint_is_not_used(self): self._save_edit(bay, position="3") self.assertEqual((self._names(optic), self._names(other)), (["a-3/1"], ["et-1/0/1"])) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) class BayEditTransactionTest(BayEditTestCase): @@ -564,7 +532,7 @@ def test_several_edits_of_one_bay_reapply_once_from_its_earliest_state(self): bay = self._bay(self.device) module = self._install(self.plain_type, bay) - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): self._save_edit(bay, position="5") self._save_edit(bay, position="7") @@ -578,20 +546,20 @@ def test_an_edit_the_same_transaction_undoes_reapplies_nothing(self): edited_bay = self._bay(self.device, "Bay 1") edited = self._install(self.plain_type, edited_bay) - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): self._save_edit(returned_bay, position="5") self._save_edit(returned_bay, position="0") self._save_edit(edited_bay, position="4") self.assertEqual(reapplies.call_count, 1) self.assertEqual((self._names(returned), self._names(edited)), (["operator-name"], ["et-1/0/4"])) - self.assertEqual(_journal(returned), []) + self.assertEqual(journal(returned), []) def test_an_edit_in_a_rolled_back_savepoint_causes_no_reapply_and_a_later_edit_reapplies_once(self): bay = self._bay(self.device) module = self._install(self.plain_type, bay) - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): with self.assertRaises(RuntimeError), transaction.atomic(): self._save_edit(bay, position="9") raise RuntimeError("roll back the savepoint") @@ -609,7 +577,7 @@ def test_an_edit_in_a_rolled_back_savepoint_schedules_no_reapply(self): with self.assertRaises(RuntimeError), transaction.atomic(): self._save_edit(bay, position="9") raise RuntimeError("roll back the savepoint") - with _module_reapplies() as reapplies: + with module_reapplies() as reapplies: for callback in callbacks: callback() @@ -620,7 +588,7 @@ def test_an_edit_rolled_back_after_an_earlier_edit_keeps_the_earlier_reapply(sel bay = self._bay(self.device) module = self._install(self.plain_type, bay) - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): self._save_edit(bay, position="5") with self.assertRaises(RuntimeError), transaction.atomic(): self._save_edit(bay, position="7") @@ -654,7 +622,7 @@ def test_a_bay_edit_joins_a_plan_that_a_save_outside_a_capture_left_pending(self def test_an_install_and_an_edit_of_its_bay_name_the_module_for_the_edited_bay(self): bay = self._bay(self.device) - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): module = Module.objects.create(device=self.device, module_bay=bay, module_type=self.plain_type) self._save_edit(bay, position="5") @@ -663,7 +631,7 @@ def test_an_install_and_an_edit_of_its_bay_name_the_module_for_the_edited_bay(se def test_an_install_and_an_edit_of_its_bay_under_a_flat_rule_build_the_family(self): flat_type = self._module_type("Flat", "{module}") - _flat_rule(flat_type, "f-{bay_position}:{channel}") + flat_rule(flat_type, "f-{bay_position}:{channel}") bay = self._bay(self.device) with self.captureOnCommitCallbacks(execute=True), transaction.atomic(): @@ -671,13 +639,13 @@ def test_an_install_and_an_edit_of_its_bay_under_a_flat_rule_build_the_family(se self._save_edit(bay, position="5") self.assertEqual(self._names(module), ["f-5:0", "f-5:1"]) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) def test_a_module_moved_into_a_bay_that_is_then_edited_reapplies_once_for_the_edited_bay(self): module = self._install(self.plain_type, self._bay(self.device)) bay = self._bay(self.device, "Bay 1") - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): self._save_move(module, bay) self._save_edit(bay, position="4") @@ -757,8 +725,8 @@ def _assert_reapplied_once(self, model, chassis_first, before, after): self.assertEqual( (self._names(optic), self._names(other)), ([after.format(raw=self._raw_name(optic))], ["et-3/0/10"]) ) - self.assertEqual(_reapplied(reapplies), sorted((card.pk, optic.pk, other.pk))) - self.assertEqual((_journal(card), _journal(optic), _journal(self.device)), ([], [], [])) + self.assertEqual(reapplied(reapplies), sorted((card.pk, optic.pk, other.pk))) + self.assertEqual((journal(card), journal(optic), journal(self.device)), ([], [], [])) def test_a_bay_edit_then_a_chassis_position_change_reapply_a_plain_rule_once(self): self._assert_reapplied_once("Plain", False, "et-1/0/1", "et-3/2/1") @@ -784,10 +752,10 @@ def _assert_a_collision_is_reported_once(self, chassis_first): reapplies = self._edit_with(bay, self._change_the_chassis_position, chassis_first) - self.assertEqual((self._names(optic), _reapplied(reapplies).count(optic.pk)), (["et-1/0/1"], 1)) - (entry,) = _journal(card) + self.assertEqual((self._names(optic), reapplied(reapplies).count(optic.pk)), (["et-1/0/1"], 1)) + (entry,) = journal(card) self.assertEqual(entry.comments.count(f"`et-1/0/1` to `et-3/2/1`: {TAKEN}"), 1) - self.assertEqual((_journal(optic), _journal(self.device)), ([], [])) + self.assertEqual((journal(optic), journal(self.device)), ([], [])) def test_a_bay_edit_then_a_chassis_position_change_report_a_collision_once(self): self._assert_a_collision_is_reported_once(chassis_first=False) @@ -801,9 +769,9 @@ def _assert_leaving_renames_nothing_and_reports_once(self, leave_first): reapplies = self._edit_with(bay, self._leave_the_chassis, leave_first) self.assertEqual((self._names(optic), self._names(other)), (["x10/1"], ["et-1/0/10"])) - self.assertEqual(_reapplied(reapplies), sorted((card.pk, optic.pk, other.pk))) - (card_entry,) = _journal(card) - (device_entry,) = _journal(self.device) + self.assertEqual(reapplied(reapplies), sorted((card.pk, optic.pk, other.pk))) + (card_entry,) = journal(card) + (device_entry,) = journal(self.device) self.assertEqual(card_entry.comments.count(f"`x10/1`: {UNAVAILABLE}"), 1) self.assertIn(f"`et-1/0/10`: {UNAVAILABLE}", device_entry.comments) self.assertNotIn("x10/1", device_entry.comments) @@ -825,7 +793,7 @@ def _assert_joining_renames_once(self, join_first): reapplies = self._edit_with(bay, functools.partial(self._join_the_chassis, chassis), join_first) self.assertEqual((self._names(optic), self._names(other)), (["x32/1"], ["et-3/0/10"])) - self.assertEqual(_reapplied(reapplies), sorted((card.pk, optic.pk, other.pk))) + self.assertEqual(reapplied(reapplies), sorted((card.pk, optic.pk, other.pk))) self.assertEqual(JournalEntry.objects.count(), entries) def test_a_bay_edit_then_joining_a_chassis_rename_each_module_once(self): @@ -863,8 +831,8 @@ def _install_after_a_device_change(self, device_change, module_type): def test_a_chassis_position_change_an_install_and_a_bay_edit_recognise_the_raw_name_of_the_install(self): module, reapplies = self._install_after_a_device_change(self._change_the_chassis_position, self.token_type) - self.assertEqual((self._names(module), _reapplied(reapplies)), (["et-3/2"], [module.pk])) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual((self._names(module), reapplied(reapplies)), (["et-3/2"], [module.pk])) + self.assertEqual((journal(module), journal(self.device)), ([], [])) @skipUnless(supports_vc_position_token(), REQUIRES_VC_POSITION_TOKEN) def test_an_install_between_two_chassis_position_changes_is_recognised_at_the_position_of_the_install(self): @@ -878,8 +846,8 @@ def test_an_install_between_two_chassis_position_changes_is_recognised_at_the_po ) (module,) = installed - self.assertEqual((self._names(module), _reapplied(reapplies)), (["et-5/2"], [module.pk])) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual((self._names(module), reapplied(reapplies)), (["et-5/2"], [module.pk])) + self.assertEqual((journal(module), journal(self.device)), ([], [])) @skipUnless(supports_vc_position_token(), REQUIRES_VC_POSITION_TOKEN) def test_joining_a_chassis_an_install_and_a_bay_edit_recognise_the_raw_name_of_the_install(self): @@ -891,21 +859,21 @@ def test_joining_a_chassis_an_install_and_a_bay_edit_recognise_the_raw_name_of_t functools.partial(self._join_the_chassis, chassis), self.token_type ) - self.assertEqual((self._names(module), _reapplied(reapplies)), (["et-3/2"], [module.pk])) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual((self._names(module), reapplied(reapplies)), (["et-3/2"], [module.pk])) + self.assertEqual((journal(module), journal(self.device)), ([], [])) @skipUnless(supports_vc_position_token(), REQUIRES_VC_POSITION_TOKEN) def test_leaving_a_chassis_an_install_and_a_bay_edit_recognise_the_raw_name_of_the_install(self): module, reapplies = self._install_after_a_device_change(self._leave_the_chassis, self.token_base_type) - self.assertEqual((self._names(module), _reapplied(reapplies)), (["p0/2"], [module.pk])) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual((self._names(module), reapplied(reapplies)), (["p0/2"], [module.pk])) + self.assertEqual((journal(module), journal(self.device)), ([], [])) def _assert_an_installed_raw_name_is_renamed_once(self, chassis_first): module, reapplies = self._install_and_edit_a_token_module(self._change_the_chassis_position, chassis_first) - self.assertEqual((self._names(module), _reapplied(reapplies)), (["et-3/2"], [module.pk])) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual((self._names(module), reapplied(reapplies)), (["et-3/2"], [module.pk])) + self.assertEqual((journal(module), journal(self.device)), ([], [])) @skipUnless(supports_vc_position_token(), REQUIRES_VC_POSITION_TOKEN) def test_an_install_a_bay_edit_and_a_chassis_position_change_rename_a_raw_name_that_reads_the_position(self): @@ -926,7 +894,7 @@ def _assert_a_raw_name_off_the_chassis_is_renamed_after_a_join(self, join_first) reapplies = self._edit_with(bay, functools.partial(self._join_the_chassis, chassis), join_first) - self.assertEqual((self._names(module), _reapplied(reapplies)), (["et-3/2"], [module.pk])) + self.assertEqual((self._names(module), reapplied(reapplies)), (["et-3/2"], [module.pk])) self.assertEqual(JournalEntry.objects.count(), entries) @skipUnless(supports_vc_position_token(), REQUIRES_VC_POSITION_TOKEN) @@ -946,7 +914,7 @@ def test_a_bay_save_fails_with_the_previous_state_read_error(self): self._install(self.plain_type, bay) with ( - _previous_state_read_fails(BAY_STATE_READ) as replaced, + previous_state_read_fails(BAY_STATE_READ) as replaced, self.assertRaisesMessage(DataError, "division by zero"), transaction.atomic(), ): @@ -960,7 +928,7 @@ def test_a_naming_read_that_fails_fails_the_bay_edit_with_its_error(self): module = self._install(self.plain_type, bay) with ( - connection.execute_wrapper(_fail_the_naming_read), + connection.execute_wrapper(fail_the_naming_read), self.assertRaisesMessage(DataError, "division by zero"), transaction.atomic(), ): @@ -970,7 +938,7 @@ def test_a_naming_read_that_fails_fails_the_bay_edit_with_its_error(self): self.assertEqual(self._names(module), ["et-1/0/0"]) -class BayEditAPITest(_MoveFixture, APITestCase): +class BayEditAPITest(MoveFixture, APITestCase): """A REST API bay edit reaches the rename trigger through NetBox's own write path.""" model = ModuleBay @@ -993,4 +961,4 @@ def test_patching_the_bay_position_renames_the_installed_module(self): self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) self.assertEqual(self._names(module), ["et-1/0/5"]) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) diff --git a/netbox_interface_name_rules/tests/test_branch_jobs.py b/netbox_interface_name_rules/tests/test_branch_jobs.py index bd1298c1..36ff6e50 100644 --- a/netbox_interface_name_rules/tests/test_branch_jobs.py +++ b/netbox_interface_name_rules/tests/test_branch_jobs.py @@ -14,26 +14,21 @@ from netbox_interface_name_rules.api.views import BACKGROUND_IN_A_BRANCH from netbox_interface_name_rules.models import InterfaceNameRule +from netbox_interface_name_rules.tests.branch_cases import FLAT_NAMES, BranchWriteCase, ConversionCase, PlainModuleCase from netbox_interface_name_rules.tests.helpers import ( branch_cookie, make_manufacturer, make_module_type, + names_of, queued_job, register_a_worker, ) -from netbox_interface_name_rules.tests.test_branch_writes import ( - FLAT_NAMES, - _BranchWriteCase, - _ConversionCase, - _PlainModuleCase, - names_of, -) COMPLETED = JobStatusChoices.STATUS_COMPLETED ERRORED = JobStatusChoices.STATUS_ERRORED -class _JobCase(_BranchWriteCase): +class _JobCase(BranchWriteCase): """Enqueue a job through the Apply page in the branch, and run it from the queue as a worker does.""" def enqueue(self, action): @@ -64,7 +59,7 @@ def alias_error(self): return repr(RuntimeError(f"The write alias is 'default', but the operation expects {self.alias!r}.")) -class ApplyJobInABranchTest(_JobCase, _PlainModuleCase): +class ApplyJobInABranchTest(_JobCase, PlainModuleCase): PREFIX = "BrJobApply" def build(self): @@ -113,7 +108,7 @@ def test_a_job_whose_branch_is_no_longer_ready_fails_naming_both_aliases_and_ren self.assertFalse(self.change_diffs().exists()) -class StartCheckBeforeTheRuleReadTest(_JobCase, _PlainModuleCase): +class StartCheckBeforeTheRuleReadTest(_JobCase, PlainModuleCase): """The rule exists in the branch only, so a job that read it on main would find no rule and complete.""" PREFIX = "BrJobRuleRead" @@ -136,7 +131,7 @@ def test_a_job_whose_branch_is_no_longer_ready_fails_before_it_reads_the_rule(se self.assertEqual(names_of(self.module), ["0"]) -class ConvertJobInABranchTest(_JobCase, _ConversionCase): +class ConvertJobInABranchTest(_JobCase, ConversionCase): PREFIX = "BrJobConvert" def test_the_job_converts_in_the_branch_only(self): @@ -159,7 +154,7 @@ def test_a_job_whose_branch_is_no_longer_ready_fails_and_converts_nothing(self): self.assertEqual(names_of(self.module), list(FLAT_NAMES)) -class BackgroundRuleRequestTest(_BranchWriteCase): +class BackgroundRuleRequestTest(BranchWriteCase): """NetBox runs a background REST request on main, so the rule endpoints refuse one in a branch.""" PREFIX = "BrBackground" diff --git a/netbox_interface_name_rules/tests/test_branch_replay.py b/netbox_interface_name_rules/tests/test_branch_replay.py index 4fcc4a48..20d5f599 100644 --- a/netbox_interface_name_rules/tests/test_branch_replay.py +++ b/netbox_interface_name_rules/tests/test_branch_replay.py @@ -16,8 +16,11 @@ from extras.models import JournalEntry from netbox_interface_name_rules.models import InterfaceNameRule +from netbox_interface_name_rules.tests.branch_cases import BranchWriteCase, KeptChannelCase from netbox_interface_name_rules.tests.helpers import ( + PLAIN_TYPE, branch_cookie, + install_form, make_device, make_device_type, make_manufacturer, @@ -25,9 +28,6 @@ make_module_type, queued_job, ) -from netbox_interface_name_rules.tests.test_branch_triggers import install_form -from netbox_interface_name_rules.tests.test_branch_writes import _BranchWriteCase, _KeptChannelCase -from netbox_interface_name_rules.tests.test_channelization import PLAIN_TYPE COMPLETED = JobStatusChoices.STATUS_COMPLETED ERRORED = JobStatusChoices.STATUS_ERRORED @@ -46,7 +46,7 @@ def merging(strategy, commit=True): return {"merge_strategy": strategy, **({"commit": "on"} if commit else {})} -class _ReplayCase(_BranchWriteCase): +class _ReplayCase(BranchWriteCase): """Run netbox-branching's merge, revert and sync as an operator and a worker do.""" def enqueue(self, action, **form): @@ -285,7 +285,7 @@ def test_a_merge_of_the_branch_in_another_worker_does_not_skip_a_save_here(self) self.assert_an_install_on_main_gets_the_names_of_the_rule() -class _CascadeCase(_KeptChannelCase, _ReplayCase): +class _CascadeCase(KeptChannelCase, _ReplayCase): """NetBox's cascade renames a kept channel again at the commit of a replay: the documented limit. The rule keeps channel 2 at ``1:2`` while it renames the parent. A replay renames the parent before diff --git a/netbox_interface_name_rules/tests/test_branch_transactions.py b/netbox_interface_name_rules/tests/test_branch_transactions.py index 1eabc18d..f9112b12 100644 --- a/netbox_interface_name_rules/tests/test_branch_transactions.py +++ b/netbox_interface_name_rules/tests/test_branch_transactions.py @@ -13,15 +13,15 @@ from netbox.context import events_queue from netbox_interface_name_rules.models import InterfaceNameRule +from netbox_interface_name_rules.tests.branch_cases import BRANCH_BEFORE, DEFAULT_BEFORE, SERVER_DEFAULT, BranchTestCase from netbox_interface_name_rules.tests.helpers import ( activate, lock_timeout, make_manufacturer, make_module_type, + request_context, set_lock_timeout, ) -from netbox_interface_name_rules.tests.test_branching import BranchTestCase -from netbox_interface_name_rules.tests.test_transactions import request_context from netbox_interface_name_rules.transactions import ( LOCK_TIMEOUT, SET_LOCK_TIMEOUT, @@ -31,11 +31,6 @@ ) User = get_user_model() -# Each connection starts from its own value, so a test can tell which value came back where. -DEFAULT_BEFORE = "3s" -BRANCH_BEFORE = "4s" -# The value that PostgreSQL gives a new session. -SERVER_DEFAULT = "0" def abort_the_transaction(alias): diff --git a/netbox_interface_name_rules/tests/test_branch_triggers.py b/netbox_interface_name_rules/tests/test_branch_triggers.py index 5ac6761c..f5d39a49 100644 --- a/netbox_interface_name_rules/tests/test_branch_triggers.py +++ b/netbox_interface_name_rules/tests/test_branch_triggers.py @@ -27,8 +27,17 @@ from netbox_interface_name_rules.models import InterfaceNameRule from netbox_interface_name_rules.rename_triggers import PlanRunner +from netbox_interface_name_rules.tests.branch_cases import ( + BRANCH_BEFORE, + DEFAULT_BEFORE, + SERVER_DEFAULT, + BranchWriteCase, + ChannelCase, +) from netbox_interface_name_rules.tests.helpers import ( + PLAIN_TYPE, branch_cookie, + install_form, interface_signal, lock_timeout, make_device, @@ -37,29 +46,16 @@ make_manufacturer, make_module_bay_templates, make_module_type, + names_of, set_lock_timeout, ) -from netbox_interface_name_rules.tests.test_branch_transactions import BRANCH_BEFORE, DEFAULT_BEFORE, SERVER_DEFAULT -from netbox_interface_name_rules.tests.test_branch_writes import _BranchWriteCase, _ChannelCase, names_of -from netbox_interface_name_rules.tests.test_channelization import PLAIN_TYPE from netbox_interface_name_rules.transactions import LOCK_TIMEOUT # The caller's transactions at the save, outermost first, when the branch connection holds one. NESTINGS = (("branch",), ("default", "branch"), ("branch", "default")) -def install_form(bay, module_type): - """Return the form data of NetBox's module edit view that installs *module_type* in *bay*.""" - return { - "device": bay.device_id, - "module_bay": bay.pk, - "module_type": module_type.pk, - "status": "active", - "replicate_components": "on", - } - - -class _InstallCase(_BranchWriteCase): +class _InstallCase(BranchWriteCase): """A device with empty module bays, and a module type whose rule appends ``.br`` to NetBox's names. NetBox names the two interfaces of a module in the bay at position ``n`` ``an`` and ``bn``. @@ -241,7 +237,7 @@ def test_a_save_through_default_in_a_branch_raises_before_the_row_is_written(sel self.assertEqual(Device.objects.get(pk=self.device.pk).name, self.device.name) -class _RuledChannelCase(_ChannelCase): +class _RuledChannelCase(ChannelCase): """The channelized case with its rule on main, so an install in the branch is a rename trigger.""" def build(self): diff --git a/netbox_interface_name_rules/tests/test_branch_writes.py b/netbox_interface_name_rules/tests/test_branch_writes.py index 70838b3d..7ec13522 100644 --- a/netbox_interface_name_rules/tests/test_branch_writes.py +++ b/netbox_interface_name_rules/tests/test_branch_writes.py @@ -13,56 +13,46 @@ from unittest import skipUnless from core.models import ObjectChange -from dcim.models import Interface, InterfaceTemplate, Module, ModuleBay, VirtualChassis +from dcim.models import Interface, VirtualChassis from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.contrib.messages import get_messages from django.db import connections, transaction from django.db.models.signals import post_save, pre_save from django.urls import reverse -from netbox_interface_name_rules.choices import BreakoutModeChoices from netbox_interface_name_rules.engine import ( apply_device_interface_rules, - apply_rule_to_existing, find_matching_rule, supports_channelization, ) from netbox_interface_name_rules.models import InterfaceNameRule from netbox_interface_name_rules.rule_selection import pinned_rule_cache +from netbox_interface_name_rules.tests.branch_cases import ( + FLAT_NAMES, + BranchWriteCase, + ConversionCase, + KeptChannelCase, + PlainModuleCase, +) from netbox_interface_name_rules.tests.helpers import ( + CHANNEL_TYPE, + FLAT, + PARENT_TYPE, + PLAIN_TYPE, + REQUIRES_CHANNELIZATION, activate, - branch_cookie, interface_signal, make_device, make_device_type, make_manufacturer, - make_module_bay_templates, - make_module_type, make_placement, + names_of, row_lock_in_another_session, set_lock_timeout, ) from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band -from netbox_interface_name_rules.tests.test_branching import BranchTestCase -from netbox_interface_name_rules.tests.test_channelization import ( - CHANNEL_TYPE, - PARENT_TYPE, - PLAIN_TYPE, - REQUIRES_CHANNELIZATION, - _channelized_module_type, -) User = get_user_model() -FLAT = BreakoutModeChoices.FLAT -CHANNELIZED = BreakoutModeChoices.CHANNELIZED -# The names of the flat family that each _ConversionCase builds in bay 3. -FLAT_NAMES = ("xe-0/0/3:0", "xe-0/0/3:1", "xe-0/0/3:2", "xe-0/0/3:3") - - -def names_of(module): - """Return the sorted interface names of *module* on the active branch.""" - return sorted(Interface.objects.filter(module=module).values_list("name", flat=True)) def messages_of(response): @@ -86,77 +76,7 @@ def create_in_another_session(branch, **fields): return executor.submit(_create_in_its_own_session, branch, fields).result() -class _BranchWriteCase(BranchTestCase): - """A superuser logged in with netbox-branching's cookie of a branch provisioned from the rows ``build`` made.""" - - PREFIX = "" - - def setUp(self): - self.user = User.objects.create_superuser(username=f"{self.PREFIX.lower()}-operator") - self.build() - self.branch = self.provision_branch(self.PREFIX, self.user) - self.alias = self.branch.connection_name - self.client.force_login(self.user) - self.client.cookies[branch_cookie()] = self.branch.schema_id - - def build(self): - """Create the rows on main that the branch copies.""" - raise NotImplementedError - - def in_branch(self): - return activate(self.branch) - - def bay(self, position): - return ModuleBay.objects.get(device=self.device, name=f"Bay {position}") - - def interfaces_at(self, position): - """Return ``(pk, name)`` of each interface of the module in the bay at *position*, on the active branch or main.""" - interfaces = Interface.objects.filter(device=self.device, module__module_bay__name=f"Bay {position}") - return list(interfaces.values_list("pk", "name")) - - def interfaces_in_branch(self, position): - """Return ``(pk, name)`` of each interface in the branch of the module in the bay at *position*.""" - with self.in_branch(): - return self.interfaces_at(position) - - def names_in_branch(self, position): - """Return the sorted interface names in the branch of the module in the bay at *position*.""" - return sorted(name for _, name in self.interfaces_in_branch(position)) - - def apply_url(self, rule): - return reverse("plugins:netbox_interface_name_rules:interfacenamerule_apply_detail", kwargs={"pk": rule.pk}) - - def change_diffs(self): - """Return the ChangeDiff rows of the branch, which netbox-branching keeps on ``default``.""" - from netbox_branching.models import ChangeDiff - - return ChangeDiff.objects.filter(branch=self.branch) - - def branch_updates_of(self, instance): - """Return ``(name before, name after)`` for each update record of *instance* in the branch.""" - changes = ObjectChange.objects.using(self.alias).filter( - changed_object_type=ContentType.objects.get_for_model(instance), changed_object_id=instance.pk - ) - return [(change.prechange_data["name"], change.postchange_data["name"]) for change in changes] - - -class _PlainModuleCase(_BranchWriteCase): - """One device with one module bay, and a module whose one interface NetBox named ``0``.""" - - def build(self): - manufacturer = make_manufacturer(self.PREFIX) - device_type = make_device_type(manufacturer, self.PREFIX) - make_module_bay_templates(device_type, ("Bay 0",)) - self.device = make_device(self.PREFIX, device_type) - self.device_type = device_type - self.module_type = make_module_type(manufacturer, self.PREFIX) - InterfaceTemplate.objects.create(module_type=self.module_type, name="{module}", type=PLAIN_TYPE) - # No rule exists yet, so the interface keeps NetBox's raw name. - self.module = Module.objects.create(device=self.device, module_bay=self.bay(0), module_type=self.module_type) - self.interface = Interface.objects.get(module=self.module) - - -class ForegroundApplyInABranchTest(_PlainModuleCase): +class ForegroundApplyInABranchTest(PlainModuleCase): PREFIX = "BrApply" def build(self): @@ -188,7 +108,7 @@ def test_the_toggle_writes_the_flag_in_the_branch_only(self): self.assertTrue(InterfaceNameRule.objects.get(pk=self.rule.pk).enabled) -class TwoAliasAtomicityTest(_PlainModuleCase): +class TwoAliasAtomicityTest(PlainModuleCase): PREFIX = "BrAtomic" def build(self): @@ -220,7 +140,7 @@ def test_a_flat_family_blocked_after_partial_writes_leaves_no_row_and_no_change_ self.assertFalse(self.change_diffs().exists()) -class RuleCacheInABranchTest(_PlainModuleCase): +class RuleCacheInABranchTest(PlainModuleCase): PREFIX = "BrCache" def build(self): @@ -261,35 +181,7 @@ def test_a_rule_changed_in_the_branch_only_changes_the_selection_in_the_branch_o self.assertEqual((on_main.name_template, on_main._state.db), ("et-0/0/{bay_position}", "default")) -@skipUnless(supports_channelization(), REQUIRES_CHANNELIZATION) -class _ConversionCase(_BranchWriteCase): - """A flat family built on main by a flat rule, and the rule then switched to the channelized topology.""" - - def build(self): - manufacturer = make_manufacturer(self.PREFIX) - device_type = make_device_type(manufacturer, self.PREFIX) - make_module_bay_templates(device_type, ("Bay 0", "Bay 1", "Bay 2", "Bay 3")) - self.device = make_device(self.PREFIX, device_type) - module_type = make_module_type(manufacturer, self.PREFIX) - InterfaceTemplate.objects.create(module_type=module_type, name="{module}", type=PLAIN_TYPE) - self.rule = InterfaceNameRule.objects.create( - module_type=module_type, - name_template="xe-0/0/{bay_position}:{channel}", - breakout_mode=FLAT, - channel_count=4, - channel_start=0, - ) - # The rename trigger builds the family when the install commits. - with transaction.atomic(): - self.module = Module.objects.create(device=self.device, module_bay=self.bay(3), module_type=module_type) - self.rule.snapshot() - self.rule.breakout_mode = CHANNELIZED - self.rule.parent_name_template = "et-0/0/{bay_position}" - self.rule.save() - self.base = Interface.objects.get(module=self.module, name="xe-0/0/3:0") - - -class ForegroundConvertInABranchTest(_ConversionCase): +class ForegroundConvertInABranchTest(ConversionCase): PREFIX = "BrConvert" def test_the_conversion_rewrites_the_family_in_the_branch_only(self): @@ -315,76 +207,7 @@ def test_the_conversion_preview_writes_nothing_on_either_connection(self): self.assertFalse(self.change_diffs().exists()) -@skipUnless(supports_channelization(), REQUIRES_CHANNELIZATION) -class _ChannelCase(_BranchWriteCase): - """Empty module bays, a channelized module type, and an interface on the target of channel 2 at each position. - - NetBox names a family ```` and ``:``. The rule that ``add_rule`` creates - keeps channel 2 at its old name while it renames the parent. NetBox's cascade then renames the kept - channel after the parent, and the plugin's reconciliation gives it back the name it kept. - """ - - POSITIONS = ("1",) - - def build(self): - manufacturer = make_manufacturer(self.PREFIX) - device_type = make_device_type(manufacturer, self.PREFIX) - make_module_bay_templates(device_type, [f"Bay {index}" for index in range(max(map(int, self.POSITIONS)) + 1)]) - self.device = make_device(self.PREFIX, device_type) - self.module_type = _channelized_module_type(manufacturer, f"{self.PREFIX}-QSFP") - for position in self.POSITIONS: - Interface.objects.create(device=self.device, name=f"xe-0/0/{position}:1", type=PLAIN_TYPE) - - def add_rule(self): - self.rule = InterfaceNameRule.objects.create( - module_type=self.module_type, - name_template="xe-0/0/{bay_position}:{channel}", - parent_name_template="et-0/0/{bay_position}", - breakout_mode=CHANNELIZED, - channel_count=4, - channel_start=0, - ) - - @staticmethod - def kept(position): - """Return the names of the family at *position* after the reconciliation gave channel 2 its kept name.""" - return [ - f"{position}:2", - f"et-0/0/{position}", - f"xe-0/0/{position}:0", - f"xe-0/0/{position}:2", - f"xe-0/0/{position}:3", - ] - - @staticmethod - def cascaded(position): - """Return the names of the family at *position* when channel 2 carries the name of NetBox's cascade.""" - return sorted([f"et-0/0/{position}:2", f"et-0/0/{position}", *(f"xe-0/0/{position}:{c}" for c in (0, 2, 3))]) - - -class _KeptChannelCase(_ChannelCase): - """The families installed on main before the rule exists, so they keep NetBox's raw names, and the rule.""" - - def build(self): - super().build() - self.modules = { - position: Module.objects.create( - device=self.device, module_bay=self.bay(position), module_type=self.module_type - ) - for position in self.POSITIONS - } - self.add_rule() - - def parent(self, position): - return Interface.objects.get(module=self.modules[position], channels__isnull=False) - - def apply(self, position): - """Apply the rule in the branch to the family at *position*.""" - with self.in_branch(): - return apply_rule_to_existing(self.rule, interface_ids=[self.parent(position).pk]) - - -class CommitOrderInEveryNestingTest(_KeptChannelCase): +class CommitOrderInEveryNestingTest(KeptChannelCase): PREFIX = "BrNesting" POSITIONS = ("1", "2", "3", "4", "5") # The caller's transactions at the call, outermost first. @@ -424,7 +247,7 @@ def test_default_savepoint_rollback_discards_reconciliation_before_branch_commit self.assertEqual(self.names_in_branch("1"), self.cascaded("1")) -class CollisionInABranchTest(_KeptChannelCase): +class CollisionInABranchTest(KeptChannelCase): PREFIX = "BrCollide" def test_a_collision_via_apply_skips_that_member_and_keeps_the_rest(self): @@ -455,7 +278,7 @@ def occupy(sender, instance, **kwargs): self.assertEqual(Interface.objects.get(pk=occupied[0]).name, "xe-0/0/1:2") -class ReconciliationLockTimeoutTest(_KeptChannelCase): +class ReconciliationLockTimeoutTest(KeptChannelCase): PREFIX = "BrReconcile" def test_a_reconciliation_lock_timeout_keeps_the_change_diff_rows_and_names_each_kept_channel(self): @@ -486,7 +309,7 @@ def lock_after_the_cascade(sender, instance, **kwargs): @skipUnless(supports_channelization(), REQUIRES_CHANNELIZATION) -class DocumentedEngineFunctionInACallerTransactionTest(_BranchWriteCase): +class DocumentedEngineFunctionInACallerTransactionTest(BranchWriteCase): """``apply_device_interface_rules`` inside a caller's transaction keeps a blocked channel at its name. The caller frees the target of the blocked channel before it commits, so NetBox's cascade at the diff --git a/netbox_interface_name_rules/tests/test_branching.py b/netbox_interface_name_rules/tests/test_branching.py index a0c4934f..bf15e613 100644 --- a/netbox_interface_name_rules/tests/test_branching.py +++ b/netbox_interface_name_rules/tests/test_branching.py @@ -16,19 +16,16 @@ from unittest import skipUnless from dcim.models import Interface -from django.apps import apps from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.db import connection, connections, router -from django.test import SimpleTestCase, TransactionTestCase +from django.test import SimpleTestCase from netbox_interface_name_rules import branching from netbox_interface_name_rules.branching import REPLAY_MARK, REPLAYING_METHODS, check_version +from netbox_interface_name_rules.tests.branch_cases import BRANCHING_INSTALLED, BRANCHING_SKIP_REASON, BranchTestCase from netbox_interface_name_rules.tests.helpers import activate, make_device, make_device_type, make_manufacturer -BRANCHING_INSTALLED = apps.is_installed("netbox_branching") -BRANCHING_SKIP_REASON = "netbox-branching is not installed" - def schema_exists(schema_name): """Return whether the database holds the schema *schema_name*.""" @@ -198,29 +195,6 @@ def test_the_scan_reports_each_call_with_its_enclosing_function(self): ) -def remove_branch(branch): - """Close the connection of *branch*, then drop its schema.""" - connections[branch.connection_name].close() - branch.deprovision() - - -@skipUnless(BRANCHING_INSTALLED, BRANCHING_SKIP_REASON) -class BranchTestCase(TransactionTestCase): - """Provision real branches. Each branch is removed when its test ends, because ``--reuse-db`` keeps schemas.""" - - def provision_branch(self, name, user): - """Return a new branch named *name*, provisioned by *user* as netbox-branching's own tests do.""" - from netbox_branching.models import Branch - - branch = Branch(name=name) - branch.save(provision=False) - self.addCleanup(remove_branch, branch) - branch.provision(user=user) - # provision() writes the status with a queryset update, which the instance does not see. - branch.refresh_from_db() - return branch - - class BranchProvisioningTest(BranchTestCase): """A branch provisions, takes the writes made while it is active, and is removed at teardown.""" diff --git a/netbox_interface_name_rules/tests/test_breakout_mode.py b/netbox_interface_name_rules/tests/test_breakout_mode.py index f108718c..072eff11 100644 --- a/netbox_interface_name_rules/tests/test_breakout_mode.py +++ b/netbox_interface_name_rules/tests/test_breakout_mode.py @@ -54,38 +54,30 @@ from netbox_interface_name_rules.models import InterfaceNameRule from netbox_interface_name_rules.name_template import referenced_variables from netbox_interface_name_rules.rule_selection import _VERSION_COLUMNS -from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band -from netbox_interface_name_rules.tests.test_channelization import ( +from netbox_interface_name_rules.tests.helpers import ( + CHANNELIZED, + FLAT, PARENT_TYPE, PLUGIN_LOGGER, REQUIRES_CHANNELIZATION, + TEST_PASSWORD, ChannelizationTestCase, - _build_device, + build_device, + plain_module_type, ) +from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band from netbox_interface_name_rules.views import RulePreview -FLAT = "flat" -CHANNELIZED = "channelized" - -TEST_PASSWORD = "testpass123" # noqa: S105 - Test credential only. - User = get_user_model() -def _plain_module_type(manufacturer, model, iface_type=PARENT_TYPE): - """Create a ModuleType with a single plain (non-channelized) port template.""" - module_type = ModuleType.objects.create(manufacturer=manufacturer, model=model, part_number=model) - InterfaceTemplate.objects.create(module_type=module_type, name="{module}", type=iface_type) - return module_type - - class BreakoutModeFieldTest(TestCase): """The model exposes the mode and the parent template with the defaults existing rules rely on.""" @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("BrkField") - cls.module_type = _plain_module_type(manufacturer, "BrkField-QSFP") + manufacturer, cls.device = build_device("BrkField") + cls.module_type = plain_module_type(manufacturer, "BrkField-QSFP") def test_a_new_rule_defaults_to_the_flat_topology(self): """Nothing changes for a rule that never mentions the mode — flat is what it always did.""" @@ -138,8 +130,8 @@ class BreakoutModeValidationTest(TestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("BrkValid") - cls.module_type = _plain_module_type(manufacturer, "BrkValid-QSFP") + manufacturer, cls.device = build_device("BrkValid") + cls.module_type = plain_module_type(manufacturer, "BrkValid-QSFP") def _rule(self, **kwargs): """Return an unsaved module rule with *kwargs* applied over sane defaults.""" @@ -278,8 +270,8 @@ class BreakoutModeExportTest(TestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("BrkExp") - cls.module_type = _plain_module_type(manufacturer, "BrkExp-QSFP") + manufacturer, cls.device = build_device("BrkExp") + cls.module_type = plain_module_type(manufacturer, "BrkExp-QSFP") cls.channelized = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -314,7 +306,7 @@ def test_yaml_export_names_the_mode(self): def test_yaml_export_of_a_flat_rule_still_names_the_mode(self): """flat is a real value, not an absence — an importer must not have to guess it.""" flat = InterfaceNameRule.objects.create( - module_type=_plain_module_type(self.module_type.manufacturer, "BrkExp-QSFP-FLAT"), + module_type=plain_module_type(self.module_type.manufacturer, "BrkExp-QSFP-FLAT"), name_template="xe-0/0/{bay_position}:{channel}", channel_count=4, ) @@ -333,9 +325,9 @@ def setUpTestData(cls): cls.superuser = User.objects.create_superuser( username="brkimport", password=TEST_PASSWORD, email="brkimport@example.com" ) - manufacturer, cls.device = _build_device("BrkImp") - cls.module_type = _plain_module_type(manufacturer, "BrkImp-QSFP") - cls.target_type = _plain_module_type(manufacturer, "BrkImp-QSFP-TARGET") + manufacturer, cls.device = build_device("BrkImp") + cls.module_type = plain_module_type(manufacturer, "BrkImp-QSFP") + cls.target_type = plain_module_type(manufacturer, "BrkImp-QSFP-TARGET") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -382,9 +374,9 @@ def setUpTestData(cls): cls.superuser = User.objects.create_superuser( username="brkbulk", password=TEST_PASSWORD, email="brkbulk@example.com" ) - manufacturer, cls.device = _build_device("BrkBulk") + manufacturer, cls.device = build_device("BrkBulk") cls.rule = InterfaceNameRule.objects.create( - module_type=_plain_module_type(manufacturer, "BrkBulk-QSFP"), + module_type=plain_module_type(manufacturer, "BrkBulk-QSFP"), name_template="xe-0/0/{bay_position}:{channel}", parent_name_template="et-0/0/{bay_position}", breakout_mode=CHANNELIZED, @@ -463,9 +455,9 @@ def setUpTestData(cls): cls.superuser = User.objects.create_superuser( username="brkdetail", password=TEST_PASSWORD, email="brkdetail@example.com" ) - manufacturer, cls.device = _build_device("BrkDetail") + manufacturer, cls.device = build_device("BrkDetail") cls.rule = InterfaceNameRule.objects.create( - module_type=_plain_module_type(manufacturer, "BrkDetail-QSFP"), + module_type=plain_module_type(manufacturer, "BrkDetail-QSFP"), name_template="xe-0/0/{bay_position}:{channel}", parent_name_template="et-XYZZY/0/{bay_position}", breakout_mode=CHANNELIZED, @@ -498,9 +490,9 @@ class BreakoutModeAPITest(APITestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("BrkApi") - cls.module_type = _plain_module_type(manufacturer, "BrkApi-QSFP") - cls.other_type = _plain_module_type(manufacturer, "BrkApi-QSFP-2") + manufacturer, cls.device = build_device("BrkApi") + cls.module_type = plain_module_type(manufacturer, "BrkApi-QSFP") + cls.other_type = plain_module_type(manufacturer, "BrkApi-QSFP-2") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -571,16 +563,16 @@ class BreakoutModeGraphQLTest(APITestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("BrkGql") + manufacturer, cls.device = build_device("BrkGql") cls.channelized = InterfaceNameRule.objects.create( - module_type=_plain_module_type(manufacturer, "BrkGql-QSFP-CH"), + module_type=plain_module_type(manufacturer, "BrkGql-QSFP-CH"), name_template="xe-0/0/{bay_position}:{channel}", parent_name_template="et-0/0/{bay_position}", breakout_mode=CHANNELIZED, channel_count=4, ) cls.flat = InterfaceNameRule.objects.create( - module_type=_plain_module_type(manufacturer, "BrkGql-QSFP-FL"), + module_type=plain_module_type(manufacturer, "BrkGql-QSFP-FL"), name_template="xe-0/0/{bay_position}:{channel}", channel_count=4, ) @@ -630,16 +622,16 @@ class BreakoutModeFilterSetTest(TestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("BrkFs") + manufacturer, cls.device = build_device("BrkFs") cls.match = InterfaceNameRule.objects.create( - module_type=_plain_module_type(manufacturer, "BrkFs-QSFP-A"), + module_type=plain_module_type(manufacturer, "BrkFs-QSFP-A"), name_template="xe-0/0/{bay_position}:{channel}", parent_name_template="et-XYZZY/0/{bay_position}", breakout_mode=CHANNELIZED, channel_count=4, ) cls.other = InterfaceNameRule.objects.create( - module_type=_plain_module_type(manufacturer, "BrkFs-QSFP-B"), + module_type=plain_module_type(manufacturer, "BrkFs-QSFP-B"), name_template="xe-0/0/{bay_position}:{channel}", channel_count=4, ) @@ -659,8 +651,8 @@ def setUpTestData(cls): cls.superuser = User.objects.create_superuser( username="brkform", password=TEST_PASSWORD, email="brkform@example.com" ) - manufacturer, cls.device = _build_device("BrkForm") - cls.module_type = _plain_module_type(manufacturer, "BrkForm-QSFP") + manufacturer, cls.device = build_device("BrkForm") + cls.module_type = plain_module_type(manufacturer, "BrkForm-QSFP") def setUp(self): """Log in before posting to the rule test view.""" @@ -967,8 +959,8 @@ class BreakoutModeFingerprintTest(TestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("BrkFp") - cls.module_type = _plain_module_type(manufacturer, "BrkFp-QSFP") + manufacturer, cls.device = build_device("BrkFp") + cls.module_type = plain_module_type(manufacturer, "BrkFp-QSFP") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -1005,9 +997,9 @@ class FlatBreakoutModeTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("BrkFlat", ["3", "4"]) - cls.explicit_type = _plain_module_type(manufacturer, "BrkFlat-QSFP") - cls.default_type = _plain_module_type(manufacturer, "BrkFlat-QSFP-DEF") + manufacturer, cls.device = build_device("BrkFlat", ["3", "4"]) + cls.explicit_type = plain_module_type(manufacturer, "BrkFlat-QSFP") + cls.default_type = plain_module_type(manufacturer, "BrkFlat-QSFP-DEF") cls.explicit_rule = InterfaceNameRule.objects.create( module_type=cls.explicit_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -1035,7 +1027,7 @@ def setUpTestData(cls): channel_count=4, channel_start=1, ) - cls.second_channel_fails_type = _plain_module_type(manufacturer, "BrkFlat-DIV") + cls.second_channel_fails_type = plain_module_type(manufacturer, "BrkFlat-DIV") # The second channel divides by zero, so the rule can name no family at all. cls.second_channel_fails_rule = InterfaceNameRule.objects.create( module_type=cls.second_channel_fails_type, @@ -1098,7 +1090,7 @@ class FlatBreakoutClaimGateTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "BrkGate", ["5"], virtual_chassis=VirtualChassis.objects.create(name="brkgate-vc"), vc_position=2 ) cls.module_type = ModuleType.objects.create( @@ -1224,7 +1216,7 @@ class ExecutionOutcomeCoverageTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("BrkExec", ["5"]) + manufacturer, cls.device = build_device("BrkExec", ["5"]) cls.module_type = ModuleType.objects.create( manufacturer=manufacturer, model="BrkExec-ZERO", part_number="BrkExec-ZERO" ) @@ -1493,8 +1485,8 @@ class ChannelizedModeWithoutSupportTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("BrkNoSup", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "BrkNoSup-QSFP") + manufacturer, cls.device = build_device("BrkNoSup", ["3"]) + cls.module_type = plain_module_type(manufacturer, "BrkNoSup-QSFP") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="xe-0/0/{bay_position}:{channel}", diff --git a/netbox_interface_name_rules/tests/test_bulk_families.py b/netbox_interface_name_rules/tests/test_bulk_families.py index 2d6330f9..2787045a 100644 --- a/netbox_interface_name_rules/tests/test_bulk_families.py +++ b/netbox_interface_name_rules/tests/test_bulk_families.py @@ -5,7 +5,6 @@ from unittest import skipUnless from unittest.mock import MagicMock, patch -from dcim.choices import InterfaceTypeChoices from dcim.models import ( Device, DeviceRole, @@ -50,12 +49,14 @@ from netbox_interface_name_rules.jobs import rule_job_kwargs from netbox_interface_name_rules.models import InterfaceNameRule from netbox_interface_name_rules.tests.committed_callbacks import run_the_reapply -from netbox_interface_name_rules.tests.helpers import make_job, run_job_logged +from netbox_interface_name_rules.tests.helpers import ( + PLAIN_TYPE, + REQUIRES_CHANNELIZATION, + channelized_module_type, + make_job, + run_job_logged, +) from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band -from netbox_interface_name_rules.tests.test_channelization import _channelized_module_type - -PLAIN_TYPE = InterfaceTypeChoices.TYPE_10GE_SFP_PLUS -REQUIRES_CHANNELIZATION = "requires a NetBox that models channelized interfaces" class BulkTestCase(TestCase): @@ -530,7 +531,7 @@ class BulkApplyChannelizedFamiliesTest(BulkTestCase): @classmethod def setUpTestData(cls): super().setUpTestData() - cls.channelized_type = _channelized_module_type(cls.manufacturer, "BULK-CHANNELIZED") + cls.channelized_type = channelized_module_type(cls.manufacturer, "BULK-CHANNELIZED") def setUp(self): self.rule = self._flat_rule(self.channelized_type, name_template="xe-0/0/{bay_position}:{channel}") diff --git a/netbox_interface_name_rules/tests/test_change_log.py b/netbox_interface_name_rules/tests/test_change_log.py index 6b6aa7c7..d064549e 100644 --- a/netbox_interface_name_rules/tests/test_change_log.py +++ b/netbox_interface_name_rules/tests/test_change_log.py @@ -29,6 +29,7 @@ from netbox_interface_name_rules.jobs import ApplyRuleJob, rule_job_kwargs, run_as_job_user from netbox_interface_name_rules.models import InterfaceNameRule from netbox_interface_name_rules.tests.helpers import ( + PLAIN_TYPE, empty_the_webhook_queue, make_device, make_device_type, @@ -42,7 +43,6 @@ ) User = get_user_model() -PLAIN_TYPE = "10gbase-x-sfpp" UPDATE = ObjectChangeActionChoices.ACTION_UPDATE diff --git a/netbox_interface_name_rules/tests/test_channelization.py b/netbox_interface_name_rules/tests/test_channelization.py index 53472162..2b73216b 100644 --- a/netbox_interface_name_rules/tests/test_channelization.py +++ b/netbox_interface_name_rules/tests/test_channelization.py @@ -14,19 +14,10 @@ import os from unittest import skipUnless -from dcim.choices import InterfaceTypeChoices from dcim.models import ( - Device, - DeviceRole, - DeviceType, Interface, InterfaceTemplate, - Manufacturer, - Module, - ModuleBay, - ModuleBayTemplate, ModuleType, - Site, VirtualChassis, ) from django.test import TestCase @@ -43,94 +34,17 @@ ) from netbox_interface_name_rules.family import FamilyStatus, execute_installed_plan_set, plan_installed_families from netbox_interface_name_rules.models import InterfaceNameRule - -# Resolved defensively so this module still imports on NetBox releases without channelization. -CHANNEL_TYPE = getattr(InterfaceTypeChoices, "TYPE_CHANNEL", "channel") -PARENT_TYPE = InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS -PLAIN_TYPE = InterfaceTypeChoices.TYPE_10GE_SFP_PLUS - -REQUIRES_CHANNELIZATION = "requires a NetBox that models channelized interfaces (4.7+)" -PLUGIN_LOGGER = "netbox_interface_name_rules" - - -def _build_device(prefix, bay_positions=(), **device_kwargs): - """Create a manufacturer, a device type with module bays at *bay_positions*, and one device.""" - slug = prefix.lower() - manufacturer = Manufacturer.objects.create(name=f"{prefix}Mfg", slug=f"{slug}-mfg") - device_type = DeviceType.objects.create(manufacturer=manufacturer, model=f"{prefix}-Dev", slug=f"{slug}-dev") - for position in bay_positions: - ModuleBayTemplate.objects.create(device_type=device_type, name=f"Bay {position}", position=position) - role = DeviceRole.objects.create(name=f"{prefix}Role", slug=f"{slug}-role") - site = Site.objects.create(name=f"{prefix}Site", slug=f"{slug}-site") - device = Device.objects.create(name=f"{slug}-sw1", device_type=device_type, role=role, site=site, **device_kwargs) - return manufacturer, device - - -def _channelized_family(module_type, parent_name, child_names, channels=4): - """Add one channelized parent template plus its channel templates to *module_type*. - - *child_names* maps a channel_id to the template name that channel takes. - """ - parent = InterfaceTemplate.objects.create( - module_type=module_type, name=parent_name, type=PARENT_TYPE, channels=channels - ) - for channel_id, name in child_names.items(): - InterfaceTemplate.objects.create( - module_type=module_type, - name=name, - type=CHANNEL_TYPE, - parent=parent, - channel_id=channel_id, - ) - return parent - - -def _channelized_module_type(manufacturer, model, channels=4, child_channel_ids=(1, 2, 3, 4), child_names=None): - """Create a ModuleType whose interface templates form a channelized family. - - The parent template is ``{module}`` with *channels* set; each entry in *child_channel_ids* adds a - channel-type template bound to it. *child_names* maps a channel_id to a template name, defaulting - to the upstream ``:`` convention. - """ - module_type = ModuleType.objects.create(manufacturer=manufacturer, model=model, part_number=model) - names = child_names or {channel_id: f"{{module}}:{channel_id}" for channel_id in child_channel_ids} - _channelized_family( - module_type, "{module}", {channel_id: names[channel_id] for channel_id in child_channel_ids}, channels=channels - ) - return module_type - - -class ChannelizationTestCase(TestCase): - """Install helpers shared by the channelized module-install test cases.""" - - def _install(self, module_type, position, run_rules=True): - """Install a module into the bay at *position*; run the post-commit rename unless told not to. - - ``run_rules=False`` leaves the freshly instantiated (raw-named) family in place so a test can - call the engine directly and assert its return value. - """ - bay = ModuleBay.objects.get(device=self.device, name=f"Bay {position}") - if run_rules: - with self.captureOnCommitCallbacks(execute=True): - module = Module.objects.create(device=self.device, module_bay=bay, module_type=module_type) - else: - module = Module.objects.create(device=self.device, module_bay=bay, module_type=module_type) - return module, bay - - @staticmethod - def _names(module): - """Return the sorted interface names of *module*.""" - return sorted(Interface.objects.filter(module=module).values_list("name", flat=True)) - - @staticmethod - def _parent(module): - """Return the channelized parent interface of *module*.""" - return Interface.objects.get(module=module, channels__isnull=False) - - @staticmethod - def _child(module, channel_id): - """Return the channel subinterface of *module* bound to *channel_id*.""" - return Interface.objects.get(module=module, channel_id=channel_id) +from netbox_interface_name_rules.tests.helpers import ( + CHANNEL_TYPE, + PARENT_TYPE, + PLAIN_TYPE, + PLUGIN_LOGGER, + REQUIRES_CHANNELIZATION, + ChannelizationTestCase, + build_device, + channelized_family, + channelized_module_type, +) class LiteralBaseInstallTest(ChannelizationTestCase): @@ -138,7 +52,7 @@ class LiteralBaseInstallTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("LiteralBase", ["3"]) + manufacturer, cls.device = build_device("LiteralBase", ["3"]) cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model="LiteralBase-SFP") def test_install_and_reapply_preserve_arithmetic_braces_in_base(self): @@ -172,10 +86,10 @@ class ChannelizedSimpleRuleTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanSimple", ["3", "f4"]) - cls.module_type = _channelized_module_type(manufacturer, "ChanSimple-QSFP") + manufacturer, cls.device = build_device("ChanSimple", ["3", "f4"]) + cls.module_type = channelized_module_type(manufacturer, "ChanSimple-QSFP") # One child carries a free-form name that shares no prefix with the parent template. - cls.free_form_type = _channelized_module_type( + cls.free_form_type = channelized_module_type( manufacturer, "ChanSimple-QSFP-FF", child_names={1: "{module}:1", 2: "{module}:2", 3: "{module}:3", 4: "mgmt-chan"}, @@ -250,8 +164,8 @@ class ChannelizedCollisionTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanCol", ["3"]) - cls.module_type = _channelized_module_type(manufacturer, "ChanCol-QSFP") + manufacturer, cls.device = build_device("ChanCol", ["3"]) + cls.module_type = channelized_module_type(manufacturer, "ChanCol-QSFP") cls.rule = InterfaceNameRule.objects.create(module_type=cls.module_type, name_template="et-0/0/{bay_position}") def _occupy(self, name): @@ -309,12 +223,12 @@ class ChannelizedBreakoutRuleTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanBrk", ["3", "b4", "e5", "p6", "m8"]) - cls.module_type = _channelized_module_type(manufacturer, "ChanBrk-QSFP") - cls.base_type = _channelized_module_type(manufacturer, "ChanBrk-QSFP-BASE") - cls.empty_type = _channelized_module_type(manufacturer, "ChanBrk-QSFP-EMPTY", child_channel_ids=()) - cls.partial_type = _channelized_module_type(manufacturer, "ChanBrk-QSFP-PART", child_channel_ids=(1, 2)) - cls.mismatch_type = _channelized_module_type(manufacturer, "ChanBrk-QSFP-MM", channels=8) + manufacturer, cls.device = build_device("ChanBrk", ["3", "b4", "e5", "p6", "m8"]) + cls.module_type = channelized_module_type(manufacturer, "ChanBrk-QSFP") + cls.base_type = channelized_module_type(manufacturer, "ChanBrk-QSFP-BASE") + cls.empty_type = channelized_module_type(manufacturer, "ChanBrk-QSFP-EMPTY", child_channel_ids=()) + cls.partial_type = channelized_module_type(manufacturer, "ChanBrk-QSFP-PART", child_channel_ids=(1, 2)) + cls.mismatch_type = channelized_module_type(manufacturer, "ChanBrk-QSFP-MM", channels=8) for module_type in (cls.module_type, cls.empty_type, cls.partial_type, cls.mismatch_type): InterfaceNameRule.objects.create( module_type=module_type, @@ -403,15 +317,15 @@ class ChannelizedEnumerationTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanEnum", ["3", "9"]) - cls.module_type = _channelized_module_type(manufacturer, "ChanEnum-QSFP") + manufacturer, cls.device = build_device("ChanEnum", ["3", "9"]) + cls.module_type = channelized_module_type(manufacturer, "ChanEnum-QSFP") # A standalone interface alongside the family, so "families counted once" stays distinguishable # from "interfaces not counted at all". InterfaceTemplate.objects.create(module_type=cls.module_type, name="{module}-mgmt", type=PLAIN_TYPE) cls.rule = InterfaceNameRule.objects.create(module_type=cls.module_type, name_template="et-{base}") # A second family whose template does not feed the current name back in, so "already correctly # named" is a state the rule can actually reach (an "et-{base}" rule renames on every pass). - cls.stable_type = _channelized_module_type(manufacturer, "ChanEnum-QSFP-STABLE") + cls.stable_type = channelized_module_type(manufacturer, "ChanEnum-QSFP-STABLE") cls.stable_rule = InterfaceNameRule.objects.create( module_type=cls.stable_type, name_template="et-0/0/{bay_position}" ) @@ -501,7 +415,7 @@ class ChannelizedDeviceRuleTest(TestCase): @classmethod def setUpTestData(cls): vc = VirtualChassis.objects.create(name="chanvc-vc") - _, cls.device = _build_device("ChanVC", virtual_chassis=vc, vc_position=1) + _, cls.device = build_device("ChanVC", virtual_chassis=vc, vc_position=1) cls.device_type = cls.device.device_type cls.parent = Interface.objects.create(device=cls.device, name="et0", type=PARENT_TYPE, channels=4, module=None) for channel_id in range(1, 5): @@ -576,8 +490,8 @@ class ChannelizedBreakoutStandaloneTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanMixed", ["7"]) - cls.module_type = _channelized_module_type(manufacturer, "ChanMixed-QSFP") + manufacturer, cls.device = build_device("ChanMixed", ["7"]) + cls.module_type = channelized_module_type(manufacturer, "ChanMixed-QSFP") InterfaceTemplate.objects.create(module_type=cls.module_type, name="{module}-mgmt", type=PLAIN_TYPE) cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, @@ -622,14 +536,14 @@ class ChannelizedPredictionTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanPred", ["3", "5", "8"]) - cls.breakout_type = _channelized_module_type(manufacturer, "ChanPred-QSFP-BRK") - cls.simple_type = _channelized_module_type( + manufacturer, cls.device = build_device("ChanPred", ["3", "5", "8"]) + cls.breakout_type = channelized_module_type(manufacturer, "ChanPred-QSFP-BRK") + cls.simple_type = channelized_module_type( manufacturer, "ChanPred-QSFP-SMP", child_names={1: "{module}:1", 2: "{module}:2", 3: "{module}:3", 4: "mgmt-chan"}, ) - cls.mismatch_type = _channelized_module_type(manufacturer, "ChanPred-QSFP-MM", channels=8) + cls.mismatch_type = channelized_module_type(manufacturer, "ChanPred-QSFP-MM", channels=8) InterfaceNameRule.objects.create( module_type=cls.breakout_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -692,8 +606,8 @@ class ChannelizedBreakoutTemplateErrorTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanErr", ["4"]) - cls.module_type = _channelized_module_type(manufacturer, "ChanErr-QSFP") + manufacturer, cls.device = build_device("ChanErr", ["4"]) + cls.module_type = channelized_module_type(manufacturer, "ChanErr-QSFP") InterfaceTemplate.objects.create( module_type=cls.module_type, name="mgmt-{module}", @@ -731,18 +645,18 @@ class ChannelizedSuffixRecoveryTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanRec", ["1", "2"]) + manufacturer, cls.device = build_device("ChanRec", ["1", "2"]) # Two families on one module type, each with its own suffix convention for the same channel_id. cls.two_family_type = ModuleType.objects.create( manufacturer=manufacturer, model="ChanRec-QSFP-2F", part_number="ChanRec-QSFP-2F" ) - _channelized_family( + channelized_family( cls.two_family_type, "{module}a", {channel_id: f"{{module}}a:{channel_id}" for channel_id in range(1, 5)} ) - _channelized_family( + channelized_family( cls.two_family_type, "{module}b", {channel_id: f"{{module}}b.{channel_id}" for channel_id in range(1, 5)} ) - cls.single_family_type = _channelized_module_type(manufacturer, "ChanRec-QSFP-1F") + cls.single_family_type = channelized_module_type(manufacturer, "ChanRec-QSFP-1F") for module_type in (cls.two_family_type, cls.single_family_type): InterfaceNameRule.objects.create(module_type=module_type, name_template="{base}-x") diff --git a/netbox_interface_name_rules/tests/test_channelized_mode.py b/netbox_interface_name_rules/tests/test_channelized_mode.py index 34db6899..041149de 100644 --- a/netbox_interface_name_rules/tests/test_channelized_mode.py +++ b/netbox_interface_name_rules/tests/test_channelized_mode.py @@ -37,21 +37,19 @@ from netbox_interface_name_rules.family.batch import CHANNELIZED_MODULE_REASON from netbox_interface_name_rules.models import InterfaceNameRule from netbox_interface_name_rules.name_template import TEMPLATE_VARIABLES, NamingContext -from netbox_interface_name_rules.tests.test_breakout_mode import ( +from netbox_interface_name_rules.tests.helpers import ( + CHANNEL_TYPE, CHANNELIZED, FLAT, - TEST_PASSWORD, - _plain_module_type, -) -from netbox_interface_name_rules.tests.test_channelization import ( - CHANNEL_TYPE, PARENT_TYPE, PLAIN_TYPE, PLUGIN_LOGGER, REQUIRES_CHANNELIZATION, + TEST_PASSWORD, ChannelizationTestCase, - _build_device, - _channelized_module_type, + build_device, + channelized_module_type, + plain_module_type, ) User = get_user_model() @@ -63,13 +61,13 @@ class ChannelizedModeInstallTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanMode", ["3", "4", "5", "6", "7", "8"]) - cls.named_type = _plain_module_type(manufacturer, "ChanMode-QSFP") - cls.bare_type = _plain_module_type(manufacturer, "ChanMode-QSFP-BARE") - cls.offset_type = _plain_module_type(manufacturer, "ChanMode-QSFP-OFF") - cls.vars_type = _plain_module_type(manufacturer, "ChanMode-QSFP-VARS") - cls.base_type = _plain_module_type(manufacturer, "ChanMode-QSFP-BASE") - cls.conventional_type = _plain_module_type(manufacturer, "ChanMode-QSFP-CONVENTIONAL") + manufacturer, cls.device = build_device("ChanMode", ["3", "4", "5", "6", "7", "8"]) + cls.named_type = plain_module_type(manufacturer, "ChanMode-QSFP") + cls.bare_type = plain_module_type(manufacturer, "ChanMode-QSFP-BARE") + cls.offset_type = plain_module_type(manufacturer, "ChanMode-QSFP-OFF") + cls.vars_type = plain_module_type(manufacturer, "ChanMode-QSFP-VARS") + cls.base_type = plain_module_type(manufacturer, "ChanMode-QSFP-BASE") + cls.conventional_type = plain_module_type(manufacturer, "ChanMode-QSFP-CONVENTIONAL") cls.rule = InterfaceNameRule.objects.create( module_type=cls.named_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -228,8 +226,8 @@ class ChannelizedModePreflightTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanPre", ["3", "4", "5"]) - cls.module_type = _plain_module_type(manufacturer, "ChanPre-QSFP") + manufacturer, cls.device = build_device("ChanPre", ["3", "4", "5"]) + cls.module_type = plain_module_type(manufacturer, "ChanPre-QSFP") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -326,8 +324,8 @@ class ChannelizedModeFlatFamilyTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanFlat", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "ChanFlat-QSFP") + manufacturer, cls.device = build_device("ChanFlat", ["3"]) + cls.module_type = plain_module_type(manufacturer, "ChanFlat-QSFP") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="xe-0/0/{base}:{channel}", @@ -411,8 +409,8 @@ class ChannelizedModeRetemplatedFlatFamilyTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanReTpl", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "ChanReTpl-QSFP") + manufacturer, cls.device = build_device("ChanReTpl", ["3"]) + cls.module_type = plain_module_type(manufacturer, "ChanReTpl-QSFP") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -485,9 +483,9 @@ class ChannelizedModeExistingFamilyTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanExist", ["3", "4"]) - cls.channelized_type = _channelized_module_type(manufacturer, "ChanExist-QSFP-CH") - cls.flat_type = _channelized_module_type(manufacturer, "ChanExist-QSFP-FL") + manufacturer, cls.device = build_device("ChanExist", ["3", "4"]) + cls.channelized_type = channelized_module_type(manufacturer, "ChanExist-QSFP-CH") + cls.flat_type = channelized_module_type(manufacturer, "ChanExist-QSFP-FL") InterfaceNameRule.objects.create( module_type=cls.channelized_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -571,8 +569,8 @@ class ChannelizedModeClaimedPlainInterfaceTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanPlain", ["3"]) - cls.module_type = _channelized_module_type(manufacturer, "ChanPlain-QSFP") + manufacturer, cls.device = build_device("ChanPlain", ["3"]) + cls.module_type = channelized_module_type(manufacturer, "ChanPlain-QSFP") InterfaceTemplate.objects.create(module_type=cls.module_type, name="mgmt{module}", type=PLAIN_TYPE) cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, @@ -607,8 +605,8 @@ def setUpTestData(cls): cls.superuser = User.objects.create_superuser( username="chanmodeview", password=TEST_PASSWORD, email="chanmodeview@example.com" ) - manufacturer, cls.device = _build_device("ChanPrev", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "ChanPrev-QSFP") + manufacturer, cls.device = build_device("ChanPrev", ["3"]) + cls.module_type = plain_module_type(manufacturer, "ChanPrev-QSFP") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -699,11 +697,11 @@ class ChannelizedModePredictionTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanPredMode", ["3", "4", "5", "6"]) - cls.named_type = _plain_module_type(manufacturer, "ChanPredMode-QSFP") - cls.bare_type = _plain_module_type(manufacturer, "ChanPredMode-QSFP-BARE") - cls.family_type = _channelized_module_type(manufacturer, "ChanPredMode-QSFP-FAM") - cls.installed_type = _plain_module_type(manufacturer, "ChanPredMode-QSFP-FLAT") + manufacturer, cls.device = build_device("ChanPredMode", ["3", "4", "5", "6"]) + cls.named_type = plain_module_type(manufacturer, "ChanPredMode-QSFP") + cls.bare_type = plain_module_type(manufacturer, "ChanPredMode-QSFP-BARE") + cls.family_type = channelized_module_type(manufacturer, "ChanPredMode-QSFP-FAM") + cls.installed_type = plain_module_type(manufacturer, "ChanPredMode-QSFP-FLAT") cls.installed_rule = InterfaceNameRule.objects.create( module_type=cls.installed_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -798,8 +796,8 @@ class ChannelizedJuniperE2ETest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ChanJnpr", ["5"]) - cls.module_type = _plain_module_type(manufacturer, "QSFP-4X10G-LR-CHAN") + manufacturer, cls.device = build_device("ChanJnpr", ["5"]) + cls.module_type = plain_module_type(manufacturer, "QSFP-4X10G-LR-CHAN") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, device_type=cls.device.device_type, diff --git a/netbox_interface_name_rules/tests/test_conversion.py b/netbox_interface_name_rules/tests/test_conversion.py index b87f5296..4c94219c 100644 --- a/netbox_interface_name_rules/tests/test_conversion.py +++ b/netbox_interface_name_rules/tests/test_conversion.py @@ -47,32 +47,27 @@ from netbox_interface_name_rules.models import InterfaceNameRule from netbox_interface_name_rules.naming import build_variables from netbox_interface_name_rules.tests.helpers import ( - empty_the_webhook_queue, - make_interface_webhook_rule, - make_job, - queued_webhooks, -) -from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band -from netbox_interface_name_rules.tests.test_breakout_mode import ( + CHANNEL_TYPE, CHANNELIZED, FLAT, - TEST_PASSWORD, - _plain_module_type, -) -from netbox_interface_name_rules.tests.test_channelization import ( - CHANNEL_TYPE, PARENT_TYPE, PLAIN_TYPE, PLUGIN_LOGGER, REQUIRES_CHANNELIZATION, + REQUIRES_NO_CHANNELIZATION, + TEST_PASSWORD, ChannelizationTestCase, - _build_device, + build_device, + empty_the_webhook_queue, + make_interface_webhook_rule, + make_job, + plain_module_type, + queued_webhooks, ) +from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band User = get_user_model() -REQUIRES_NO_CHANNELIZATION = "requires a NetBox that cannot model channelized interfaces (4.6 and older)" - class ConversionTestCase(ChannelizationTestCase): """Installs flat families with a flat rule, then points that rule at the channelized topology.""" @@ -137,8 +132,8 @@ class ConversionVerdictTest(ConversionTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ConvVerdict", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "ConvVerdict-QSFP") + manufacturer, cls.device = build_device("ConvVerdict", ["3"]) + cls.module_type = plain_module_type(manufacturer, "ConvVerdict-QSFP") cls.rule = cls._flat_rule(cls.module_type) def setUp(self): @@ -294,8 +289,8 @@ class ConversionTest(ConversionTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ConvApply", ["3", "4"]) - cls.module_type = _plain_module_type(manufacturer, "ConvApply-QSFP") + manufacturer, cls.device = build_device("ConvApply", ["3", "4"]) + cls.module_type = plain_module_type(manufacturer, "ConvApply-QSFP") cls.rule = cls._flat_rule(cls.module_type) def setUp(self): @@ -450,8 +445,8 @@ class Ch0MetadataSplitTest(ConversionTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ConvMeta", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "ConvMeta-QSFP") + manufacturer, cls.device = build_device("ConvMeta", ["3"]) + cls.module_type = plain_module_type(manufacturer, "ConvMeta-QSFP") cls.rule = cls._flat_rule(cls.module_type) cls.custom_field = CustomField.objects.create(name="conv_note", type="text") cls.custom_field.object_types.set([ObjectType.objects.get_for_model(Interface)]) @@ -546,8 +541,8 @@ class ConversionPreflightTest(ConversionTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ConvBlock", ["3", "4"]) - cls.module_type = _plain_module_type(manufacturer, "ConvBlock-QSFP") + manufacturer, cls.device = build_device("ConvBlock", ["3", "4"]) + cls.module_type = plain_module_type(manufacturer, "ConvBlock-QSFP") cls.rule = cls._flat_rule(cls.module_type) def setUp(self): @@ -743,8 +738,8 @@ def setUpTestData(cls): cls.superuser = User.objects.create_superuser( username="convview", password=TEST_PASSWORD, email="convview@example.com" ) - manufacturer, cls.device = _build_device("ConvView", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "ConvView-QSFP") + manufacturer, cls.device = build_device("ConvView", ["3"]) + cls.module_type = plain_module_type(manufacturer, "ConvView-QSFP") cls.rule = cls._flat_rule(cls.module_type) def setUp(self): @@ -894,8 +889,8 @@ def setUpTestData(cls): cls.superuser = User.objects.create_superuser( username="convlimit", password=TEST_PASSWORD, email="convlimit@example.com" ) - manufacturer, cls.device = _build_device("ConvLimitView", ["3", "4", "5"]) - cls.module_type = _plain_module_type(manufacturer, "ConvLimitView-QSFP") + manufacturer, cls.device = build_device("ConvLimitView", ["3", "4", "5"]) + cls.module_type = plain_module_type(manufacturer, "ConvLimitView-QSFP") cls.rule = cls._flat_rule(cls.module_type) def setUp(self): @@ -1008,8 +1003,8 @@ class ConversionScanCostTest(ConversionTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ConvCost", ["3", "4"]) - cls.module_type = _plain_module_type(manufacturer, "ConvCost-QSFP") + manufacturer, cls.device = build_device("ConvCost", ["3", "4"]) + cls.module_type = plain_module_type(manufacturer, "ConvCost-QSFP") cls.rule = cls._flat_rule(cls.module_type) def setUp(self): @@ -1053,8 +1048,8 @@ class ConversionScanLimitTest(ConversionTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ConvLimit", ["3", "4", "5"]) - cls.module_type = _plain_module_type(manufacturer, "ConvLimit-QSFP") + manufacturer, cls.device = build_device("ConvLimit", ["3", "4", "5"]) + cls.module_type = plain_module_type(manufacturer, "ConvLimit-QSFP") cls.rule = cls._flat_rule(cls.module_type) def setUp(self): @@ -1144,8 +1139,8 @@ def setUpTestData(cls): cls.superuser = User.objects.create_superuser( username="convlog", password=TEST_PASSWORD, email="convlog@example.com" ) - manufacturer, cls.device = _build_device("ConvLog", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "ConvLog-QSFP") + manufacturer, cls.device = build_device("ConvLog", ["3"]) + cls.module_type = plain_module_type(manufacturer, "ConvLog-QSFP") cls.rule = cls._flat_rule(cls.module_type) cls.fhrp_group = FHRPGroup.objects.create(group_id=81, protocol="vrrp2") @@ -1225,8 +1220,8 @@ class ConversionJobTest(ConversionTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ConvJob", ["3", "4"]) - cls.module_type = _plain_module_type(manufacturer, "ConvJob-QSFP") + manufacturer, cls.device = build_device("ConvJob", ["3", "4"]) + cls.module_type = plain_module_type(manufacturer, "ConvJob-QSFP") cls.rule = cls._flat_rule(cls.module_type) cls.operator = User.objects.create_user(username="convjob-operator") @@ -1300,8 +1295,8 @@ class ConversionEventTest(ConversionTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ConvEvent", ["3", "4"]) - cls.module_type = _plain_module_type(manufacturer, "ConvEvent-QSFP") + manufacturer, cls.device = build_device("ConvEvent", ["3", "4"]) + cls.module_type = plain_module_type(manufacturer, "ConvEvent-QSFP") cls.rule = cls._flat_rule(cls.module_type) cls.event_rule = make_interface_webhook_rule("ConvEvent") cls.operator = User.objects.create_user(username="convevent-operator", is_superuser=True) @@ -1363,8 +1358,8 @@ class FlatToChannelizedJuniperE2ETest(ConversionTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ConvJnpr", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "QSFP-4X10G-LR-CONV") + manufacturer, cls.device = build_device("ConvJnpr", ["3"]) + cls.module_type = plain_module_type(manufacturer, "QSFP-4X10G-LR-CONV") cls.rule = cls._flat_rule(cls.module_type, device_type=cls.device.device_type) cls.tag = Tag.objects.create(name="ConvJnprTag", slug="convjnpr-tag") @@ -1456,8 +1451,8 @@ def setUpTestData(cls): cls.superuser = User.objects.create_superuser( username="convnosup", password=TEST_PASSWORD, email="convnosup@example.com" ) - manufacturer, cls.device = _build_device("ConvNoSup", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "ConvNoSup-QSFP") + manufacturer, cls.device = build_device("ConvNoSup", ["3"]) + cls.module_type = plain_module_type(manufacturer, "ConvNoSup-QSFP") cls.rule = cls._flat_rule(cls.module_type) def setUp(self): diff --git a/netbox_interface_name_rules/tests/test_installed_families.py b/netbox_interface_name_rules/tests/test_installed_families.py index 6f8887f9..02cef8fe 100644 --- a/netbox_interface_name_rules/tests/test_installed_families.py +++ b/netbox_interface_name_rules/tests/test_installed_families.py @@ -39,14 +39,15 @@ from netbox_interface_name_rules.family.execution import _lock_family from netbox_interface_name_rules.models import InterfaceNameRule from netbox_interface_name_rules.name_template import evaluate_name_template -from netbox_interface_name_rules.tests.helpers import make_placement +from netbox_interface_name_rules.tests.helpers import ( + CHANNEL_TYPE, + PARENT_TYPE, + PLAIN_TYPE, + REQUIRES_CHANNELIZATION, + make_placement, +) from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band -CHANNEL_TYPE = getattr(InterfaceTypeChoices, "TYPE_CHANNEL", "channel") -PARENT_TYPE = InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS -PLAIN_TYPE = InterfaceTypeChoices.TYPE_10GE_SFP_PLUS -REQUIRES_CHANNELIZATION = "requires a NetBox that models channelized interfaces" - class RejectNthInterfaceUpdate: """Fail the *nth* interface UPDATE so earlier successful writes must also roll back.""" diff --git a/netbox_interface_name_rules/tests/test_module_move_trigger.py b/netbox_interface_name_rules/tests/test_module_move_trigger.py index 867f7612..3ec3133f 100644 --- a/netbox_interface_name_rules/tests/test_module_move_trigger.py +++ b/netbox_interface_name_rules/tests/test_module_move_trigger.py @@ -14,15 +14,10 @@ import functools import os import re -from contextlib import contextmanager -from typing import NamedTuple from unittest import skipIf, skipUnless -from unittest.mock import patch -from dcim.models import Interface, InterfaceTemplate, Module, ModuleBay, ModuleBayTemplate, Platform, VirtualChassis -from django.contrib.contenttypes.models import ContentType -from django.db import DataError, IntegrityError, connection, transaction -from django.test import TestCase +from dcim.models import Interface, InterfaceTemplate, Module, ModuleBay, ModuleBayTemplate +from django.db import DataError, connection, transaction from django.test.utils import CaptureQueriesContext from django.urls import reverse from extras.choices import JournalEntryKindChoices @@ -30,7 +25,6 @@ from rest_framework import status from utilities.testing import APITestCase -from netbox_interface_name_rules import engine from netbox_interface_name_rules.choices import BreakoutModeChoices from netbox_interface_name_rules.engine import supports_channelization, supports_vc_position_token from netbox_interface_name_rules.family import supports_module_moves @@ -38,33 +32,39 @@ from netbox_interface_name_rules.rename_triggers import PlanRunner from netbox_interface_name_rules.tests.committed_callbacks import run_the_reapply from netbox_interface_name_rules.tests.helpers import ( - make_device, - make_device_type, - make_manufacturer, + PLAIN_TYPE, + REQUIRES_CHANNELIZATION, + REQUIRES_VC_POSITION_TOKEN, + channelized_module_type, make_module_type, - make_placement, - slug_for, ) from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band -from netbox_interface_name_rules.tests.test_channelization import REQUIRES_CHANNELIZATION, _channelized_module_type -from netbox_interface_name_rules.tests.test_rename_triggers import _give_the_next_module_id, _reject_reads_of -from netbox_interface_name_rules.tests.test_vc_drift import REQUIRES_VC_POSITION_TOKEN +from netbox_interface_name_rules.tests.trigger_cases import ( + CHASSIS_RULES, + FLAT, + NO_RULE, + REQUIRES_SUBTREE_MOVES, + TAKEN, + UNAVAILABLE, + ModuleMoveTestCase, + MoveFixture, + fail_the_naming_read, + give_the_next_module_id, + journal, + module_reapplies, + naming_reads, + reapplied, + reject_interface_updates, + reject_reads_of, +) -PLAIN_TYPE = "10gbase-x-sfpp" -BAYS = (("Bay 0", "0"), ("Bay 1", "1"), ("Bay 2", "2"), ("Bay 10", "10")) -REQUIRES_SUBTREE_MOVES = "requires a NetBox that moves a module's nested bays with it (4.7+)" REQUIRES_DEVICE_MOVES = "requires a NetBox that moves a module's interfaces to its new device (4.7+)" REQUIRES_MOVE_RENAMES = "requires a NetBox that renames a moved module's raw interface names (4.7+)" UNCLAIMED = "no single interface template claims" -FLAT = "a flat breakout family is not renamed after a move, a bay edit or a parent module type change" NOT_RENAMED = "the module is not renamed while one of its interfaces is unclaimed" -NO_RULE = "no rule matches the module after the change" ELSEWHERE = "the interface is not on the device of its module" STALE_BAY = "the module bay still has the parent bay it had before its module moved" -TAKEN = "target name is already in use" -UNAVAILABLE = "{vc_position} is not available on this device" WRITE = re.compile(r'\s*(INSERT INTO|UPDATE|DELETE FROM) "(\w+)"') -NAMING_READ = re.compile(r'SELECT .* FROM "dcim_module" .*"dcim_platform"') def _writes(queries): @@ -72,187 +72,6 @@ def _writes(queries): return [match.groups() for query in queries if (match := WRITE.match(query["sql"]))] -def _reject_interface_updates(execute, sql, params, many, context): - if sql.lstrip().startswith('UPDATE "dcim_interface"'): - raise IntegrityError("injected reapply failure") - return execute(sql, params, many, context) - - -def _fail_the_naming_read(execute, sql, params, many, context): - if NAMING_READ.match(sql): - return execute("SELECT 1/0", None, many, context) - return execute(sql, params, many, context) - - -def _journal(instance): - """Return the journal entries on *instance*, oldest first.""" - return list( - JournalEntry.objects.filter( - assigned_object_type=ContentType.objects.get_for_model(instance), assigned_object_id=instance.pk - ).order_by("pk") - ) - - -@contextmanager -def _module_reapplies(): - """Count the module reapplies; each call still runs the real function.""" - with patch.object(engine, "module_rule_outcomes", wraps=engine.module_rule_outcomes) as spy: - yield spy - - -def _reapplied(spy): - """Return the primary key of the module of each reapply that *spy* recorded, sorted.""" - return sorted(call.args[0].pk for call in spy.call_args_list) - - -class ChassisRule(NamedTuple): - """One rule shape that the tests of a chassis change with a module change cover.""" - - model: str - name_template: str - - -# A plain rule, a rule that reads {base}, and a rule with the virtual-chassis position in arithmetic. -CHASSIS_RULES = ( - ChassisRule("Plain", "et-{vc_position}/{slot}/{bay_position}"), - ChassisRule("Base", "p{base}-{vc_position}/{slot}"), - ChassisRule("Arithmetic", "x{{vc_position} * 10 + {slot_num}}/{bay_position}"), -) - - -@contextmanager -def _naming_reads(): - """Record the queries and the result of each subtree naming read; each call still runs the real function.""" - reads = [] - real = engine.read_subtree_naming - - def read(module_pk): - with CaptureQueriesContext(connection) as queries: - naming = real(module_pk) - reads.append((queries.captured_queries, naming)) - return naming - - with patch.object(engine, "read_subtree_naming", read): - yield reads - - -class _MoveFixture: - """Two device types with the same bays, and three devices in two virtual chassis. - - ``device`` and ``peer`` are virtual-chassis positions 1 and 2 of one chassis; ``remote`` has - another device type and platform, at position 5 of another chassis. - """ - - @classmethod - def build(cls, prefix): - """Create the fixture objects and return them as class attributes of *cls*.""" - cls.prefix = prefix - cls.manufacturer = make_manufacturer(prefix) - cls.device_type = make_device_type(cls.manufacturer, prefix) - cls.other_device_type = make_device_type(cls.manufacturer, f"{prefix} Other") - for device_type in (cls.device_type, cls.other_device_type): - for name, position in BAYS: - ModuleBayTemplate.objects.create(device_type=device_type, name=name, position=position) - cls.platform = Platform.objects.create(name=f"{prefix} OS", slug=slug_for(prefix, "os")) - cls.other_platform = Platform.objects.create(name=f"{prefix} Other OS", slug=slug_for(prefix, "other-os")) - placement = make_placement(prefix) - chassis = VirtualChassis.objects.create(name=f"{prefix} VC") - remote_chassis = VirtualChassis.objects.create(name=f"{prefix} Remote VC") - cls.device = cls._device(placement, "01", cls.device_type, cls.platform, chassis, 1) - cls.peer = cls._device(placement, "02", cls.device_type, cls.other_platform, chassis, 2) - cls.remote = cls._device(placement, "03", cls.other_device_type, cls.other_platform, remote_chassis, 5) - - @classmethod - def _device(cls, placement, suffix, device_type, platform, chassis, position): - return make_device( - cls.prefix, - device_type, - placement, - name=slug_for(cls.prefix, suffix), - platform=platform, - virtual_chassis=chassis, - vc_position=position, - ) - - @classmethod - def _module_type(cls, model, *templates): - module_type = make_module_type(cls.manufacturer, model, model=f"{cls.prefix} {model}") - for template in templates: - InterfaceTemplate.objects.create(module_type=module_type, name=template, type=PLAIN_TYPE) - return module_type - - @classmethod - def _card_type(cls, model, bay_position): - """Return a module type that holds one nested bay at *bay_position*, and no interfaces.""" - card_type = make_module_type(cls.manufacturer, model, model=f"{cls.prefix} {model}") - ModuleBayTemplate.objects.create(module_type=card_type, name="Port", position=bay_position) - return card_type - - @staticmethod - def _bay(device, name="Bay 0"): - return ModuleBay.objects.get(device=device, module__isnull=True, name=name) - - @staticmethod - def _names(module): - return sorted(Interface.objects.filter(module=module).values_list("name", flat=True)) - - -class ModuleMoveTestCase(_MoveFixture, TestCase): - """Install and move modules through real saves, with the committed callbacks run.""" - - @classmethod - def setUpTestData(cls): - cls.build(cls.__name__) - - def _install(self, module_type, bay): - with self.captureOnCommitCallbacks(execute=True): - return Module.objects.create(device=bay.device, module_bay=bay, module_type=module_type) - - @staticmethod - def _save_move(module, bay): - module.device = bay.device - module.module_bay = bay - module.save() - - def _move(self, module, bay): - with self.captureOnCommitCallbacks(execute=True): - self._save_move(module, bay) - - def _install_card(self, card_type, bay): - """Install *card_type* in *bay* and return it with its nested bay.""" - card = self._install(card_type, bay) - return card, ModuleBay.objects.get(module=card) - - def _change_the_chassis_position(self, position=3): - self.device.vc_position = position - self.device.save() - - def _leave_the_chassis(self): - self.device.virtual_chassis = None - self.device.vc_position = None - self.device.save() - - def _join_the_chassis(self, chassis): - self.device.virtual_chassis = chassis - self.device.vc_position = 3 - self.device.save() - - def _save_in_one_transaction(self, *saves): - """Run each of *saves* in order in one transaction; return the spy of the module reapplies.""" - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): - for save in saves: - save() - return reapplies - - def _save_with_a_device_change(self, device_change, save, device_first, before=()): - """Run *device_change* and *save* in one transaction, *device_change* first when *device_first*. - - The saves in *before* run first in the same transaction. - """ - ordered = (device_change, save) if device_first else (save, device_change) - return self._save_in_one_transaction(*before, *ordered) - - class ModuleMoveTest(ModuleMoveTestCase): """A moved module gets the names its rule gives at the new position, for every rule shape.""" @@ -271,7 +90,7 @@ def test_a_module_moved_to_another_bay_is_renamed_for_the_new_bay(self): self._move(module, self._bay(self.device, "Bay 1")) self.assertEqual(self._names(module), ["et-1/0/1"]) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) @skipUnless(supports_module_moves(), REQUIRES_DEVICE_MOVES) def test_a_module_moved_to_another_device_in_the_chassis_is_renamed_for_its_position(self): @@ -300,7 +119,7 @@ def test_a_base_rule_is_renamed_from_the_raw_name_of_the_new_bay(self): @skipUnless(supports_channelization(), REQUIRES_CHANNELIZATION) def test_a_channelized_family_is_renamed_for_the_new_bay(self): - module_type = _channelized_module_type( + module_type = channelized_module_type( self.manufacturer, f"{self.prefix} Channelized", channels=2, child_channel_ids=(1, 2) ) InterfaceNameRule.objects.create( @@ -320,7 +139,7 @@ def test_a_channelized_family_is_renamed_for_the_new_bay(self): @skipUnless(supports_channelization(), REQUIRES_CHANNELIZATION) def test_a_channelized_family_whose_parent_keeps_its_raw_name_is_renamed_for_the_new_bay(self): - module_type = _channelized_module_type( + module_type = channelized_module_type( self.manufacturer, f"{self.prefix} Kept Parent", channels=2, child_channel_ids=(1, 2) ) InterfaceNameRule.objects.create( @@ -339,7 +158,7 @@ def test_a_channelized_family_whose_parent_keeps_its_raw_name_is_renamed_for_the @skipUnless(supports_channelization(), REQUIRES_CHANNELIZATION) def test_an_unclaimed_interface_beside_a_channelized_family_keeps_every_name(self): - module_type = _channelized_module_type( + module_type = channelized_module_type( self.manufacturer, f"{self.prefix} Beside", channels=2, child_channel_ids=(1, 2) ) InterfaceNameRule.objects.create( @@ -359,7 +178,7 @@ def test_an_unclaimed_interface_beside_a_channelized_family_keeps_every_name(sel callback() self.assertEqual(self._names(module), saved) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertIn(f"`operator-name`: {UNCLAIMED}", entry.comments) for name in saved: if name != "operator-name": @@ -378,7 +197,7 @@ def test_a_subinterface_does_not_stop_the_rename(self): self._move(module, self._bay(self.device, "Bay 1")) self.assertEqual(self._names(module), ["et-1/0/0.100", "et-1/0/1"]) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) @skipUnless(supports_module_moves(), REQUIRES_SUBTREE_MOVES) @@ -435,7 +254,7 @@ def test_after_a_type_change_and_a_move_the_card_is_reapplied_as_a_type_change_a self._save_move(card, self._bay(self.device, "Bay 2")) self.assertEqual((self._names(card), self._names(optic)), (["p0-1"], ["et-1/2/1"])) - (entry,) = _journal(card) + (entry,) = journal(card) self.assertIn(f"`p0-1`: {UNCLAIMED}", entry.comments) def test_a_move_rolled_back_in_a_savepoint_leaves_the_pending_reapply_as_it_was(self): @@ -472,7 +291,7 @@ def test_a_card_under_a_flat_rule_keeps_its_names_and_its_nested_modules_are_ren self._move(card, self._bay(self.remote, "Bay 1")) self.assertEqual((self._names(card), self._names(optic)), (["a-0:0", "a-0:1"], ["et-5/1/1"])) - (entry,) = _journal(card) + (entry,) = journal(card) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_WARNING) for name in ("a-0:0", "a-0:1"): self.assertIn(f"`{name}`: {FLAT}", entry.comments) @@ -489,16 +308,16 @@ def test_a_nested_module_whose_reapply_fails_is_reported_with_the_outcomes_befor with self.captureOnCommitCallbacks() as callbacks: self._save_move(card, self._bay(self.remote, "Bay 1")) - with connection.execute_wrapper(_reject_interface_updates), self.assertLogs("netbox_interface_name_rules"): + with connection.execute_wrapper(reject_interface_updates), self.assertLogs("netbox_interface_name_rules"): for callback in callbacks: callback() - (entry,) = _journal(card) + (entry,) = journal(card) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_DANGER) self.assertIn(f"`et-1/0/1` to `et-5/1/1`: {TAKEN}", entry.comments) self.assertIn("injected reapply failure", entry.comments) self.assertEqual((self._names(blocked), self._names(failed)), (["et-1/0/1"], ["et-1/0/2"])) - self.assertEqual((_journal(blocked), _journal(failed)), ([], [])) + self.assertEqual((journal(blocked), journal(failed)), ([], [])) def test_the_subtree_reports_in_one_journal_entry_on_the_moved_module(self): card, port = self._install_card(self.card_type, self._bay(self.device)) @@ -512,10 +331,10 @@ def test_the_subtree_reports_in_one_journal_entry_on_the_moved_module(self): callback() self.assertEqual(self._names(optic), ["et-1/0/1"]) - (entry,) = _journal(card) + (entry,) = journal(card) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_WARNING) self.assertIn(f"`et-1/0/1` to `et-5/1/1`: {TAKEN}", entry.comments) - self.assertEqual(_journal(optic), []) + self.assertEqual(journal(optic), []) class RuleWinnerMoveTest(ModuleMoveTestCase): @@ -598,7 +417,7 @@ def test_without_a_rule_after_the_move_the_names_the_old_rule_gave_stay_and_are_ self._move(module, self._bay(self.remote, "Bay 1")) self.assertEqual(self._names(module), ["a0", "operator-name"]) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_WARNING) self.assertIn(f"`a0`: {NO_RULE}", entry.comments) self.assertNotIn("operator-name", entry.comments) @@ -619,7 +438,7 @@ def test_a_move_from_a_plain_rule_to_a_flat_breakout_rule_builds_the_family(self self._move(module, self._bay(self.remote, "Bay 1")) self.assertEqual(self._names(module), ["b-1:0", "b-1:1"]) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) @skipUnless(supports_module_moves(), REQUIRES_DEVICE_MOVES) def test_a_move_into_a_flat_rule_builds_no_family_while_an_interface_is_unclaimed(self): @@ -637,13 +456,13 @@ def test_a_move_into_a_flat_rule_builds_no_family_while_an_interface_is_unclaime self._move(module, self._bay(self.remote, "Bay 1")) self.assertEqual(self._names(module), ["a-0", "a-0:1"]) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertIn(f"`a-0`: {NOT_RENAMED}", entry.comments) self.assertIn(f"`a-0:1`: {UNCLAIMED}", entry.comments) @skipUnless(supports_channelization(), REQUIRES_CHANNELIZATION) def test_without_a_rule_after_the_move_the_channels_renamed_with_their_parent_are_reported(self): - module_type = _channelized_module_type( + module_type = channelized_module_type( self.manufacturer, f"{self.prefix} Lockstep", channels=2, child_channel_ids=(1, 2) ) self._rule("et-{bay_position}", module_type=module_type, device_type=self.device_type) @@ -653,7 +472,7 @@ def test_without_a_rule_after_the_move_the_channels_renamed_with_their_parent_ar self._move(module, self._bay(self.remote, "Bay 1")) self.assertEqual(self._names(module), ["et-0", "et-0:1", "et-0:2"]) - (entry,) = _journal(module) + (entry,) = journal(module) for name in ("et-0", "et-0:1", "et-0:2"): self.assertIn(f"`{name}`: {NO_RULE}", entry.comments) @@ -675,7 +494,7 @@ def test_without_a_rule_after_the_move_the_family_a_channelized_rule_built_is_re self._move(module, self._bay(self.remote, "Bay 1")) self.assertEqual(self._names(module), ["et-0", "et-0:1", "et-0:2"]) - (entry,) = _journal(module) + (entry,) = journal(module) for name in ("et-0", "et-0:1", "et-0:2"): self.assertIn(f"`{name}`: {NO_RULE}", entry.comments) @@ -700,7 +519,7 @@ def test_one_template_that_matches_two_interfaces_renames_neither(self): self._move(module, self._bay(self.device, "Bay 1")) self.assertEqual(self._names(module), ["p0-1", "p1-1"]) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertIn(f"`p0-1`: {UNCLAIMED}", entry.comments) self.assertIn(f"`p1-1`: {UNCLAIMED}", entry.comments) @@ -712,7 +531,7 @@ def test_two_templates_that_match_one_interface_rename_nothing_and_are_reported( self._move(module, self._bay(self.device, "Bay 10")) self.assertEqual(self._names(module), ["operator-name", "x10"]) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertIn(f"`x10`: {UNCLAIMED}", entry.comments) self.assertIn(f"`operator-name`: {UNCLAIMED}", entry.comments) @@ -725,7 +544,7 @@ def test_an_interface_of_a_module_type_without_templates_is_reported_after_a_mov self._move(module, self._bay(self.device, "Bay 1")) self.assertEqual(self._names(module), ["operator-name"]) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertIn(f"`operator-name`: {UNCLAIMED}", entry.comments) def test_an_interface_no_template_matches_keeps_its_name_and_is_reported(self): @@ -735,14 +554,14 @@ def test_an_interface_no_template_matches_keeps_its_name_and_is_reported(self): self._move(module, self._bay(self.device, "Bay 1")) self.assertEqual(self._names(module), ["operator-name"]) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_WARNING) self.assertIn(f"`operator-name`: {UNCLAIMED}", entry.comments) def test_a_move_writes_only_the_module_the_rename_and_the_search_cache(self): module = self._install(self.plain_type, self._bay(self.device)) - with _naming_reads() as reads, CaptureQueriesContext(connection) as queries: + with naming_reads() as reads, CaptureQueriesContext(connection) as queries: self._move(module, self._bay(self.device, "Bay 1")) self.assertEqual(self._names(module), ["et-1/0/1"]) @@ -761,7 +580,7 @@ def test_a_save_that_moves_nothing_reads_no_naming(self): module = self._install(self.plain_type, self._bay(self.device)) other_type = self._module_type("Other", "{module}") - with _naming_reads() as reads, self.captureOnCommitCallbacks(execute=True): + with naming_reads() as reads, self.captureOnCommitCallbacks(execute=True): module.description = "unrelated edit" module.save() module.module_type = other_type @@ -774,7 +593,7 @@ def test_a_naming_read_that_fails_fails_the_move_with_its_error(self): module = self._install(self.plain_type, self._bay(self.device)) with ( - connection.execute_wrapper(_fail_the_naming_read), + connection.execute_wrapper(fail_the_naming_read), self.assertRaisesMessage(DataError, "division by zero"), transaction.atomic(), ): @@ -812,7 +631,7 @@ def _move_and_reapply(self, module, bay): def _assert_kept_and_reported(self, module, names): self.assertEqual(self._names(module), names) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_WARNING) for name in names: self.assertIn(f"`{name}`: {FLAT}", entry.comments) @@ -851,7 +670,7 @@ def test_a_second_move_under_a_simple_rule_does_not_split_the_family(self): self._move(module, self._bay(self.remote, "Bay 2")) self.assertEqual(self._names(module), ["p1:0", "p1:1"]) - entry = _journal(module)[-1] + entry = journal(module)[-1] self.assertIn(f"`p1:0`: {NOT_RENAMED}", entry.comments) self.assertIn(f"`p1:1`: {UNCLAIMED}", entry.comments) @@ -866,7 +685,7 @@ def test_an_install_and_a_move_in_one_transaction_build_the_family_of_a_flat_rul self._save_move(module, self._bay(self.device, "Bay 2")) self.assertEqual(self._names(module), ["f-2:0", "f-2:1"]) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) @skipUnless(supports_vc_position_token(), REQUIRES_VC_POSITION_TOKEN) @skipUnless(supports_module_moves(), REQUIRES_DEVICE_MOVES) @@ -896,7 +715,7 @@ def setUpTestData(cls): def test_a_move_in_a_rolled_back_savepoint_causes_no_reapply_and_a_later_move_reapplies_once(self): module = self._install(self.plain_type, self._bay(self.device)) - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): with self.assertRaises(RuntimeError), transaction.atomic(): self._save_move(module, self._bay(self.device, "Bay 1")) raise RuntimeError("roll back the savepoint") @@ -909,7 +728,7 @@ def test_a_move_in_a_rolled_back_savepoint_causes_no_reapply_and_a_later_move_re def test_a_move_rolled_back_after_an_earlier_move_keeps_the_earlier_reapply(self): module = self._install(self.plain_type, self._bay(self.device)) - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): self._save_move(module, self._bay(self.device, "Bay 2")) with self.assertRaises(RuntimeError), transaction.atomic(): self._save_move(module, self._bay(self.device, "Bay 1")) @@ -922,7 +741,7 @@ def test_a_move_rolled_back_after_an_earlier_move_keeps_the_earlier_reapply(self def test_two_moves_in_one_transaction_reapply_once_from_the_state_before_it(self): module = self._install(self.plain_type, self._bay(self.device)) - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): self._save_move(module, self._bay(self.device, "Bay 1")) self._save_move(module, self._bay(self.peer, "Bay 2")) @@ -934,7 +753,7 @@ def test_a_move_the_same_transaction_undoes_reapplies_nothing(self): rename_out_of_band(Interface.objects.get(module=returned), "operator-name") moved = self._install(self.plain_type, self._bay(self.device, "Bay 1")) - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): self._save_move(returned, self._bay(self.device, "Bay 2")) self._save_move(returned, self._bay(self.device)) self._save_move(moved, self._bay(self.device, "Bay 10")) @@ -946,14 +765,14 @@ def test_a_bay_edited_before_the_move_in_one_transaction_is_renamed_from_the_sta module = self._install(self.plain_type, self._bay(self.device)) bay = self._bay(self.device) - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): bay.position = "5" bay.save() self._save_move(module, self._bay(self.device, "Bay 1")) self.assertEqual(reapplies.call_count, 1) self.assertEqual(self._names(module), ["et-1/0/1"]) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) class ChassisPositionMoveTest(ModuleMoveTestCase): @@ -1023,8 +842,8 @@ def move_all(): reapplies = self._save_with_a_device_change(device_change, move_all, device_first) - self.assertEqual(_reapplied(reapplies), sorted(module.pk for module in (*modules, other))) - self.assertEqual([_journal(module) for module in modules], [[], [], []]) + self.assertEqual(reapplied(reapplies), sorted(module.pk for module in (*modules, other))) + self.assertEqual([journal(module) for module in modules], [[], [], []]) return [self._names(module) for module in modules], self._names(other) def _device_bays(self): @@ -1037,7 +856,7 @@ def _assert_moved_with_a_position_change(self, targets, chassis_first, names): moved, other = self._move_all(targets, self._change_the_chassis_position, chassis_first) self.assertEqual((moved, other), (names, ["et-3/10/10"])) - self.assertEqual(_journal(self.device), []) + self.assertEqual(journal(self.device), []) def test_moves_then_a_chassis_position_change_rename_each_module_once(self): self._assert_moved_with_a_position_change(self._device_bays(), False, [["et-3/5/5"], ["p6-3/6"], ["x37/7"]]) @@ -1068,14 +887,14 @@ def move_all(): reapplies = self._save_in_one_transaction(self._change_the_chassis_position, move_all) self.assertEqual([self._names(module) for module in modules], [["et-3/5/5"], ["p6-3/6"], ["x37/7"]]) - self.assertEqual(_reapplied(reapplies), sorted(module.pk for module in modules)) - self.assertEqual([_journal(module) for module in (*modules, self.device)], [[], [], [], []]) + self.assertEqual(reapplied(reapplies), sorted(module.pk for module in modules)) + self.assertEqual([journal(module) for module in (*modules, self.device)], [[], [], [], []]) def _assert_moved_out_with_a_leave(self, leave_first): moved, other = self._move_all(self._peer_bays(), self._leave_the_chassis, leave_first) self.assertEqual((moved, other), ([["et-2/0/0"], ["p1-2/1"], ["x22/2"]], ["et-1/10/10"])) - (entry,) = _journal(self.device) + (entry,) = journal(self.device) self.assertIn(f"`et-1/10/10`: {UNAVAILABLE}", entry.comments) @skipUnless(supports_module_moves(), REQUIRES_DEVICE_MOVES) @@ -1100,8 +919,8 @@ def _assert_an_undone_move_leaves_the_module_to_the_chassis_position_change(self reapplies = self._save_in_one_transaction(*saves) - self.assertEqual((self._names(module), _reapplied(reapplies)), (["et-3/0/0"], [module.pk])) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual((self._names(module), reapplied(reapplies)), (["et-3/0/0"], [module.pk])) + self.assertEqual((journal(module), journal(self.device)), ([], [])) # Only the module unit passes the naming points of the moves. moves = [point.move for call in reapplies.call_args_list for point in call.kwargs.get("naming_points", ())] self.assertEqual(any(moves), change_at == 1 and supports_module_moves()) @@ -1131,7 +950,7 @@ def _assert_joining_with_a_move_renames_once(self, join_first): ) self.assertEqual((self._names(module), self._names(other)), (["et-3/5/5"], ["et-3/10/10"])) - self.assertEqual(_reapplied(reapplies), sorted((module.pk, other.pk))) + self.assertEqual(reapplied(reapplies), sorted((module.pk, other.pk))) self.assertEqual(JournalEntry.objects.count(), entries) def test_a_move_then_joining_a_chassis_rename_each_module_once(self): @@ -1149,8 +968,8 @@ def _assert_a_raw_name_is_renamed_once_after_a_move_into_a_rule(self, chassis_fi self._change_the_chassis_position, functools.partial(self._save_move, module, port), chassis_first ) - self.assertEqual((self._names(module), _reapplied(reapplies).count(module.pk)), (["et-3/1/1"], 1)) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual((self._names(module), reapplied(reapplies).count(module.pk)), (["et-3/1/1"], 1)) + self.assertEqual((journal(module), journal(self.device)), ([], [])) @skipUnless(supports_vc_position_token(), REQUIRES_VC_POSITION_TOKEN) def test_a_move_into_a_rule_then_a_chassis_position_change_rename_a_raw_name_that_reads_the_position(self): @@ -1169,8 +988,8 @@ def test_a_name_netbox_gives_at_a_move_is_recognised_after_a_later_position_chan functools.partial(self._save_move, module, port), self._change_the_chassis_position ) - self.assertEqual((self._names(module), _reapplied(reapplies).count(module.pk)), (["et-3/1/1"], 1)) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual((self._names(module), reapplied(reapplies).count(module.pk)), (["et-3/1/1"], 1)) + self.assertEqual((journal(module), journal(self.device)), ([], [])) @skipUnless(supports_module_moves(), REQUIRES_MOVE_RENAMES) def test_a_move_off_a_chassis_then_a_join_recognise_the_name_netbox_gave_at_the_move(self): @@ -1185,8 +1004,8 @@ def test_a_move_off_a_chassis_then_a_join_recognise_the_name_netbox_gave_at_the_ functools.partial(self._save_move, module, port), functools.partial(self._join_the_chassis, chassis) ) - self.assertEqual((self._names(module), _reapplied(reapplies).count(module.pk)), (["et-3/1/2"], 1)) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual((self._names(module), reapplied(reapplies).count(module.pk)), (["et-3/1/2"], 1)) + self.assertEqual((journal(module), journal(self.device)), ([], [])) @skipUnless(supports_module_moves(), REQUIRES_MOVE_RENAMES) def test_a_card_moved_to_another_device_then_its_position_change_recognise_the_nested_name(self): @@ -1202,8 +1021,8 @@ def renumber_the_peer(): functools.partial(self._save_move, card, self._bay(self.peer, "Bay 2")), renumber_the_peer ) - self.assertEqual((self._names(optic), _reapplied(reapplies).count(optic.pk)), (["et-4/2/1"], 1)) - self.assertEqual((_journal(card), _journal(optic), _journal(self.peer)), ([], [], [])) + self.assertEqual((self._names(optic), reapplied(reapplies).count(optic.pk)), (["et-4/2/1"], 1)) + self.assertEqual((journal(card), journal(optic), journal(self.peer)), ([], [], [])) @skipUnless(supports_module_moves(), REQUIRES_MOVE_RENAMES) def test_a_move_into_a_bay_whose_position_is_the_token_then_its_position_change_recognise_the_name(self): @@ -1217,8 +1036,8 @@ def renumber_the_peer(): reapplies = self._save_in_one_transaction(functools.partial(self._save_move, module, bay), renumber_the_peer) - self.assertEqual((self._names(module), _reapplied(reapplies).count(module.pk)), (["et-4/7"], 1)) - self.assertEqual((_journal(module), _journal(self.peer)), ([], [])) + self.assertEqual((self._names(module), reapplied(reapplies).count(module.pk)), (["et-4/7"], 1)) + self.assertEqual((journal(module), journal(self.peer)), ([], [])) @skipUnless(supports_module_moves(), REQUIRES_MOVE_RENAMES) def test_two_moves_around_a_chassis_position_change_recognise_the_name_of_the_first_move(self): @@ -1233,8 +1052,8 @@ def test_two_moves_around_a_chassis_position_change_recognise_the_name_of_the_fi functools.partial(self._save_move, module, self._bay(self.device, "Bay 2")), ) - self.assertEqual((self._names(module), _reapplied(reapplies)), (["et-3/2"], [module.pk])) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual((self._names(module), reapplied(reapplies)), (["et-3/2"], [module.pk])) + self.assertEqual((journal(module), journal(self.device)), ([], [])) def _install_a_raw_module_with_a_base_rule(self): """Install a module whose raw name reads the position in Bay 0, then enable a {base} rule for it.""" @@ -1254,8 +1073,8 @@ def test_a_move_out_and_back_around_a_chassis_position_change_recognise_the_name functools.partial(self._save_move, module, self._bay(self.device)), ) - self.assertEqual((self._names(module), _reapplied(reapplies)), (["p3/0"], [module.pk])) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual((self._names(module), reapplied(reapplies)), (["p3/0"], [module.pk])) + self.assertEqual((journal(module), journal(self.device)), ([], [])) def _install_a_raw_optic_in_a_card_with_a_base_rule(self): """Install a card whose port position is the card's bay, a raw optic in it, and a {base} rule for the optic.""" @@ -1276,8 +1095,8 @@ def test_a_card_moved_out_and_back_around_a_chassis_position_change_recognise_th functools.partial(self._save_move, card, self._bay(self.device)), ) - self.assertEqual((self._names(optic), _reapplied(reapplies).count(optic.pk)), (["p3/0"], 1)) - self.assertEqual((_journal(card), _journal(optic), _journal(self.device)), ([], [], [])) + self.assertEqual((self._names(optic), reapplied(reapplies).count(optic.pk)), (["p3/0"], 1)) + self.assertEqual((journal(card), journal(optic), journal(self.device)), ([], [], [])) @skipUnless(supports_module_moves(), REQUIRES_MOVE_RENAMES) def test_a_card_moved_out_and_back_around_a_port_edit_that_is_undone_recognise_the_nested_name(self): @@ -1295,8 +1114,8 @@ def edit_the_port(position): functools.partial(edit_the_port, "0"), ) - self.assertEqual((self._names(optic), _reapplied(reapplies)), (["p1/0"], [optic.pk])) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((self._names(optic), reapplied(reapplies)), (["p1/0"], [optic.pk])) + self.assertEqual((journal(card), journal(optic)), ([], [])) @skipUnless(supports_module_moves(), REQUIRES_MOVE_RENAMES) def test_a_position_change_of_a_device_the_module_left_before_its_return_recognise_the_name_given_there(self): @@ -1313,8 +1132,8 @@ def renumber_the_peer(): functools.partial(self._save_move, module, self._bay(self.device)), ) - self.assertEqual((self._names(module), _reapplied(reapplies)), (["p1/0"], [module.pk])) - self.assertEqual((_journal(module), _journal(self.device), _journal(self.peer)), ([], [], [])) + self.assertEqual((self._names(module), reapplied(reapplies)), (["p1/0"], [module.pk])) + self.assertEqual((journal(module), journal(self.device), journal(self.peer)), ([], [], [])) @skipUnless(supports_module_moves(), REQUIRES_MOVE_RENAMES) def test_a_move_out_an_edit_of_the_new_bay_and_a_move_back_recognise_the_name_of_the_move_out(self): @@ -1332,8 +1151,8 @@ def edit_the_new_bay(): functools.partial(self._save_move, module, self._bay(self.device)), ) - self.assertEqual((self._names(module), _reapplied(reapplies)), (["p1/0"], [module.pk])) - self.assertEqual(_journal(module), []) + self.assertEqual((self._names(module), reapplied(reapplies)), (["p1/0"], [module.pk])) + self.assertEqual(journal(module), []) @skipUnless(supports_module_moves(), REQUIRES_MOVE_RENAMES) def test_a_move_with_a_position_change_undone_ends_as_the_move_alone(self): @@ -1344,7 +1163,7 @@ def test_a_move_with_a_position_change_undone_ends_as_the_move_alone(self): for device_changes in ((), undone): module = self._install(self.adjacent_type, self._bay(self.device)) self._save_in_one_transaction(functools.partial(self._save_move, module, port), *device_changes) - outcomes.append((self._names(module), [entry.comments for entry in _journal(module)])) + outcomes.append((self._names(module), [entry.comments for entry in journal(module)])) module.delete() self.assertEqual(outcomes, [(["et-1/1/1"], []), (["et-1/1/1"], [])]) @@ -1359,10 +1178,10 @@ def _assert_a_collision_is_reported_once(self, chassis_first): chassis_first, ) - self.assertEqual((self._names(module), _reapplied(reapplies)), (["et-1/0/0"], [module.pk])) - (entry,) = _journal(module) + self.assertEqual((self._names(module), reapplied(reapplies)), (["et-1/0/0"], [module.pk])) + (entry,) = journal(module) self.assertEqual(entry.comments.count(f"`et-1/0/0` to `et-3/5/5`: {TAKEN}"), 1) - self.assertEqual(_journal(self.device), []) + self.assertEqual(journal(self.device), []) def test_a_move_then_a_chassis_position_change_report_a_collision_once(self): self._assert_a_collision_is_reported_once(chassis_first=False) @@ -1413,8 +1232,8 @@ def _assert_an_adjacent_token_install_is_named_once(self, chassis_first, bay, na module, other, reapplies = self._install_with_the_chassis_change(chassis_first, self.adjacent_type, bay) self.assertEqual((self._names(module), self._names(other)), (names, ["et-3/10/10"])) - self.assertEqual(_reapplied(reapplies), sorted((module.pk, other.pk, *(card.pk for card in cards)))) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual(reapplied(reapplies), sorted((module.pk, other.pk, *(card.pk for card in cards)))) + self.assertEqual((journal(module), journal(self.device)), ([], [])) @skipUnless(supports_vc_position_token(), REQUIRES_VC_POSITION_TOKEN) def test_an_install_then_a_chassis_position_change_recognise_the_raw_name_at_the_position_of_the_install(self): @@ -1441,8 +1260,8 @@ def install(): reapplies = self._save_in_one_transaction(install, functools.partial(self._join_the_chassis, chassis)) (module,) = installed - self.assertEqual((self._names(module), _reapplied(reapplies)), (["pxe-3"], [module.pk])) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual((self._names(module), reapplied(reapplies)), (["pxe-3"], [module.pk])) + self.assertEqual((journal(module), journal(self.device)), ([], [])) def test_an_install_without_templates_then_a_chassis_position_change_rename_its_interface(self): bare_type = self._module_type("Bare") @@ -1457,8 +1276,8 @@ def install(): reapplies = self._save_in_one_transaction(install, self._change_the_chassis_position) (module,) = installed - self.assertEqual((self._names(module), _reapplied(reapplies)), (["et-3/0"], [module.pk])) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual((self._names(module), reapplied(reapplies)), (["et-3/0"], [module.pk])) + self.assertEqual((journal(module), journal(self.device)), ([], [])) @skipUnless(supports_module_moves(), REQUIRES_MOVE_RENAMES) def test_a_replacement_under_the_key_of_a_moved_module_is_recognised_at_the_position_of_its_install(self): @@ -1471,7 +1290,7 @@ def move_and_delete(): module.delete() def install_a_replacement(): - _give_the_next_module_id(keys[0]) + give_the_next_module_id(keys[0]) installed.append( Module.objects.create( device=self.device, module_bay=self._bay(self.device), module_type=self.adjacent_type @@ -1487,8 +1306,8 @@ def install_a_replacement(): (replacement,) = installed self.assertEqual(replacement.pk, keys[0]) - self.assertEqual((self._names(replacement), _reapplied(reapplies)), (["et-5/0"], [replacement.pk])) - self.assertEqual((_journal(replacement), _journal(self.device)), ([], [])) + self.assertEqual((self._names(replacement), reapplied(reapplies)), (["et-5/0"], [replacement.pk])) + self.assertEqual((journal(replacement), journal(self.device)), ([], [])) @skipUnless(supports_vc_position_token(), REQUIRES_VC_POSITION_TOKEN) def test_two_templates_that_claim_one_name_at_different_naming_points_rename_nothing(self): @@ -1506,7 +1325,7 @@ def install(): (module,) = installed self.assertEqual(self._names(module), ["1/0", "3/0"]) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertEqual((entry.comments.count("`1/0`"), entry.comments.count("`3/0`")), (1, 1)) def test_a_failed_install_reapply_is_reported_on_each_module_and_not_by_the_device(self): @@ -1521,16 +1340,16 @@ def test_a_failed_install_reapply_is_reported_on_each_module_and_not_by_the_devi failure = f"injected {InterfaceTemplate._meta.db_table} read failure" with ( - connection.execute_wrapper(_reject_reads_of(InterfaceTemplate._meta.db_table)), + connection.execute_wrapper(reject_reads_of(InterfaceTemplate._meta.db_table)), self.assertLogs("netbox_interface_name_rules", "ERROR"), ): run_the_reapply(callbacks) for module in (first, second): - (entry,) = _journal(module) + (entry,) = journal(module) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_DANGER) self.assertEqual(entry.comments.count(failure), 1) - self.assertEqual((self._names(first), self._names(second), _journal(self.device)), (["0"], ["1"], [])) + self.assertEqual((self._names(first), self._names(second), journal(self.device)), (["0"], ["1"], [])) @skipUnless(supports_vc_position_token(), REQUIRES_VC_POSITION_TOKEN) def test_an_install_in_a_card_then_a_chassis_position_change_recognise_the_nested_raw_name(self): @@ -1542,8 +1361,8 @@ def _assert_an_install_is_named_once(self, chassis_first): module, other, reapplies = self._install_with_the_chassis_change(chassis_first) self.assertEqual((self._names(module), self._names(other)), (["et-3/0/0"], ["et-3/10/10"])) - self.assertEqual(_reapplied(reapplies), sorted((module.pk, other.pk))) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual(reapplied(reapplies), sorted((module.pk, other.pk))) + self.assertEqual((journal(module), journal(self.device)), ([], [])) def test_an_install_then_a_chassis_position_change_name_the_module_once(self): self._assert_an_install_is_named_once(chassis_first=False) @@ -1556,10 +1375,10 @@ def _assert_a_collision_is_reported_once(self, chassis_first): module, _other, reapplies = self._install_with_the_chassis_change(chassis_first) - self.assertEqual((self._names(module), _reapplied(reapplies).count(module.pk)), (["0"], 1)) - (entry,) = _journal(module) + self.assertEqual((self._names(module), reapplied(reapplies).count(module.pk)), (["0"], 1)) + (entry,) = journal(module) self.assertEqual(entry.comments.count(f"`0` to `et-3/0/0`: {TAKEN}"), 1) - self.assertEqual(_journal(self.device), []) + self.assertEqual(journal(self.device), []) def test_an_install_then_a_chassis_position_change_report_a_collision_once(self): self._assert_a_collision_is_reported_once(chassis_first=False) @@ -1650,7 +1469,7 @@ def install(): self._save_in_one_transaction(install, *device_changes) (module,) = installed - outcome = (self._names(module), [entry.comments for entry in _journal(module)]) + outcome = (self._names(module), [entry.comments for entry in journal(module)]) module.delete() return outcome @@ -1664,11 +1483,11 @@ def test_an_install_with_a_position_change_undone_ends_as_the_install_alone(self alone = self._install_outcome(module_type, bay, hand_added) self.assertEqual(alone, (names, [])) self.assertEqual(self._install_outcome(module_type, bay, hand_added, *undone), alone) - self.assertEqual(_journal(self.device), []) + self.assertEqual(journal(self.device), []) @skipUnless(supports_channelization(), REQUIRES_CHANNELIZATION) def test_a_channelized_install_with_a_position_change_undone_ends_as_the_install_alone(self): - module_type = _channelized_module_type( + module_type = channelized_module_type( self.manufacturer, f"{self.prefix} Channelized", channels=2, child_channel_ids=(1, 2) ) InterfaceNameRule.objects.create( @@ -1740,7 +1559,7 @@ def test_a_move_renames_the_moved_module_from_its_old_raw_names_and_reports_the_ callback() self.assertEqual((self._names(card), self._names(optic)), (["ge-1/2"], ["et-1/0/1"])) - (entry,) = _journal(card) + (entry,) = journal(card) self.assertIn(f"`et-1/0/1`: {STALE_BAY}", entry.comments) self.assertNotIn("ge-1/2", entry.comments) @@ -1763,7 +1582,7 @@ def test_a_nested_module_whose_bay_keeps_the_old_parent_is_not_renamed_by_anothe self._move(card, self._bay(self.device, "Bay 2")) self.assertEqual(self._names(optic), ["a-1"]) - (entry,) = _journal(card) + (entry,) = journal(card) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_WARNING) self.assertIn(f"`a-1`: {STALE_BAY}", entry.comments) @@ -1777,7 +1596,7 @@ def test_a_position_change_after_a_move_does_not_rename_a_nested_module_whose_ba self.device.save() self.assertEqual(self._names(optic), ["a-1"]) - (entry,) = _journal(self.device) + (entry,) = journal(self.device) self.assertIn(f"`a-1`: {STALE_BAY}", entry.comments) def test_a_move_to_another_device_renames_nothing_while_the_interfaces_stay_on_the_old_device(self): @@ -1788,7 +1607,7 @@ def test_a_move_to_another_device_renames_nothing_while_the_interfaces_stay_on_t interface = Interface.objects.get(module=module) self.assertEqual((interface.device, interface.name), (self.device, "et-1/0/0")) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_WARNING) self.assertIn(f"`et-1/0/0`: {ELSEWHERE}", entry.comments) @@ -1802,11 +1621,11 @@ def test_a_position_change_of_the_new_device_renames_nothing_while_the_interface interface = Interface.objects.get(module=module) self.assertEqual((interface.device, interface.name), (self.device, "et-1/0/0")) - (entry,) = _journal(self.peer) + (entry,) = journal(self.peer) self.assertIn(f"`et-1/0/0`: {ELSEWHERE}", entry.comments) -class ModuleMoveAPITest(_MoveFixture, APITestCase): +class ModuleMoveAPITest(MoveFixture, APITestCase): """A REST API move reaches the rename trigger through NetBox's own write path.""" model = Module @@ -1832,4 +1651,4 @@ def test_patching_the_module_bay_renames_the_interfaces_for_the_new_bay(self): self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) self.assertEqual(self._names(module), ["et-1/0/1"]) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) diff --git a/netbox_interface_name_rules/tests/test_naming_point_sequences.py b/netbox_interface_name_rules/tests/test_naming_point_sequences.py index a9d5bec3..ee6f50d4 100644 --- a/netbox_interface_name_rules/tests/test_naming_point_sequences.py +++ b/netbox_interface_name_rules/tests/test_naming_point_sequences.py @@ -23,12 +23,8 @@ from netbox_interface_name_rules.family import supports_module_moves from netbox_interface_name_rules.models import InterfaceNameRule from netbox_interface_name_rules.naming import bay_naming_values, chassis_position -from netbox_interface_name_rules.tests.test_module_move_trigger import ( - PLAIN_TYPE, - ModuleMoveTestCase, - _module_reapplies, -) -from netbox_interface_name_rules.tests.test_vc_drift import REQUIRES_VC_POSITION_TOKEN +from netbox_interface_name_rules.tests.helpers import PLAIN_TYPE, REQUIRES_VC_POSITION_TOKEN +from netbox_interface_name_rules.tests.trigger_cases import ModuleMoveTestCase, module_reapplies # The template name after the module's code, or None for a module type without interface templates. SHAPES = { @@ -191,7 +187,7 @@ def edit_the_bays(): "bay edit": edit_the_bays, **dict.fromkeys(MOVES, move), } - with _module_reapplies() as spy: + with module_reapplies() as spy: self._save_in_one_transaction(*(saves[op] for op in sequence)) reapplies = collections.Counter(call.args[0].pk for call in spy.call_args_list) diff --git a/netbox_interface_name_rules/tests/test_prospective_families.py b/netbox_interface_name_rules/tests/test_prospective_families.py index 4d33fd87..03781e47 100644 --- a/netbox_interface_name_rules/tests/test_prospective_families.py +++ b/netbox_interface_name_rules/tests/test_prospective_families.py @@ -37,18 +37,18 @@ resolved_template_names, ) from netbox_interface_name_rules.models import InterfaceNameRule -from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band -from netbox_interface_name_rules.tests.test_breakout_mode import CHANNELIZED, _plain_module_type -from netbox_interface_name_rules.tests.test_channelization import ( +from netbox_interface_name_rules.tests.helpers import ( CHANNEL_TYPE, + CHANNELIZED, PLAIN_TYPE, REQUIRES_CHANNELIZATION, + REQUIRES_NO_CHANNELIZATION, ChannelizationTestCase, - _build_device, - _channelized_module_type, + build_device, + channelized_module_type, + plain_module_type, ) - -REQUIRES_NO_CHANNELIZATION = "requires a NetBox that cannot model channelized interfaces (4.6 and older)" +from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band def _projection(plan): @@ -146,8 +146,8 @@ class ProspectiveFlatPlanTest(ProspectivePlanTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ProspFlat", ["3", "4"]) - cls.module_type = _plain_module_type(manufacturer, "ProspFlat-QSFP") + manufacturer, cls.device = build_device("ProspFlat", ["3", "4"]) + cls.module_type = plain_module_type(manufacturer, "ProspFlat-QSFP") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -225,8 +225,8 @@ class ProspectiveUnsupportedTopologyTest(ProspectivePlanTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ProspUnsup", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "ProspUnsup-QSFP") + manufacturer, cls.device = build_device("ProspUnsup", ["3"]) + cls.module_type = plain_module_type(manufacturer, "ProspUnsup-QSFP") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -258,8 +258,8 @@ class ProspectivePlansAreNotExecutableTest(ProspectivePlanTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ProspExec", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "ProspExec-QSFP") + manufacturer, cls.device = build_device("ProspExec", ["3"]) + cls.module_type = plain_module_type(manufacturer, "ProspExec-QSFP") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -292,11 +292,11 @@ class ProspectiveChannelizedPlanTest(ProspectivePlanTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ProspChan", ["3", "5", "8", "9"]) - cls.breakout_type = _channelized_module_type(manufacturer, "ProspChan-BRK") - cls.incomplete_type = _channelized_module_type(manufacturer, "ProspChan-INC", child_channel_ids=(1, 2, 3)) - cls.mismatch_type = _channelized_module_type(manufacturer, "ProspChan-MM", channels=8) - cls.simple_type = _channelized_module_type( + manufacturer, cls.device = build_device("ProspChan", ["3", "5", "8", "9"]) + cls.breakout_type = channelized_module_type(manufacturer, "ProspChan-BRK") + cls.incomplete_type = channelized_module_type(manufacturer, "ProspChan-INC", child_channel_ids=(1, 2, 3)) + cls.mismatch_type = channelized_module_type(manufacturer, "ProspChan-MM", channels=8) + cls.simple_type = channelized_module_type( manufacturer, "ProspChan-SMP", child_names={1: "{module}:1", 2: "{module}:2", 3: "{module}:3", 4: "mgmt-chan"}, @@ -370,9 +370,9 @@ class ProspectiveStructuralPlanTest(ProspectivePlanTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ProspStruct", ["3", "4"]) - cls.module_type = _plain_module_type(manufacturer, "ProspStruct-QSFP") - cls.collision_type = _plain_module_type(manufacturer, "ProspStruct-COL") + manufacturer, cls.device = build_device("ProspStruct", ["3", "4"]) + cls.module_type = plain_module_type(manufacturer, "ProspStruct-QSFP") + cls.collision_type = plain_module_type(manufacturer, "ProspStruct-COL") InterfaceTemplate.objects.create(module_type=cls.collision_type, name="et-0/0/{module}", type=PLAIN_TYPE) cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, @@ -424,9 +424,9 @@ class ProspectiveMatchesInstalledPlanningTest(ProspectivePlanTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ProspSame", ["3", "5"]) - cls.breakout_type = _channelized_module_type(manufacturer, "ProspSame-BRK") - cls.mismatch_type = _channelized_module_type(manufacturer, "ProspSame-MM", channels=8) + manufacturer, cls.device = build_device("ProspSame", ["3", "5"]) + cls.breakout_type = channelized_module_type(manufacturer, "ProspSame-BRK") + cls.mismatch_type = channelized_module_type(manufacturer, "ProspSame-MM", channels=8) for module_type in (cls.breakout_type, cls.mismatch_type): InterfaceNameRule.objects.create( module_type=module_type, @@ -474,8 +474,8 @@ class ProspectivePreviewIsNotAppliedTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ProspReplan", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "ProspReplan-QSFP") + manufacturer, cls.device = build_device("ProspReplan", ["3"]) + cls.module_type = plain_module_type(manufacturer, "ProspReplan-QSFP") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="et-0/0/{bay_position}", @@ -508,8 +508,8 @@ class ProspectivePlanningIsReadOnlyTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ProspRead", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "ProspRead-QSFP") + manufacturer, cls.device = build_device("ProspRead", ["3"]) + cls.module_type = plain_module_type(manufacturer, "ProspRead-QSFP") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -539,8 +539,8 @@ class PreviewComesFromTheFamilyPlanTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ProspPrev", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "ProspPrev-QSFP") + manufacturer, cls.device = build_device("ProspPrev", ["3"]) + cls.module_type = plain_module_type(manufacturer, "ProspPrev-QSFP") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -581,8 +581,8 @@ class PreviewFollowsTheApplyClassificationTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ProspClass", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "ProspClass-QSFP") + manufacturer, cls.device = build_device("ProspClass", ["3"]) + cls.module_type = plain_module_type(manufacturer, "ProspClass-QSFP") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="et-0/0/{bay_position}", @@ -621,8 +621,8 @@ class PreviewReadsNoTemplatesForDerivableSuffixesTest(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("ProspQuery", ["3"]) - cls.module_type = _channelized_module_type(manufacturer, "ProspQuery-QSFP") + manufacturer, cls.device = build_device("ProspQuery", ["3"]) + cls.module_type = channelized_module_type(manufacturer, "ProspQuery-QSFP") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="et-0/0/{bay_position}", diff --git a/netbox_interface_name_rules/tests/test_raw_base.py b/netbox_interface_name_rules/tests/test_raw_base.py index dcb2737a..861baee1 100644 --- a/netbox_interface_name_rules/tests/test_raw_base.py +++ b/netbox_interface_name_rules/tests/test_raw_base.py @@ -23,20 +23,20 @@ supports_vc_position_token, ) from netbox_interface_name_rules.models import InterfaceNameRule -from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band -from netbox_interface_name_rules.tests.test_breakout_mode import CHANNELIZED, FLAT, _plain_module_type -from netbox_interface_name_rules.tests.test_channelization import ( +from netbox_interface_name_rules.tests.helpers import ( + CHANNELIZED, + FLAT, PLAIN_TYPE, PLUGIN_LOGGER, REQUIRES_CHANNELIZATION, - _build_device, - _channelized_module_type, -) -from netbox_interface_name_rules.tests.test_vc_drift import ( REQUIRES_VC_POSITION_TOKEN, VcDriftTestCase, - _token_module_type, + build_device, + channelized_module_type, + plain_module_type, + token_module_type, ) +from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band class RawBasePlainRenameTest(VcDriftTestCase): @@ -44,17 +44,17 @@ class RawBasePlainRenameTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "RawBase", ["3", "4", "5", "6", "7"], virtual_chassis=VirtualChassis.objects.create(name="rawbase-vc"), vc_position=1, ) - cls.module_type = _plain_module_type(manufacturer, "RawBase-SFP", PLAIN_TYPE) + cls.module_type = plain_module_type(manufacturer, "RawBase-SFP", PLAIN_TYPE) cls.rule = InterfaceNameRule.objects.create(module_type=cls.module_type, name_template="{base}-x") - cls.vc_type = _plain_module_type(manufacturer, "RawBase-VC", PLAIN_TYPE) + cls.vc_type = plain_module_type(manufacturer, "RawBase-VC", PLAIN_TYPE) InterfaceNameRule.objects.create(module_type=cls.vc_type, name_template="{base}.{vc_position}") - cls.fixed_type = _plain_module_type(manufacturer, "RawBase-FIXED", PLAIN_TYPE) + cls.fixed_type = plain_module_type(manufacturer, "RawBase-FIXED", PLAIN_TYPE) cls.fixed_rule = InterfaceNameRule.objects.create( module_type=cls.fixed_type, name_template="et-0/0/{bay_position}" ) @@ -66,7 +66,7 @@ def setUpTestData(cls): InterfaceTemplate.objects.create(module_type=cls.overlap_type, name="port{module}", type=PLAIN_TYPE) InterfaceTemplate.objects.create(module_type=cls.overlap_type, name="port{module}.2", type=PLAIN_TYPE) InterfaceNameRule.objects.create(module_type=cls.overlap_type, name_template="{base}.{vc_position}") - cls.flat_type = _plain_module_type(manufacturer, "RawBase-FLAT", PLAIN_TYPE) + cls.flat_type = plain_module_type(manufacturer, "RawBase-FLAT", PLAIN_TYPE) cls.flat_rule = InterfaceNameRule.objects.create( module_type=cls.flat_type, name_template="{base}:{channel}", @@ -74,17 +74,17 @@ def setUpTestData(cls): channel_count=2, channel_start=0, ) - cls.arithmetic_type = _plain_module_type(manufacturer, "RawBase-ARITH", PLAIN_TYPE) + cls.arithmetic_type = plain_module_type(manufacturer, "RawBase-ARITH", PLAIN_TYPE) InterfaceNameRule.objects.create(module_type=cls.arithmetic_type, name_template="{{base} + 100}") - cls.vc_arithmetic_type = _plain_module_type(manufacturer, "RawBase-VCARITH", PLAIN_TYPE) + cls.vc_arithmetic_type = plain_module_type(manufacturer, "RawBase-VCARITH", PLAIN_TYPE) InterfaceNameRule.objects.create( module_type=cls.vc_arithmetic_type, name_template="{{base} + 100}.{vc_position}" ) - cls.literal_marker_type = _plain_module_type(manufacturer, "RawBase-LITERAL", PLAIN_TYPE) + cls.literal_marker_type = plain_module_type(manufacturer, "RawBase-LITERAL", PLAIN_TYPE) InterfaceNameRule.objects.create( module_type=cls.literal_marker_type, name_template="{base}-InrRawBaseMark0.{vc_position}" ) - cls.assembled_marker_type = _plain_module_type(manufacturer, "RawBase-ASSEMBLED", PLAIN_TYPE) + cls.assembled_marker_type = plain_module_type(manufacturer, "RawBase-ASSEMBLED", PLAIN_TYPE) InterfaceNameRule.objects.create( module_type=cls.assembled_marker_type, name_template="{base}-InrRawBaseMark{0}.{vc_position}" ) @@ -255,8 +255,8 @@ class RawBaseChannelizedFamilyTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("RawBaseChan", ["3", "7", "8"]) - cls.parent_type = _plain_module_type(manufacturer, "RawBaseChan-PARENT") + manufacturer, cls.device = build_device("RawBaseChan", ["3", "7", "8"]) + cls.parent_type = plain_module_type(manufacturer, "RawBaseChan-PARENT") cls.parent_rule = InterfaceNameRule.objects.create( module_type=cls.parent_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -265,7 +265,7 @@ def setUpTestData(cls): channel_count=4, channel_start=0, ) - cls.channel_type = _plain_module_type(manufacturer, "RawBaseChan-CHANNEL") + cls.channel_type = plain_module_type(manufacturer, "RawBaseChan-CHANNEL") cls.channel_rule = InterfaceNameRule.objects.create( module_type=cls.channel_type, name_template="{base}:{channel}", @@ -274,7 +274,7 @@ def setUpTestData(cls): channel_count=4, channel_start=1, ) - cls.lockstep_type = _channelized_module_type(manufacturer, "RawBaseChan-LOCKSTEP") + cls.lockstep_type = channelized_module_type(manufacturer, "RawBaseChan-LOCKSTEP") cls.lockstep_rule = InterfaceNameRule.objects.create(module_type=cls.lockstep_type, name_template="{base}-l") def _assert_reapplies_rename_nothing(self, rule, module, bay, names): @@ -309,7 +309,7 @@ def test_a_family_whose_parent_no_template_claims_keeps_its_names(self): self.assertIn("'custom'", "\n".join(logs.output)) def test_no_family_is_built_on_an_unclaimed_plain_interface(self): - plain_type = _plain_module_type(ModuleType.objects.get(pk=self.parent_type.pk).manufacturer, "RawBaseChan-BARE") + plain_type = plain_module_type(ModuleType.objects.get(pk=self.parent_type.pk).manufacturer, "RawBaseChan-BARE") module, _ = self._install_on(self.device, plain_type, "3") rename_out_of_band(Interface.objects.get(module=module), "custom") rule = InterfaceNameRule.objects.create( @@ -339,10 +339,10 @@ class RawBaseBaseFreeParentTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "RawBaseFree", ["3"], virtual_chassis=VirtualChassis.objects.create(name="rawbasefree-vc"), vc_position=1 ) - cls.module_type = _channelized_module_type(manufacturer, "RawBaseFree-QSFP") + cls.module_type = channelized_module_type(manufacturer, "RawBaseFree-QSFP") InterfaceTemplate.objects.create(module_type=cls.module_type, name="mgmt{module}", type=PLAIN_TYPE) cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, @@ -374,7 +374,7 @@ def test_a_parent_whose_channels_carry_two_bases_keeps_its_names(self): self.assertIn("'et-1/0/3'", "\n".join(logs.output)) def test_a_renumber_moves_a_built_parent_beside_another_template(self): - plain_type = _plain_module_type(ModuleType.objects.get(pk=self.module_type.pk).manufacturer, "RawBaseFree-SFP") + plain_type = plain_module_type(ModuleType.objects.get(pk=self.module_type.pk).manufacturer, "RawBaseFree-SFP") InterfaceTemplate.objects.create(module_type=plain_type, name="mgmt{module}", type=PLAIN_TYPE) InterfaceNameRule.objects.create( module_type=plain_type, @@ -400,13 +400,13 @@ class RawBaseFlatFamilyPreviewTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "RawBaseFlat", ["3", "4"], virtual_chassis=VirtualChassis.objects.create(name="rawbaseflat-vc"), vc_position=1, ) - cls.module_type = _token_module_type(manufacturer, "RawBaseFlat-QSFP", "xe-{vc_position:0}/0/{module}") + cls.module_type = token_module_type(manufacturer, "RawBaseFlat-QSFP", "xe-{vc_position:0}/0/{module}") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template="brk-{base}:{channel}", @@ -447,11 +447,11 @@ class RawBaseDriftedCreationTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "RawBaseDrift", ["3"], virtual_chassis=VirtualChassis.objects.create(name="rawbasedrift-vc"), vc_position=1 ) - cls.module_type = _token_module_type(manufacturer, "RawBaseDrift-QSFP", "xe-{vc_position:0}/0/{module}") - cls.dotted_type = _channelized_module_type( + cls.module_type = token_module_type(manufacturer, "RawBaseDrift-QSFP", "xe-{vc_position:0}/0/{module}") + cls.dotted_type = channelized_module_type( manufacturer, "RawBaseDrift-DOTTED", channels=2, @@ -485,7 +485,7 @@ def test_a_blank_parent_template_keeps_the_drifted_name(self): self.assertEqual(self._names(module), ["xe-1/0/3", "xe-2/0/3:0", "xe-2/0/3:1"]) def test_a_drifted_flat_family_whose_rule_spells_a_text_sentinel_is_offered_for_conversion(self): - drift_type = _token_module_type( + drift_type = token_module_type( ModuleType.objects.get(pk=self.module_type.pk).manufacturer, "RawBaseFlat-SENT", "xe-{vc_position:0}/0/{module}", @@ -517,8 +517,8 @@ class RawBaseUnusedParentTemplateTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("RawBaseUnused", ["3"]) - cls.module_type = _plain_module_type(manufacturer, "RawBaseUnused-SFP", PLAIN_TYPE) + manufacturer, cls.device = build_device("RawBaseUnused", ["3"]) + cls.module_type = plain_module_type(manufacturer, "RawBaseUnused-SFP", PLAIN_TYPE) def _rule(self, parent_name_template): """Return an unsaved flat rule, as the Build Rule tester previews one.""" diff --git a/netbox_interface_name_rules/tests/test_rename_triggers.py b/netbox_interface_name_rules/tests/test_rename_triggers.py index d7b18211..329265cb 100644 --- a/netbox_interface_name_rules/tests/test_rename_triggers.py +++ b/netbox_interface_name_rules/tests/test_rename_triggers.py @@ -17,7 +17,6 @@ from unittest.mock import patch from dcim.models import Device, Interface, InterfaceTemplate, Module, ModuleBay, VirtualChassis -from django.contrib.contenttypes.models import ContentType from django.core import serializers from django.core.management import call_command from django.db import DEFAULT_DB_ALIAS, DatabaseError, DataError, IntegrityError, connection, transaction @@ -37,6 +36,12 @@ from netbox_interface_name_rules.rename_outcomes import OutcomeKind from netbox_interface_name_rules.tests.committed_callbacks import run_the_reapply from netbox_interface_name_rules.tests.helpers import ( + PARENT_TYPE, + PLAIN_TYPE, + PLUGIN_LOGGER, + REQUIRES_CHANNELIZATION, + WriteInterfacesTo, + channelized_module_type, interface_signal, lock_timeout, make_device, @@ -48,15 +53,15 @@ set_lock_timeout, ) from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band -from netbox_interface_name_rules.tests.test_channelization import ( - PARENT_TYPE, - REQUIRES_CHANNELIZATION, - _channelized_module_type, +from netbox_interface_name_rules.tests.trigger_cases import ( + give_the_next_module_id, + journal, + module_reapplies, + previous_state_read_fails, + reject_interface_updates, + reject_reads_of, ) -from netbox_interface_name_rules.tests.test_transactions import WriteInterfacesTo -PLUGIN_LOGGER = "netbox_interface_name_rules" -PLAIN_TYPE = "10gbase-x-sfpp" VIRTUAL_TYPE = "virtual" MODULE_STATE_READ = re.compile( r'SELECT "dcim_module"\."module_type_id"(?: AS "module_type_id")?, ' @@ -76,33 +81,10 @@ def _counting(entry_point): yield spy -def _module_reapplies(): - return _counting("module_rule_outcomes") - - def _device_reapplies(): return _counting("device_module_rule_outcomes") -def _journal(instance): - """Return the journal entries on *instance*, oldest first.""" - return list( - JournalEntry.objects.filter( - assigned_object_type=ContentType.objects.get_for_model(instance), assigned_object_id=instance.pk - ).order_by("pk") - ) - - -def _give_the_next_module_id(pk): - """Make the database assign *pk* to the next module that NetBox creates.""" - # NetBox before 4.7 creates no components for a module saved with an explicit pk. - with connection.cursor() as cursor: - cursor.execute( - "SELECT setval(pg_get_serial_sequence(%s, %s), %s, false)", - [Module._meta.db_table, Module._meta.pk.column, pk], - ) - - class _RenameTriggerFixture: """One device at virtual-chassis position 1 and three module types, each with its own rule. @@ -216,7 +198,7 @@ def test_a_save_that_changes_no_compared_value_causes_no_reapply(self): module = self._install() with ( - _module_reapplies() as module_reapplies, + module_reapplies() as module_calls, _device_reapplies() as device_reapplies, self.captureOnCommitCallbacks(execute=True), ): @@ -228,10 +210,10 @@ def test_a_save_that_changes_no_compared_value_causes_no_reapply(self): f"{self.prefix} New", self.device.device_type, virtual_chassis=self.virtual_chassis, vc_position=4 ) - self.assertEqual((module_reapplies.call_count, device_reapplies.call_count), (0, 0)) + self.assertEqual((module_calls.call_count, device_reapplies.call_count), (0, 0)) def test_a_module_deleted_before_commit_is_skipped(self): - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True): module = Module.objects.create(device=self.device, module_bay=self._bay(), module_type=self.type_a) module.delete() @@ -317,25 +299,10 @@ def test_the_journal_entry_names_the_request_user_as_its_author(self): self._patch(self.module, {"module_type": self.type_b.pk}) - (entry,) = _journal(self.module) + (entry,) = journal(self.module) self.assertEqual(entry.created_by, self.user) -@contextmanager -def _previous_state_read_fails(statement): - """Replace the previous-state read that *statement* matches by SQL that PostgreSQL rejects.""" - replaced = [] - - def divide_by_zero(execute, sql, params, many, context): - if statement.match(sql): - replaced.append(sql) - return execute("SELECT 1/0", None, many, context) - return execute(sql, params, many, context) - - with connection.execute_wrapper(divide_by_zero): - yield replaced - - class PreviousStateReadFailureTest(RenameTriggerTestCase): """A save whose previous state cannot be read fails with the read error, on both paths.""" @@ -343,7 +310,7 @@ def test_a_module_save_fails_with_the_read_error(self): module = self._install() with ( - _previous_state_read_fails(MODULE_STATE_READ) as replaced, + previous_state_read_fails(MODULE_STATE_READ) as replaced, self.assertRaisesMessage(DataError, "division by zero"), transaction.atomic(), ): @@ -354,7 +321,7 @@ def test_a_module_save_fails_with_the_read_error(self): def test_a_device_save_fails_with_the_read_error(self): with ( - _previous_state_read_fails(DEVICE_STATE_READ) as replaced, + previous_state_read_fails(DEVICE_STATE_READ) as replaced, self.assertRaisesMessage(DataError, "division by zero"), transaction.atomic(), ): @@ -370,7 +337,7 @@ class CoalescedTriggerTest(RenameTriggerTestCase): def test_two_module_type_changes_reapply_once_with_the_final_rule(self): module = self._install() - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): self._change_type(module, self.type_b) self._change_type(module, self.type_c) @@ -378,7 +345,7 @@ def test_two_module_type_changes_reapply_once_with_the_final_rule(self): self.assertEqual(self._names(module), ["ge-1/0/0"]) def test_an_install_and_a_type_change_reapply_once_with_force(self): - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): module = Module.objects.create(device=self.device, module_bay=self._bay(), module_type=self.type_a) self._change_type(module, self.type_b) @@ -389,7 +356,7 @@ def test_a_module_type_changed_and_changed_back_is_compared_with_the_earliest_st module = self._install() rename_out_of_band(Interface.objects.get(module=module), "operator-name") - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): self._change_type(module, self.type_b) self._change_type(module, self.type_a) @@ -403,7 +370,7 @@ def test_an_install_under_a_reused_key_is_not_absorbed_by_a_pending_type_change( self._change_type(module, self.type_b) pk = module.pk module.delete() - _give_the_next_module_id(pk) + give_the_next_module_id(pk) module = Module.objects.create(device=self.device, module_bay=self._bay(), module_type=self.type_a) self.assertEqual(module.pk, pk) @@ -470,12 +437,6 @@ def test_a_rolled_back_savepoint_keeps_the_earlier_reapply(self): self.assertEqual(self._names(module), ["et-2/0/0"]) -def _reject_interface_updates(execute, sql, params, many, context): - if sql.lstrip().startswith('UPDATE "dcim_interface"'): - raise IntegrityError("injected reapply failure") - return execute(sql, params, many, context) - - def _reject_device_updates(execute, sql, params, many, context): if sql.lstrip().startswith('UPDATE "dcim_device"'): raise IntegrityError("injected device save failure") @@ -513,7 +474,7 @@ def test_a_post_save_sent_without_a_model_save_is_not_a_trigger(self): moved = Module.objects.get(pk=module.pk) moved.module_type = self.type_b - with _module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True): + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True): post_save.send(sender=Module, instance=moved, created=False, raw=False, using="default", update_fields=None) self.assertEqual(reapplies.call_count, 0) @@ -550,7 +511,7 @@ def test_a_failed_module_reapply_is_logged(self): with self.captureOnCommitCallbacks() as callbacks: self._change_type(module, self.type_b) - with connection.execute_wrapper(_reject_interface_updates), self.assertLogs(PLUGIN_LOGGER, "ERROR") as logs: + with connection.execute_wrapper(reject_interface_updates), self.assertLogs(PLUGIN_LOGGER, "ERROR") as logs: for callback in callbacks: callback() @@ -567,7 +528,7 @@ def test_a_failed_device_reapply_is_logged_for_modules_and_device_interfaces(sel with self.captureOnCommitCallbacks() as callbacks: self._move_to_position(2) - with connection.execute_wrapper(_reject_interface_updates), self.assertLogs(PLUGIN_LOGGER, "ERROR") as logs: + with connection.execute_wrapper(reject_interface_updates), self.assertLogs(PLUGIN_LOGGER, "ERROR") as logs: for callback in callbacks: callback() @@ -582,7 +543,7 @@ def test_a_failed_module_reload_is_logged(self): self._change_type(module, self.type_b) with ( - connection.execute_wrapper(_reject_reads_of("dcim_module")), + connection.execute_wrapper(reject_reads_of("dcim_module")), self.assertLogs(PLUGIN_LOGGER, "ERROR") as logs, ): run_the_reapply(callbacks) @@ -597,7 +558,7 @@ def test_a_module_type_read_failure_does_not_escape_the_reapply(self): self._change_type(module, self.type_b) with ( - connection.execute_wrapper(_reject_reads_of("dcim_moduletype")), + connection.execute_wrapper(reject_reads_of("dcim_moduletype")), self.assertLogs(PLUGIN_LOGGER, "ERROR") as logs, ): run_the_reapply(callbacks) @@ -613,7 +574,7 @@ def test_a_failed_device_reload_is_logged(self): self._move_to_position(2) with ( - connection.execute_wrapper(_reject_reads_of("dcim_device")), + connection.execute_wrapper(reject_reads_of("dcim_device")), self.assertLogs(PLUGIN_LOGGER, "ERROR") as logs, ): run_the_reapply(callbacks) @@ -623,17 +584,6 @@ def test_a_failed_device_reload_is_logged(self): self.assertEqual(self._names(module), ["et-1/0/0"]) -def _reject_reads_of(table): - """Return an execute wrapper that fails every read of *table*.""" - - def reject(execute, sql, params, many, context): - if sql.lstrip().startswith("SELECT") and (f'FROM "{table}"' in sql or f'JOIN "{table}"' in sql): - raise DatabaseError(f"injected {table} read failure") - return execute(sql, params, many, context) - - return reject - - def _reject_journal_writes(execute, sql, params, many, context): if sql.lstrip().startswith('INSERT INTO "extras_journalentry"'): raise DatabaseError("injected journal write failure") @@ -674,12 +624,12 @@ def test_a_name_collision_writes_one_warning_entry_on_the_module(self): with self.captureOnCommitCallbacks(execute=True): self._change_type(module, self.type_b) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_WARNING) self.assertIn("`et-1/0/0` to `xe-1/0/0`", entry.comments) self.assertIn("already in use", entry.comments) self.assertIsNone(entry.created_by) - self.assertEqual(_journal(self.device), []) + self.assertEqual(journal(self.device), []) self.assertEqual(self._names(module), ["et-1/0/0"]) def test_the_entry_names_only_the_interfaces_left_unrenamed(self): @@ -689,7 +639,7 @@ def test_the_entry_names_only_the_interfaces_left_unrenamed(self): module = self._install(module_type) self.assertEqual(self._names(module), ["a0.1", "b0"]) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertIn("`b0` to `b0.1`", entry.comments) self.assertNotIn("a0", entry.comments) @@ -700,7 +650,7 @@ def test_an_interface_no_template_claims_is_reported(self): with self.captureOnCommitCallbacks(execute=True): self._change_type(module, claiming_type) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_WARNING) self.assertIn("`et-1/0/0`", entry.comments) self.assertIn("no single interface template claims", entry.comments) @@ -731,7 +681,7 @@ def _type_change_to_breakout(self, bay_name, name_template): return module, renamed.name def _assert_reports_each_unclaimed_top_level_interface(self, module, renamed): - (entry,) = _journal(module) + (entry,) = journal(module) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_WARNING) for name in (renamed, "mgmt-extra"): with self.subTest(name=name): @@ -759,7 +709,7 @@ def test_a_template_variable_the_device_lacks_is_reported(self): module_type=self.type_a, ) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_WARNING) self.assertIn("`0`", entry.comments) self.assertIn("{vc_position} is not available", entry.comments) @@ -783,7 +733,7 @@ def test_a_rule_that_cannot_be_evaluated_writes_a_danger_entry(self): module = self._install(module_type) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_DANGER) self.assertIn("`0`", entry.comments) self.assertIn("Invalid arithmetic expression", entry.comments) @@ -795,11 +745,11 @@ def test_a_failed_module_reapply_writes_a_danger_entry(self): with self.captureOnCommitCallbacks() as callbacks: self._change_type(module, self.type_b) - with connection.execute_wrapper(_reject_interface_updates), self.assertLogs(PLUGIN_LOGGER, "ERROR"): + with connection.execute_wrapper(reject_interface_updates), self.assertLogs(PLUGIN_LOGGER, "ERROR"): for callback in callbacks: callback() - (entry,) = _journal(module) + (entry,) = journal(module) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_DANGER) self.assertIn("injected reapply failure", entry.comments) @@ -812,14 +762,14 @@ def test_a_failed_device_reapply_writes_one_danger_entry_on_the_device(self): with self.captureOnCommitCallbacks() as callbacks: self._move_to_position(2) - with connection.execute_wrapper(_reject_interface_updates), self.assertLogs(PLUGIN_LOGGER, "ERROR"): + with connection.execute_wrapper(reject_interface_updates), self.assertLogs(PLUGIN_LOGGER, "ERROR"): for callback in callbacks: callback() - (entry,) = _journal(self.device) + (entry,) = journal(self.device) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_DANGER) self.assertEqual(entry.comments.count("injected reapply failure"), 2) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) def test_leaving_the_virtual_chassis_renames_nothing_and_reports_the_skip(self): module = self._install() @@ -835,14 +785,14 @@ def test_leaving_the_virtual_chassis_renames_nothing_and_reports_the_skip(self): self._leave() - (entry,) = _journal(self.device) + (entry,) = journal(self.device) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_WARNING) self.assertIn("`et-1/0/0`", entry.comments) self.assertIn("`mgmt0`", entry.comments) self.assertIn("{vc_position} is not available", entry.comments) self.assertNotIn("eth0", entry.comments) self.assertNotIn("operator-name", entry.comments) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) self.assertEqual((self._names(module), self._names(by_hand)), (["et-1/0/0"], ["operator-name"])) self.assertEqual( sorted(Interface.objects.filter(device=self.device, module=None).values_list("name", flat=True)), @@ -856,7 +806,7 @@ def test_a_virtual_chassis_member_without_a_position_renames_nothing_and_reports with self.captureOnCommitCallbacks(execute=True): self._move_to_position(None) - (entry,) = _journal(self.device) + (entry,) = journal(self.device) self.assertIn("`et-1/0/0`", entry.comments) self.assertNotIn("operator-name", entry.comments) self.assertEqual((self._names(module), self._names(by_hand)), (["et-1/0/0"], ["operator-name"])) @@ -878,7 +828,7 @@ def test_a_breakout_rule_the_device_cannot_evaluate_reports_the_raw_interface(se module_type=flat_type, ) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertIn("`0`: {vc_position} is not available", entry.comments) self.assertEqual(self._names(module), ["0"]) @@ -901,7 +851,7 @@ def test_a_breakout_rule_the_device_cannot_evaluate_does_not_report_an_interface # No template claims "0:5", so the install scope leaves it out of every plan. Interface.objects.create(device=standalone, module=module, name="0:5", type=PLAIN_TYPE) - (entry,) = _journal(module) + (entry,) = journal(module) self.assertIn("`0`: {vc_position} is not available", entry.comments) self.assertNotIn("0:5", entry.comments) self.assertEqual(self._names(module), ["0", "0:5"]) @@ -943,7 +893,7 @@ def test_leaving_the_virtual_chassis_reports_every_member_of_a_flat_family(self) self._leave() - (entry,) = _journal(self.device) + (entry,) = journal(self.device) self.assertIn("`et-1/0:0`", entry.comments) self.assertIn("`et-1/0:1`", entry.comments) self.assertEqual(self._names(module), ["et-1/0:0", "et-1/0:1"]) @@ -963,14 +913,14 @@ def test_leaving_the_virtual_chassis_does_not_report_a_parent_that_keeps_its_nam self._leave() - (entry,) = _journal(self.device) + (entry,) = journal(self.device) self.assertIn("`xe-1/0/0:0`", entry.comments) self.assertIn("`xe-1/0/0:1`", entry.comments) self.assertNotIn("`0`", entry.comments) def _install_channelized_family(self, model, **rule_fields): """Install a module whose templates form a two-channel family, under a rule with *rule_fields*.""" - module_type = _channelized_module_type(self.type_a.manufacturer, model, channels=2, child_channel_ids=(1, 2)) + module_type = channelized_module_type(self.type_a.manufacturer, model, channels=2, child_channel_ids=(1, 2)) InterfaceNameRule.objects.create(module_type=module_type, **rule_fields) return self._install(module_type) @@ -981,7 +931,7 @@ def test_leaving_the_virtual_chassis_reports_a_parent_a_simple_rule_renames(self self._leave() - (entry,) = _journal(self.device) + (entry,) = journal(self.device) for name in ("et-1/0", "et-1/0:1", "et-1/0:2"): self.assertIn(f"`{name}`", entry.comments) @@ -997,7 +947,7 @@ def test_leaving_the_virtual_chassis_does_not_report_a_parent_a_flat_breakout_ru self._leave() - (entry,) = _journal(self.device) + (entry,) = journal(self.device) self.assertIn("`xe-1/0/0:0`", entry.comments) self.assertNotIn("`0`", entry.comments) @@ -1016,7 +966,7 @@ def test_a_failure_stays_reported_when_a_lower_priority_rule_renames_the_interfa self._move_to_position(2) self.assertTrue(Interface.objects.filter(device=self.device, name="oob-2").exists()) - (entry,) = _journal(self.device) + (entry,) = journal(self.device) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_DANGER) self.assertIn("`mgmt0`", entry.comments) @@ -1040,7 +990,7 @@ def test_an_interface_a_lower_priority_rule_renames_is_not_reported(self): self._move_to_position(2) self.assertTrue(Interface.objects.filter(device=self.device, name="oob-2").exists()) - (entry,) = _journal(self.device) + (entry,) = journal(self.device) self.assertIn("`eth0` to `lan-2`", entry.comments) self.assertNotIn("mgmt0", entry.comments) @@ -1052,11 +1002,11 @@ def test_a_device_reapply_that_fails_on_one_module_keeps_the_earlier_outcomes(se with self.captureOnCommitCallbacks() as callbacks: self._move_to_position(2) - with connection.execute_wrapper(_reject_interface_updates), self.assertLogs(PLUGIN_LOGGER, "ERROR"): + with connection.execute_wrapper(reject_interface_updates), self.assertLogs(PLUGIN_LOGGER, "ERROR"): for callback in callbacks: callback() - (entry,) = _journal(self.device) + (entry,) = journal(self.device) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_DANGER) self.assertIn("`et-1/0/0` to `et-2/0/0`", entry.comments) self.assertIn("injected reapply failure", entry.comments) @@ -1076,11 +1026,11 @@ def test_a_device_reapply_that_fails_on_one_device_interface_family_keeps_the_ea with self.captureOnCommitCallbacks() as callbacks: self._move_to_position(2) - with connection.execute_wrapper(_reject_interface_updates), self.assertLogs(PLUGIN_LOGGER, "ERROR"): + with connection.execute_wrapper(reject_interface_updates), self.assertLogs(PLUGIN_LOGGER, "ERROR"): for callback in callbacks: callback() - (entry,) = _journal(self.device) + (entry,) = journal(self.device) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_DANGER) self.assertIn("`mgmt0` to `mgmt-2`", entry.comments) self.assertIn("injected reapply failure", entry.comments) @@ -1095,11 +1045,11 @@ def test_a_module_reapply_that_fails_on_one_family_keeps_the_earlier_outcomes(se with self.captureOnCommitCallbacks() as callbacks: module = Module.objects.create(device=self.device, module_bay=self._bay(), module_type=module_type) - with connection.execute_wrapper(_reject_interface_updates), self.assertLogs(PLUGIN_LOGGER, "ERROR"): + with connection.execute_wrapper(reject_interface_updates), self.assertLogs(PLUGIN_LOGGER, "ERROR"): for callback in callbacks: callback() - (entry,) = _journal(module) + (entry,) = journal(module) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_DANGER) self.assertIn("`a0` to `a0.1`", entry.comments) self.assertIn("injected reapply failure", entry.comments) @@ -1136,7 +1086,7 @@ def test_a_failed_journal_write_is_logged_and_does_not_raise(self): callback() self.assertEqual([str(record.exc_info[1]) for record in logs.records], ["injected journal write failure"]) - self.assertEqual(_journal(module), []) + self.assertEqual(journal(module), []) self.assertEqual(Module.objects.get(pk=module.pk).module_type, self.type_b) @@ -1200,7 +1150,7 @@ def setUp(self): device_type = make_device_type(manufacturer, "ReconJournal") make_module_bay_templates(device_type, ("Bay 0", "Bay 1")) self.device = make_device("ReconJournal", device_type) - self.module_type = _channelized_module_type(manufacturer, "ReconJournal-QSFP") + self.module_type = channelized_module_type(manufacturer, "ReconJournal-QSFP") InterfaceNameRule.objects.create( module_type=self.module_type, name_template="xe-0/0/{bay_position}:{channel}", @@ -1228,7 +1178,7 @@ def lock_after_the_cascade(sender, instance, **kwargs): with interface_signal(post_save, lock_after_the_cascade), transaction.atomic(): module = Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type) - [entry] = _journal(module) + [entry] = journal(module) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_DANGER) self.assertIn("Rename each channel back: `et-0/0/1:2` to `1:2`", entry.comments) self.assertEqual( diff --git a/netbox_interface_name_rules/tests/test_structural_families.py b/netbox_interface_name_rules/tests/test_structural_families.py index 292973c1..1649b06b 100644 --- a/netbox_interface_name_rules/tests/test_structural_families.py +++ b/netbox_interface_name_rules/tests/test_structural_families.py @@ -29,20 +29,20 @@ ) from netbox_interface_name_rules.family import names as family_names from netbox_interface_name_rules.models import InterfaceNameRule -from netbox_interface_name_rules.tests.helpers import make_device -from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band -from netbox_interface_name_rules.tests.test_breakout_mode import CHANNELIZED, _plain_module_type -from netbox_interface_name_rules.tests.test_channelization import ( +from netbox_interface_name_rules.tests.helpers import ( + CHANNELIZED, PLAIN_TYPE, PLUGIN_LOGGER, REQUIRES_CHANNELIZATION, + REQUIRES_NO_CHANNELIZATION, ChannelizationTestCase, - _build_device, + build_device, + make_device, + plain_module_type, ) +from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band from netbox_interface_name_rules.transactions import write_scope -REQUIRES_NO_CHANNELIZATION = "requires a NetBox that cannot model channelized interfaces (4.6 and older)" - class StructuralFamilyTestCase(ChannelizationTestCase): """One device, one plain module type, and the channelized rule under test.""" @@ -52,8 +52,8 @@ class StructuralFamilyTestCase(ChannelizationTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device(cls.PREFIX, ["3", "4"]) - cls.module_type = _plain_module_type(manufacturer, f"{cls.PREFIX}-QSFP") + manufacturer, cls.device = build_device(cls.PREFIX, ["3", "4"]) + cls.module_type = plain_module_type(manufacturer, f"{cls.PREFIX}-QSFP") cls.rule = InterfaceNameRule.objects.create( module_type=cls.module_type, name_template=cls.NAME_TEMPLATE, diff --git a/netbox_interface_name_rules/tests/test_transactions.py b/netbox_interface_name_rules/tests/test_transactions.py index 41dde503..37113aff 100644 --- a/netbox_interface_name_rules/tests/test_transactions.py +++ b/netbox_interface_name_rules/tests/test_transactions.py @@ -3,8 +3,7 @@ """An atomic block keeps the NetBox events it queued only when it commits, coalesced as NetBox does.""" import contextvars -import uuid -from contextlib import contextmanager, nullcontext +from contextlib import nullcontext from itertools import product from unittest import skipUnless @@ -12,16 +11,16 @@ from dcim.models import Interface, Site from django.contrib.auth import get_user_model from django.db import IntegrityError, connection, transaction -from django.http import HttpRequest from django.test import SimpleTestCase, TestCase, TransactionTestCase, override_settings from django.test.utils import CaptureQueriesContext from extras import events as netbox_events from extras.events import serialize_for_event from extras.models import Tag -from netbox.context import current_request, events_queue +from netbox.context import events_queue from netbox_interface_name_rules.jobs import run_as_job_user from netbox_interface_name_rules.tests.helpers import ( + WriteInterfacesTo, empty_the_webhook_queue, make_device, make_device_type, @@ -30,6 +29,7 @@ make_manufacturer, queued_webhook_jobs, queued_webhooks, + request_context, ) from netbox_interface_name_rules.transactions import atomic_with_events, on_commit, write_scope @@ -248,21 +248,6 @@ def generated_cases(): yield event_before, levels -@contextmanager -def request_context(user): - """Set the request and the event queue as NetBox's event_tracking does, without its flush.""" - request = HttpRequest() - request.user = user - request.id = uuid.uuid4() - request_token = current_request.set(request) - queue_token = events_queue.set({}) - try: - yield - finally: - events_queue.reset(queue_token) - current_request.reset(request_token) - - def _event_key(pk): return f"dcim.interface:{pk}" @@ -469,16 +454,6 @@ def test_the_generator_yields_every_valid_combination(self): self.assertEqual(len(list(generated_cases())), 708) -class WriteInterfacesTo: - """A database router that sends each write of an interface to one alias.""" - - def __init__(self, alias): - self.alias = alias - - def db_for_write(self, model, **hints): - return self.alias if model is Interface else None - - class WriteScopeOnMainTest(TestCase): """On main a write scope holds ``default`` alone, and the scope and its blocks add no query.""" diff --git a/netbox_interface_name_rules/tests/test_type_change_trigger.py b/netbox_interface_name_rules/tests/test_type_change_trigger.py index 15bb7938..a5e6acc9 100644 --- a/netbox_interface_name_rules/tests/test_type_change_trigger.py +++ b/netbox_interface_name_rules/tests/test_type_change_trigger.py @@ -23,24 +23,24 @@ from netbox_interface_name_rules.family import supports_module_moves from netbox_interface_name_rules.models import InterfaceNameRule from netbox_interface_name_rules.tests.committed_callbacks import run_the_reapply -from netbox_interface_name_rules.tests.test_bay_edit_trigger import BayEditTestCase, _flat_rule -from netbox_interface_name_rules.tests.test_module_move_trigger import ( +from netbox_interface_name_rules.tests.helpers import PLAIN_TYPE, REQUIRES_VC_POSITION_TOKEN +from netbox_interface_name_rules.tests.trigger_cases import ( FLAT, NAMING_READ, NO_RULE, - PLAIN_TYPE, REQUIRES_SUBTREE_MOVES, TAKEN, UNAVAILABLE, - _journal, - _MoveFixture, - _reapplied, + BayEditTestCase, + MoveFixture, + flat_rule, + journal, + reapplied, + reject_reads_of, ) -from netbox_interface_name_rules.tests.test_rename_triggers import _reject_reads_of -from netbox_interface_name_rules.tests.test_vc_drift import REQUIRES_VC_POSITION_TOKEN -class _CardFixture(_MoveFixture): +class _CardFixture(MoveFixture): """Two card types with two ports each, and an optic whose rule each card type scopes as its parent.""" @classmethod @@ -102,7 +102,7 @@ def test_a_nested_module_whose_rule_changes_is_renamed_from_the_names_the_old_ru self._change_type(card, self.second_card_type) self.assertEqual(self._names(optic), ["b-0/1"]) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) def test_a_nested_module_whose_rule_does_not_change_keeps_its_names_and_is_not_reported(self): card, second_port, optic = self._card_with_optic() @@ -113,7 +113,7 @@ def test_a_nested_module_whose_rule_does_not_change_keeps_its_names_and_is_not_r self._change_type(card, self.second_card_type) self.assertEqual((self._names(optic), self._names(fixed)), (["b-0/1"], ["et-1/0/2", "operator-name"])) - self.assertEqual((_journal(card), _journal(optic), _journal(fixed)), ([], [], [])) + self.assertEqual((journal(card), journal(optic), journal(fixed)), ([], [], [])) def test_a_module_two_levels_down_keeps_its_names_and_is_not_reported(self): card, second_port, optic = self._card_with_optic() @@ -129,7 +129,7 @@ def test_a_module_two_levels_down_keeps_its_names_and_is_not_reported(self): self._change_type(card, self.second_card_type) self.assertEqual((self._names(optic), self._names(deep)), (["b-0/1"], ["g-3", "operator-name"])) - self.assertEqual((_journal(card), _journal(sub_card), _journal(deep)), ([], [], [])) + self.assertEqual((journal(card), journal(sub_card), journal(deep)), ([], [], [])) def test_a_nested_module_left_without_a_rule_keeps_the_names_the_old_rule_gave_and_is_reported(self): card, _second_port, optic = self._card_with_optic() @@ -137,14 +137,14 @@ def test_a_nested_module_left_without_a_rule_keeps_the_names_the_old_rule_gave_a self._change_type(card, self._two_port_card("Bare Card")) self.assertEqual(self._names(optic), ["a-0/1"]) - (entry,) = _journal(card) + (entry,) = journal(card) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_WARNING) self.assertIn(f"`a-0/1`: {NO_RULE}", entry.comments) - self.assertEqual(_journal(optic), []) + self.assertEqual(journal(optic), []) def test_a_nested_flat_breakout_family_keeps_its_names_and_is_reported(self): flat_optic_type = self._module_type("Flat Optic", "{module}") - _flat_rule(flat_optic_type, "x-{slot}/{bay_position}:{channel}", parent_module_type=self.first_card_type) + flat_rule(flat_optic_type, "x-{slot}/{bay_position}:{channel}", parent_module_type=self.first_card_type) InterfaceNameRule.objects.create( module_type=flat_optic_type, parent_module_type=self.second_card_type, @@ -157,11 +157,11 @@ def test_a_nested_flat_breakout_family_keeps_its_names_and_is_reported(self): self._change_type(card, self.second_card_type) self.assertEqual(self._names(optic), ["x-0/1:0", "x-0/1:1"]) - (entry,) = _journal(card) + (entry,) = journal(card) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_WARNING) for name in ("x-0/1:0", "x-0/1:1"): self.assertIn(f"`{name}`: {FLAT}", entry.comments) - self.assertEqual(_journal(optic), []) + self.assertEqual(journal(optic), []) def test_the_subtree_reports_in_one_journal_entry_on_the_module_whose_type_changed(self): card, second_port, optic = self._card_with_optic() @@ -172,11 +172,11 @@ def test_the_subtree_reports_in_one_journal_entry_on_the_module_whose_type_chang self._change_type(card, self.second_card_type) self.assertEqual((self._names(optic), self._names(other_optic)), (["a-0/1"], ["a-0/2"])) - (entry,) = _journal(card) + (entry,) = journal(card) self.assertEqual(entry.kind, JournalEntryKindChoices.KIND_WARNING) self.assertIn(f"`a-0/1` to `b-0/1`: {TAKEN}", entry.comments) self.assertIn(f"`a-0/2` to `b-0/2`: {TAKEN}", entry.comments) - self.assertEqual((_journal(optic), _journal(other_optic)), ([], [])) + self.assertEqual((journal(optic), journal(other_optic)), ([], [])) class TypeChangeTransactionTest(TypeChangeTestCase): @@ -191,7 +191,7 @@ def test_a_type_change_then_a_bay_edit_rename_the_nested_module_from_the_names_b self._save_edit(bay, position="2") self.assertEqual(self._names(optic), ["b-2/1"]) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) @skipUnless(supports_module_moves(), REQUIRES_SUBTREE_MOVES) def test_a_type_change_then_a_move_rename_the_nested_module_from_the_names_before_the_type_change(self): @@ -202,7 +202,7 @@ def test_a_type_change_then_a_move_rename_the_nested_module_from_the_names_befor self._save_move(card, self._bay(self.device, "Bay 2")) self.assertEqual(self._names(optic), ["b-2/1"]) - self.assertEqual((_journal(card), _journal(optic)), ([], [])) + self.assertEqual((journal(card), journal(optic)), ([], [])) class ChassisPositionTypeChangeTest(TypeChangeTestCase): @@ -245,8 +245,8 @@ def _assert_the_nested_module_is_renamed_once(self, chassis_first): reapplies = self._retype_with_the_chassis_change(card, self.second_card_type, chassis_first) self.assertEqual((self._names(optic), self._names(other)), (["y30/1"], ["et-3/0/10"])) - self.assertEqual(_reapplied(reapplies), sorted((card.pk, optic.pk, other.pk))) - self.assertEqual((_journal(card), _journal(optic), _journal(self.device)), ([], [], [])) + self.assertEqual(reapplied(reapplies), sorted((card.pk, optic.pk, other.pk))) + self.assertEqual((journal(card), journal(optic), journal(self.device)), ([], [], [])) def test_a_type_change_then_a_chassis_position_change_rename_the_nested_module_once(self): self._assert_the_nested_module_is_renamed_once(chassis_first=False) @@ -262,8 +262,8 @@ def _assert_an_unchanged_nested_rule_is_left_to_the_chassis_position_change(self reapplies = self._retype_with_the_chassis_change(card, self.second_card_type, chassis_first) self.assertEqual((self._names(optic), self._names(fixed)), (["b-0/1"], ["et-3/0/2"])) - self.assertEqual(_reapplied(reapplies), sorted((card.pk, optic.pk, fixed.pk))) - self.assertEqual((_journal(card), _journal(fixed), _journal(self.device)), ([], [], [])) + self.assertEqual(reapplied(reapplies), sorted((card.pk, optic.pk, fixed.pk))) + self.assertEqual((journal(card), journal(fixed), journal(self.device)), ([], [], [])) def test_a_type_change_then_a_chassis_position_change_rename_a_nested_module_whose_rule_does_not_change(self): self._assert_an_unchanged_nested_rule_is_left_to_the_chassis_position_change(chassis_first=False) @@ -280,9 +280,9 @@ def _assert_leaving_with_a_type_change_renames_nothing_and_reports_once(self, le ) self.assertEqual((self._names(module), self._names(other)), (["et-1/0/0"], ["et-1/0/10"])) - self.assertEqual(_reapplied(reapplies), sorted((module.pk, other.pk))) - (module_entry,) = _journal(module) - (device_entry,) = _journal(self.device) + self.assertEqual(reapplied(reapplies), sorted((module.pk, other.pk))) + (module_entry,) = journal(module) + (device_entry,) = journal(self.device) self.assertEqual(module_entry.comments.count(f"`et-1/0/0`: {UNAVAILABLE}"), 1) self.assertIn(f"`et-1/0/10`: {UNAVAILABLE}", device_entry.comments) self.assertNotIn("`et-1/0/0`", device_entry.comments) @@ -299,8 +299,8 @@ def _assert_a_raw_name_with_adjacent_tokens_is_renamed_by_the_forced_reapply(sel reapplies = self._retype_with_the_chassis_change(module, self.adjacent_type, chassis_first) - self.assertEqual((self._names(module), _reapplied(reapplies)), (["ge-3/0"], [module.pk])) - self.assertEqual((_journal(module), _journal(self.device)), ([], [])) + self.assertEqual((self._names(module), reapplied(reapplies)), (["ge-3/0"], [module.pk])) + self.assertEqual((journal(module), journal(self.device)), ([], [])) @skipUnless(supports_vc_position_token(), REQUIRES_VC_POSITION_TOKEN) def test_a_type_change_then_a_chassis_position_change_rename_a_raw_name_with_adjacent_tokens(self): @@ -316,10 +316,10 @@ def _assert_a_collision_is_reported_once(self, chassis_first): reapplies = self._retype_with_the_chassis_change(module, self.other_plain_type, chassis_first) - self.assertEqual((self._names(module), _reapplied(reapplies)), (["et-1/0/0"], [module.pk])) - (entry,) = _journal(module) + self.assertEqual((self._names(module), reapplied(reapplies)), (["et-1/0/0"], [module.pk])) + (entry,) = journal(module) self.assertEqual(entry.comments.count(f"`et-1/0/0` to `ge-3/0/0`: {TAKEN}"), 1) - self.assertEqual(_journal(self.device), []) + self.assertEqual(journal(self.device), []) def test_a_type_change_then_a_chassis_position_change_report_a_collision_once(self): self._assert_a_collision_is_reported_once(chassis_first=False) @@ -340,18 +340,18 @@ def test_a_rule_read_failure_is_reported_for_each_module_and_the_later_modules_s failure = f"injected {InterfaceNameRule._meta.db_table} read failure" with ( - connection.execute_wrapper(_reject_reads_of(InterfaceNameRule._meta.db_table)), + connection.execute_wrapper(reject_reads_of(InterfaceNameRule._meta.db_table)), self.assertLogs("netbox_interface_name_rules", "ERROR"), ): run_the_reapply(callbacks) - (card_entry,) = _journal(card) + (card_entry,) = journal(card) self.assertEqual(card_entry.kind, JournalEntryKindChoices.KIND_DANGER) self.assertEqual(card_entry.comments.count(failure), 2) - (other_entry,) = _journal(other) + (other_entry,) = journal(other) self.assertEqual(other_entry.kind, JournalEntryKindChoices.KIND_DANGER) self.assertEqual(other_entry.comments.count(failure), 1) - self.assertEqual((self._names(optic), self._names(other), _journal(optic)), (["a-0/1"], ["et-1/0/1"], [])) + self.assertEqual((self._names(optic), self._names(other), journal(optic)), (["a-0/1"], ["et-1/0/1"], [])) class TypeChangeCostTest(TypeChangeTestCase): @@ -378,7 +378,7 @@ def test_a_type_change_reads_no_nested_naming_without_an_enabled_rule_scoped_to_ self.assertEqual(reads, (0, 1)) self.assertEqual(self._names(optic), ["u-0/1"]) - self.assertEqual(_journal(card), []) + self.assertEqual(journal(card), []) def test_a_type_change_reads_the_nested_naming_when_an_enabled_rule_is_scoped_to_the_new_type(self): card, _second_port, optic = self._card_with_optic() @@ -418,4 +418,4 @@ def test_patching_the_module_type_renames_the_nested_module(self): self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) self.assertEqual(self._names(optic), ["b-0/1"]) - self.assertEqual(_journal(card), []) + self.assertEqual(journal(card), []) diff --git a/netbox_interface_name_rules/tests/test_vc_drift.py b/netbox_interface_name_rules/tests/test_vc_drift.py index 0b3f5d68..8c72f14b 100644 --- a/netbox_interface_name_rules/tests/test_vc_drift.py +++ b/netbox_interface_name_rules/tests/test_vc_drift.py @@ -48,7 +48,6 @@ from extras.models import JournalEntry from netbox_interface_name_rules import engine -from netbox_interface_name_rules.choices import BreakoutModeChoices from netbox_interface_name_rules.engine import ( apply_interface_name_rules, apply_rule_to_existing, @@ -63,33 +62,23 @@ from netbox_interface_name_rules.models import InterfaceNameRule from netbox_interface_name_rules.naming import build_variables from netbox_interface_name_rules.rename_triggers import ModuleTrigger, reapply -from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band -from netbox_interface_name_rules.tests.test_channelization import ( +from netbox_interface_name_rules.tests.helpers import ( CHANNEL_TYPE, + CHANNELIZED, + FLAT, PARENT_TYPE, PLAIN_TYPE, PLUGIN_LOGGER, REQUIRES_CHANNELIZATION, - ChannelizationTestCase, - _build_device, + REQUIRES_VC_POSITION_TOKEN, + VcDriftTestCase, + build_device, + token_module_type, ) +from netbox_interface_name_rules.tests.out_of_band import rename_out_of_band -FLAT = BreakoutModeChoices.FLAT -CHANNELIZED = BreakoutModeChoices.CHANNELIZED CLAIM_LOGGER = "netbox_interface_name_rules.family.raw_bases" -# Every fixture spelling a name NetBox resolved from the token needs the release that resolves it: -# on 4.5 and older the token stays literal in the interface name and the drift cannot even occur. -REQUIRES_VC_POSITION_TOKEN = "requires a NetBox that resolves {vc_position} in template names (4.6+)" # noqa: S105 - Skip reason, not a credential. - - -def _token_module_type(manufacturer, model, *template_names, iface_type=PLAIN_TYPE): - """Create a ModuleType whose interface templates are named *template_names*, in order.""" - module_type = ModuleType.objects.create(manufacturer=manufacturer, model=model, part_number=model) - for name in template_names: - InterfaceTemplate.objects.create(module_type=module_type, name=name, type=iface_type) - return module_type - def _raw_name_patterns(module): """Return the structural raw-name matchers of *module*'s templates (see the module docstring).""" @@ -120,36 +109,6 @@ def _without_vc_position_re(): return mock.patch.dict(sys.modules, {"dcim.constants": _ConstantsWithoutVcToken(dcim.constants)}) -class VcDriftTestCase(ChannelizationTestCase): - """VC transitions go through a real ``Device.save()`` so the plugin's signals do the scheduling.""" - - def _save_vc_state(self, device, virtual_chassis, position): - with self.captureOnCommitCallbacks(execute=True): - device.virtual_chassis = virtual_chassis - device.vc_position = position - device.save() - - def _join(self, vc, position, device=None): - """Add *device* to *vc* at *position* — the join direction (fallback → position).""" - self._save_vc_state(device or self.device, vc, position) - - def _renumber(self, position, device=None): - """Move *device* to another position inside its VC — the renumber direction (P → Q).""" - device = device or self.device - self._save_vc_state(device, device.virtual_chassis, position) - - def _leave(self, device=None): - """Remove *device* from its VC — the leave direction (position → fallback).""" - self._save_vc_state(device or self.device, None, None) - - def _install_on(self, device, module_type, position): - """Install a module of *module_type* into *device*'s bay at *position*, rules and all.""" - bay = ModuleBay.objects.get(device=device, name=f"Bay {position}") - with self.captureOnCommitCallbacks(execute=True): - module = Module.objects.create(device=device, module_bay=bay, module_type=module_type) - return module, bay - - # --------------------------------------------------------------------------- # Join: the device was standalone when NetBox named the interfaces # --------------------------------------------------------------------------- @@ -161,8 +120,8 @@ class VcPositionJoinDriftTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("VcJoin", ["3", "4"]) - cls.module_type = _token_module_type(manufacturer, "VcJoin-QSFP", "xe-{vc_position:0}/0/{module}") + manufacturer, cls.device = build_device("VcJoin", ["3", "4"]) + cls.module_type = token_module_type(manufacturer, "VcJoin-QSFP", "xe-{vc_position:0}/0/{module}") def test_joining_a_vc_renames_a_flat_channel_family_named_with_the_fallback(self): """The worst case from the issue: under force, a breakout rule matches its family by raw name.""" @@ -206,11 +165,11 @@ class VcPositionRenumberDriftTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "VcRenum", ["3", "5"], virtual_chassis=VirtualChassis.objects.create(name="vcrenum-vc"), vc_position=1 ) - cls.module_type = _token_module_type(manufacturer, "VcRenum-QSFP", "xe-{vc_position:0}/0/{module}") - cls.simple_type = _token_module_type(manufacturer, "VcRenum-SFP", "xe-{vc_position:0}/0/{module}") + cls.module_type = token_module_type(manufacturer, "VcRenum-QSFP", "xe-{vc_position:0}/0/{module}") + cls.simple_type = token_module_type(manufacturer, "VcRenum-SFP", "xe-{vc_position:0}/0/{module}") def test_renumbering_renames_a_family_named_at_an_earlier_position(self): """The token sits in a middle path segment, so the drifted name is structural, not a suffix.""" @@ -248,10 +207,10 @@ class VcPositionForceBaseMatchingTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "VcForm", ["5", "7"], virtual_chassis=VirtualChassis.objects.create(name="vcform-vc"), vc_position=1 ) - cls.module_type = _token_module_type(manufacturer, "VcForm-QSFP", "{vc_position}-{module}") + cls.module_type = token_module_type(manufacturer, "VcForm-QSFP", "{vc_position}-{module}") def _breakout_rule(self, name_template): return InterfaceNameRule.objects.create( @@ -289,10 +248,10 @@ class VcPositionLeaveDriftTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "VcLeave", ["3", "4", "5"], virtual_chassis=VirtualChassis.objects.create(name="vcleave-vc"), vc_position=2 ) - cls.module_type = _token_module_type(manufacturer, "VcLeave-SFP", "xe-{vc_position:0}/0/{module}") + cls.module_type = token_module_type(manufacturer, "VcLeave-SFP", "xe-{vc_position:0}/0/{module}") def test_leaving_a_vc_renames_nothing(self): """Deliberate: what the interfaces are called off a VC is an operator decision, even for a rule @@ -334,16 +293,14 @@ class VcPositionAmbiguityTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "VcAmb", ["3", "4"], virtual_chassis=VirtualChassis.objects.create(name="vcamb-vc"), vc_position=1 ) # One token template plus a plain one whose interface an earlier rename moved onto the # token template's fallback variant. - cls.decoy_type = _token_module_type( - manufacturer, "VcAmb-QSFP", "xe-{vc_position:0}/0/{module}", "mgmt-{module}" - ) + cls.decoy_type = token_module_type(manufacturer, "VcAmb-QSFP", "xe-{vc_position:0}/0/{module}", "mgmt-{module}") # Two token templates whose matchers overlap on 'xe-1/0/4'. - cls.overlap_type = _token_module_type( + cls.overlap_type = token_module_type( manufacturer, "VcAmb-SFP", "xe-{vc_position}/0/{module}", "xe-1/{vc_position}/{module}" ) @@ -506,10 +463,10 @@ class VcPositionOneClaimPassTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "VcPass", ["0"], virtual_chassis=VirtualChassis.objects.create(name="vcpass-vc"), vc_position=1 ) - cls.module_type = _token_module_type(manufacturer, "VcPass-QSFP", "{vc_position}/{module}") + cls.module_type = token_module_type(manufacturer, "VcPass-QSFP", "{vc_position}/{module}") def _flat_rule(self): return InterfaceNameRule.objects.create( @@ -602,11 +559,11 @@ class VcPositionAdjacentTokenTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "VcAdj", ["3", "4"], virtual_chassis=VirtualChassis.objects.create(name="vcadj-vc"), vc_position=1 ) - cls.adjacent_type = _token_module_type(manufacturer, "VcAdj-QSFP", "xe-{vc_position}{vc_position}/0/{module}") - cls.separated_type = _token_module_type(manufacturer, "VcAdj-SFP", "xe-{vc_position}/{vc_position}/{module}") + cls.adjacent_type = token_module_type(manufacturer, "VcAdj-QSFP", "xe-{vc_position}{vc_position}/0/{module}") + cls.separated_type = token_module_type(manufacturer, "VcAdj-SFP", "xe-{vc_position}/{vc_position}/{module}") @skipUnless(supports_vc_position_token(), REQUIRES_VC_POSITION_TOKEN) def test_adjacent_tokens_build_no_matcher_at_all(self): @@ -635,10 +592,10 @@ class VcPositionResolutionTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "VcRes", ["3"], virtual_chassis=VirtualChassis.objects.create(name="vcres-vc"), vc_position=1 ) - cls.module_type = _token_module_type( + cls.module_type = token_module_type( manufacturer, "VcRes-QSFP", "xe-{vc_position}/{module}", @@ -698,8 +655,8 @@ class VcPositionNoTokenControlTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device("VcNoTok", ["3", "4"]) - cls.module_type = _token_module_type(manufacturer, "VcNoTok-QSFP", "{module}") + manufacturer, cls.device = build_device("VcNoTok", ["3", "4"]) + cls.module_type = token_module_type(manufacturer, "VcNoTok-QSFP", "{module}") @skipUnless(supports_vc_position_token(), REQUIRES_VC_POSITION_TOKEN) def test_a_tokenless_module_type_builds_no_matchers(self): @@ -735,10 +692,10 @@ class VcPositionLegacyNetboxTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "VcLegacy", ["3"], virtual_chassis=VirtualChassis.objects.create(name="vclegacy-vc"), vc_position=1 ) - cls.module_type = _token_module_type(manufacturer, "VcLegacy-SFP", "xe-{vc_position:0}/0/{module}") + cls.module_type = token_module_type(manufacturer, "VcLegacy-SFP", "xe-{vc_position:0}/0/{module}") def test_the_feature_check_is_false_without_the_constant(self): """Probed from ``dcim.constants``, lazily — an upstream removal must flip the check, not crash.""" @@ -777,14 +734,14 @@ class VcPositionNestedBayTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "VcNest", ["2"], virtual_chassis=VirtualChassis.objects.create(name="vcnest-vc"), vc_position=1 ) cls.chassis_type = ModuleType.objects.create( manufacturer=manufacturer, model="VcNest-Chassis", part_number="VcNest-Chassis" ) ModuleBayTemplate.objects.create(module_type=cls.chassis_type, name="LC Bay", position="1") - cls.leaf_type = _token_module_type(manufacturer, "VcNest-LEAF", "xe-{vc_position:0}/{module}") + cls.leaf_type = token_module_type(manufacturer, "VcNest-LEAF", "xe-{vc_position:0}/{module}") def _install_leaf(self): """Install the chassis in the device bay and a leaf module in the chassis' own bay.""" @@ -833,10 +790,10 @@ class VcPositionPredictionTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "VcPred", ["3"], virtual_chassis=VirtualChassis.objects.create(name="vcpred-vc"), vc_position=2 ) - cls.module_type = _token_module_type(manufacturer, "VcPred-SFP", "xe-{vc_position:0}/0/{module}") + cls.module_type = token_module_type(manufacturer, "VcPred-SFP", "xe-{vc_position:0}/0/{module}") def test_names_resolved_at_call_time_still_predict_correctly(self): """The documented precondition: the caller resolves the names, so same-instant input is exact.""" @@ -865,7 +822,7 @@ class VcPositionAsymmetricFamilyTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "VcAsym", ["3"], virtual_chassis=VirtualChassis.objects.create(name="vcasym-vc"), vc_position=1 ) cls.module_type = ModuleType.objects.create( @@ -908,18 +865,18 @@ class VcPositionConversionRecoveryTest(VcDriftTestCase): @classmethod def setUpTestData(cls): - manufacturer, cls.device = _build_device( + manufacturer, cls.device = build_device( "VcConv", ["3", "4", "5", "6", "7"], virtual_chassis=VirtualChassis.objects.create(name="vcconv-vc"), vc_position=1, ) cls.manufacturer = manufacturer - cls.wrap_type = _token_module_type(manufacturer, "VcConv-WRAP", "xe-{vc_position:0}/0/{module}") - cls.twice_type = _token_module_type(manufacturer, "VcConv-TWICE", "xe-{vc_position:0}/0/{module}") - cls.arith_type = _token_module_type(manufacturer, "VcConv-ARITH", "{vc_position}{module}") - cls.free_type = _token_module_type(manufacturer, "VcConv-FREE", "xe-{vc_position:0}/0/{module}") - cls.two_base_type = _token_module_type( + cls.wrap_type = token_module_type(manufacturer, "VcConv-WRAP", "xe-{vc_position:0}/0/{module}") + cls.twice_type = token_module_type(manufacturer, "VcConv-TWICE", "xe-{vc_position:0}/0/{module}") + cls.arith_type = token_module_type(manufacturer, "VcConv-ARITH", "{vc_position}{module}") + cls.free_type = token_module_type(manufacturer, "VcConv-FREE", "xe-{vc_position:0}/0/{module}") + cls.two_base_type = token_module_type( manufacturer, "VcConv-TWOBASE", "xe-{vc_position:0}/0/{module}", "xe-{vc_position:9}/0/{module}" ) @@ -1012,7 +969,7 @@ def test_a_family_matcher_that_captures_two_bases_is_not_offered(self): def test_an_unrelated_family_survives_overlapping_historical_claims(self): """A multi-base claim rejects its shared base but leaves an unrelated family available.""" - module_type = _token_module_type( + module_type = token_module_type( self.manufacturer, "VcConv-OVERLAP", "xe-{vc_position}/0/{module}", @@ -1044,7 +1001,7 @@ def test_an_unrelated_family_survives_overlapping_historical_claims(self): def test_a_family_a_current_and_a_historical_base_both_spell_is_not_offered(self): """The rule gives the family to one template now and to the other at position 1: neither converts it.""" - module_type = _token_module_type( + module_type = token_module_type( self.manufacturer, "VcConv-CURRENT-FIRST", "xe-{vc_position:0}/0/{module}", @@ -1081,7 +1038,7 @@ def test_a_rule_without_a_base_is_identified_after_a_renumber(self): def test_a_family_a_current_and_a_historical_base_both_spell_is_not_planned(self): """One claim over every form: a name the rule gives two templates is neither template's.""" - module_type = _token_module_type( + module_type = token_module_type( self.manufacturer, "VcConv-CURRENT-OVERLAP", "xe-{vc_position:0}/0/{module}", diff --git a/netbox_interface_name_rules/tests/test_views.py b/netbox_interface_name_rules/tests/test_views.py index 9cf6af14..1547141d 100644 --- a/netbox_interface_name_rules/tests/test_views.py +++ b/netbox_interface_name_rules/tests/test_views.py @@ -28,12 +28,11 @@ rule_tester_variable_rows, variable_reference_rows, ) -from netbox_interface_name_rules.tests.helpers import make_device, queued_job +from netbox_interface_name_rules.tests.helpers import TEST_PASSWORD, make_device, queued_job from netbox_interface_name_rules.views import RuleTestView User = get_user_model() -TEST_PASSWORD = "testpass123" # noqa: S105 - Test credential only. # The preview variables and override fields the conftest guard is expected to know about. _VAR_FIELDS = frozenset({"slot", "bay_position", "parent_bay_position", "base", "vc_position"}) diff --git a/netbox_interface_name_rules/tests/trigger_cases.py b/netbox_interface_name_rules/tests/trigger_cases.py new file mode 100644 index 00000000..9f3af9fc --- /dev/null +++ b/netbox_interface_name_rules/tests/trigger_cases.py @@ -0,0 +1,292 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Fixtures and probes that several rename-trigger test modules share. + +``MoveFixture`` builds two device types with the same bays and three devices in two virtual chassis. +``ModuleMoveTestCase`` and ``BayEditTestCase`` install, move and edit through real saves. The probes +count the reapplies, read the journal and inject database failures. +""" + +import re +from contextlib import contextmanager +from typing import NamedTuple +from unittest.mock import patch + +from dcim.models import Interface, InterfaceTemplate, Module, ModuleBay, ModuleBayTemplate, Platform, VirtualChassis +from django.contrib.contenttypes.models import ContentType +from django.db import DatabaseError, IntegrityError, connection, transaction +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from extras.models import JournalEntry + +from netbox_interface_name_rules import engine +from netbox_interface_name_rules.choices import BreakoutModeChoices +from netbox_interface_name_rules.models import InterfaceNameRule +from netbox_interface_name_rules.tests.helpers import ( + PLAIN_TYPE, + make_device, + make_device_type, + make_manufacturer, + make_module_type, + make_placement, + slug_for, +) + +BAYS = (("Bay 0", "0"), ("Bay 1", "1"), ("Bay 2", "2"), ("Bay 10", "10")) +REQUIRES_SUBTREE_MOVES = "requires a NetBox that moves a module's nested bays with it (4.7+)" +FLAT = "a flat breakout family is not renamed after a move, a bay edit or a parent module type change" +NO_RULE = "no rule matches the module after the change" +TAKEN = "target name is already in use" +UNAVAILABLE = "{vc_position} is not available on this device" +NAMING_READ = re.compile(r'SELECT .* FROM "dcim_module" .*"dcim_platform"') + + +class ChassisRule(NamedTuple): + """One rule shape that the tests of a chassis change with a module change cover.""" + + model: str + name_template: str + + +# A plain rule, a rule that reads {base}, and a rule with the virtual-chassis position in arithmetic. +CHASSIS_RULES = ( + ChassisRule("Plain", "et-{vc_position}/{slot}/{bay_position}"), + ChassisRule("Base", "p{base}-{vc_position}/{slot}"), + ChassisRule("Arithmetic", "x{{vc_position} * 10 + {slot_num}}/{bay_position}"), +) + + +def reject_interface_updates(execute, sql, params, many, context): + """Fail each update of an interface, as an execute wrapper.""" + if sql.lstrip().startswith('UPDATE "dcim_interface"'): + raise IntegrityError("injected reapply failure") + return execute(sql, params, many, context) + + +def fail_the_naming_read(execute, sql, params, many, context): + """Replace the subtree naming read by SQL that PostgreSQL rejects, as an execute wrapper.""" + if NAMING_READ.match(sql): + return execute("SELECT 1/0", None, many, context) + return execute(sql, params, many, context) + + +def journal(instance): + """Return the journal entries on *instance*, oldest first.""" + return list( + JournalEntry.objects.filter( + assigned_object_type=ContentType.objects.get_for_model(instance), assigned_object_id=instance.pk + ).order_by("pk") + ) + + +@contextmanager +def module_reapplies(): + """Count the module reapplies; each call still runs the real function.""" + with patch.object(engine, "module_rule_outcomes", wraps=engine.module_rule_outcomes) as spy: + yield spy + + +def reapplied(spy): + """Return the primary key of the module of each reapply that *spy* recorded, sorted.""" + return sorted(call.args[0].pk for call in spy.call_args_list) + + +@contextmanager +def naming_reads(): + """Record the queries and the result of each subtree naming read; each call still runs the real function.""" + reads = [] + real = engine.read_subtree_naming + + def read(module_pk): + with CaptureQueriesContext(connection) as queries: + naming = real(module_pk) + reads.append((queries.captured_queries, naming)) + return naming + + with patch.object(engine, "read_subtree_naming", read): + yield reads + + +def give_the_next_module_id(pk): + """Make the database assign *pk* to the next module that NetBox creates.""" + # NetBox before 4.7 creates no components for a module saved with an explicit pk. + with connection.cursor() as cursor: + cursor.execute( + "SELECT setval(pg_get_serial_sequence(%s, %s), %s, false)", + [Module._meta.db_table, Module._meta.pk.column, pk], + ) + + +@contextmanager +def previous_state_read_fails(statement): + """Replace the previous-state read that *statement* matches by SQL that PostgreSQL rejects.""" + replaced = [] + + def divide_by_zero(execute, sql, params, many, context): + if statement.match(sql): + replaced.append(sql) + return execute("SELECT 1/0", None, many, context) + return execute(sql, params, many, context) + + with connection.execute_wrapper(divide_by_zero): + yield replaced + + +def reject_reads_of(table): + """Return an execute wrapper that fails every read of *table*.""" + + def reject(execute, sql, params, many, context): + if sql.lstrip().startswith("SELECT") and (f'FROM "{table}"' in sql or f'JOIN "{table}"' in sql): + raise DatabaseError(f"injected {table} read failure") + return execute(sql, params, many, context) + + return reject + + +class MoveFixture: + """Two device types with the same bays, and three devices in two virtual chassis. + + ``device`` and ``peer`` are virtual-chassis positions 1 and 2 of one chassis; ``remote`` has + another device type and platform, at position 5 of another chassis. + """ + + @classmethod + def build(cls, prefix): + """Create the fixture objects and return them as class attributes of *cls*.""" + cls.prefix = prefix + cls.manufacturer = make_manufacturer(prefix) + cls.device_type = make_device_type(cls.manufacturer, prefix) + cls.other_device_type = make_device_type(cls.manufacturer, f"{prefix} Other") + for device_type in (cls.device_type, cls.other_device_type): + for name, position in BAYS: + ModuleBayTemplate.objects.create(device_type=device_type, name=name, position=position) + cls.platform = Platform.objects.create(name=f"{prefix} OS", slug=slug_for(prefix, "os")) + cls.other_platform = Platform.objects.create(name=f"{prefix} Other OS", slug=slug_for(prefix, "other-os")) + placement = make_placement(prefix) + chassis = VirtualChassis.objects.create(name=f"{prefix} VC") + remote_chassis = VirtualChassis.objects.create(name=f"{prefix} Remote VC") + cls.device = cls._device(placement, "01", cls.device_type, cls.platform, chassis, 1) + cls.peer = cls._device(placement, "02", cls.device_type, cls.other_platform, chassis, 2) + cls.remote = cls._device(placement, "03", cls.other_device_type, cls.other_platform, remote_chassis, 5) + + @classmethod + def _device(cls, placement, suffix, device_type, platform, chassis, position): + return make_device( + cls.prefix, + device_type, + placement, + name=slug_for(cls.prefix, suffix), + platform=platform, + virtual_chassis=chassis, + vc_position=position, + ) + + @classmethod + def _module_type(cls, model, *templates): + module_type = make_module_type(cls.manufacturer, model, model=f"{cls.prefix} {model}") + for template in templates: + InterfaceTemplate.objects.create(module_type=module_type, name=template, type=PLAIN_TYPE) + return module_type + + @classmethod + def _card_type(cls, model, bay_position): + """Return a module type that holds one nested bay at *bay_position*, and no interfaces.""" + card_type = make_module_type(cls.manufacturer, model, model=f"{cls.prefix} {model}") + ModuleBayTemplate.objects.create(module_type=card_type, name="Port", position=bay_position) + return card_type + + @staticmethod + def _bay(device, name="Bay 0"): + return ModuleBay.objects.get(device=device, module__isnull=True, name=name) + + @staticmethod + def _names(module): + return sorted(Interface.objects.filter(module=module).values_list("name", flat=True)) + + +class ModuleMoveTestCase(MoveFixture, TestCase): + """Install and move modules through real saves, with the committed callbacks run.""" + + @classmethod + def setUpTestData(cls): + cls.build(cls.__name__) + + def _install(self, module_type, bay): + with self.captureOnCommitCallbacks(execute=True): + return Module.objects.create(device=bay.device, module_bay=bay, module_type=module_type) + + @staticmethod + def _save_move(module, bay): + module.device = bay.device + module.module_bay = bay + module.save() + + def _move(self, module, bay): + with self.captureOnCommitCallbacks(execute=True): + self._save_move(module, bay) + + def _install_card(self, card_type, bay): + """Install *card_type* in *bay* and return it with its nested bay.""" + card = self._install(card_type, bay) + return card, ModuleBay.objects.get(module=card) + + def _change_the_chassis_position(self, position=3): + self.device.vc_position = position + self.device.save() + + def _leave_the_chassis(self): + self.device.virtual_chassis = None + self.device.vc_position = None + self.device.save() + + def _join_the_chassis(self, chassis): + self.device.virtual_chassis = chassis + self.device.vc_position = 3 + self.device.save() + + def _save_in_one_transaction(self, *saves): + """Run each of *saves* in order in one transaction; return the spy of the module reapplies.""" + with module_reapplies() as reapplies, self.captureOnCommitCallbacks(execute=True), transaction.atomic(): + for save in saves: + save() + return reapplies + + def _save_with_a_device_change(self, device_change, save, device_first, before=()): + """Run *device_change* and *save* in one transaction, *device_change* first when *device_first*. + + The saves in *before* run first in the same transaction. + """ + ordered = (device_change, save) if device_first else (save, device_change) + return self._save_in_one_transaction(*before, *ordered) + + +def flat_rule(module_type, name_template, **scope): + """Create a flat breakout rule with two channels for *module_type*.""" + return InterfaceNameRule.objects.create( + module_type=module_type, + name_template=name_template, + breakout_mode=BreakoutModeChoices.FLAT, + channel_count=2, + channel_start=0, + **scope, + ) + + +class BayEditTestCase(ModuleMoveTestCase): + """Install modules and edit their bays through real saves, with the committed callbacks run.""" + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.plain_type = cls._module_type("Plain", "{module}") + InterfaceNameRule.objects.create(module_type=cls.plain_type, name_template="et-{vc_position}/0/{bay_position}") + + @staticmethod + def _save_edit(bay, **values): + for field, value in values.items(): + setattr(bay, field, value) + bay.save() + + def _edit(self, bay, **values): + with self.captureOnCommitCallbacks(execute=True): + self._save_edit(bay, **values) From a27508dcf1520d99767360eab643807ac81dd644 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 1 Oct 2026 16:58:01 +0200 Subject: [PATCH 06/13] test(boundaries): refuse an import of a test module inside the test package Test modules imported constants, builders and test case bases from one another, and copies of shared constants grew next to them. The previous commit moved every shared name into helpers.py, trigger_cases.py and branch_cases.py. This guard keeps it that way. It parses each module of the test package and reports each import statement that names a test_* module of the package: import a.tests.test_x, from a.tests.test_x import y, from a.tests import test_x, and the relative from .test_x import y and from . import test_x, also inside a function. It reads import statements only, so a dotted-path string such as a MIDDLEWARE setting is not an import. A shared module, a test_* name imported from a shared module, and a package with a similar name are not reported. With "from .test_channelization import CHANNEL_TYPE" added to test_bulk_families.py, the guard fails and names that file and statement. The tree passes. Follow-up of #143. --- .../tests/test_module_boundaries.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/netbox_interface_name_rules/tests/test_module_boundaries.py b/netbox_interface_name_rules/tests/test_module_boundaries.py index f6bf164d..135b0b1d 100644 --- a/netbox_interface_name_rules/tests/test_module_boundaries.py +++ b/netbox_interface_name_rules/tests/test_module_boundaries.py @@ -91,6 +91,7 @@ LANGUAGE_MODULE = PACKAGE / "name_template.py" PYPROJECT = PACKAGE.parent / "pyproject.toml" BRANCHING_MODULE = PACKAGE / "branching.py" +TESTS_PACKAGE = f"{PLUGIN_PACKAGE}.tests" def _family_submodules() -> set[str]: @@ -299,6 +300,21 @@ def _netbox_branching_imports(path: pathlib.Path) -> list[str]: ] +def _imports_a_test_module(record) -> bool: + """Return whether *record*, read in the test package, imports one of its ``test_*`` modules.""" + module = record.absolute(TESTS_PACKAGE) + if module == TESTS_PACKAGE and record.name is not None: + module = f"{module}.{record.name}" + child = module.removeprefix(f"{TESTS_PACKAGE}.") + return child != module and child.split(".", 1)[0].startswith("test_") + + +def _test_module_imports(path: pathlib.Path) -> list[str]: + """Return the source of each import statement in *path* that imports a ``test_*`` module of the test package.""" + records = import_records(ast.walk(ast.parse(path.read_text(encoding="utf-8"), filename=str(path)))) + return [ast.unparse(statement) for statement in import_statements(r for r in records if _imports_a_test_module(r))] + + def _production_bulk_writes() -> Counter: """Count each bulk write of the production modules by ``(module, call source)``.""" return Counter( @@ -1021,6 +1037,55 @@ def test_a_similar_name_a_relative_import_and_the_app_label_are_not_reported(sel self.assertEqual(_netbox_branching_imports(path), []) +class TestModuleImportTest(SimpleTestCase): + """No module of the test package imports a ``test_*`` module: helpers.py, trigger_cases.py and branch_cases.py share.""" + + def test_no_module_of_the_test_package_imports_a_test_module(self): + violations = {(path.name, statement) for path in _test_modules() for statement in _test_module_imports(path)} + + self.assertEqual(violations, set()) + + def test_the_detector_reports_every_spelling(self): + spellings = { + "import netbox_interface_name_rules.tests.test_views\n": [ + "import netbox_interface_name_rules.tests.test_views" + ], + "import os, netbox_interface_name_rules.tests.test_views as views\n": [ + "import os, netbox_interface_name_rules.tests.test_views as views" + ], + "from netbox_interface_name_rules.tests.test_views import ViewTest\n": [ + "from netbox_interface_name_rules.tests.test_views import ViewTest" + ], + "from netbox_interface_name_rules.tests import helpers, test_views\n": [ + "from netbox_interface_name_rules.tests import helpers, test_views" + ], + "from .test_views import ViewTest\n": ["from .test_views import ViewTest"], + "from . import test_views\n": ["from . import test_views"], + "def f():\n from .test_views import ViewTest\n": ["from .test_views import ViewTest"], + } + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "sample.py" + for source, expected in spellings.items(): + with self.subTest(source=source): + path.write_text(source, encoding="utf-8") + self.assertEqual(_test_module_imports(path), expected) + + def test_a_shared_module_a_similar_name_and_a_dotted_path_string_are_not_reported(self): + source = ( + "from .helpers import PLAIN_TYPE\n" + "from netbox_interface_name_rules.tests import helpers\n" + "from netbox_interface_name_rules.tests.helpers import test_password\n" + "from ..engine import test_rule\n" + "import netbox_interface_name_rules.tests_extra.test_views\n" + "MIDDLEWARE = ('netbox_interface_name_rules.tests.test_views._route',)\n" + ) + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "sample.py" + path.write_text(source, encoding="utf-8") + + self.assertEqual(_test_module_imports(path), []) + + class UnisolatedReverseTest(SimpleTestCase): """A `SimpleTestCase` must not resolve a URL against NetBox's real root URLconf. From 12c10b467d2b55e146d163890a6675f0c1881fa1 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 1 Oct 2026 17:07:55 +0200 Subject: [PATCH 07/13] test(branching): count every replay reference with its receiver and scope The replay call-site scan recorded only direct calls, as a set of (name, module, function). A later 1.2.x release could add an unwrapped replay path and both allow-list tests would stay green: - A call through an indirect reference: replay = change.apply, then replay(branch); getattr(change, "apply")(branch); or an import alias such as from .utilities import update_object as u. - A second receiver in an allowed scope: strategy.merge(...) next to the allowed branch.merge(...) in MergeBranchJob.run gave the same tuple. - A deferred call: transaction.on_commit(lambda: strategy.merge(...)) in Branch.merge ran after the wrapper reset its mark and gave the same tuple. The scan now records each reference to a tracked name: an attribute, a name, an imported name, and the string of a getattr, setattr or hasattr call. Each site is (name, receiver, module, scope). The receiver is the object of the attribute or the call, the source of the import, or empty. A lambda opens a scope of its own, as a function does. The tests compare a Counter, so a second occurrence in an allowed scope changes the result. A failure lists each added or missing site. The allow-lists now hold a count and a note for each site. The real 1.2.1 tree adds these references, each read in the source: - apply.alters_data and undo.alters_data (models/changes.py:156, 228), merge.alters_data and revert.alters_data (models/branches.py:1171, 1247): attribute flags, no call. - The imports of deserialize_object (models/changes.py:15) and update_object (models/changes.py:17-22). - hasattr(model, 'deserialize_object') in ObjectChange.apply (models/changes.py:106). - The revert flag of ObjectChange.migrate (models/changes.py:67-74), a parameter, not the method. Each finding has a regression case in the scan self-test. The three cases failed against the earlier scan and pass now. On copies of the installed package, the earlier scan stays green and the new one fails naming the added site for each of: an indirect reference, a getattr string, an import alias, a second receiver in MergeBranchJob.run, and a lambda in Branch.merge. Both scans fail on the two earlier mutations, and both pass on the installed 1.2.1 tree. Follow-up of #143 (ADR 0016). --- .../tests/test_branching.py | 168 +++++++++++++----- 1 file changed, 119 insertions(+), 49 deletions(-) diff --git a/netbox_interface_name_rules/tests/test_branching.py b/netbox_interface_name_rules/tests/test_branching.py index bf15e613..7378633f 100644 --- a/netbox_interface_name_rules/tests/test_branching.py +++ b/netbox_interface_name_rules/tests/test_branching.py @@ -7,6 +7,7 @@ """ import ast +import collections import inspect import os import subprocess @@ -101,43 +102,74 @@ def test_a_second_start_wraps_nothing_again(self): self.assertEqual(self.replaying_methods(), wrapped) -# The calls that save a replayed object, each with the wrapped Branch method that reaches it (netbox-branching 1.2.1). +# Each (name, receiver, module, scope) reference to a name that saves a replayed object in 1.2.1: count, what reaches it. REPLAY_SAVES = { - ("apply", "merge_strategies/iterative.py", "IterativeMergeStrategy.merge"): "Branch.merge", - ("undo", "merge_strategies/iterative.py", "IterativeMergeStrategy.revert"): "Branch.revert", - ("apply", "merge_strategies/squash.py", "SquashMergeStrategy.merge"): "Branch.merge", - ("undo", "merge_strategies/squash.py", "SquashMergeStrategy.revert"): "Branch.revert", - ("apply", "models/branches.py", "Branch._apply_sync_update"): "Branch.sync", - ("apply", "models/branches.py", "Branch._handle_sync_delete"): "Branch.sync", - ("deserialize_object", "models/changes.py", "ObjectChange.apply"): "each apply above", - ("update_object", "models/changes.py", "ObjectChange.apply"): "each apply above", - ("deserialize_object", "models/changes.py", "ObjectChange.undo"): "each undo above", - ("update_object", "models/changes.py", "ObjectChange.undo"): "each undo above", + ("apply", "change", "merge_strategies/iterative.py", "IterativeMergeStrategy.merge"): (1, "Branch.merge"), + ("undo", "change", "merge_strategies/iterative.py", "IterativeMergeStrategy.revert"): (1, "Branch.revert"), + ("apply", "dummy_change", "merge_strategies/squash.py", "SquashMergeStrategy.merge"): (1, "Branch.merge"), + ("undo", "dummy_change", "merge_strategies/squash.py", "SquashMergeStrategy.revert"): (1, "Branch.revert"), + ("apply", "change", "models/branches.py", "Branch._apply_sync_update"): (3, "Branch.sync"), + ("apply", "change", "models/branches.py", "Branch._handle_sync_delete"): (1, "Branch.sync"), + ("apply", "", "models/changes.py", "ObjectChange"): (1, "apply.alters_data, no call"), + ("undo", "", "models/changes.py", "ObjectChange"): (1, "undo.alters_data, no call"), + ("deserialize_object", "from utilities.serialization", "models/changes.py", ""): (1, "the import"), + ("update_object", "from netbox_branching.utilities", "models/changes.py", ""): (1, "the import"), + ("deserialize_object", "hasattr(model)", "models/changes.py", "ObjectChange.apply"): (1, "each apply above"), + ("deserialize_object", "model", "models/changes.py", "ObjectChange.apply"): (1, "each apply above"), + ("deserialize_object", "", "models/changes.py", "ObjectChange.apply"): (1, "each apply above"), + ("update_object", "", "models/changes.py", "ObjectChange.apply"): (1, "each apply above"), + ("deserialize_object", "", "models/changes.py", "ObjectChange.undo"): (1, "each undo above"), + ("update_object", "", "models/changes.py", "ObjectChange.undo"): (1, "each undo above"), } -# The calls that start a replay: a merge strategy or a sync helper, or a job that calls a wrapped method. +# Each reference to a name that starts a replay: a merge strategy, a sync helper, or a wrapped method. REPLAY_ENTRIES = { - ("merge", "models/branches.py", "Branch.merge"): "the strategy, inside the wrapped Branch.merge", - ("revert", "models/branches.py", "Branch.revert"): "the strategy, inside the wrapped Branch.revert", - ("_apply_sync_update", "models/branches.py", "Branch.sync"): "inside the wrapped Branch.sync", - ("_handle_sync_delete", "models/branches.py", "Branch.sync"): "inside the wrapped Branch.sync", - ("merge", "jobs.py", "MergeBranchJob.run"): "the wrapped Branch.merge", - ("revert", "jobs.py", "RevertBranchJob.run"): "the wrapped Branch.revert", + ("merge", "strategy_class()", "models/branches.py", "Branch.merge"): (1, "inside the wrapped Branch.merge"), + ("revert", "strategy_class()", "models/branches.py", "Branch.revert"): (1, "inside the wrapped Branch.revert"), + ("_apply_sync_update", "self", "models/branches.py", "Branch.sync"): (1, "inside the wrapped Branch.sync"), + ("_handle_sync_delete", "self", "models/branches.py", "Branch.sync"): (1, "inside the wrapped Branch.sync"), + ("merge", "branch", "jobs.py", "MergeBranchJob.run"): (1, "the wrapped Branch.merge"), + ("revert", "branch", "jobs.py", "RevertBranchJob.run"): (1, "the wrapped Branch.revert"), + ("merge", "", "models/branches.py", "Branch"): (1, "merge.alters_data, no call"), + ("revert", "", "models/branches.py", "Branch"): (1, "revert.alters_data, no call"), + ("revert", "", "models/changes.py", "ObjectChange.migrate"): (1, "its revert flag, not a method"), } - - -def replay_call_sites(package, names): - """Return ``(callee, module, enclosing qualname)`` of each call of one of *names* in *package*, its tests excluded.""" - sites = set() +# The calls that name an attribute by a string. +NAMING_CALLS = frozenset({"getattr", "setattr", "hasattr"}) + + +def _references(node, names): + """Yield ``(name, receiver)`` for each reference that *node* itself makes to one of *names*.""" + if isinstance(node, ast.Attribute) and node.attr in names: + yield node.attr, ast.unparse(node.value) + elif isinstance(node, ast.Name) and node.id in names: + yield node.id, "" + elif isinstance(node, (ast.Import, ast.ImportFrom)): + source = f"from {'.' * node.level}{node.module or ''}" if isinstance(node, ast.ImportFrom) else "import" + for alias in node.names: + if (name := alias.name.rsplit(".", 1)[-1]) in names: + yield name, source + elif isinstance(node, ast.Call) and getattr(node.func, "id", None) in NAMING_CALLS and len(node.args) > 1: + attribute = node.args[1] + if isinstance(attribute, ast.Constant) and attribute.value in names: + yield attribute.value, f"{node.func.id}({ast.unparse(node.args[0])})" + + +def replay_references(package, names): + """Count each reference to one of *names* in *package*, its tests excluded, by ``(name, receiver, module, scope)``. + + A reference is an attribute, a name, an imported name, or the string of a getattr, setattr or hasattr call. The + receiver is the object of the attribute or the call, the source of the import, or empty. A function, a class and a + lambda each open a scope, so a deferred call is a site of its own. + """ + sites = collections.Counter() def visit(node, scope, module): for child in ast.iter_child_nodes(node): - if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): - visit(child, (*scope, child.name), module) + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)): + visit(child, (*scope, getattr(child, "name", "")), module) continue - if isinstance(child, ast.Call): - callee = getattr(child.func, "attr", getattr(child.func, "id", None)) - if callee in names: - sites.add((callee, module, ".".join(scope) or "")) + for name, receiver in _references(child, names): + sites[(name, receiver, module, ".".join(scope) or "")] += 1 visit(child, scope, module) for path in sorted(package.rglob("*.py")): @@ -147,53 +179,91 @@ def visit(node, scope, module): return sites -def replay_names(sites): - """Return the callee of each of the ``(callee, module, qualname)`` *sites*.""" - return {callee for callee, _, _ in sites} +def reviewed(allowed): + """Return the reviewed count of each site of *allowed*.""" + return collections.Counter({site: count for site, (count, _) in allowed.items()}) @skipUnless(BRANCHING_INSTALLED, BRANCHING_SKIP_REASON) class ReplayCallSiteContractTest(SimpleTestCase): """Each replay of the installed netbox-branching runs inside a wrapped method, as in the reviewed release.""" - def installed_sites(self, allowed): + maxDiff = None + + def assert_reviewed(self, allowed): + """Assert that the installed netbox-branching makes the references of *allowed*, each as often, and no other.""" import netbox_branching - return replay_call_sites(Path(netbox_branching.__file__).parent, replay_names(allowed)) + found = replay_references(Path(netbox_branching.__file__).parent, {name for name, *_ in allowed}) + self.assertDictEqual(dict(found), dict(reviewed(allowed))) - def test_each_save_of_a_replayed_object_is_a_reviewed_call_site(self): - self.assertEqual(self.installed_sites(REPLAY_SAVES), set(REPLAY_SAVES)) + def test_each_reference_that_saves_a_replayed_object_is_reviewed(self): + self.assert_reviewed(REPLAY_SAVES) def test_each_replay_starts_in_a_wrapped_method(self): - self.assertEqual(self.installed_sites(REPLAY_ENTRIES), set(REPLAY_ENTRIES)) + self.assert_reviewed(REPLAY_ENTRIES) class ReplayCallSiteScanTest(SimpleTestCase): - """The scan finds a replay call wherever a release adds one.""" + """The scan finds a replay reference wherever a release adds one.""" - def test_the_scan_reports_each_call_with_its_enclosing_function(self): - source = ( - "class Strategy:\n def merge(self, change):\n change.apply(self)\n" - "def helper(instance, data):\n def nested():\n update_object(instance, data, using=None)\n" - "change.undo(None)\n" - ) + def scan(self, source, names): + """Return the sites that the scan finds in a package whose module ``added.py`` holds *source*.""" with tempfile.TemporaryDirectory() as directory: package = Path(directory) (package / "added.py").write_text(source, encoding="utf-8") (package / "tests").mkdir() (package / "tests" / "test_added.py").write_text("change.apply(None)\n", encoding="utf-8") + return replay_references(package, names) - sites = replay_call_sites(package, replay_names(REPLAY_SAVES)) + def test_the_scan_reports_each_call_with_its_receiver_and_scope(self): + source = ( + "class Strategy:\n def merge(self, change):\n change.apply(self)\n" + "def helper(instance, data):\n def nested():\n update_object(instance, data, using=None)\n" + "change.undo(None)\n" + ) self.assertEqual( - sites, + self.scan(source, {"apply", "undo", "update_object"}), { - ("apply", "added.py", "Strategy.merge"), - ("update_object", "added.py", "helper.nested"), - ("undo", "added.py", ""), + ("apply", "change", "added.py", "Strategy.merge"): 1, + ("update_object", "", "added.py", "helper.nested"): 1, + ("undo", "change", "added.py", ""): 1, }, ) + def test_an_indirect_reference_is_reported(self): + source = ( + "def indirect(change, branch):\n replay = change.apply\n replay(branch)\n" + "def by_name(change, branch):\n getattr(change, 'apply')(branch)\n" + "from .utilities import update_object as u\n" + ) + + self.assertEqual( + self.scan(source, {"apply", "update_object"}), + { + ("apply", "change", "added.py", "indirect"): 1, + ("apply", "getattr(change)", "added.py", "by_name"): 1, + ("update_object", "from .utilities", "added.py", ""): 1, + }, + ) + + def test_a_second_receiver_and_a_second_call_in_one_scope_are_reported(self): + source = ( + "class Job:\n def run(self, branch, strategy):\n" + " branch.merge(None)\n strategy.merge(None)\n strategy.merge(None)\n" + ) + + self.assertEqual( + self.scan(source, {"merge"}), + {("merge", "branch", "added.py", "Job.run"): 1, ("merge", "strategy", "added.py", "Job.run"): 2}, + ) + + def test_a_lambda_is_a_scope_of_its_own(self): + source = "class Branch:\n def merge(self, strategy):\n on_commit(lambda: strategy.merge(self))\n" + + self.assertEqual(self.scan(source, {"merge"}), {("merge", "strategy", "added.py", "Branch.merge."): 1}) + class BranchProvisioningTest(BranchTestCase): """A branch provisions, takes the writes made while it is active, and is removed at teardown.""" From 22a7430534151655cc9e6f8534203336a6200239 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 1 Oct 2026 17:24:33 +0200 Subject: [PATCH 08/13] test(branching): fingerprint each reviewed scope of netbox-branching The reference scan cannot see every way to defer a replay past the wrapper. A stored reference called from a lambda (replay = change.apply, then on_commit(lambda: replay(branch))) and a generator ((change.apply(branch) for change in changes)) inside an allowed function give the same references as the direct call. Each new syntax form needs its own rule, and a missed form stays green. So the test now fails closed. It records a fingerprint of each scope that the allow-lists name: a hash of the source of the function, or of the statements of the class or the module outside its nested scopes, from the AST, without positions or comments. A change to the body of any of those 17 scopes on a netbox-branching update fails the test, and the message tells the reader to read the scope again and to update its fingerprint and the allow-lists together. A reference outside the allowed scopes still fails as a new site, and the reference multiset still checks the receiver and the count. The scan and the fingerprint name scopes through one walk. The scan self-test has a case for each deferral: the references stay equal and the fingerprint changes. With the scan of the previous commit, the same two programs give equal Counters, so that check stays green. On copies of the installed package, the deferral by a stored reference in IterativeMergeStrategy.merge and the generator in Branch._handle_sync_delete leave the references unchanged: the earlier checks pass, and the new test fails naming the changed scope. The installed 1.2.1 tree passes. Follow-up of #143 (ADR 0016). --- .../tests/test_branching.py | 124 ++++++++++++++++-- 1 file changed, 111 insertions(+), 13 deletions(-) diff --git a/netbox_interface_name_rules/tests/test_branching.py b/netbox_interface_name_rules/tests/test_branching.py index 7378633f..b673c2e6 100644 --- a/netbox_interface_name_rules/tests/test_branching.py +++ b/netbox_interface_name_rules/tests/test_branching.py @@ -8,6 +8,7 @@ import ast import collections +import hashlib import inspect import os import subprocess @@ -135,6 +136,33 @@ def test_a_second_start_wraps_nothing_again(self): } # The calls that name an attribute by a string. NAMING_CALLS = frozenset({"getattr", "setattr", "hasattr"}) +# The nodes that open a scope. +SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda) +# The fingerprint of each scope that the allow-lists name, in netbox-branching 1.2.1; see fingerprint. +REVIEWED_FINGERPRINTS = { + ("jobs.py", "MergeBranchJob.run"): "d039282f2832f890", + ("jobs.py", "RevertBranchJob.run"): "e0be2037fbdbaca7", + ("merge_strategies/iterative.py", "IterativeMergeStrategy.merge"): "42aa32ddbda92a55", + ("merge_strategies/iterative.py", "IterativeMergeStrategy.revert"): "882b00bb19bab80d", + ("merge_strategies/squash.py", "SquashMergeStrategy.merge"): "c57d4abe51355d00", + ("merge_strategies/squash.py", "SquashMergeStrategy.revert"): "257f571c7d9201bb", + ("models/branches.py", "Branch"): "5c1032613ea2f2a4", + ("models/branches.py", "Branch._apply_sync_update"): "7e235e9385c13ac4", + ("models/branches.py", "Branch._handle_sync_delete"): "ff0d26c6de4ae7df", + ("models/branches.py", "Branch.merge"): "3e0ad757b725963d", + ("models/branches.py", "Branch.revert"): "c736016e1ddc2011", + ("models/branches.py", "Branch.sync"): "395aa970f1eeb7fd", + ("models/changes.py", ""): "6ed9b80466510e19", + ("models/changes.py", "ObjectChange"): "b449c0c1b1db6de6", + ("models/changes.py", "ObjectChange.apply"): "6b9f955fbd2370cf", + ("models/changes.py", "ObjectChange.migrate"): "c1927873bbc41a6d", + ("models/changes.py", "ObjectChange.undo"): "24e38af98d10d67c", +} +# The message of a changed reviewed scope. +RE_REVIEW = ( + "A reviewed scope of netbox-branching changed. A change in it can defer a replay past the wrapper without a new " + "reference. Read the scope again, then update its fingerprint and the allow-lists together." +) def _references(node, names): @@ -154,6 +182,22 @@ def _references(node, names): yield attribute.value, f"{node.func.id}({ast.unparse(node.args[0])})" +def _scoped_nodes(node, scope=()): + """Yield ``(scope, node)`` for each node under *node*; a function, a class and a lambda open a scope.""" + for child in ast.iter_child_nodes(node): + inner = (*scope, getattr(child, "name", "")) if isinstance(child, SCOPES) else scope + yield inner, child + yield from _scoped_nodes(child, inner) + + +def _modules(package): + """Yield ``(module, tree)`` for each module of *package*, its tests excluded.""" + for path in sorted(package.rglob("*.py")): + module = path.relative_to(package).as_posix() + if not module.startswith("tests/"): + yield module, ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + def replay_references(package, names): """Count each reference to one of *names* in *package*, its tests excluded, by ``(name, receiver, module, scope)``. @@ -162,23 +206,39 @@ def replay_references(package, names): lambda each open a scope, so a deferred call is a site of its own. """ sites = collections.Counter() - - def visit(node, scope, module): - for child in ast.iter_child_nodes(node): - if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)): - visit(child, (*scope, getattr(child, "name", "")), module) - continue - for name, receiver in _references(child, names): + for module, tree in _modules(package): + for scope, node in _scoped_nodes(tree): + for name, receiver in _references(node, names): sites[(name, receiver, module, ".".join(scope) or "")] += 1 - visit(child, scope, module) - - for path in sorted(package.rglob("*.py")): - module = path.relative_to(package).as_posix() - if not module.startswith("tests/"): - visit(ast.parse(path.read_text(encoding="utf-8"), filename=str(path)), (), module) return sites +def fingerprint(node): + """Hash the source of a function, or of the statements of a class or module outside its nested scopes.""" + own = ( + [node] + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) + else [s for s in node.body if not isinstance(s, SCOPES)] + ) + return hashlib.sha256(ast.unparse(ast.Module(own, [])).encode()).hexdigest()[:16] + + +def scope_fingerprints(package, scopes): + """Return the fingerprint of each ``(module, scope)`` of *scopes* that *package* has, scopes named as above.""" + found = {} + for module, tree in _modules(package): + scope_nodes = [(("",), tree)] + [(s, n) for s, n in _scoped_nodes(tree) if isinstance(n, SCOPES)] + for scope, node in scope_nodes: + if (key := (module, ".".join(scope))) in scopes: + found[key] = fingerprint(node) + return found + + +def reviewed_scopes(): + """Return each ``(module, scope)`` that the allow-lists name.""" + return {(module, scope) for _, _, module, scope in (*REPLAY_SAVES, *REPLAY_ENTRIES)} + + def reviewed(allowed): """Return the reviewed count of each site of *allowed*.""" return collections.Counter({site: count for site, (count, _) in allowed.items()}) @@ -203,6 +263,14 @@ def test_each_reference_that_saves_a_replayed_object_is_reviewed(self): def test_each_replay_starts_in_a_wrapped_method(self): self.assert_reviewed(REPLAY_ENTRIES) + def test_each_reviewed_scope_is_unchanged(self): + import netbox_branching + + found = scope_fingerprints(Path(netbox_branching.__file__).parent, reviewed_scopes()) + + self.assertEqual(set(REVIEWED_FINGERPRINTS), reviewed_scopes()) + self.assertDictEqual(found, REVIEWED_FINGERPRINTS, RE_REVIEW) + class ReplayCallSiteScanTest(SimpleTestCase): """The scan finds a replay reference wherever a release adds one.""" @@ -259,6 +327,36 @@ def test_a_second_receiver_and_a_second_call_in_one_scope_are_reported(self): {("merge", "branch", "added.py", "Job.run"): 1, ("merge", "strategy", "added.py", "Job.run"): 2}, ) + def reviewed_state(self, source): + """Return the references and the fingerprint of ``Strategy.merge`` in a package whose ``added.py`` is *source*.""" + with tempfile.TemporaryDirectory() as directory: + package = Path(directory) + (package / "added.py").write_text(source, encoding="utf-8") + return replay_references(package, {"apply"}), scope_fingerprints(package, {("added.py", "Strategy.merge")}) + + def test_a_deferral_inside_a_reviewed_function_changes_its_fingerprint(self): + reviewed_source = ( + "class Strategy:\n def merge(self, branch, changes):\n" + " for change in changes:\n change.apply(branch)\n" + ) + deferrals = { + "a stored reference called later": ( + "class Strategy:\n def merge(self, branch, changes):\n for change in changes:\n" + " replay = change.apply\n on_commit(lambda: replay(branch))\n" + ), + "a generator": ( + "class Strategy:\n def merge(self, branch, changes):\n" + " return (change.apply(branch) for change in changes)\n" + ), + } + references, fingerprints = self.reviewed_state(reviewed_source) + for deferral, source in deferrals.items(): + with self.subTest(deferral=deferral): + deferred_references, deferred_fingerprints = self.reviewed_state(source) + + self.assertEqual(deferred_references, references) + self.assertNotEqual(deferred_fingerprints, fingerprints) + def test_a_lambda_is_a_scope_of_its_own(self): source = "class Branch:\n def merge(self, strategy):\n on_commit(lambda: strategy.merge(self))\n" From 2af2bb0d118674167bb92ca748571f9bfb7f9c7e Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 1 Oct 2026 17:27:44 +0200 Subject: [PATCH 09/13] test(boundaries): read the whole test package, subpackages and __init__.py included The guard against imports of test modules read only the top-level files of the test package and skipped __init__.py. It resolved each relative import against the test package, and it checked only the first component under it. So these stayed green: - from .test_views import ViewTest in tests/__init__.py; - an import in a module of a test subpackage; - import netbox_interface_name_rules.tests.sub.test_views as views; - from netbox_interface_name_rules.tests.sub import test_views; - from ..test_views import ViewTest in tests/sub/shared.py, which resolved against the wrong package. The guard now reads each .py file under the test package, __init__.py included. It derives the package of each module from its path and resolves a relative import against that package. It reports an import whose target has a test_* component at any depth under the test package, and the name of a from-import counts as part of the target. So a test_* name imported from a shared module is refused too: pytest collects a test function where it is imported. With temporary imports in tests/__init__.py, in a new tests/sub subpackage and in helpers.py, the earlier guard passed and the new one fails naming the four statements. The new detector subtests for the two absolute subpackage spellings and the test_* name failed with the earlier detector. New tests cover a relative import from a subpackage, and __init__.py and subpackage modules in a temporary test package. The tree passes. Follow-up of #143. --- .../tests/test_module_boundaries.py | 80 +++++++++++++++---- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/netbox_interface_name_rules/tests/test_module_boundaries.py b/netbox_interface_name_rules/tests/test_module_boundaries.py index 135b0b1d..ebbbeb9a 100644 --- a/netbox_interface_name_rules/tests/test_module_boundaries.py +++ b/netbox_interface_name_rules/tests/test_module_boundaries.py @@ -300,19 +300,31 @@ def _netbox_branching_imports(path: pathlib.Path) -> list[str]: ] -def _imports_a_test_module(record) -> bool: - """Return whether *record*, read in the test package, imports one of its ``test_*`` modules.""" - module = record.absolute(TESTS_PACKAGE) - if module == TESTS_PACKAGE and record.name is not None: - module = f"{module}.{record.name}" - child = module.removeprefix(f"{TESTS_PACKAGE}.") - return child != module and child.split(".", 1)[0].startswith("test_") +def _imports_a_test_module(record, package: str) -> bool: + """Return whether *record*, read in *package*, imports a ``test_*`` module or name at any depth of the test package.""" + module = record.absolute(package) + target = module if record.name is None else f"{module}.{record.name}" + inside = target.removeprefix(f"{TESTS_PACKAGE}.") + return inside != target and any(part.startswith("test_") for part in inside.split(".")) -def _test_module_imports(path: pathlib.Path) -> list[str]: - """Return the source of each import statement in *path* that imports a ``test_*`` module of the test package.""" +def _test_module_imports(path: pathlib.Path, package: str = TESTS_PACKAGE) -> list[str]: + """Return the source of each import statement in *path*, a module of *package*, that imports a test module.""" records = import_records(ast.walk(ast.parse(path.read_text(encoding="utf-8"), filename=str(path)))) - return [ast.unparse(statement) for statement in import_statements(r for r in records if _imports_a_test_module(r))] + return [ + ast.unparse(statement) + for statement in import_statements(r for r in records if _imports_a_test_module(r, package)) + ] + + +def _test_package_violations(tests_root: pathlib.Path) -> set[tuple[str, str]]: + """Return ``(path, statement)`` for each import of a test module in any module of the test package at *tests_root*.""" + violations = set() + for path in sorted(tests_root.rglob("*.py")): + relative = path.relative_to(tests_root) + package = ".".join((TESTS_PACKAGE, *relative.parent.parts)) + violations.update((relative.as_posix(), statement) for statement in _test_module_imports(path, package)) + return violations def _production_bulk_writes() -> Counter: @@ -1038,12 +1050,13 @@ def test_a_similar_name_a_relative_import_and_the_app_label_are_not_reported(sel class TestModuleImportTest(SimpleTestCase): - """No module of the test package imports a ``test_*`` module: helpers.py, trigger_cases.py and branch_cases.py share.""" + """No module of the test package imports a ``test_*`` module: helpers.py, trigger_cases.py and branch_cases.py share. - def test_no_module_of_the_test_package_imports_a_test_module(self): - violations = {(path.name, statement) for path in _test_modules() for statement in _test_module_imports(path)} + A ``test_*`` name imported from a shared module is refused too: pytest would collect a test function there. + """ - self.assertEqual(violations, set()) + def test_no_module_of_the_test_package_imports_a_test_module(self): + self.assertEqual(_test_package_violations(PACKAGE / "tests"), set()) def test_the_detector_reports_every_spelling(self): spellings = { @@ -1053,12 +1066,21 @@ def test_the_detector_reports_every_spelling(self): "import os, netbox_interface_name_rules.tests.test_views as views\n": [ "import os, netbox_interface_name_rules.tests.test_views as views" ], + "import netbox_interface_name_rules.tests.sub.test_views as views\n": [ + "import netbox_interface_name_rules.tests.sub.test_views as views" + ], "from netbox_interface_name_rules.tests.test_views import ViewTest\n": [ "from netbox_interface_name_rules.tests.test_views import ViewTest" ], "from netbox_interface_name_rules.tests import helpers, test_views\n": [ "from netbox_interface_name_rules.tests import helpers, test_views" ], + "from netbox_interface_name_rules.tests.sub import test_views\n": [ + "from netbox_interface_name_rules.tests.sub import test_views" + ], + "from netbox_interface_name_rules.tests.helpers import test_password\n": [ + "from netbox_interface_name_rules.tests.helpers import test_password" + ], "from .test_views import ViewTest\n": ["from .test_views import ViewTest"], "from . import test_views\n": ["from . import test_views"], "def f():\n from .test_views import ViewTest\n": ["from .test_views import ViewTest"], @@ -1070,11 +1092,39 @@ def test_the_detector_reports_every_spelling(self): path.write_text(source, encoding="utf-8") self.assertEqual(_test_module_imports(path), expected) + def test_a_relative_import_resolves_against_the_package_of_its_module(self): + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "sample.py" + path.write_text("from .test_views import ViewTest\nfrom ..test_views import ViewTest\n", encoding="utf-8") + + reported = _test_module_imports(path, f"{TESTS_PACKAGE}.sub") + + self.assertEqual(reported, ["from .test_views import ViewTest", "from ..test_views import ViewTest"]) + + def test_the_guard_reads_every_module_of_the_test_package_and_its_subpackages(self): + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + (root / "sub").mkdir() + (root / "__init__.py").write_text("from .test_views import ViewTest\n", encoding="utf-8") + (root / "sub" / "__init__.py").write_text("from . import test_views\n", encoding="utf-8") + (root / "sub" / "shared.py").write_text("from ..test_views import ViewTest\n", encoding="utf-8") + (root / "helpers.py").write_text("from .sub import shared\n", encoding="utf-8") + + violations = _test_package_violations(root) + + self.assertEqual( + violations, + { + ("__init__.py", "from .test_views import ViewTest"), + ("sub/__init__.py", "from . import test_views"), + ("sub/shared.py", "from ..test_views import ViewTest"), + }, + ) + def test_a_shared_module_a_similar_name_and_a_dotted_path_string_are_not_reported(self): source = ( "from .helpers import PLAIN_TYPE\n" "from netbox_interface_name_rules.tests import helpers\n" - "from netbox_interface_name_rules.tests.helpers import test_password\n" "from ..engine import test_rule\n" "import netbox_interface_name_rules.tests_extra.test_views\n" "MIDDLEWARE = ('netbox_interface_name_rules.tests.test_views._route',)\n" From 1e66e42b948d707c5fc6d96410bbf5b9b0855072 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 1 Oct 2026 17:42:10 +0200 Subject: [PATCH 10/13] test(branching): pin the reviewed netbox-branching release A new upstream function that dispatches with getattr(change, action) (branch), where action is a variable, adds a replay path outside every reviewed scope. Neither the reference counts nor the fingerprints change. This is the third finding of one family: each scan rule misses the next form of dispatch. So the contract now answers the question at its source: is the installed netbox-branching the release that was reviewed? A test asserts that the installed version equals REVIEWED_NETBOX_BRANCHING, 1.2.1. When it differs, the message says what to re-review: run the scan, read every new reference, every changed fingerprint and any dynamic dispatch such as getattr with a variable name, then update the release, the fingerprints and the allow-lists together. The scan and the fingerprints stay, as the aid for that review. branching.installed_version() now reads the version, for the version gate and for this test. The gate still accepts any 1.2.x release. With the constant set to 1.2.2, the test fails with that message. With 1.2.1, it passes on the branch leg. Follow-up of #143 (ADR 0016). --- netbox_interface_name_rules/branching.py | 7 ++++++- netbox_interface_name_rules/tests/test_branching.py | 12 +++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/netbox_interface_name_rules/branching.py b/netbox_interface_name_rules/branching.py index 274a9a1a..dcf7ab48 100644 --- a/netbox_interface_name_rules/branching.py +++ b/netbox_interface_name_rules/branching.py @@ -28,11 +28,16 @@ def check_version(version: str) -> None: ) +def installed_version() -> str: + """Return the version of the installed netbox-branching.""" + return apps.get_app_config(APP_LABEL).version + + def ready() -> None: """When netbox-branching is installed, check its version and mark each of its replays in the context that runs it.""" if not apps.is_installed(APP_LABEL): return - check_version(apps.get_app_config(APP_LABEL).version) + check_version(installed_version()) from netbox_branching.models import Branch for name in REPLAYING_METHODS: diff --git a/netbox_interface_name_rules/tests/test_branching.py b/netbox_interface_name_rules/tests/test_branching.py index b673c2e6..33682e0b 100644 --- a/netbox_interface_name_rules/tests/test_branching.py +++ b/netbox_interface_name_rules/tests/test_branching.py @@ -158,6 +158,13 @@ def test_a_second_start_wraps_nothing_again(self): ("models/changes.py", "ObjectChange.migrate"): "c1927873bbc41a6d", ("models/changes.py", "ObjectChange.undo"): "24e38af98d10d67c", } +# The netbox-branching release whose replay paths the allow-lists and the fingerprints record. +REVIEWED_NETBOX_BRANCHING = "1.2.1" +RE_REVIEW_RELEASE = ( + "The installed netbox-branching is not the release whose replay paths were reviewed. Re-review its replay paths: " + "run the scan, read every new reference, every changed fingerprint and any dynamic dispatch, such as getattr with " + "a variable name. Then update REVIEWED_NETBOX_BRANCHING, the fingerprints and the allow-lists together." +) # The message of a changed reviewed scope. RE_REVIEW = ( "A reviewed scope of netbox-branching changed. A change in it can defer a replay past the wrapper without a new " @@ -246,10 +253,13 @@ def reviewed(allowed): @skipUnless(BRANCHING_INSTALLED, BRANCHING_SKIP_REASON) class ReplayCallSiteContractTest(SimpleTestCase): - """Each replay of the installed netbox-branching runs inside a wrapped method, as in the reviewed release.""" + """The installed netbox-branching is the reviewed release; the scan and the fingerprints help review the next one.""" maxDiff = None + def test_the_installed_netbox_branching_is_the_reviewed_release(self): + self.assertEqual(branching.installed_version(), REVIEWED_NETBOX_BRANCHING, RE_REVIEW_RELEASE) + def assert_reviewed(self, allowed): """Assert that the installed netbox-branching makes the references of *allowed*, each as often, and no other.""" import netbox_branching From 6414a1c701e580c133626f228ca7b4092a3159af Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 1 Oct 2026 17:43:07 +0200 Subject: [PATCH 11/13] test(boundaries): refuse a wildcard import from the test package With __all__ = ["test_views"] in tests/__init__.py, from . import * in another module of the test package loads tests.test_views, and the guard reported nothing: the import names no test module. The guard now refuses each wildcard import whose source resolves inside the test package. It does not read __all__. No module of the test package has such an import; the one wildcard import under tests, in isolated_settings.py, imports netbox.settings. The detector reports from . import *, from .helpers import * and from netbox_interface_name_rules.tests import *. A wildcard import from outside the test package is not reported. The earlier detector missed the three spellings, and the __all__ case in a temporary test package. Follow-up of #143. --- .../tests/test_module_boundaries.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/netbox_interface_name_rules/tests/test_module_boundaries.py b/netbox_interface_name_rules/tests/test_module_boundaries.py index ebbbeb9a..c9aa8bac 100644 --- a/netbox_interface_name_rules/tests/test_module_boundaries.py +++ b/netbox_interface_name_rules/tests/test_module_boundaries.py @@ -301,8 +301,10 @@ def _netbox_branching_imports(path: pathlib.Path) -> list[str]: def _imports_a_test_module(record, package: str) -> bool: - """Return whether *record*, read in *package*, imports a ``test_*`` module or name at any depth of the test package.""" + """Return whether *record*, read in *package*, imports a ``test_*`` module or name, or ``*``, from the test package.""" module = record.absolute(package) + if record.name == "*": + return module == TESTS_PACKAGE or module.startswith(f"{TESTS_PACKAGE}.") target = module if record.name is None else f"{module}.{record.name}" inside = target.removeprefix(f"{TESTS_PACKAGE}.") return inside != target and any(part.startswith("test_") for part in inside.split(".")) @@ -1052,7 +1054,8 @@ def test_a_similar_name_a_relative_import_and_the_app_label_are_not_reported(sel class TestModuleImportTest(SimpleTestCase): """No module of the test package imports a ``test_*`` module: helpers.py, trigger_cases.py and branch_cases.py share. - A ``test_*`` name imported from a shared module is refused too: pytest would collect a test function there. + A ``test_*`` name imported from a shared module is refused too: pytest would collect a test function there. A ``*`` + import from the test package is refused, because ``__all__`` can name a test module. """ def test_no_module_of_the_test_package_imports_a_test_module(self): @@ -1083,6 +1086,9 @@ def test_the_detector_reports_every_spelling(self): ], "from .test_views import ViewTest\n": ["from .test_views import ViewTest"], "from . import test_views\n": ["from . import test_views"], + "from . import *\n": ["from . import *"], + "from .helpers import *\n": ["from .helpers import *"], + "from netbox_interface_name_rules.tests import *\n": ["from netbox_interface_name_rules.tests import *"], "def f():\n from .test_views import ViewTest\n": ["from .test_views import ViewTest"], } with tempfile.TemporaryDirectory() as directory: @@ -1127,6 +1133,8 @@ def test_a_shared_module_a_similar_name_and_a_dotted_path_string_are_not_reporte "from netbox_interface_name_rules.tests import helpers\n" "from ..engine import test_rule\n" "import netbox_interface_name_rules.tests_extra.test_views\n" + "from netbox.settings import *\n" + "from netbox_interface_name_rules.tests_extra import *\n" "MIDDLEWARE = ('netbox_interface_name_rules.tests.test_views._route',)\n" ) with tempfile.TemporaryDirectory() as directory: From e7e5b58032df5dfeeedf82779d516afe435b0a9e Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 1 Oct 2026 18:17:09 +0200 Subject: [PATCH 12/13] fix(models): check the write alias on every rule save InterfaceNameRule.save() compared the save alias with the alias that the router gives for a rule only when update_fields named a validated field. A full save and a targeted save of other fields (for example description) skipped the check. In a netbox-branching branch, rule.save(using="default") on a fully loaded rule therefore wrote the main row, while the same save of name_template raised. The comparison now runs before the update_fields branching, so every save refuses an alias that is not the routed write alias, before any query. The write scope membership check stays inside the locked block. Tests cover a full save and a description save through default in a branch, and through an unknown alias on main. --- netbox_interface_name_rules/models.py | 4 ++-- .../tests/test_branch_transactions.py | 22 +++++++++++++++++++ .../tests/test_rule_validation_agreement.py | 18 +++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/netbox_interface_name_rules/models.py b/netbox_interface_name_rules/models.py index 42013ebd..13bc98bb 100644 --- a/netbox_interface_name_rules/models.py +++ b/netbox_interface_name_rules/models.py @@ -347,6 +347,8 @@ class Meta: def save(self, **kwargs): """Normalise the mode fields and validate topology and templates before a plain ORM write.""" using = kwargs.get("using") or router.db_for_write(self.__class__, instance=self) + if using != (routed := router.db_for_write(self.__class__)): + raise RuntimeError(f"A rule save writes to {using!r}, but the router gives {routed!r} for a rule.") update_fields = kwargs.get("update_fields") if update_fields is not None: # Django accepts any iterable. Reading a generator here would leave Django an empty @@ -364,8 +366,6 @@ def save(self, **kwargs): if written := _RULE_VALIDATION_FIELDS.intersection(update_fields): # Validate the stored row on the alias Model.save() writes to, locked against a concurrent save. kwargs["using"] = using - if using != (routed := router.db_for_write(self.__class__)): - raise RuntimeError(f"A rule save writes to {using!r}, but the router gives {routed!r} for a rule.") with atomic_with_events() as block: # netbox-branching routes an exempted rule model to default, which is an alias of every scope. if using not in block.aliases: diff --git a/netbox_interface_name_rules/tests/test_branch_transactions.py b/netbox_interface_name_rules/tests/test_branch_transactions.py index f9112b12..5860403e 100644 --- a/netbox_interface_name_rules/tests/test_branch_transactions.py +++ b/netbox_interface_name_rules/tests/test_branch_transactions.py @@ -295,6 +295,28 @@ def test_a_save_through_default_in_a_branch_is_refused_before_any_query(self): self.assertEqual(InterfaceNameRule.objects.get(pk=rule.pk).name_template, "xe-{bay_position}") + def test_a_full_save_through_default_in_a_branch_is_refused_before_any_query(self): + module_type = make_module_type(make_manufacturer("BrRuleFull"), "BrRuleFull") + rule = InterfaceNameRule.objects.create(module_type=module_type, name_template="xe-{bay_position}") + rule.name_template = "xe-0/{bay_position}" + + with activate(self.branch), self.assertNumQueries(0), self.assertNumQueries(0, using=self.alias): + with self.assertRaisesMessage(RuntimeError, f"'{self.alias}'"): + rule.save(using="default") + + self.assertEqual(InterfaceNameRule.objects.get(pk=rule.pk).name_template, "xe-{bay_position}") + + def test_a_description_save_through_default_in_a_branch_is_refused_before_any_query(self): + module_type = make_module_type(make_manufacturer("BrRuleDesc"), "BrRuleDesc") + rule = InterfaceNameRule.objects.create(module_type=module_type, name_template="xe-{bay_position}") + rule.description = "after" + + with activate(self.branch), self.assertNumQueries(0), self.assertNumQueries(0, using=self.alias): + with self.assertRaisesMessage(RuntimeError, f"'{self.alias}'"): + rule.save(using="default", update_fields=["description"]) + + self.assertEqual(InterfaceNameRule.objects.get(pk=rule.pk).description, "") + class ExemptRuleModelInABranchTest(_ScopeCase): """An operator can exempt the plugin's models from netbox-branching; a rule then lives on ``default`` alone.""" diff --git a/netbox_interface_name_rules/tests/test_rule_validation_agreement.py b/netbox_interface_name_rules/tests/test_rule_validation_agreement.py index e2e14516..2937cb5f 100644 --- a/netbox_interface_name_rules/tests/test_rule_validation_agreement.py +++ b/netbox_interface_name_rules/tests/test_rule_validation_agreement.py @@ -273,6 +273,24 @@ def test_a_targeted_save_through_an_alias_other_than_the_write_alias_is_refused( self.assertEqual(InterfaceNameRule.objects.get(pk=rule.pk).name_template, "xe-{bay_position}") + def test_a_full_save_through_an_alias_other_than_the_write_alias_is_refused(self): + rule = InterfaceNameRule.objects.create(module_type=self.module_type, name_template="xe-{bay_position}") + rule.name_template = "xe-0/{bay_position}" + + with self.assertRaisesMessage(RuntimeError, "'schema_elsewhere'"): + rule.save(using="schema_elsewhere") + + self.assertEqual(InterfaceNameRule.objects.get(pk=rule.pk).name_template, "xe-{bay_position}") + + def test_a_save_of_an_unrelated_field_through_an_alias_other_than_the_write_alias_is_refused(self): + rule = InterfaceNameRule.objects.create(module_type=self.module_type, name_template="xe-{bay_position}") + rule.description = "after" + + with self.assertRaisesMessage(RuntimeError, "'schema_elsewhere'"): + rule.save(using="schema_elsewhere", update_fields=["description"]) + + self.assertEqual(InterfaceNameRule.objects.get(pk=rule.pk).description, "") + def test_a_targeted_save_whose_routed_alias_is_outside_the_write_scope_is_refused(self): """The router may send a rule where the write scope of an interface write does not reach.""" rule = InterfaceNameRule.objects.create(module_type=self.module_type, name_template="xe-{bay_position}") From 660895eb38290ff5fe5db42e96344e1cf98aedba Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 1 Oct 2026 18:28:44 +0200 Subject: [PATCH 13/13] fix(models): leave a replayed rule save to netbox-branching A merge or revert started from a shell with the branch active replays the logged changes on default, while the router gives the branch alias. netbox-branching replays a rule update through ObjectChange.apply(), update_object() and instance.save(using="default"). The rule save now checks the write alias on every save, so this replay raised RuntimeError, the same failure that the rename triggers had before they returned early during a replay. InterfaceNameRule.save() now skips its write-alias comparison while replay_in_progress() is true. The write scope check inside the locked block stays: default is always in the scope. The new test changes a rule through the REST API in the branch, then merges and reverts it with the branch active. --- docs/design/netbox-branching.md | 3 +- netbox_interface_name_rules/models.py | 4 ++- .../tests/test_branch_replay.py | 32 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/docs/design/netbox-branching.md b/docs/design/netbox-branching.md index 8127ae7d..5d714a6a 100644 --- a/docs/design/netbox-branching.md +++ b/docs/design/netbox-branching.md @@ -182,7 +182,8 @@ version check. - Version gate: raise `ImproperlyConfigured` unless the installed netbox-branching is 1.2.x. - Replay suppression: wrap `Branch.merge`, `Branch.revert` and `Branch.sync` once (idempotent, `functools.wraps`). Each wrapper sets a ContextVar token and resets it in `finally`. - `replay_in_progress() -> bool`. + `replay_in_progress() -> bool`. `InterfaceNameRule.save()` skips its write-alias check while it is + true: a merge or revert started in an active branch replays a rule update on `default`. - Job identity: `branch_identity() -> str | None` (the active branch's schema id) and `activate_on(request, identity)`, which sets BR's branch cookie on a synthetic request. diff --git a/netbox_interface_name_rules/models.py b/netbox_interface_name_rules/models.py index 13bc98bb..e974c291 100644 --- a/netbox_interface_name_rules/models.py +++ b/netbox_interface_name_rules/models.py @@ -9,6 +9,7 @@ from netbox.models import NetBoxModel from taggit.managers import TaggableManager +from .branching import replay_in_progress from .choices import BreakoutModeChoices from .name_template import validate_rule from .regex_safety import compile_module_type_pattern @@ -347,7 +348,8 @@ class Meta: def save(self, **kwargs): """Normalise the mode fields and validate topology and templates before a plain ORM write.""" using = kwargs.get("using") or router.db_for_write(self.__class__, instance=self) - if using != (routed := router.db_for_write(self.__class__)): + # A merge started in an active branch replays its changes on default. + if not replay_in_progress() and using != (routed := router.db_for_write(self.__class__)): raise RuntimeError(f"A rule save writes to {using!r}, but the router gives {routed!r} for a rule.") update_fields = kwargs.get("update_fields") if update_fields is not None: diff --git a/netbox_interface_name_rules/tests/test_branch_replay.py b/netbox_interface_name_rules/tests/test_branch_replay.py index 20d5f599..19205932 100644 --- a/netbox_interface_name_rules/tests/test_branch_replay.py +++ b/netbox_interface_name_rules/tests/test_branch_replay.py @@ -226,6 +226,38 @@ def test_a_merge_and_a_revert_started_in_the_branch_replay_a_rename_trigger_with self.assertEqual((merged, self.position_on_main()), (2, 1)) +class RuleReplayInTheActiveBranchTest(_ReplayCase): + """A merge and a revert, started from a shell in which the branch is active, replay a rule update.""" + + PREFIX = "BrRuleReplayActive" + + def build(self): + module_type = make_module_type(make_manufacturer(self.PREFIX), self.PREFIX) + self.rule = InterfaceNameRule.objects.create(module_type=module_type, name_template="xe-{bay_position}") + + def template_on_main(self): + return InterfaceNameRule.objects.get(pk=self.rule.pk).name_template + + def test_a_merge_and_a_revert_started_in_the_branch_replay_a_rule_update_without_an_error(self): + response = self.client.patch( + reverse( + "plugins-api:netbox_interface_name_rules-api:interfacenamerule-detail", kwargs={"pk": self.rule.pk} + ), + {"name_template": "xe-0/{bay_position}", "description": "branch"}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 200, response.content) + self.branch.refresh_from_db() + + with self.in_branch(): + self.branch.merge(user=self.user) + merged = self.template_on_main() + with self.in_branch(): + self.branch.revert(user=self.user) + + self.assertEqual((merged, self.template_on_main()), ("xe-0/{bay_position}", "xe-{bay_position}")) + + class MergeExitTest(_InstallAndMoveCase): """A merge that returns early or fails leaves the next save on main a rename trigger.