Ultrabeam on a serial port never worked, and three faults were stacked so
each hid the next:
- Stop() did not wait for the poll loop, so a stopped client kept the COM
port. Every later client then failed with "Serial port busy" — the
program holding it being OpsLog itself.
- startUltrabeam tore the old client down CONCURRENTLY with starting the
new one, and "Test connection" built a second client on a port already
ours. Harmless over TCP, fatal on a port with one owner.
- A silent serial port returns (0, nil) and bufio retries that a hundred
times: a 4 s timeout became ~7 minutes of a frozen poll loop logging
nothing.
The controller then answered at once. Confirmed on hardware: the USB cable
presents TWO COM ports, only the second reaches the controller, and only at
19200 baud — so the speed is pinned in code (an FTDI cable opens at any
speed, and a wrong one is indistinguishable from a dead controller) and the
port field says which one to pick. The first exchange after each connect is
hex-dumped, which separates silence from a wrong baud from a misread frame.
Databases now carry only the tables their role needs. Every target used to
get the whole migration set, so a shared MySQL logbook grew settings and
station_profiles tables nothing ever wrote to — an operator inspecting the
server could not tell which copy was authoritative. Statements are filtered
by role, unknown tables are kept in both (fail-safe), and existing databases
are cleaned once, dropping only EMPTY tables. Settings → Database gains a
Compact button, since SQLite frees pages inside the file and never shrinks it.
Also:
- Awards: the callsigns behind a cell open the QSL Manager on Paper QSL,
searched, ready for the card dates.
- The record button no longer goes missing after an update: whether manual
recording is possible is a per-profile question that was asked once, at
startup, before the profile was known.
- Alert rules and filter presets confirm that they were saved.
- Spot clicks on the radio panadapter carry the POTA park into F3.
- The build gate is re-checked wherever the active callsign can change; it
ran at startup alone, and a fresh install has no callsign then.
284 lines
9.6 KiB
Go
284 lines
9.6 KiB
Go
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")
|
|
}
|
|
}
|