package qso import ( "context" "path/filepath" "testing" "time" "hamlog/internal/db" ) // openRepo gives a migrated, empty logbook on disk. func openRepo(t *testing.T) *Repo { t.Helper() conn, err := db.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { conn.Close() }) return NewRepo(conn) } func addQSO(t *testing.T, r *Repo, call string, when time.Time) int64 { t.Helper() id, err := r.Add(context.Background(), QSO{ Callsign: call, QSODate: when, Band: "20m", Mode: "CW", }) if err != nil { t.Fatalf("insert %s: %v", call, err) } return id } // A QSO written and read back must come out whole. selectCols and scanQSO are // two hand-maintained lists that must line up column for column, and adding // sync_uid touched both — a drift there fails every read at runtime, which no // compiler catches. func TestSyncUIDRoundTrips(t *testing.T) { r := openRepo(t) ctx := context.Background() id := addQSO(t, r, "M0ABC", time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC)) got, err := r.GetByID(ctx, id) if err != nil { t.Fatalf("read back: %v", err) } if got.Callsign != "M0ABC" { t.Fatalf("read back %+v", got) } if got.SyncUID != "" { t.Errorf("a fresh QSO has identity %q — it should have none until sync is switched on", got.SyncUID) } if err := r.SetSyncUID(ctx, id, "abc123"); err != nil { t.Fatalf("SetSyncUID: %v", err) } got, _ = r.GetByID(ctx, id) if got.SyncUID != "abc123" { t.Errorf("SyncUID = %q after stamping", got.SyncUID) } } // An ordinary edit must NEVER clobber the identity. sync_uid is deliberately // outside columnList for this reason: another machine that has already seen the // contact addresses it by that id, and losing it makes the same QSO arrive // again as a new one. func TestAnEditDoesNotClobberTheIdentity(t *testing.T) { r := openRepo(t) ctx := context.Background() id := addQSO(t, r, "M0ABC", time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC)) if err := r.SetSyncUID(ctx, id, "keepme"); err != nil { t.Fatal(err) } q, _ := r.GetByID(ctx, id) q.Name = "Edited" q.SyncUID = "" // exactly what a caller that knows nothing about sync sends if err := r.Update(ctx, q); err != nil { t.Fatalf("update: %v", err) } got, _ := r.GetByID(ctx, id) if got.Name != "Edited" { t.Errorf("the edit did not take: %+v", got) } if got.SyncUID != "keepme" { t.Errorf("SyncUID = %q — an edit wiped the sync identity", got.SyncUID) } } // The lookup an incoming change performs, once per record. func TestIDBySyncUID(t *testing.T) { r := openRepo(t) ctx := context.Background() id := addQSO(t, r, "M0ABC", time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC)) _ = r.SetSyncUID(ctx, id, "u-1") got, ok, err := r.IDBySyncUID(ctx, "u-1") if err != nil || !ok || got != id { t.Fatalf("IDBySyncUID = (%d,%v,%v), want (%d,true,nil)", got, ok, err, id) } if _, ok, _ := r.IDBySyncUID(ctx, "nope"); ok { t.Error("an unknown identity was resolved") } // An empty id must never match the rows that have none. if _, ok, _ := r.IDBySyncUID(ctx, ""); ok { t.Error("the empty identity matched a row — every un-stamped QSO would be that row") } } // Two machines can already hold the SAME old contact — the second was seeded by // copying the database or importing an ADIF — with different row ids and no // identity on either. The day one of them edits it, it stamps an identity and // sends a change naming it; the other has never seen that identity and would // insert a duplicate. Matching on the contact itself recognises it instead. func TestIDByDedupeKeyRecognisesTheSameContact(t *testing.T) { r := openRepo(t) ctx := context.Background() when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC) id := addQSO(t, r, "M0ABC", when) minute := when.UTC().Format("2006-01-02T15:04") gotID, uid, found, err := r.IDByDedupeKey(ctx, "M0ABC", minute, "20m", "CW") if err != nil || !found { t.Fatalf("IDByDedupeKey = (%d,%q,%v,%v), want it found", gotID, uid, found, err) } if gotID != id { t.Errorf("resolved to id %d, want %d", gotID, id) } if uid != "" { t.Errorf("uid = %q, want empty until something stamps it", uid) } // A contact this machine does not have must NOT match something else. if _, _, found, _ := r.IDByDedupeKey(ctx, "M0ABC", minute, "40m", "CW"); found { t.Error("a different band matched — the key must be all four parts") } if _, _, found, _ := r.IDByDedupeKey(ctx, "M0XYZ", minute, "20m", "CW"); found { t.Error("a different callsign matched") } // An empty key must never match anything. if _, _, found, _ := r.IDByDedupeKey(ctx, "", "", "", ""); found { t.Error("an empty key matched a row") } }