From 0fe49796f6e1a2cef79eebdfac8802495d0c4a73 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 3 Jul 2026 14:11:49 +0000 Subject: [PATCH] Add unit tests for data migrations --- .../InvenTree/common/test_migrations.py | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) diff --git a/src/backend/InvenTree/common/test_migrations.py b/src/backend/InvenTree/common/test_migrations.py index c6e62cefef..f4ede60bef 100644 --- a/src/backend/InvenTree/common/test_migrations.py +++ b/src/backend/InvenTree/common/test_migrations.py @@ -290,6 +290,237 @@ class TestAttachmentThumbnailMigration(MigratorTestCase): self.assertFalse(att.thumbnail) +class TestNoteMigrations(MigratorTestCase): + """Test data migration of legacy 'notes' fields to the new Note model. + + - Migration common.0047 copies existing 'notes' field data into the Note model, + converting the markdown content to HTML, and links any associated NotesImage objects. + - Migration common.0048 removes the legacy 'model_type' and 'model_id' fields from NotesImage. + """ + + # Note: these targets must match the dependencies of the data migration (common.0047), + # to ensure that the migration plan is truncated *before* the data migration is applied + migrate_from = [ + ('common', '0046_note'), + ('build', '0059_build_tags'), + ('company', '0079_auto_20260212_1054'), + ('order', '0119_transferorderlineitem_line_int'), + ('part', '0150_part_maximum_stock'), + ('stock', '0123_remove_stockitem_review_needed'), + ] + migrate_to = ('common', '0048_remove_notesimage_model_id_and_more') + + def generate_image(self, name: str): + """Generate a dummy image file for upload.""" + buf = io.BytesIO() + Image.new('RGB', (64, 64), color='red').save(buf, format='PNG') + return ContentFile(buf.getvalue(), name=name) + + def get_model(self, app: str, model: str): + """Fetch a historical model, working around the duplicate-column ORM bug. + + Historical models with a custom status field enumerate 'status_custom_key' twice + (once from contribute_to_class on the status field, once from the explicit + AddField migration), so ORM-generated INSERTs fail with + 'column specified more than once'. Remove the duplicated field entries here. + """ + model_class = self.old_state.apps.get_model(app, model) + + seen = set() + + for field in list(model_class._meta.local_fields): + if field.name in seen: + model_class._meta.local_fields.remove(field) + else: + seen.add(field.name) + + model_class._meta._expire_cache() + + return model_class + + def prepare(self): + """Create instances of each model type which supports notes.""" + # Dummy MPPT data + tree = {'tree_id': 0, 'level': 0, 'lft': 0, 'rght': 0} + + NotesImage = self.get_model('common', 'NotesImage') + + Part = self.get_model('part', 'Part') + Build = self.get_model('build', 'Build') + StockItem = self.get_model('stock', 'StockItem') + Company = self.get_model('company', 'Company') + ManufacturerPart = self.get_model('company', 'ManufacturerPart') + SupplierPart = self.get_model('company', 'SupplierPart') + PurchaseOrder = self.get_model('order', 'PurchaseOrder') + SalesOrder = self.get_model('order', 'SalesOrder') + ReturnOrder = self.get_model('order', 'ReturnOrder') + SalesOrderShipment = self.get_model('order', 'SalesOrderShipment') + TransferOrder = self.get_model('order', 'TransferOrder') + + # An image which is not linked to any model, but is embedded in the notes markdown + embedded_image = NotesImage.objects.create( + image=self.generate_image('embedded.png') + ) + + # An image which is not linked to any model, and not referenced anywhere + NotesImage.objects.create(image=self.generate_image('orphan.png')) + + part = Part.objects.create( + name='Test Part', + description='Test Part Description', + active=True, + assembly=True, + purchaseable=True, + notes=f'Some **bold** part notes\n\n![image]({embedded_image.image.url})', + **tree, + ) + + # Parts with empty notes should not generate a Note entry + Part.objects.create( + name='Part with empty notes', description='x', notes='', **tree + ) + Part.objects.create( + name='Part with null notes', description='x', notes=None, **tree + ) + + # An image which is directly linked to the part instance + NotesImage.objects.create( + image=self.generate_image('linked.png'), model_type='part', model_id=part.pk + ) + + company = Company.objects.create( + name='Test Company', + description='Test Company Description', + is_customer=True, + is_manufacturer=True, + is_supplier=True, + notes='Some **bold** company notes', + ) + + so = SalesOrder.objects.create( + reference='SO-12345', + customer=company, + description='Test Sales Order Description', + notes='Some **bold** sales order notes', + ) + + instances = [ + ('part', part), + ('company', company), + ('salesorder', so), + ( + 'manufacturerpart', + ManufacturerPart.objects.create( + part=part, + manufacturer=company, + MPN='MPN-123', + notes='Some **bold** manufacturer part notes', + ), + ), + ( + 'supplierpart', + SupplierPart.objects.create( + part=part, + supplier=company, + SKU='SKU-123', + notes='Some **bold** supplier part notes', + ), + ), + ( + 'build', + Build.objects.create( + part=part, + reference='BO-0001', + title='Test Build', + quantity=10, + notes='Some **bold** build notes', + **tree, + ), + ), + ( + 'stockitem', + StockItem.objects.create( + part=part, + quantity=10, + notes='Some **bold** stock item notes', + **tree, + ), + ), + ( + 'purchaseorder', + PurchaseOrder.objects.create( + reference='PO-12345', + supplier=company, + description='Test Purchase Order Description', + notes='Some **bold** purchase order notes', + ), + ), + ( + 'returnorder', + ReturnOrder.objects.create( + reference='RO-12345', + customer=company, + description='Test Return Order Description', + notes='Some **bold** return order notes', + ), + ), + ( + 'salesordershipment', + SalesOrderShipment.objects.create( + order=so, reference='SHIP-001', notes='Some **bold** shipment notes' + ), + ), + ( + 'transferorder', + TransferOrder.objects.create( + reference='TO-12345', + description='Test Transfer Order Description', + notes='Some **bold** transfer order notes', + ), + ), + ] + + # Record the expected (model_type, model_id) values for later comparison + self.expected_notes = [(model, instance.pk) for model, instance in instances] + self.part_pk = part.pk + + def test_notes_migrated(self): + """Test that a Note object has been created for each legacy notes field.""" + Note = self.new_state.apps.get_model('common', 'Note') + ContentType = self.new_state.apps.get_model('contenttypes', 'ContentType') + + # One note per instance with non-empty notes (and no extras) + self.assertEqual(Note.objects.count(), len(self.expected_notes)) + + for model, pk in self.expected_notes: + content_type = ContentType.objects.get(model=model) + note = Note.objects.get(model_type=content_type, model_id=pk) + + self.assertEqual(note.title, 'Note') + self.assertTrue(note.primary) + self.assertFalse(note.template) + + # Markdown content has been converted to HTML + self.assertIn('bold', note.content) + self.assertNotIn('**', note.content) + + def test_images_migrated(self): + """Test that NotesImage objects are correctly linked or removed.""" + Note = self.new_state.apps.get_model('common', 'Note') + ContentType = self.new_state.apps.get_model('contenttypes', 'ContentType') + NotesImage = self.new_state.apps.get_model('common', 'NotesImage') + + # The orphaned image has been removed + self.assertEqual(NotesImage.objects.count(), 2) + + content_type = ContentType.objects.get(model='part') + note = Note.objects.get(model_type=content_type, model_id=self.part_pk) + + # Both the directly linked image and the embedded image point to the part note + for image in NotesImage.objects.all(): + self.assertEqual(image.note.pk, note.pk) + + def prep_currency_migration(self, vals: str): """Prepare the environment for the currency migration tests.""" # Set keys