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") } } // Backfill: batched, and it must never overwrite an identity already there. func TestAssignSyncUIDsOnlyFillsTheEmptyOnes(t *testing.T) { r := openRepo(t) ctx := context.Background() base := time.Date(2026, 8, 16, 14, 0, 0, 0, time.UTC) a := addQSO(t, r, "M0AAA", base) b := addQSO(t, r, "M0BBB", base.Add(time.Minute)) _ = r.SetSyncUID(ctx, a, "already") n, err := r.CountWithoutSyncUID(ctx) if err != nil || n != 1 { t.Fatalf("CountWithoutSyncUID = %d (%v), want 1", n, err) } ids, err := r.NeedSyncUID(ctx, 100) if err != nil || len(ids) != 1 || ids[0] != b { t.Fatalf("NeedSyncUID = %v (%v), want [%d]", ids, err, b) } // Try to overwrite BOTH; only the empty one may take. if err := r.AssignSyncUIDs(ctx, map[int64]string{a: "stomp", b: "fresh"}); err != nil { t.Fatalf("AssignSyncUIDs: %v", err) } qa, _ := r.GetByID(ctx, a) qb, _ := r.GetByID(ctx, b) if qa.SyncUID != "already" { t.Errorf("an existing identity was overwritten: %q", qa.SyncUID) } if qb.SyncUID != "fresh" { t.Errorf("the empty one was not filled: %q", qb.SyncUID) } if n, _ := r.CountWithoutSyncUID(ctx); n != 0 { t.Errorf("%d contacts still without an identity", n) } } // Two machines holding the SAME imported log must not copy it to each other. // An incoming contact with an unknown identity but a matching dedupe key is the // same contact, and adopts the incoming id rather than being inserted twice. func TestSyncUIDByDedupeKeyFindsTheSameContact(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) byKey, err := r.SyncUIDByDedupeKey(ctx) if err != nil { t.Fatalf("SyncUIDByDedupeKey: %v", err) } key := DedupeKey("M0ABC", when.UTC().Format("2006-01-02T15:04"), "20m", "CW") hit, ok := byKey[key] if !ok { t.Fatalf("key %q not found among %d entries", key, len(byKey)) } if hit.ID != id { t.Errorf("key resolved to id %d, want %d", hit.ID, id) } if hit.UID != "" { t.Errorf("UID = %q, want empty before any stamping", hit.UID) } }