package qso import ( "context" "database/sql" "encoding/json" "path/filepath" "testing" _ "modernc.org/sqlite" ) // openBulkTestDB builds the minimum of the qso table this needs. It does not go // through db.Open (that would pull the whole migration set and an import cycle); // the columns BulkSetExtra touches are extras_json and updated_at. func openBulkTestDB(t *testing.T) *sql.DB { t.Helper() conn, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "t.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { conn.Close() }) if _, err := conn.Exec(`CREATE TABLE qso ( id INTEGER PRIMARY KEY AUTOINCREMENT, callsign TEXT NOT NULL, extras_json TEXT, updated_at TEXT )`); err != nil { t.Fatal(err) } return conn } func extras(t *testing.T, conn *sql.DB, id int64) map[string]any { t.Helper() var raw sql.NullString if err := conn.QueryRow(`SELECT extras_json FROM qso WHERE id = ?`, id).Scan(&raw); err != nil { t.Fatal(err) } if !raw.Valid || raw.String == "" { return map[string]any{} } var m map[string]any if err := json.Unmarshal([]byte(raw.String), &m); err != nil { t.Fatalf("extras_json is not valid JSON (%q): %v", raw.String, err) } return m } // OWNER_CALLSIGN has no promoted column, so bulk-editing it means merging a key // into extras_json. The thing that must not happen is collateral damage: the // other ADIF extras on the same QSO have to survive. func TestBulkSetExtraPreservesOtherExtras(t *testing.T) { conn := openBulkTestDB(t) r := &Repo{db: conn} ctx := context.Background() // Two QSOs with existing extras, one with none at all (NULL column). conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('F5LIT', '{"SILENT_KEY":"Y","ANT_PATH":"S"}')`) conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('PA3EYF', '{"ANT_PATH":"L"}')`) conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('F4BPO', NULL)`) n, err := r.BulkSetExtra(ctx, []int64{1, 2, 3}, "OWNER_CALLSIGN", "TM2Q") if err != nil { t.Fatal(err) } if n != 3 { t.Errorf("updated %d rows, want 3", n) } e1 := extras(t, conn, 1) if e1["OWNER_CALLSIGN"] != "TM2Q" { t.Errorf("row 1 OWNER_CALLSIGN = %v, want TM2Q", e1["OWNER_CALLSIGN"]) } if e1["SILENT_KEY"] != "Y" || e1["ANT_PATH"] != "S" { t.Errorf("row 1 lost its other extras: %v", e1) } // A NULL extras_json must become a valid object, not stay null or hold "null". if e3 := extras(t, conn, 3); e3["OWNER_CALLSIGN"] != "TM2Q" { t.Errorf("row 3 (extras_json was NULL) = %v, want OWNER_CALLSIGN=TM2Q", e3) } } // Clearing the field must REMOVE the key: a blank extra would otherwise be // carried into every ADIF export from then on. func TestBulkSetExtraEmptyRemovesKey(t *testing.T) { conn := openBulkTestDB(t) r := &Repo{db: conn} conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('F5LIT', '{"OWNER_CALLSIGN":"TM2Q","ANT_PATH":"S"}')`) if _, err := r.BulkSetExtra(context.Background(), []int64{1}, "OWNER_CALLSIGN", ""); err != nil { t.Fatal(err) } e := extras(t, conn, 1) if _, present := e["OWNER_CALLSIGN"]; present { t.Errorf("OWNER_CALLSIGN should be gone, got %v", e) } if e["ANT_PATH"] != "S" { t.Errorf("clearing one extra removed another: %v", e) } } // The frontend field id must resolve to the ADIF key, and nothing else must slip // through — this map is the whitelist guarding a spliced JSON path. func TestBulkExtraKeyWhitelist(t *testing.T) { if got := BulkExtraKey("owner_callsign"); got != "OWNER_CALLSIGN" { t.Errorf(`BulkExtraKey("owner_callsign") = %q, want "OWNER_CALLSIGN"`, got) } for _, bad := range []string{"", "callsign", "notes", "OWNER_CALLSIGN", "owner_callsign'"} { if got := BulkExtraKey(bad); got != "" { t.Errorf("BulkExtraKey(%q) = %q, want empty", bad, got) } } }