diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 6c06389cae..ff23ef3b44 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -487,6 +487,16 @@ jobs: dev-install: true update: true static: true + # The database is discarded after the run - durability is not required + - name: Tune PostgreSQL for Testing + env: + PG_CONTAINER: ${{ job.services.postgres.id }} + run: | + docker exec "$PG_CONTAINER" psql -U inventree \ + -c "ALTER SYSTEM SET fsync = off" \ + -c "ALTER SYSTEM SET synchronous_commit = off" \ + -c "ALTER SYSTEM SET full_page_writes = off" \ + -c "SELECT pg_reload_conf()" # Coverage is collected here (not on SQLite) so that tests which require # select_for_update (concurrency / locking tests) contribute to coverage - name: Coverage Tests diff --git a/pyproject.toml b/pyproject.toml index 87f8c04734..5fc042f6d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,12 +118,12 @@ unused-ignore-comment = "error" [tool.coverage.run] source = ["src/backend/InvenTree", "InvenTree"] -dynamic_context = "test_function" +# sys.monitoring based measurement (Python 3.12+) is much faster than the default tracer. +# Note: sysmon does not support 'dynamic_context', so per-test contexts are not recorded. +# On older Python versions, coverage falls back to the default core (with a warning). +core = "sysmon" omit = ["*/test_migrations.py", "*/migrations/*"] -[tool.coverage.html] -show_contexts = true - [tool.djlint] ignore = "D018,H006,H008,H020,H021,H023,H025,H030,H031,T002" diff --git a/src/backend/InvenTree/common/test_tasks.py b/src/backend/InvenTree/common/test_tasks.py index c477864805..9335c39928 100644 --- a/src/backend/InvenTree/common/test_tasks.py +++ b/src/backend/InvenTree/common/test_tasks.py @@ -1,8 +1,12 @@ """Tests for tasks in app common.""" +from unittest.mock import patch + from django.conf import settings from django.test import TestCase +import requests + from common.models import NewsFeedEntry, NotificationEntry from InvenTree.tasks import offload_task @@ -58,9 +62,18 @@ class NewsFeedTests(TestCase): """ settings.INVENTREE_NEWS_URL = 'http://10.255.255.1:81' - with self.assertLogs('inventree', level='WARNING'): + # Simulate the connection timing out, rather than waiting for a real timeout + with ( + patch( + 'common.tasks.requests.get', + side_effect=requests.exceptions.ConnectTimeout, + ) as mock_get, + self.assertLogs('inventree', level='WARNING'), + ): common_tasks.update_news_feed() + mock_get.assert_called_once_with(settings.INVENTREE_NEWS_URL, timeout=30) + self.assertEqual(NewsFeedEntry.objects.all().count(), 0) def test_unset_url(self): diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index e3a89d1999..b23e5da4be 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -1161,12 +1161,12 @@ class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase): class PluginUserSettingsApiTest(PluginMixin, InvenTreeAPITestCase): """Tests for the plugin user settings API.""" - def setUp(self): - """Ensure plugin is activated.""" + @classmethod + def setUpTestData(cls): + """Ensure plugin is activated (once for the whole class).""" + super().setUpTestData() registry.set_plugin_state('sample', True) - super().setUp() - def test_user_setting_list(self): """Test the plugin user setting list API.""" url = reverse('api-plugin-user-setting-list', kwargs={'plugin': 'sample'}) diff --git a/src/backend/InvenTree/machine/test_api.py b/src/backend/InvenTree/machine/test_api.py index 1e66d3fc77..ebf8aae011 100644 --- a/src/backend/InvenTree/machine/test_api.py +++ b/src/backend/InvenTree/machine/test_api.py @@ -19,13 +19,16 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase): roles = ['admin.add', 'admin.view', 'admin.change', 'admin.delete'] + @classmethod + def setUpTestData(cls): + """Ensure the test plugin is loaded (once for the whole class).""" + super().setUpTestData() + plg_registry.set_plugin_state('label-printer-test-plugin', True) + def setUp(self): """Setup some testing drivers/machines.""" registry.initialize() - # Ensure the test plugin is loaded - plg_registry.set_plugin_state('label-printer-test-plugin', True) - super().setUp() def test_machine_type_list(self): diff --git a/src/backend/InvenTree/plugin/_internal/registry.py b/src/backend/InvenTree/plugin/_internal/registry.py index 93a50a57e1..c5a891fd51 100644 --- a/src/backend/InvenTree/plugin/_internal/registry.py +++ b/src/backend/InvenTree/plugin/_internal/registry.py @@ -333,13 +333,38 @@ class PluginsRegistry: slug (str): Plugin slug state (bool): Plugin state - true = active, false = inactive """ - if slug not in self.plugins_full: - logger.warning("Plugin registry has no record of plugin '%s'", slug) - return + self.set_plugin_states({slug: state}) - cfg = self.get_plugin_config(slug) - cfg.active = state - cfg.save() + @registry_entrypoint() + def set_plugin_states(self, states: dict[str, bool]): + """Set the state (active/inactive) of multiple plugins, with a single reload. + + Changing the active state of a PluginConfig triggers a full registry reload, + which is expensive. This method updates all of the provided plugins first, + and then performs (at most) one reload. + + Arguments: + states (dict): Mapping of {slug: state} - true = active, false = inactive + """ + changed = False + + for slug, state in states.items(): + if slug not in self.plugins_full: + logger.warning("Plugin registry has no record of plugin '%s'", slug) + continue + + cfg = self.get_plugin_config(slug) + cfg.active = state + + # Same condition as PluginConfig.save() uses to trigger a reload + if cfg.reload_required(): + changed = True + cfg.warn_reload(stacklevel=4) + + cfg.save(no_reload=True) + + if changed: + self.reload_plugins(full_reload=True, force_reload=True, collect=True) # Update the registry hash value self.update_plugin_hash() diff --git a/src/backend/InvenTree/plugin/base/locate/test_locate.py b/src/backend/InvenTree/plugin/base/locate/test_locate.py index 6744d2520c..f35bf9f306 100644 --- a/src/backend/InvenTree/plugin/base/locate/test_locate.py +++ b/src/backend/InvenTree/plugin/base/locate/test_locate.py @@ -11,11 +11,10 @@ from stock.models import StockItem, StockLocation class LocatePluginTests(InvenTreeAPITestCase): """Tests for LocateMixin.""" - def setUp(self): - """Set up the test case.""" - super().setUp() - - # Activate plugin + @classmethod + def setUpTestData(cls): + """Activate the plugin once for the whole class.""" + super().setUpTestData() registry.set_plugin_state('samplelocate', True) fixtures = ['category', 'part', 'location', 'stock'] diff --git a/src/backend/InvenTree/plugin/builtin/events/test_auto_create_builds.py b/src/backend/InvenTree/plugin/builtin/events/test_auto_create_builds.py index 92d2711f4f..212254e7d6 100644 --- a/src/backend/InvenTree/plugin/builtin/events/test_auto_create_builds.py +++ b/src/backend/InvenTree/plugin/builtin/events/test_auto_create_builds.py @@ -22,6 +22,9 @@ class AutoCreateBuildsTest(InvenTreeTestCase): """ super().setUpTestData() + # Changing plugin state forces a registry reload - do it once per class + registry.set_plugin_state('autocreatebuilds', True) + cls.top = Part.objects.create(name='Top', description='Top', assembly=True) cls.sub_1 = Part.objects.create( name='Sub 1', description='Sub 1', assembly=True, component=True @@ -41,7 +44,6 @@ class AutoCreateBuildsTest(InvenTreeTestCase): """Activate the plugin and create a top-level build order.""" super().setUp() - registry.set_plugin_state('autocreatebuilds', True) self.plugin = registry.get_plugin('autocreatebuilds') self.build = Build.objects.create( diff --git a/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py b/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py index c86f8feaff..3d91a92179 100644 --- a/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py +++ b/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py @@ -9,6 +9,14 @@ from part.models import Part from plugin import registry from stock.models import StockItem, StockLocation +# Supplier barcode plugins which must be active for these tests +SUPPLIER_PLUGINS = { + 'digikeyplugin': True, + 'mouserplugin': True, + 'lcscplugin': True, + 'tmeplugin': True, +} + class SupplierBarcodeTests(InvenTreeAPITestCase): """Tests barcode parsing for all suppliers.""" @@ -28,10 +36,7 @@ class SupplierBarcodeTests(InvenTreeAPITestCase): super().setUpTestData() # Enable the plugins - registry.set_plugin_state('digikeyplugin', True) - registry.set_plugin_state('mouserplugin', True) - registry.set_plugin_state('lcscplugin', True) - registry.set_plugin_state('tmeplugin', True) + registry.set_plugin_states(SUPPLIER_PLUGINS) part = Part.objects.create(name='Test Part', description='Test Part') @@ -67,11 +72,9 @@ class SupplierBarcodeTests(InvenTreeAPITestCase): # Assign supplier information to the plugins # Add supplier information to each custom plugin - registry.set_plugin_state('digikeyplugin', True) digikey_plugin = registry.get_plugin('digikeyplugin') digikey_plugin.set_setting('SUPPLIER_ID', digikey_supplier.pk) - registry.set_plugin_state('mouserplugin', True) mouser_plugin = registry.get_plugin('mouserplugin') mouser_plugin.set_setting('SUPPLIER_ID', mouser_supplier.pk) @@ -185,14 +188,25 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): roles = ['stock.view', 'stock_location.view', 'purchase_order.add'] + @classmethod + def setUpTestData(cls): + """Enable the supplier plugins once for the whole class. + + Changing plugin state forces a full registry reload, so avoid doing it per-test. + """ + super().setUpTestData() + registry.set_plugin_states(SUPPLIER_PLUGINS) + def setUp(self): """Create supplier part and purchase_order.""" super().setUp() - registry.set_plugin_state('digikeyplugin', True) - registry.set_plugin_state('mouserplugin', True) - registry.set_plugin_state('lcscplugin', True) - registry.set_plugin_state('tmeplugin', True) + # The plugin instances persist across tests (no per-test registry reload), + # so clear the cached supplier - it refers to a company from a previous test + for slug in SUPPLIER_PLUGINS: + plugin = registry.get_plugin(slug) + if hasattr(plugin, '_supplier'): + del plugin._supplier self.loc_1 = StockLocation.objects.create(name='Location 1') self.loc_2 = StockLocation.objects.create(name='Location 2') @@ -249,11 +263,9 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): self.purchase_order2.add_line_item(supplier_part, 5, destination=self.loc_2) # Add supplier information to each custom plugin - registry.set_plugin_state('digikeyplugin', True) digikey_plugin = registry.get_plugin('digikeyplugin') digikey_plugin.set_setting('SUPPLIER_ID', digikey_supplier.pk) - registry.set_plugin_state('mouserplugin', True) mouser_plugin = registry.get_plugin('mouserplugin') mouser_plugin.set_setting('SUPPLIER_ID', mouser.pk) diff --git a/src/backend/InvenTree/plugin/models.py b/src/backend/InvenTree/plugin/models.py index 86704bd245..ced9834ca5 100644 --- a/src/backend/InvenTree/plugin/models.py +++ b/src/backend/InvenTree/plugin/models.py @@ -141,6 +141,25 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model): ) # plugin cannot be pickled in some circumstances when used with drf views, remove it (#5408) return state + def warn_reload(self, stacklevel: int = 2): + """Emit a warning (in plugin testing mode) that this config triggered a registry reload.""" + if settings.PLUGIN_TESTING: + warnings.warn( + f'A plugin registry reload was triggered for plugin {self.key}', + stacklevel=stacklevel, + ) + + def reload_required(self) -> bool: + """Return True if saving this config would require a plugin registry reload. + + A reload is required when the 'active' status has changed since the + config was loaded (or last saved), unless the plugin is mandatory. + """ + if self.is_mandatory(): + return False + + return self.active != self.__org_active + def save(self, force_insert=False, force_update=False, *args, **kwargs): """Extend save method to reload plugins if the 'active' status changes.""" no_reload = kwargs.pop('no_reload', False) # check if no_reload flag is set @@ -151,14 +170,12 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model): # Force active if mandatory plugin self.active = True + reload_required = self.reload_required() + super().save(force_insert, force_update, *args, **kwargs) - if not no_reload and self.active != self.__org_active and not mandatory: - if settings.PLUGIN_TESTING: - warnings.warn( - f'A plugin registry reload was triggered for plugin {self.key}', - stacklevel=2, - ) + if not no_reload and reload_required: + self.warn_reload(stacklevel=3) registry.reload_plugins(full_reload=True, force_reload=True, collect=True) self.__org_active = self.active diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index 363b2ad266..1e67d732c0 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -704,13 +704,18 @@ class PluginLockedSettingsTest(PluginMixin, InvenTreeAPITestCase): UNLOCKED_KEY = 'CHOICE_SETTING' - def setUp(self): - """Activate the sample plugin and confirm it is ready.""" - super().setUp() + @classmethod + def setUpTestData(cls): + """Activate the sample plugin once for the whole class.""" + super().setUpTestData() from plugin import registry - registry.set_plugin_state(self.PLUGIN_SLUG, True) + registry.set_plugin_state(cls.PLUGIN_SLUG, True) + + def setUp(self): + """Confirm the sample plugin is ready.""" + super().setUp() self.cfg = PluginConfig.objects.filter(key=self.PLUGIN_SLUG).first() self.assertIsNotNone(self.cfg) @@ -840,12 +845,17 @@ class PluginUnauthenticatedAccessTest(PluginMixin, InvenTreeAPITestCase): PLUGIN_SLUG = 'sample' SETTING_KEY = 'API_KEY' - def setUp(self): - """Activate sample plugin, then log out to simulate an anonymous client.""" - super().setUp() + @classmethod + def setUpTestData(cls): + """Activate the sample plugin once for the whole class.""" + super().setUpTestData() from plugin import registry - registry.set_plugin_state(self.PLUGIN_SLUG, True) + registry.set_plugin_state(cls.PLUGIN_SLUG, True) + + def setUp(self): + """Log out to simulate an anonymous client.""" + super().setUp() self.client.logout() def test_plugin_detail_unauthenticated(self):