Artifact 10de9a92e9bae2978d080d23af5d93a3fa2034ea7f5b1080180ae0b3f65ad71d:
- File
hub/hub_test.go
— part of check-in
[754914fd82]
at
2026-05-13 20:27:25
on branch trunk
— refactor(hub): register modernc SQL driver internally (#166)
The hub package promised consumers wouldn't need to import libfossil directly, but they still had to blank-import libfossil's SQL driver to satisfy the underlying db layer's registration requirement. Move the blank import into hub itself (hub/driver.go) so the package fulfills its own dependency.
Consumers like sesh can now reduce their main.go to a plain `import "github.com/danmestas/EdgeSync/hub"` (or whatever wrapper they use) without taking a direct dep on libfossil's internal SQL machinery.
modernc is registered as the production default. If a consumer ever needs ncruces (WASM/WASI), an escape hatch can be added then.
Tests in hub/ drop their now-redundant blank imports.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> (user: [email protected], size: 8967) [annotate] [blame] [check-ins using]
package hub import ( "bytes" "context" "fmt" "net/http" "os" "path/filepath" "slices" "testing" "time" "github.com/nats-io/nats.go" ) func newTestHub(t *testing.T) *Hub { t.Helper() dir := t.TempDir() h, err := NewHub(context.Background(), Config{ RepoPath: filepath.Join(dir, "hub.fossil"), }) if err != nil { t.Fatalf("NewHub: %v", err) } t.Cleanup(func() { _ = h.Stop() }) return h } func TestNewHub_BootstrapAndAddresses(t *testing.T) { h := newTestHub(t) if h.HTTPAddr() == "" { t.Error("HTTPAddr empty after NewHub") } if h.NATSURL() == "" { t.Error("NATSURL empty after NewHub") } if h.LeafURL() == "" { t.Error("LeafURL empty after NewHub") } } func TestNewHub_AutoPortsAreDistinct(t *testing.T) { h := newTestHub(t) natsURL := h.NATSURL() // nats://127.0.0.1:NNNN leafURL := h.LeafURL() // nats-leaf://127.0.0.1:LLLL httpAddr := h.HTTPAddr() // 127.0.0.1:HHHH if natsURL == leafURL || natsURL == httpAddr || leafURL == httpAddr { t.Errorf("expected three distinct addresses, got NATS=%s leaf=%s http=%s", natsURL, leafURL, httpAddr) } } func TestHub_NATSURL_Connects(t *testing.T) { h := newTestHub(t) conn, err := nats.Connect(h.NATSURL(), nats.Timeout(2*time.Second)) if err != nil { t.Fatalf("nats.Connect(%s): %v", h.NATSURL(), err) } defer conn.Close() if !conn.IsConnected() { t.Error("nats connection not established") } } func TestHub_OpenExistingRepo(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "hub.fossil") h1, err := NewHub(context.Background(), Config{RepoPath: repoPath}) if err != nil { t.Fatalf("NewHub (bootstrap): %v", err) } if _, err := h1.Commit(context.Background(), CommitOpts{ Files: []FileToCommit{{Name: "f", Content: []byte("v1")}}, Message: "first", Author: "hub", }); err != nil { t.Fatalf("Commit: %v", err) } _ = h1.Stop() h2, err := NewHub(context.Background(), Config{RepoPath: repoPath}) if err != nil { t.Fatalf("NewHub (open): %v", err) } defer h2.Stop() got, err := h2.Read(context.Background(), "f") if err != nil { t.Fatalf("Read after reopen: %v", err) } if !bytes.Equal(got, []byte("v1")) { t.Errorf("Read = %q, want %q", got, "v1") } } func TestHub_CommitReadReadAtRoundtrip(t *testing.T) { h := newTestHub(t) ctx := context.Background() rev, err := h.Commit(ctx, CommitOpts{ Files: []FileToCommit{ {Name: "a.txt", Content: []byte("alpha")}, {Name: "dir/b.txt", Content: []byte("beta")}, }, Message: "initial", Author: "hub", }) if err != nil { t.Fatalf("Commit: %v", err) } got, err := h.Read(ctx, "a.txt") if err != nil { t.Fatalf("Read: %v", err) } if !bytes.Equal(got, []byte("alpha")) { t.Errorf("Read a.txt = %q, want %q", got, "alpha") } gotRev, err := h.ReadAt(ctx, rev, "dir/b.txt") if err != nil { t.Fatalf("ReadAt: %v", err) } if !bytes.Equal(gotRev, []byte("beta")) { t.Errorf("ReadAt dir/b.txt = %q, want %q", gotRev, "beta") } gotTrunk, err := h.ReadAt(ctx, "trunk", "a.txt") if err != nil { t.Fatalf("ReadAt trunk: %v", err) } if !bytes.Equal(gotTrunk, []byte("alpha")) { t.Errorf("ReadAt trunk a.txt = %q, want %q", gotTrunk, "alpha") } } func TestHub_Commit_RejectsEmptyAuthor(t *testing.T) { h := newTestHub(t) if _, err := h.Commit(context.Background(), CommitOpts{ Files: []FileToCommit{{Name: "x", Content: []byte("y")}}, Message: "no author", }); err == nil { t.Fatal("Commit with empty Author returned nil") } } func TestHub_ReadAt_RejectsEmptyRev(t *testing.T) { h := newTestHub(t) if _, err := h.ReadAt(context.Background(), "", "any"); err == nil { t.Fatal("ReadAt with empty rev returned nil") } } func TestHub_UserMgmt_RoundTrip(t *testing.T) { h := newTestHub(t) if err := h.AddUser(User{Login: "alice", Caps: "admin"}); err != nil { t.Fatalf("AddUser: %v", err) } if !h.HasUser("alice") { t.Error("HasUser(alice) = false after AddUser") } if h.HasUser("definitely-not-a-real-user-zzz") { t.Error("HasUser(definitely-not-a-real-user-zzz) = true; want false") } got, err := h.GetUser("alice") if err != nil { t.Fatalf("GetUser: %v", err) } if got.Login != "alice" || got.Caps != "admin" { t.Errorf("GetUser alice = %+v, want {alice admin}", got) } users, err := h.ListUsers() if err != nil { t.Fatalf("ListUsers: %v", err) } hasAlice := slices.ContainsFunc(users, func(u User) bool { return u.Login == "alice" }) if !hasAlice { t.Errorf("ListUsers = %+v, want one with Login=alice", users) } if err := h.RemoveUser("alice"); err != nil { t.Fatalf("RemoveUser: %v", err) } if h.HasUser("alice") { t.Error("HasUser(alice) = true after RemoveUser") } } func TestHub_AddUser_RejectsEmptyLogin(t *testing.T) { h := newTestHub(t) if err := h.AddUser(User{}); err == nil { t.Fatal("AddUser with empty Login returned nil") } } func TestHub_ServeHTTP_RespondsAndShutsDownOnContextCancel(t *testing.T) { h := newTestHub(t) ctx, cancel := context.WithCancel(context.Background()) serveDone := make(chan error, 1) go func() { serveDone <- h.ServeHTTP(ctx) }() // Wait for server to come up. deadline := time.Now().Add(2 * time.Second) var resp *http.Response var err error for time.Now().Before(deadline) { resp, err = http.Get("http://" + h.HTTPAddr() + "/") if err == nil { break } time.Sleep(20 * time.Millisecond) } if err != nil { t.Fatalf("HTTP GET %s: %v", h.HTTPAddr(), err) } resp.Body.Close() if resp.StatusCode == 0 { t.Errorf("HTTP GET = status %d", resp.StatusCode) } cancel() select { case err := <-serveDone: if err != nil { t.Errorf("ServeHTTP returned error: %v", err) } case <-time.After(2 * time.Second): t.Error("ServeHTTP did not shut down within 2s of ctx cancel") } } func TestHub_Stop_IsIdempotent(t *testing.T) { dir := t.TempDir() h, err := NewHub(context.Background(), Config{ RepoPath: filepath.Join(dir, "hub.fossil"), }) if err != nil { t.Fatalf("NewHub: %v", err) } if err := h.Stop(); err != nil { t.Errorf("first Stop: %v", err) } if err := h.Stop(); err != nil { t.Errorf("second Stop: %v", err) } } // TestHub_StopLeavesRepoVanillaReadable asserts the contract that after // Hub.Stop returns nil, hub.fossil is a self-contained file: no WAL/SHM // sidecar required, and a fresh hub.OpenRepo against the path succeeds. // This is the behavior consumers like bones rely on for vanilla-fossil // interop. Regression for #143. func TestHub_StopLeavesRepoVanillaReadable(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "hub.fossil") h, err := NewHub(context.Background(), Config{RepoPath: repoPath}) if err != nil { t.Fatalf("NewHub: %v", err) } if _, err := h.Commit(context.Background(), CommitOpts{ Files: []FileToCommit{{Name: "f", Content: bytes.Repeat([]byte("x"), 8192)}}, Message: "trigger WAL traffic", Author: "hub", }); err != nil { t.Fatalf("Commit: %v", err) } if err := h.Stop(); err != nil { t.Fatalf("Stop: %v", err) } walPath := repoPath + "-wal" if info, err := os.Stat(walPath); err == nil && info.Size() != 0 { t.Errorf("WAL still present with size %d after Stop; expected 0 or absent", info.Size()) } r, err := OpenRepo(repoPath) if err != nil { t.Fatalf("OpenRepo on stopped hub.fossil: %v", err) } t.Cleanup(func() { _ = r.Close() }) got, err := r.Read(context.Background(), "f") if err != nil { t.Fatalf("Read after reopen: %v", err) } if !bytes.Equal(got, bytes.Repeat([]byte("x"), 8192)) { t.Errorf("Read after reopen: content mismatch (len=%d)", len(got)) } } // TestHub_PeriodicCheckpointPopulatesMainFile asserts the periodic PASSIVE // checkpoint goroutine runs while the hub serves: after several commits and // a few ticker periods, the main hub.fossil file holds more than the SQLite // stub. Without periodic checkpointing the main file stays at the stub size // (~4096 bytes) until SQLite's own auto-checkpoint at ~4 MiB fires, and // vanilla fossil rejects it. Regression for #143. func TestHub_PeriodicCheckpointPopulatesMainFile(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "hub.fossil") h, err := NewHub(context.Background(), Config{ RepoPath: repoPath, CheckpointInterval: 20 * time.Millisecond, }) if err != nil { t.Fatalf("NewHub: %v", err) } t.Cleanup(func() { _ = h.Stop() }) for i := range 10 { if _, err := h.Commit(context.Background(), CommitOpts{ Files: []FileToCommit{{Name: fmt.Sprintf("f%d", i), Content: bytes.Repeat([]byte("x"), 4096)}}, Message: fmt.Sprintf("commit %d", i), Author: "hub", }); err != nil { t.Fatalf("Commit %d: %v", i, err) } } time.Sleep(200 * time.Millisecond) info, err := os.Stat(repoPath) if err != nil { t.Fatalf("Stat %s: %v", repoPath, err) } if info.Size() <= 4096 { t.Errorf("main file size = %d after 10 commits + 200ms; expected > 4096 (WAL not being checkpointed mid-flight)", info.Size()) } }