@@ -44,3 +44,54 @@ fn test_in_memory_database() {
4444 // Verify it's usable (we can't really check much else)
4545 drop ( db) ;
4646}
47+
48+ /// Migration 017 must drop feature_set_members that point at a feature or
49+ /// feature set that no longer exists (orphans from the pre-refactor member
50+ /// identity), while keeping members that still resolve.
51+ #[ test]
52+ fn test_017_purges_orphaned_feature_set_members ( ) {
53+ let test_db = TestDatabase :: new ( ) ;
54+ let conn = test_db. db . connection ( ) ;
55+
56+ // Seed a space, a custom FS, one live feature, and three members:
57+ // m1 — valid feature member (points to the live feature) → keep
58+ // m2 — orphaned feature member (old "server/tool" identity) → purge
59+ // m3 — composition member pointing at a deleted FS → purge
60+ conn. execute_batch (
61+ "INSERT INTO spaces (id,name,icon,description,is_default,sort_order,created_at,updated_at)
62+ VALUES ('s1','S','x','',0,0,datetime('now'),datetime('now'));
63+ INSERT INTO feature_sets
64+ (id,name,description,icon,space_id,feature_set_type,server_id,is_builtin,is_deleted,created_at,updated_at)
65+ VALUES ('fs1','FS','','','s1','custom',NULL,0,0,datetime('now'),datetime('now'));
66+ INSERT INTO server_features
67+ (id,space_id,server_id,feature_type,feature_name,discovered_at,last_seen_at,is_available)
68+ VALUES ('feat-live','s1','srv','tool','do_thing',datetime('now'),datetime('now'),1);
69+ INSERT INTO feature_set_members (id,feature_set_id,member_type,member_id,mode,created_at) VALUES
70+ ('m1','fs1','feature','feat-live','include',datetime('now')),
71+ ('m2','fs1','feature','srv/do_thing','include',datetime('now')),
72+ ('m3','fs1','feature_set','fs-gone','include',datetime('now'));" ,
73+ )
74+ . expect ( "seed failed" ) ;
75+
76+ // Re-apply migration 017 (idempotent) to exercise the purge on the seeded orphans.
77+ conn. execute_batch ( include_str ! (
78+ "../../../../crates/mcpmux-storage/src/migrations/017_purge_orphaned_feature_set_members.sql"
79+ ) )
80+ . expect ( "migration 017 failed" ) ;
81+
82+ let remaining: Vec < String > = {
83+ let mut stmt = conn
84+ . prepare ( "SELECT member_id FROM feature_set_members WHERE feature_set_id='fs1' ORDER BY member_id" )
85+ . unwrap ( ) ;
86+ stmt. query_map ( [ ] , |r| r. get :: < _ , String > ( 0 ) )
87+ . unwrap ( )
88+ . collect :: < Result < _ , _ > > ( )
89+ . unwrap ( )
90+ } ;
91+
92+ assert_eq ! (
93+ remaining,
94+ vec![ "feat-live" . to_string( ) ] ,
95+ "only the still-resolvable feature member should survive the purge"
96+ ) ;
97+ }
0 commit comments