package db import ( "path/filepath" "sort" "strings" "testing" ) func TestStmtTable(t *testing.T) { cases := map[string]string{ "CREATE TABLE station_profiles (\n id INTEGER PRIMARY KEY)": "station_profiles", "CREATE TABLE IF NOT EXISTS settings (`key` TEXT PRIMARY KEY)": "settings", "CREATE UNIQUE INDEX idx_qso_uid ON qso(sync_uid)": "qso", "CREATE INDEX idx_ref ON award_references (award_code)": "award_references", "ALTER TABLE qso ADD COLUMN ant_path TEXT NOT NULL DEFAULT ''": "qso", "ALTER TABLE `station_profiles` ADD COLUMN my_cq_zone TEXT": "station_profiles", "INSERT INTO settings(`key`, value) VALUES('x','y')": "settings", "INSERT OR IGNORE INTO cluster_servers(name) VALUES('dxc')": "cluster_servers", "UPDATE qso SET callsign = UPPER(callsign)": "qso", "DELETE FROM operating_antennas WHERE station_id IS NULL": "operating_antennas", "DROP TABLE IF EXISTS operating_stations_new": "operating_stations_new", "PRAGMA foreign_keys = off": "", "": "", } for in, want := range cases { if got := stmtTable(in); got != want { t.Errorf("stmtTable(%.40q) = %q, want %q", in, got, want) } } } func TestKeepForRole(t *testing.T) { // The settings database takes everything, exactly as before this existed. for _, s := range []string{"CREATE TABLE settings (a TEXT)", "CREATE TABLE qso (a TEXT)"} { if !keepForRole(s, RoleAll) { t.Fatalf("RoleAll dropped %q", s) } } // A logbook takes the contacts and refuses the settings side. if !keepForRole("CREATE INDEX i ON qso(callsign)", RoleLogbook) { t.Fatal("logbook dropped a qso statement") } if keepForRole("CREATE TABLE station_profiles (id INTEGER)", RoleLogbook) { t.Fatal("logbook accepted station_profiles") } // An unrecognised statement — a future table, a PRAGMA — is kept, so a new // migration behaves as it does today rather than vanishing from one database. if !keepForRole("CREATE TABLE something_new (id INTEGER)", RoleLogbook) { t.Fatal("logbook dropped an unknown table") } if !keepForRole("PRAGMA foreign_keys = off", RoleLogbook) { t.Fatal("logbook dropped a PRAGMA") } } // tablesIn lists the tables of an open database. func tablesIn(t *testing.T, path string) []string { t.Helper() conn, err := Open(path) // RoleAll: opening must not change what is there if err != nil { t.Fatal(err) } defer conn.Close() rows, err := conn.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) if err != nil { t.Fatal(err) } defer rows.Close() var out []string for rows.Next() { var n string if err := rows.Scan(&n); err != nil { t.Fatal(err) } out = append(out, n) } sort.Strings(out) return out } // A logbook opened through OpenLogbook holds the contacts and nothing else. func TestOpenLogbookSchema(t *testing.T) { path := filepath.Join(t.TempDir(), "logbook.db") conn, err := OpenLogbook(path) if err != nil { t.Fatal(err) } var n int // The one table that matters has to be there and has to be usable. if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil { t.Fatalf("qso table unusable: %v", err) } for _, forbidden := range settingsTables { if err := conn.QueryRow(`SELECT COUNT(*) FROM ` + quoteIdent(forbidden)).Scan(&n); err == nil { t.Errorf("%s was created in a logbook database", forbidden) } } conn.Close() // Reopening as a logbook is idempotent, and the migrations already recorded // as applied must not be re-run into a half-schema. conn2, err := OpenLogbook(path) if err != nil { t.Fatalf("reopen: %v", err) } if err := conn2.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil { t.Fatalf("qso lost on reopen: %v", err) } conn2.Close() } // An existing logbook that an older version filled with the whole schema loses // the unused tables — and keeps any that hold rows. func TestPruneKeepsNonEmptyTables(t *testing.T) { path := filepath.Join(t.TempDir(), "legacy.db") conn, err := Open(path) // the old behaviour: every table everywhere if err != nil { t.Fatal(err) } if _, err := conn.Exec(`INSERT INTO station_profiles(name) VALUES('Home')`); err != nil { t.Fatal(err) } conn.Close() if got := tablesIn(t, path); len(got) < 10 { t.Fatalf("expected a full legacy schema, got %v", got) } conn, err = OpenLogbook(path) if err != nil { t.Fatal(err) } defer conn.Close() var n int // Rows are data: this one stays, whatever the schema says it is for. if err := conn.QueryRow(`SELECT COUNT(*) FROM station_profiles`).Scan(&n); err != nil || n != 1 { t.Fatalf("station_profiles dropped with a row in it (err=%v n=%d)", err, n) } // The empty ones go. if err := conn.QueryRow(`SELECT COUNT(*) FROM settings`).Scan(&n); err == nil { t.Error("empty settings table survived in a logbook") } // And the contacts are untouched throughout. if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil { t.Fatalf("qso table lost: %v", err) } } // A CREATE TABLE body is full of "ON DELETE CASCADE": the index rule must not // read it as a table name, or the table goes unrecognised and gets created in // every database. func TestStmtTableForeignKeyBody(t *testing.T) { stmt := `CREATE TABLE operating_stations ( id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, FOREIGN KEY (profile_id) REFERENCES station_profiles(id) ON DELETE CASCADE )` if got := stmtTable(stmt); got != "operating_stations" { t.Fatalf("got %q", got) } if keepForRole(stmt, RoleLogbook) { t.Fatal("a settings table reached a logbook database") } } // The settings database loses its unused qso table — and gets it back, in full, // the moment it has to serve as the logbook again. func TestDropAndRecreateQSOTable(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.db") conn, err := Open(path) if err != nil { t.Fatal(err) } defer conn.Close() // A contact in it is data: the table must survive. if _, err := conn.Exec(`INSERT INTO qso(callsign, qso_date, band, mode) VALUES('F4BPO','2026-01-01T12:00:00Z','20m','SSB')`); err != nil { t.Fatal(err) } DropEmptyQSOTable(conn, "settings") var n int if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil || n != 1 { t.Fatalf("dropped a qso table holding a contact (err=%v n=%d)", err, n) } // Empty, so it goes. if _, err := conn.Exec(`DELETE FROM qso`); err != nil { t.Fatal(err) } DropEmptyQSOTable(conn, "settings") if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err == nil { t.Fatal("empty qso table survived") } // And comes back complete when this database is pressed into service as the // logbook — a late column and an index included, not just a bare table. if err := EnsureQSOTable(conn); err != nil { t.Fatal(err) } if _, err := conn.Exec(`INSERT INTO qso(callsign, qso_date, band, mode, ant_path, sync_uid) VALUES('F1TRF','2026-01-02T13:00:00Z','40m','CW','S','uid-1')`); err != nil { t.Fatalf("recreated qso table is incomplete: %v", err) } if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil || n != 1 { t.Fatalf("recreated table unusable (err=%v n=%d)", err, n) } // Idempotent: a second call on a live table must not touch it. if err := EnsureQSOTable(conn); err != nil { t.Fatal(err) } if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil || n != 1 { t.Fatalf("EnsureQSOTable disturbed an existing table (err=%v n=%d)", err, n) } } // A brand-new logbook — the case of a fresh install, or the "New database" // button — is right from the first open: the contacts and the migration ledger, // nothing else. Pinned as an exact list so an accidentally unfiltered future // migration shows up here rather than in an operator's phpMyAdmin. func TestFreshLogbookHasOnlyContactTables(t *testing.T) { path := filepath.Join(t.TempDir(), "fresh.db") conn, err := OpenLogbook(path) if err != nil { t.Fatal(err) } rows, err := conn.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) if err != nil { t.Fatal(err) } var got []string for rows.Next() { var n string if err := rows.Scan(&n); err != nil { t.Fatal(err) } got = append(got, n) } rows.Close() conn.Close() sort.Strings(got) want := []string{"qso", "schema_migrations"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("fresh logbook holds %v, want %v", got, want) } } // The cleanup is a one-off, recorded like a migration: it must not be repeated // at every connection. Twelve COUNT(*) round trips on a remote MySQL is a cost // paid at every profile switch for something that can no longer be found. func TestPruneRunsOnlyOnce(t *testing.T) { path := filepath.Join(t.TempDir(), "once.db") conn, err := Open(path) // full legacy schema if err != nil { t.Fatal(err) } conn.Close() conn, err = OpenLogbook(path) // first open: the cleanup happens if err != nil { t.Fatal(err) } var n int if err := conn.QueryRow(`SELECT COUNT(*) FROM settings`).Scan(&n); err == nil { t.Fatal("first open did not clean up") } // Put one back by hand. A second pass would remove it again; a cleanup that // knows it is done leaves it alone. if _, err := conn.Exec("CREATE TABLE `settings` (`key` TEXT PRIMARY KEY, value TEXT)"); err != nil { t.Fatal(err) } conn.Close() conn, err = OpenLogbook(path) if err != nil { t.Fatal(err) } defer conn.Close() if err := conn.QueryRow(`SELECT COUNT(*) FROM settings`).Scan(&n); err != nil { t.Fatal("the cleanup ran a second time — it is not recorded as done") } }