package db import ( "database/sql" "fmt" "os" "path/filepath" "strings" "testing" ) const mig0024 = "0024_normalise_callsign.sql" // openWithUnappliedRewrite builds a logbook that already holds QSOs and has not // yet had the callsign-normalising migration applied — i.e. exactly what an // existing installation looks like the moment the auto-updater relaunches it. func openWithUnappliedRewrite(t *testing.T, rows [][2]string) string { t.Helper() p := filepath.Join(t.TempDir(), "logbook.db") conn, err := Open(p) if err != nil { t.Fatal(err) } for _, r := range rows { if _, err := conn.Exec( `INSERT INTO qso (callsign, qso_date, band, mode) VALUES (?, ?, '20m', 'SSB')`, r[0], r[1]); err != nil { t.Fatal(err) } } // Rewind so the migration runs against real data on the next Open. if _, err := conn.Exec(`DELETE FROM schema_migrations WHERE name = ?`, mig0024); err != nil { t.Fatal(err) } if err := conn.Close(); err != nil { t.Fatal(err) } return p } func callsigns(t *testing.T, path string) []string { t.Helper() conn, err := sql.Open("sqlite", "file:"+path) if err != nil { t.Fatal(err) } defer conn.Close() rows, err := conn.Query(`SELECT callsign FROM qso ORDER BY id`) if err != nil { t.Fatal(err) } defer rows.Close() var out []string for rows.Next() { var c string if err := rows.Scan(&c); err != nil { t.Fatal(err) } out = append(out, c) } return out } // The auto-updater migrates before the operator ever sees the changelog, so the // app has to take the safety copy itself. The copy must hold the data as it was // BEFORE the rewrite — a copy of the already-migrated rows would be worthless. func TestRewriteMigrationBacksUpOriginalData(t *testing.T) { p := openWithUnappliedRewrite(t, [][2]string{ {"f5lit", "2026-07-01"}, {" Pa3Eyf ", "2026-07-02"}, {"F4BPO", "2026-07-03"}, }) var logged []string LogSink = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) } defer func() { LogSink = nil }() conn, err := Open(p) if err != nil { t.Fatal(err) } conn.Close() // The live logbook is normalised. if got, want := callsigns(t, p), []string{"F5LIT", "PA3EYF", "F4BPO"}; strings.Join(got, ",") != strings.Join(want, ",") { t.Errorf("logbook callsigns = %v, want %v", got, want) } // The backup exists, next to the logbook, named after the migration. backup := p + ".pre-0024_normalise_callsign.bak" if _, err := os.Stat(backup); err != nil { t.Fatalf("no safety copy at %s: %v\nlogged:\n%s", backup, err, strings.Join(logged, "\n")) } // …and it holds the ORIGINAL rows, untouched. if got, want := callsigns(t, backup), []string{"f5lit", " Pa3Eyf ", "F4BPO"}; strings.Join(got, ",") != strings.Join(want, ",") { t.Errorf("backup callsigns = %v, want the pre-migration values %v", got, want) } var sawBackup bool for _, l := range logged { if strings.Contains(l, "backed up 3 QSO(s)") { sawBackup = true } } if !sawBackup { t.Errorf("the backup was not logged; got:\n%s", strings.Join(logged, "\n")) } } // No QSOs, nothing to protect: the settings database and a freshly created // logbook must not litter the folder with pointless copies. func TestRewriteMigrationSkipsBackupWhenNoQSOs(t *testing.T) { p := openWithUnappliedRewrite(t, nil) conn, err := Open(p) if err != nil { t.Fatal(err) } conn.Close() if _, err := os.Stat(p + ".pre-0024_normalise_callsign.bak"); err == nil { t.Error("an empty database should not be backed up") } } // schema_migrations is what makes "runs once" a guarantee rather than a promise: // a second launch must neither re-run the rewrite nor take a second copy. func TestRewriteMigrationRunsOnce(t *testing.T) { p := openWithUnappliedRewrite(t, [][2]string{{"f5lit", "2026-07-01"}}) conn, err := Open(p) if err != nil { t.Fatal(err) } conn.Close() backup := p + ".pre-0024_normalise_callsign.bak" first, err := os.Stat(backup) if err != nil { t.Fatal(err) } var logged []string LogSink = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) } defer func() { LogSink = nil }() conn2, err := Open(p) // second launch if err != nil { t.Fatal(err) } conn2.Close() for _, l := range logged { if strings.Contains(l, mig0024) { t.Errorf("migration ran again on the second launch: %s", l) } } second, err := os.Stat(backup) if err != nil { t.Fatal(err) } if !first.ModTime().Equal(second.ModTime()) { t.Error("the existing safety copy was overwritten on the second launch") } }