Files
OpsLog/syncapply_test.go
T
rouggy 91b21a4a36 feat(sync): the folder loop, the hooks and the panel — it can be switched on now
The three layers underneath were already there and unreachable: the change-log
format (7d664bd), the identity column (724e68b) and the no-backfill decision
(c48294b). This is the wiring that gives the operator a switch.

Settings, all profile-scoped, because each profile can point at its own logbook:
the folder, this PC's name, the machine id minted once from it, the per-peer read
offsets and the tie-break counter. Scoping the machine id is what keeps two
profiles sharing one folder from writing two logbooks into one file.

Three hooks. Add and update publish asynchronously, down with the rest of the
after-the-fact work — a folder on a network share can block for seconds and a
contact belongs on screen long before another machine hears about it. Deletion
publishes SYNCHRONOUSLY and BEFORE the row goes, for the same reason
deleteRemoteCopies does: once it is gone its identity is gone with it and the
tombstone names nothing.

The apply path uses the repository directly and never AddQSO/UpdateQSO/DeleteQSO
— those publish, and a change applied here would be written straight back out,
two machines echoing each other for ever.

Saving writes a probe file to the chosen folder rather than asking whether it
exists. A read-only cloud folder, or a share whose credentials expired, exists
perfectly well and would swallow every contact in silence; if the probe fails the
switch goes back off instead of sitting on while nothing is written.

The panel is mostly status, and deliberately: every part of this runs on another
machine and on a sync client OpsLog cannot see, so "it is not working" has to be
answerable from the settings page — which PCs are in the folder, when each last
logged, what is waiting unread.

Four tests on the apply path, the middle two being the ones that matter: an edit
made on the other PC lands on the copy already here, matched on the contact
itself, instead of becoming a second row — that is what makes the no-backfill
decision safe — and a contact with the same station on another band stays a
separate contact.
2026-08-17 01:34:00 +02:00

177 lines
6.3 KiB
Go

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