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. 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/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/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/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/__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..dcf7ab48 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,42 @@ 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 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(installed_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/models.py b/netbox_interface_name_rules/models.py index 42013ebd..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,6 +348,9 @@ 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) + # 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: # Django accepts any iterable. Reading a generator here would leave Django an empty @@ -364,8 +368,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/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/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 new file mode 100644 index 00000000..19205932 --- /dev/null +++ b/netbox_interface_name_rules/tests/test_branch_replay.py @@ -0,0 +1,378 @@ +# 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.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, + make_module_bay_templates, + make_module_type, + queued_job, +) + +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 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. + + 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_transactions.py b/netbox_interface_name_rules/tests/test_branch_transactions.py index 1eabc18d..5860403e 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): @@ -300,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_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 d5df2070..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,73 +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_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")) - - 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): @@ -184,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): @@ -216,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): @@ -257,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): @@ -311,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. @@ -420,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): @@ -451,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): @@ -482,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 5fa491a6..33682e0b 100644 --- a/netbox_interface_name_rules/tests/test_branching.py +++ b/netbox_interface_name_rules/tests/test_branching.py @@ -6,24 +6,28 @@ ``EXPECT_NETBOX_BRANCHING=1``, and there a missing netbox-branching fails the guard test instead. """ +import ast +import collections +import hashlib +import inspect import os import subprocess import sys +import tempfile +from pathlib import Path 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.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.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*.""" @@ -72,27 +76,301 @@ def test_an_unsupported_version_stops_startup(self): self.assertIn("is 1.3.0.", completed.stderr) -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 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) + + +# 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", "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"), +} +# Each reference to a name that starts a replay: a merge strategy, a sync helper, or a wrapped method. +REPLAY_ENTRIES = { + ("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"), +} +# 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 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 " + "reference. Read the scope again, then update its fingerprint and the allow-lists together." +) + + +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 _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)``. + + 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() + 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 + 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()}) @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.""" +class ReplayCallSiteContractTest(SimpleTestCase): + """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 + + found = replay_references(Path(netbox_branching.__file__).parent, {name for name, *_ in allowed}) + self.assertDictEqual(dict(found), dict(reviewed(allowed))) + + 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.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) - 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 ReplayCallSiteScanTest(SimpleTestCase): + """The scan finds a replay reference wherever a release adds one.""" + + 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) + + 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( + self.scan(source, {"apply", "undo", "update_object"}), + { + ("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 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" + + self.assertEqual(self.scan(source, {"merge"}), {("merge", "strategy", "added.py", "Branch.merge."): 1}) class BranchProvisioningTest(BranchTestCase): 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_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" 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_boundaries.py b/netbox_interface_name_rules/tests/test_module_boundaries.py index f6bf164d..c9aa8bac 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,35 @@ 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, 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(".")) + + +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, 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: """Count each bulk write of the production modules by ``(module, call source)``.""" return Counter( @@ -1021,6 +1051,99 @@ 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. + + 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): + self.assertEqual(_test_package_violations(PACKAGE / "tests"), 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" + ], + "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"], + "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: + 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_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 ..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: + 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. 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_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}") 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)