package main import ( "context" "encoding/json" "path/filepath" "testing" "time" "hamlog/internal/db" "hamlog/internal/qso" "hamlog/internal/syncfolder" ) // syncTestApp is an App with nothing but a logbook: applySyncRecord touches the // repository and the log file, and no more. Settings are nil, which is exactly // the state it must survive anyway — the loop runs before the active profile is // known. func syncTestApp(t *testing.T) *App { t.Helper() conn, err := db.Open(filepath.Join(t.TempDir(), "log.db")) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { conn.Close() }) return &App{ctx: context.Background(), qso: qso.NewRepo(conn)} } func syncRecord(t *testing.T, op syncfolder.Op, uid string, q qso.QSO) syncfolder.Record { t.Helper() rec := syncfolder.Record{V: syncfolder.FormatVersion, Op: op, UID: uid, At: time.Now().UTC(), By: "other"} if op != syncfolder.OpDelete { b, err := json.Marshal(q) if err != nil { t.Fatalf("marshal: %v", err) } rec.Data = b } return rec } func countQSOs(t *testing.T, a *App) int64 { t.Helper() n, err := a.qso.Count(a.ctx) if err != nil { t.Fatalf("count: %v", err) } return n } // The ordinary life of a contact made on the other PC: it arrives, it is // corrected, it is deleted. One row throughout — a sync that inserted a second // copy on the edit would be worse than no sync at all. func TestSyncRecordAddThenUpdateThenDelete(t *testing.T) { a := syncTestApp(t) when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC) uid := syncfolder.NewUID() if !a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, uid, qso.QSO{ Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW", Name: "Ann", })) { t.Fatal("the added contact was not applied") } if n := countQSOs(t, a); n != 1 { t.Fatalf("log holds %d QSOs after an add, want 1", n) } // Stamped with the identity from the record — without this every later // change naming it would look like a contact never seen before. id, found, err := a.qso.IDBySyncUID(a.ctx, uid) if err != nil || !found { t.Fatalf("IDBySyncUID = (%d,%v,%v), want the new row", id, found, err) } if !a.applySyncRecord(syncRecord(t, syncfolder.OpUpdate, uid, qso.QSO{ Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW", Name: "Annette", })) { t.Fatal("the correction was not applied") } if n := countQSOs(t, a); n != 1 { t.Fatalf("log holds %d QSOs after an edit, want 1 — the edit was logged as a second contact", n) } got, err := a.qso.GetByID(a.ctx, id) if err != nil { t.Fatalf("get: %v", err) } if got.Name != "Annette" { t.Errorf("name = %q after the correction, want %q", got.Name, "Annette") } if !a.applySyncRecord(syncRecord(t, syncfolder.OpDelete, uid, qso.QSO{})) { t.Fatal("the tombstone was not applied") } if n := countQSOs(t, a); n != 0 { t.Fatalf("log holds %d QSOs after the deletion, want 0", n) } // A tombstone that arrives twice — both peers relayed it, or the file was // re-read after a restore — must be quiet, not an error and not a change. if a.applySyncRecord(syncRecord(t, syncfolder.OpDelete, uid, qso.QSO{})) { t.Error("a repeated tombstone reported a change; the grid would refresh for nothing, for ever") } } // The case the whole no-backfill decision rests on. // // Both PCs already hold the operator's 123 000 contacts — seeded from one // database or one ADIF — and neither row carries an identity, because nothing // has touched them since. The day the shack PC corrects a 2019 QSO it stamps an // identity and sends an update naming it; the laptop has never seen that // identity. Inserting would give the operator two copies of a contact they // merely corrected, and would do it for every edit for ever. func TestSyncRecordAdoptsTheContactAlreadyInTheLog(t *testing.T) { a := syncTestApp(t) when := time.Date(2019, 3, 2, 9, 15, 0, 0, time.UTC) // The copy that was already here, with no identity. localID, err := a.qso.Add(a.ctx, qso.QSO{ Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "SSB", Name: "Ann", }) if err != nil { t.Fatalf("seed: %v", err) } uid := syncfolder.NewUID() // minted on the OTHER machine if !a.applySyncRecord(syncRecord(t, syncfolder.OpUpdate, uid, qso.QSO{ Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "SSB", Name: "Annette", QTH: "Bristol", })) { t.Fatal("the correction was not applied") } if n := countQSOs(t, a); n != 1 { t.Fatalf("log holds %d QSOs, want 1 — the contact was duplicated instead of recognised", n) } got, err := a.qso.GetByID(a.ctx, localID) if err != nil { t.Fatalf("get: %v", err) } if got.Name != "Annette" || got.QTH != "Bristol" { t.Errorf("the row already here was not corrected: name=%q qth=%q", got.Name, got.QTH) } // And it now carries the identity, so the NEXT change goes straight to it // without needing the contact-matching fallback again. if id, found, _ := a.qso.IDBySyncUID(a.ctx, uid); !found || id != localID { t.Errorf("IDBySyncUID = (%d,%v), want the row already here (%d)", id, found, localID) } } // A different contact must NOT be adopted. The matching is deliberately narrow // — same callsign, same minute, same band, same mode — and this pins that a // second contact with the same station on another band stays a second contact. func TestSyncRecordDoesNotAdoptADifferentContact(t *testing.T) { a := syncTestApp(t) when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC) if _, err := a.qso.Add(a.ctx, qso.QSO{Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "CW"}); err != nil { t.Fatalf("seed: %v", err) } if !a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, syncfolder.NewUID(), qso.QSO{ Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW", })) { t.Fatal("the contact was not applied") } if n := countQSOs(t, a); n != 2 { t.Fatalf("log holds %d QSOs, want 2 — a contact on another band was swallowed as a duplicate", n) } } // A record with no callsign is not a contact. It reaches here from a file // truncated by a sync client mid-upload, or from a future format read // optimistically, and inserting it would put a blank row in the log. func TestSyncRecordIgnoresAContactWithNoCallsign(t *testing.T) { a := syncTestApp(t) if a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, syncfolder.NewUID(), qso.QSO{Band: "20m", Mode: "CW"})) { t.Error("a record with no callsign was applied") } if n := countQSOs(t, a); n != 0 { t.Fatalf("log holds %d QSOs, want 0", n) } }