Files
OpsLog/internal/qso/syncuid_test.go
T
rouggy 036dc53fe8 feat(hamlog): mark what has been sent, and a QSL Manager backlog
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.
2026-08-23 12:40:09 +02:00

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)
}
}