An upload now leaves a trace on the QSO: APP_OPSLOG_HAMLOG_SENT holds the day it went. The date rather than a Y, so a log says WHEN — and so one shape serves three readers: eligibility (a stamped QSO is not sent twice), the QSL Manager's backlog, and the appearance rules' sent channel. HAMLOG joins the QSL Manager's service list. It has no status column to select on, so its backlog is the ABSENCE of that extras key — a LIKE over the extras JSON, which is a full scan and is the right trade here: it answers a button an operator presses, and the alternative is a promoted column for a field no other program would ever read. The match is on the quoted key, so a QSO whose comment merely mentions the text is not taken for one already sent — which is what the test pins.
180 lines
5.7 KiB
Go
180 lines
5.7 KiB
Go
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")
|
|
}
|
|
}
|
|
|
|
// A service with no status column of its own — HAMLOG.online, whose field the
|
|
// ADIF standard does not define — finds its backlog by the ABSENCE of an extras
|
|
// key. The LIKE has to match the key, and only the key: a QSO whose comment
|
|
// happens to contain the same text must not be mistaken for one already sent.
|
|
func TestListMissingExtraMatchesTheKeyNotItsText(t *testing.T) {
|
|
r := openRepo(t)
|
|
ctx := context.Background()
|
|
|
|
sent := addQSO(t, r, "F4BPO", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))
|
|
notSent, err := r.Add(ctx, QSO{
|
|
Callsign: "F1TRF", QSODate: time.Date(2026, 1, 2, 12, 0, 0, 0, time.UTC),
|
|
Band: "40m", Mode: "CW",
|
|
Comment: "mentions APP_OPSLOG_HAMLOG_SENT in the text",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.SetExtra(ctx, sent, "APP_OPSLOG_HAMLOG_SENT", "20260823"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := r.ListMissingExtra(ctx, "APP_OPSLOG_HAMLOG_SENT")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 || got[0].ID != notSent {
|
|
ids := make([]int64, len(got))
|
|
for i, q := range got {
|
|
ids[i] = q.ID
|
|
}
|
|
t.Fatalf("backlog = %v, want just %d (the one never sent)", ids, notSent)
|
|
}
|
|
}
|