@@ -151,3 +151,82 @@ fn test_018_rewrites_stale_starter_description_only() {
151151 "operator-customized copy must be preserved"
152152 ) ;
153153}
154+
155+ // ---------------------------------------------------------------------------
156+ // Upgrade path — applying NEW migrations to an EXISTING (older) on-disk DB.
157+ //
158+ // Every other test here uses a FRESH in-memory DB, where all migrations run at
159+ // once — so they never catch a migration that fails to apply when a real user
160+ // opens a database created by a previous release. These two do.
161+ // ---------------------------------------------------------------------------
162+
163+ fn table_exists ( db : & Database , name : & str ) -> bool {
164+ db. connection ( )
165+ . query_row (
166+ "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name=?1" ,
167+ [ name] ,
168+ |r| r. get :: < _ , bool > ( 0 ) ,
169+ )
170+ . unwrap_or ( false )
171+ }
172+
173+ fn column_exists ( db : & Database , table : & str , column : & str ) -> bool {
174+ let sql = format ! ( "SELECT COUNT(*) > 0 FROM pragma_table_info('{table}') WHERE name=?1" ) ;
175+ db. connection ( )
176+ . query_row ( & sql, [ column] , |r| r. get :: < _ , bool > ( 0 ) )
177+ . unwrap_or ( false )
178+ }
179+
180+ #[ test]
181+ fn test_new_schema_objects_exist_after_migration ( ) {
182+ // A fresh migrate must produce every object the API-key + mapping features
183+ // depend on — a regression guard against a migration being dropped or broken.
184+ let db = Database :: open_in_memory ( ) . expect ( "open" ) ;
185+ assert ! (
186+ table_exists( & db, "inbound_client_api_keys" ) ,
187+ "migration 020 must create inbound_client_api_keys"
188+ ) ;
189+ assert ! (
190+ column_exists( & db, "workspace_bindings" , "binding_type" ) ,
191+ "migration 021 must add workspace_bindings.binding_type"
192+ ) ;
193+ assert ! (
194+ column_exists( & db, "inbound_clients" , "locked_space_id" ) ,
195+ "migration 022 must add inbound_clients.locked_space_id"
196+ ) ;
197+ }
198+
199+ #[ test]
200+ fn test_pending_migrations_apply_to_an_existing_older_database ( ) {
201+ // Reproduce the real upgrade that surfaced "no such table:
202+ // inbound_client_api_keys" in the field: a DB created before 020/021/022
203+ // existed, reopened by a newer build. The pending migrations MUST apply.
204+ let dir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
205+ let path = dir. path ( ) . join ( "mcpmux.db" ) ;
206+
207+ // 1. Fully migrate, then roll the schema back to a pre-020 state.
208+ {
209+ let db = Database :: open ( & path) . expect ( "open" ) ;
210+ db. connection ( )
211+ . execute_batch (
212+ "DELETE FROM schema_migrations WHERE version >= 20;
213+ DROP TABLE IF EXISTS inbound_client_api_keys;
214+ ALTER TABLE workspace_bindings DROP COLUMN binding_type;
215+ ALTER TABLE inbound_clients DROP COLUMN locked_space_id;" ,
216+ )
217+ . expect ( "roll schema back to pre-020" ) ;
218+ assert ! (
219+ !table_exists( & db, "inbound_client_api_keys" ) ,
220+ "precondition: the rolled-back DB is missing the table"
221+ ) ;
222+ }
223+
224+ // 2. Reopen — run_migrations() must re-apply 020/021/022.
225+ let db = Database :: open ( & path) . expect ( "reopen older DB" ) ;
226+ assert ! (
227+ table_exists( & db, "inbound_client_api_keys" ) ,
228+ "reopening an older DB must re-create inbound_client_api_keys"
229+ ) ;
230+ assert ! ( column_exists( & db, "workspace_bindings" , "binding_type" ) ) ;
231+ assert ! ( column_exists( & db, "inbound_clients" , "locked_space_id" ) ) ;
232+ }
0 commit comments