@@ -328,6 +328,138 @@ func TestRestoreBackup_SucceedsWithoutSidecars(t *testing.T) {
328328 assert .NoError (t , RestoreBackup (backup .Path ))
329329}
330330
331+ // writeLegacyDBFile creates a tmpo.db on disk with a pre-migration schema (no
332+ // hourly_rate/milestone_name columns and no migration keys marked complete),
333+ // simulating a database from before the migration system existed.
334+ func writeLegacyDBFile (t * testing.T , path string ) {
335+ t .Helper ()
336+ db , err := sql .Open ("sqlite" , path )
337+ assert .NoError (t , err )
338+ defer db .Close ()
339+
340+ _ , err = db .Exec (`
341+ CREATE TABLE time_entries (
342+ id INTEGER PRIMARY KEY AUTOINCREMENT,
343+ project_name TEXT NOT NULL,
344+ start_time DATETIME NOT NULL,
345+ end_time DATETIME,
346+ description TEXT
347+ )
348+ ` )
349+ assert .NoError (t , err )
350+
351+ _ , err = db .Exec (`
352+ CREATE TABLE milestones (
353+ id INTEGER PRIMARY KEY AUTOINCREMENT,
354+ project_name TEXT NOT NULL,
355+ name TEXT NOT NULL,
356+ start_time DATETIME NOT NULL,
357+ end_time DATETIME,
358+ UNIQUE(project_name, name)
359+ )
360+ ` )
361+ assert .NoError (t , err )
362+ }
363+
364+ func TestInitialize_RemovesPreMigrationBackupOnSuccess (t * testing.T ) {
365+ tmpHome := t .TempDir ()
366+ t .Setenv ("HOME" , tmpHome )
367+ t .Setenv ("USERPROFILE" , tmpHome )
368+ t .Setenv ("TMPO_DEV" , "" )
369+
370+ // Seed a legacy database file with pending migrations
371+ tmpoDir := filepath .Join (tmpHome , ".tmpo" )
372+ assert .NoError (t , os .MkdirAll (tmpoDir , 0700 ))
373+ writeLegacyDBFile (t , filepath .Join (tmpoDir , "tmpo.db" ))
374+
375+ db , err := Initialize ()
376+ assert .NoError (t , err )
377+ defer db .Close ()
378+
379+ // Migrations succeeded, so the temporary pre-migration snapshot must be gone.
380+ // The user should see no backups they did not create themselves.
381+ backups , err := ListBackups ()
382+ assert .NoError (t , err )
383+ assert .Empty (t , backups , "successful migration should leave no pre-migration backup behind" )
384+
385+ // Migrations should have completed and upgraded the schema
386+ pending , err := db .hasPendingMigrations ()
387+ assert .NoError (t , err )
388+ assert .False (t , pending )
389+ assert .True (t , hasColumn (t , db , "time_entries" , "milestone_name" ))
390+ }
391+
392+ func TestInitialize_RetainsPreMigrationBackupOnFailure (t * testing.T ) {
393+ tmpHome := t .TempDir ()
394+ t .Setenv ("HOME" , tmpHome )
395+ t .Setenv ("USERPROFILE" , tmpHome )
396+ t .Setenv ("TMPO_DEV" , "" )
397+
398+ // Seed a legacy database whose start_time cannot be scanned as a timestamp,
399+ // forcing the UTC migration to fail partway through.
400+ tmpoDir := filepath .Join (tmpHome , ".tmpo" )
401+ assert .NoError (t , os .MkdirAll (tmpoDir , 0700 ))
402+ dbPath := filepath .Join (tmpoDir , "tmpo.db" )
403+ writeLegacyDBFile (t , dbPath )
404+
405+ seed , err := sql .Open ("sqlite" , dbPath )
406+ assert .NoError (t , err )
407+ _ , err = seed .Exec (
408+ "INSERT INTO time_entries (project_name, start_time, description) VALUES (?, ?, ?)" ,
409+ "broken-project" , "not-a-timestamp" , "corrupt row" ,
410+ )
411+ assert .NoError (t , err )
412+ assert .NoError (t , seed .Close ())
413+
414+ // Initialize must fail, and the error must point at the retained snapshot
415+ _ , err = Initialize ()
416+ assert .Error (t , err )
417+ assert .Contains (t , err .Error (), "pre-migration backup was preserved" )
418+
419+ // The snapshot must still be present for the user to restore from
420+ backups , err := ListBackups ()
421+ assert .NoError (t , err )
422+ assert .Len (t , backups , 1 )
423+ assert .True (t , strings .HasSuffix (backups [0 ].Filename , "-premigration.db" ))
424+ }
425+
426+ func TestInitialize_NoBackupForFreshDB (t * testing.T ) {
427+ tmpHome := t .TempDir ()
428+ t .Setenv ("HOME" , tmpHome )
429+ t .Setenv ("USERPROFILE" , tmpHome )
430+ t .Setenv ("TMPO_DEV" , "" )
431+
432+ // No pre-existing database file: this is a brand-new install
433+ db , err := Initialize ()
434+ assert .NoError (t , err )
435+ defer db .Close ()
436+
437+ backups , err := ListBackups ()
438+ assert .NoError (t , err )
439+ assert .Empty (t , backups , "fresh database should not trigger a pre-migration backup" )
440+ }
441+
442+ func TestInitialize_NoBackupWhenAlreadyMigrated (t * testing.T ) {
443+ tmpHome := t .TempDir ()
444+ t .Setenv ("HOME" , tmpHome )
445+ t .Setenv ("USERPROFILE" , tmpHome )
446+ t .Setenv ("TMPO_DEV" , "" )
447+
448+ // First run creates and fully migrates the database
449+ db , err := Initialize ()
450+ assert .NoError (t , err )
451+ assert .NoError (t , db .Close ())
452+
453+ // Second run against the up-to-date database must not create a backup
454+ db2 , err := Initialize ()
455+ assert .NoError (t , err )
456+ defer db2 .Close ()
457+
458+ backups , err := ListBackups ()
459+ assert .NoError (t , err )
460+ assert .Empty (t , backups , "already-migrated database should not trigger a pre-migration backup" )
461+ }
462+
331463func TestCreateBackup_SetsPrivatePermissions (t * testing.T ) {
332464 if runtime .GOOS == "windows" {
333465 t .Skip ("POSIX permissions are not enforced on Windows" )
0 commit comments