@@ -441,3 +441,197 @@ func TestRemoveDuplicatePeerKeys_NoTable(t *testing.T) {
441441 err := migration .RemoveDuplicatePeerKeys (context .Background (), db )
442442 require .NoError (t , err , "Should not fail when table does not exist" )
443443}
444+
445+ type testParent struct {
446+ ID string `gorm:"primaryKey"`
447+ }
448+
449+ func (testParent ) TableName () string {
450+ return "test_parents"
451+ }
452+
453+ type testChild struct {
454+ ID string `gorm:"primaryKey"`
455+ ParentID string
456+ }
457+
458+ func (testChild ) TableName () string {
459+ return "test_children"
460+ }
461+
462+ type testChildWithFK struct {
463+ ID string `gorm:"primaryKey"`
464+ ParentID string `gorm:"index"`
465+ Parent * testParent `gorm:"foreignKey:ParentID"`
466+ }
467+
468+ func (testChildWithFK ) TableName () string {
469+ return "test_children"
470+ }
471+
472+ func setupOrphanTestDB (t * testing.T , models ... any ) * gorm.DB {
473+ t .Helper ()
474+ db := setupDatabase (t )
475+ for _ , m := range models {
476+ _ = db .Migrator ().DropTable (m )
477+ }
478+ err := db .AutoMigrate (models ... )
479+ require .NoError (t , err , "Failed to auto-migrate tables" )
480+ return db
481+ }
482+
483+ func TestCleanupOrphanedResources_NoChildTable (t * testing.T ) {
484+ db := setupDatabase (t )
485+ _ = db .Migrator ().DropTable (& testChild {})
486+ _ = db .Migrator ().DropTable (& testParent {})
487+
488+ err := migration .CleanupOrphanedResources [testChild , testParent ](context .Background (), db , "parent_id" )
489+ require .NoError (t , err , "Should not fail when child table does not exist" )
490+ }
491+
492+ func TestCleanupOrphanedResources_NoParentTable (t * testing.T ) {
493+ db := setupDatabase (t )
494+ _ = db .Migrator ().DropTable (& testParent {})
495+ _ = db .Migrator ().DropTable (& testChild {})
496+
497+ err := db .AutoMigrate (& testChild {})
498+ require .NoError (t , err )
499+
500+ err = migration .CleanupOrphanedResources [testChild , testParent ](context .Background (), db , "parent_id" )
501+ require .NoError (t , err , "Should not fail when parent table does not exist" )
502+ }
503+
504+ func TestCleanupOrphanedResources_EmptyTables (t * testing.T ) {
505+ db := setupOrphanTestDB (t , & testParent {}, & testChild {})
506+
507+ err := migration .CleanupOrphanedResources [testChild , testParent ](context .Background (), db , "parent_id" )
508+ require .NoError (t , err , "Should not fail on empty tables" )
509+
510+ var count int64
511+ db .Model (& testChild {}).Count (& count )
512+ assert .Equal (t , int64 (0 ), count )
513+ }
514+
515+ func TestCleanupOrphanedResources_NoOrphans (t * testing.T ) {
516+ db := setupOrphanTestDB (t , & testParent {}, & testChild {})
517+
518+ require .NoError (t , db .Create (& testParent {ID : "p1" }).Error )
519+ require .NoError (t , db .Create (& testParent {ID : "p2" }).Error )
520+ require .NoError (t , db .Create (& testChild {ID : "c1" , ParentID : "p1" }).Error )
521+ require .NoError (t , db .Create (& testChild {ID : "c2" , ParentID : "p2" }).Error )
522+
523+ err := migration .CleanupOrphanedResources [testChild , testParent ](context .Background (), db , "parent_id" )
524+ require .NoError (t , err )
525+
526+ var count int64
527+ db .Model (& testChild {}).Count (& count )
528+ assert .Equal (t , int64 (2 ), count , "All children should remain when no orphans" )
529+ }
530+
531+ func TestCleanupOrphanedResources_AllOrphans (t * testing.T ) {
532+ db := setupOrphanTestDB (t , & testParent {}, & testChild {})
533+
534+ require .NoError (t , db .Exec ("INSERT INTO test_children (id, parent_id) VALUES (?, ?)" , "c1" , "gone1" ).Error )
535+ require .NoError (t , db .Exec ("INSERT INTO test_children (id, parent_id) VALUES (?, ?)" , "c2" , "gone2" ).Error )
536+ require .NoError (t , db .Exec ("INSERT INTO test_children (id, parent_id) VALUES (?, ?)" , "c3" , "gone3" ).Error )
537+
538+ err := migration .CleanupOrphanedResources [testChild , testParent ](context .Background (), db , "parent_id" )
539+ require .NoError (t , err )
540+
541+ var count int64
542+ db .Model (& testChild {}).Count (& count )
543+ assert .Equal (t , int64 (0 ), count , "All orphaned children should be deleted" )
544+ }
545+
546+ func TestCleanupOrphanedResources_MixedValidAndOrphaned (t * testing.T ) {
547+ db := setupOrphanTestDB (t , & testParent {}, & testChild {})
548+
549+ require .NoError (t , db .Create (& testParent {ID : "p1" }).Error )
550+ require .NoError (t , db .Create (& testParent {ID : "p2" }).Error )
551+
552+ require .NoError (t , db .Create (& testChild {ID : "c1" , ParentID : "p1" }).Error )
553+ require .NoError (t , db .Create (& testChild {ID : "c2" , ParentID : "p2" }).Error )
554+ require .NoError (t , db .Create (& testChild {ID : "c3" , ParentID : "p1" }).Error )
555+
556+ require .NoError (t , db .Exec ("INSERT INTO test_children (id, parent_id) VALUES (?, ?)" , "c4" , "gone1" ).Error )
557+ require .NoError (t , db .Exec ("INSERT INTO test_children (id, parent_id) VALUES (?, ?)" , "c5" , "gone2" ).Error )
558+
559+ err := migration .CleanupOrphanedResources [testChild , testParent ](context .Background (), db , "parent_id" )
560+ require .NoError (t , err )
561+
562+ var remaining []testChild
563+ require .NoError (t , db .Order ("id" ).Find (& remaining ).Error )
564+
565+ assert .Len (t , remaining , 3 , "Only valid children should remain" )
566+ assert .Equal (t , "c1" , remaining [0 ].ID )
567+ assert .Equal (t , "c2" , remaining [1 ].ID )
568+ assert .Equal (t , "c3" , remaining [2 ].ID )
569+ }
570+
571+ func TestCleanupOrphanedResources_Idempotent (t * testing.T ) {
572+ db := setupOrphanTestDB (t , & testParent {}, & testChild {})
573+
574+ require .NoError (t , db .Create (& testParent {ID : "p1" }).Error )
575+ require .NoError (t , db .Create (& testChild {ID : "c1" , ParentID : "p1" }).Error )
576+ require .NoError (t , db .Exec ("INSERT INTO test_children (id, parent_id) VALUES (?, ?)" , "c2" , "gone" ).Error )
577+
578+ ctx := context .Background ()
579+
580+ err := migration .CleanupOrphanedResources [testChild , testParent ](ctx , db , "parent_id" )
581+ require .NoError (t , err )
582+
583+ var count int64
584+ db .Model (& testChild {}).Count (& count )
585+ assert .Equal (t , int64 (1 ), count )
586+
587+ err = migration .CleanupOrphanedResources [testChild , testParent ](ctx , db , "parent_id" )
588+ require .NoError (t , err )
589+
590+ db .Model (& testChild {}).Count (& count )
591+ assert .Equal (t , int64 (1 ), count , "Count should remain the same after second run" )
592+ }
593+
594+ func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists (t * testing.T ) {
595+ engine := os .Getenv ("NETBIRD_STORE_ENGINE" )
596+ if engine != "postgres" && engine != "mysql" {
597+ t .Skip ("FK constraint early-exit test requires postgres or mysql" )
598+ }
599+
600+ db := setupDatabase (t )
601+ _ = db .Migrator ().DropTable (& testChildWithFK {})
602+ _ = db .Migrator ().DropTable (& testParent {})
603+
604+ err := db .AutoMigrate (& testParent {}, & testChildWithFK {})
605+ require .NoError (t , err )
606+
607+ require .NoError (t , db .Create (& testParent {ID : "p1" }).Error )
608+ require .NoError (t , db .Create (& testParent {ID : "p2" }).Error )
609+ require .NoError (t , db .Create (& testChildWithFK {ID : "c1" , ParentID : "p1" }).Error )
610+ require .NoError (t , db .Create (& testChildWithFK {ID : "c2" , ParentID : "p2" }).Error )
611+
612+ switch engine {
613+ case "postgres" :
614+ require .NoError (t , db .Exec ("ALTER TABLE test_children DROP CONSTRAINT fk_test_children_parent" ).Error )
615+ require .NoError (t , db .Exec ("DELETE FROM test_parents WHERE id = ?" , "p2" ).Error )
616+ require .NoError (t , db .Exec (
617+ "ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent " +
618+ "FOREIGN KEY (parent_id) REFERENCES test_parents(id) NOT VALID" ,
619+ ).Error )
620+ case "mysql" :
621+ require .NoError (t , db .Exec ("SET FOREIGN_KEY_CHECKS = 0" ).Error )
622+ require .NoError (t , db .Exec ("ALTER TABLE test_children DROP FOREIGN KEY fk_test_children_parent" ).Error )
623+ require .NoError (t , db .Exec ("DELETE FROM test_parents WHERE id = ?" , "p2" ).Error )
624+ require .NoError (t , db .Exec (
625+ "ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent " +
626+ "FOREIGN KEY (parent_id) REFERENCES test_parents(id)" ,
627+ ).Error )
628+ require .NoError (t , db .Exec ("SET FOREIGN_KEY_CHECKS = 1" ).Error )
629+ }
630+
631+ err = migration .CleanupOrphanedResources [testChildWithFK , testParent ](context .Background (), db , "parent_id" )
632+ require .NoError (t , err )
633+
634+ var count int64
635+ db .Model (& testChildWithFK {}).Count (& count )
636+ assert .Equal (t , int64 (2 ), count , "Both rows should survive — migration must skip when FK constraint exists" )
637+ }
0 commit comments