Artifact 53c4ef438b34a5dd62815d20b6ae58239677a876214d0bac3e31a6f61b278393:
- File
hub/hub_test.go
— part of check-in
[67ea682587]
at
2026-05-04 18:34:31
on branch trunk
— feat(hub): new package for hub-fossil management and HTTP serving (#109)
Adds a top-level hub/ package that hosts an EdgeSync hub in-process: fossil hub repo bootstrap, embedded NATS server with JetStream, auto-port allocation, libfossil HTTP server (timeline UI + xfer endpoint), user management, and hub-side commits/reads. All libfossil and nats-server types are wrapped internally — the public surface exposes only stdlib types and types defined here.
Public API: - Config + NewHub + ServeHTTP + Stop - NATSURL / LeafUpstream / HTTPAddr accessors - AddUser / GetUser / HasUser / ListUsers / RemoveUser (User type) - Commit / Read / ReadAt (RevID, FileToCommit, CommitOpts types)
Auto port allocation: passing 0 for FossilHTTPPort, NATSClientPort, or NATSLeafPort yields three distinct bound ports. The HTTP listener is bound during NewHub so HTTPAddr() is immediately usable, even before ServeHTTP is called.
SQLite tuning (SetMaxOpenConns(1) + busy_timeout=30s) applied at hub bootstrap, matching the leaf agent's tuning.
Lives inside the EdgeSync root module (not a separate go.mod) so it ships in lockstep with the leaf agent. Can be promoted to its own module later if downstream consumers need to depend on it independently.
Closes #102 (user: [email protected], size: 6193) [annotate] [blame] [check-ins using]
package hub import ( "bytes" "context" "net/http" "path/filepath" "slices" "testing" "time" _ "github.com/danmestas/libfossil/db/driver/modernc" "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.LeafUpstream() == "" { t.Error("LeafUpstream empty after NewHub") } } func TestNewHub_AutoPortsAreDistinct(t *testing.T) { h := newTestHub(t) natsURL := h.NATSURL() // nats://127.0.0.1:NNNN leafURL := h.LeafUpstream() // 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) } }