2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-08-06 12:01:41 +00:00

chore(backend): increase coverage (#10121)

* chore(backend): increase coverage

* add a full api based install / uninstall test

* fix asserted code

* delete unreachable code

* clean up unused code

* add more notification tests

* fix test

* order currencies
This commit is contained in:
Matthias Mair
2025-08-06 00:47:23 +02:00
committed by GitHub
parent 5574e7cf6b
commit febe5809e7
9 changed files with 185 additions and 130 deletions

View File

@@ -5,11 +5,7 @@ from django.apps import AppConfig
import structlog
import InvenTree.ready
from common.settings import (
get_global_setting,
global_setting_overrides,
set_global_setting,
)
from common.settings import get_global_setting, set_global_setting
logger = structlog.get_logger('inventree')
@@ -24,11 +20,10 @@ class CommonConfig(AppConfig):
def ready(self):
"""Initialize restart flag clearance on startup."""
if InvenTree.ready.isRunningMigrations():
if InvenTree.ready.isRunningMigrations(): # pragma: no cover
return
self.clear_restart_flag()
self.override_global_settings()
def clear_restart_flag(self):
"""Clear the SERVER_RESTART_REQUIRED setting."""
@@ -40,28 +35,5 @@ class CommonConfig(AppConfig):
if not InvenTree.ready.isImportingData():
set_global_setting('SERVER_RESTART_REQUIRED', False, None)
except Exception:
except Exception: # pragma: no cover
pass
def override_global_settings(self):
"""Update global settings based on environment variables."""
overrides = global_setting_overrides()
if not overrides:
return
for key, value in overrides.items():
try:
current_value = get_global_setting(key, create=False)
if current_value != value:
logger.info(
'INVE-I1: Overriding global setting: %s = %s',
value,
current_value,
)
set_global_setting(key, value, None, create=True)
except Exception:
logger.warning('Failed to override global setting %s -> %s', key, value)
continue

View File

@@ -18,13 +18,13 @@ class CreateModelOrSkip(migrations.CreateModel):
try:
super().database_forwards(app_label, schema_editor, from_state, to_state)
except Exception:
except Exception: # pragma: no cover
pass
def state_forwards(self, app_label, state) -> None:
try:
super().state_forwards(app_label, state)
except Exception:
except Exception: # pragma: no cover
pass

View File

@@ -49,18 +49,18 @@ def set_currencies(apps, schema_editor):
value = ','.join(valid_codes)
if not settings.TESTING:
if not settings.TESTING: # pragma: no cover
print(f"Found existing currency codes:", value)
setting = InvenTreeSetting.objects.filter(key=key).first()
if setting:
if not settings.TESTING:
if not settings.TESTING: # pragma: no cover
print(f"- Updating existing setting for currency codes")
setting.value = value
setting.save()
else:
if not settings.TESTING:
if not settings.TESTING: # pragma: no cover
print(f"- Creating new setting for currency codes")
setting = InvenTreeSetting(key=key, value=value)
setting.save()

View File

@@ -288,7 +288,7 @@ class BaseInvenTreeSetting(models.Model):
try:
cache.set(key, self, timeout=3600)
except Exception:
except Exception: # pragma: no cover
pass
@classmethod
@@ -558,7 +558,7 @@ class BaseInvenTreeSetting(models.Model):
or InvenTree.ready.isRunningMigrations()
or InvenTree.ready.isRebuildingData()
or InvenTree.ready.isRunningBackup()
):
): # pragma: no cover
create = False
access_global_cache = False
@@ -706,7 +706,7 @@ class BaseInvenTreeSetting(models.Model):
or InvenTree.ready.isRunningMigrations()
or InvenTree.ready.isRebuildingData()
or InvenTree.ready.isRunningBackup()
):
): # pragma: no cover
return
attempts = int(kwargs.get('attempts', 3))
@@ -731,7 +731,7 @@ class BaseInvenTreeSetting(models.Model):
logger.warning("Database is locked, cannot set setting '%s'", key)
# Likely the DB is locked - not much we can do here
return
except Exception as exc:
except Exception as exc: # pragma: no cover
logger.exception(
"Error setting setting '%s' for %s: %s", key, str(cls), str(type(exc))
)
@@ -766,7 +766,7 @@ class BaseInvenTreeSetting(models.Model):
except (OperationalError, ProgrammingError):
logger.warning("Database is locked, cannot set setting '%s'", key)
# Likely the DB is locked - not much we can do here
except Exception as exc:
except Exception as exc: # pragma: no cover
# Some other error
logger.exception(
"Error setting setting '%s' for %s: %s", key, str(cls), str(type(exc))

View File

@@ -22,92 +22,6 @@ logger = structlog.get_logger('inventree')
# region methods
class NotificationMethod:
"""Base class for notification methods."""
METHOD_NAME = ''
METHOD_ICON = None
CONTEXT_BUILTIN = ['name', 'message']
CONTEXT_EXTRA = []
GLOBAL_SETTING = None
USER_SETTING = None
def __init__(self, obj: Model, category: str, targets: list, context) -> None:
"""Check that the method is read.
This checks that:
- All needed functions are implemented
- The method is not disabled via plugin
- All needed context values were provided
"""
# Check if a sending fnc is defined
if (not hasattr(self, 'send')) and (not hasattr(self, 'send_bulk')):
raise NotImplementedError(
'A NotificationMethod must either define a `send` or a `send_bulk` method'
)
# No method name is no good
if self.METHOD_NAME in ('', None):
raise NotImplementedError(
f'The NotificationMethod {self.__class__} did not provide a METHOD_NAME'
)
# Check if plugin is disabled - if so do not gather targets etc.
if self.global_setting_disable():
self.targets = None
return
# Define arguments
self.obj = obj
self.category = category
self.targets = targets
self.context = self.check_context(context)
# Gather targets
self.targets = self.get_targets()
def check_context(self, context):
"""Check that all values defined in the methods CONTEXT were provided in the current context."""
def check(ref, obj):
# the obj is not accessible so we are on the end
if not isinstance(obj, (list, dict, tuple)):
return ref
# check if the ref exists
if isinstance(ref, str):
if not obj.get(ref):
return ref
return False
# nested
elif isinstance(ref, (tuple, list)):
if len(ref) == 1:
return check(ref[0], obj)
ret = check(ref[0], obj)
if ret:
return ret
return check(ref[1:], obj[ref[0]])
# other cases -> raise
raise NotImplementedError(
'This type can not be used as a context reference'
)
missing = []
for item in (*self.CONTEXT_BUILTIN, *self.CONTEXT_EXTRA):
ret = check(item, context)
if ret:
missing.append(ret)
if missing:
raise NotImplementedError(
f'The `context` is missing the following items:\n{missing}'
)
return context
@dataclass()
class NotificationBody:
"""Information needed to create a notification.
@@ -182,7 +96,7 @@ def trigger_notification(
kwargs: Additional arguments to pass to the notification method
"""
# Check if data is importing currently
if isImportingData() or isRebuildingData():
if isImportingData() or isRebuildingData(): # pragma: no cover
return
targets = kwargs.get('targets')

View File

@@ -1,6 +1,7 @@
"""Data migration unit tests for the 'common' app."""
import io
import os
from django.core.files.base import ContentFile
@@ -208,3 +209,49 @@ class TestForwardMigrations(MigratorTestCase):
'stockitem',
]:
self.assertEqual(Attachment.objects.filter(model_type=model).count(), 2)
def prep_currency_migration(self, vals: str):
"""Prepare the environment for the currency migration tests."""
# Set keys
os.environ['INVENTREE_CURRENCIES'] = vals
# And setting
InvenTreeSetting = self.old_state.apps.get_model('common', 'InvenTreeSetting')
setting = InvenTreeSetting(key='CURRENCY_CODES', value='123')
setting.save()
class TestCurrencyMigration(MigratorTestCase):
"""Test currency migration."""
migrate_from = ('common', '0022_projectcode_responsible')
migrate_to = ('common', '0023_auto_20240602_1332')
def prepare(self):
"""Prepare the environment for the migration test."""
prep_currency_migration(self, 'USD,EUR,GBP')
def test_currency_migration(self):
"""Test that the currency migration works."""
InvenTreeSetting = self.old_state.apps.get_model('common', 'InvenTreeSetting')
setting = InvenTreeSetting.objects.filter(key='CURRENCY_CODES').first()
self.assertEqual(setting.value, 'EUR,GBP,USD')
class TestCurrencyMigrationNo(MigratorTestCase):
"""Test currency migration."""
migrate_from = ('common', '0022_projectcode_responsible')
migrate_to = ('common', '0023_auto_20240602_1332')
def prepare(self):
"""Prepare the environment for the migration test."""
prep_currency_migration(self, 'YYY,ZZZ')
def test_currency_migration(self):
"""Test that no currency migration occurs if wrong currencies are set."""
InvenTreeSetting = self.old_state.apps.get_model('common', 'InvenTreeSetting')
setting = InvenTreeSetting.objects.filter(key='CURRENCY_CODES').first()
self.assertEqual(setting.value, '123')

View File

@@ -8,6 +8,7 @@ from http import HTTPStatus
from unittest import mock
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.exceptions import ValidationError
@@ -21,6 +22,7 @@ from django.urls import reverse
import PIL
import common.validators
from common.notifications import trigger_notification
from common.settings import get_global_setting, set_global_setting
from InvenTree.helpers import str2bool
from InvenTree.unit_test import (
@@ -1073,6 +1075,7 @@ class NotificationTest(InvenTreeAPITestCase):
"""Tests for NotificationEntry."""
fixtures = ['users']
roles = ['admin.view']
def test_check_notification_entries(self):
"""Test that notification entries can be created."""
@@ -1162,6 +1165,72 @@ class NotificationTest(InvenTreeAPITestCase):
self.assertEqual(NotificationMessage.objects.count(), 13)
self.assertEqual(NotificationMessage.objects.filter(user=self.user).count(), 3)
def test_simple(self):
"""Test that a simple notification can be created."""
trigger_notification(
Group.objects.get(name='Sales'),
user=self.user,
data={'message': 'This is a test notification'},
)
def test_with_group(self):
"""Test that a notification can be created with a group."""
grp = Group.objects.get(name='Sales')
trigger_notification(
grp,
user=self.user,
data={'message': 'This is a test notification with group'},
targets=[grp],
)
def test_wrong_target(self):
"""Test that a notification with an invalid target raises an error."""
with self.assertLogs() as cm:
trigger_notification(
Group.objects.get(name='Sales'),
user=self.user,
data={'message': 'This is a test notification'},
targets=['invalid_target'],
)
self.assertIn('Unknown target passed to t', str(cm[1]))
def test_wrong_obj(self):
"""Test that a object without a reference is raising an issue."""
class SampleObj:
pass
with self.assertRaises(KeyError) as cm:
trigger_notification(
SampleObj(),
user=self.user,
data={'message': 'This is a test notification'},
)
self.assertIn('Could not resolve an object reference for', str(cm.exception))
# Without reference, it should not raise an error
trigger_notification(
Group.objects.get(name='Sales'),
user=self.user,
data={'message': 'This is a test notification'},
)
def test_recent(self):
"""Test that a notification is not created if it was already sent recently."""
grp = Group.objects.get(name='Sales')
trigger_notification( #
grp, category='core', context={'name': 'test'}, targets=[self.user]
)
self.assertEqual(NotificationMessage.objects.count(), 1)
# Should not create a new notification
with self.assertLogs(logger='inventree') as cm:
trigger_notification(
grp, category='core', context={'name': 'test'}, targets=[self.user]
)
self.assertEqual(NotificationMessage.objects.count(), 1)
self.assertIn('as recently been sent for', str(cm[1]))
class CommonTest(InvenTreeAPITestCase):
"""Tests for the common config."""

View File

@@ -32,6 +32,7 @@ class InvenTreeUINotifications(NotificationMixin, InvenTreePlugin):
"""Create a UI notification entry for specified users."""
from common.models import NotificationMessage
ctx = context if context else {}
entries = []
if not users:
@@ -45,8 +46,8 @@ class InvenTreeUINotifications(NotificationMixin, InvenTreePlugin):
source_object=user,
user=user,
category=category,
name=context['name'],
message=context['message'],
name=ctx.get('name'),
message=ctx.get('message'),
)
)

View File

@@ -594,3 +594,55 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
self.assertEqual(Y_MANDATORY_2, Y_MANDATORY + 1)
self.assertEqual(N_MANDATORY_2, N_MANDATORY - 1)
class PluginFullAPITest(PluginMixin, InvenTreeAPITestCase):
"""Tests the plugin API endpoints."""
superuser = True
@override_settings(PLUGIN_TESTING_SETUP=True)
def test_full_process(self):
"""Test the full plugin install/uninstall process via API."""
install_slug = 'inventree-brother-plugin'
slug = 'brother'
# Install a plugin
data = self.post(
reverse('api-plugin-install'),
{'confirm': True, 'packagename': install_slug},
expected_code=201,
max_query_time=30,
max_query_count=370,
).data
self.assertEqual(data['success'], 'Installed plugin successfully')
# Activate the plugin
data = self.patch(
reverse('api-plugin-detail-activate', kwargs={'plugin': slug}),
data={'active': True},
max_query_count=320,
).data
self.assertEqual(data['active'], True)
# Check if the plugin is installed
test_plg = PluginConfig.objects.get(key=slug)
self.assertIsNotNone(test_plg, 'Test plugin not found')
self.assertTrue(test_plg.is_active())
# De-activate and uninstall the plugin
data = self.patch(
reverse('api-plugin-detail-activate', kwargs={'plugin': slug}),
data={'active': False},
max_query_count=380,
).data
self.assertEqual(data['active'], False)
response = self.patch(
reverse('api-plugin-uninstall', kwargs={'plugin': slug}),
max_query_count=305,
)
self.assertEqual(response.status_code, 200)
# Successful uninstallation
with self.assertRaises(PluginConfig.DoesNotExist):
PluginConfig.objects.get(key=slug)