Artifact 9c8402feee408faa6a11c2162bb0e86f8951fd26de774b65ed131cf81d8df559:
- File
sim/hub_nats_sync_test.go
— part of check-in
[ad1d55f5b9]
at
2026-05-04 22:05:56
on branch trunk
— feat: close three gaps blocking bones go.mod libfossil drop (#137) (#138)
* feat(leaf/agent): clone-if-absent via Config.CloneFromHubURL (#137 gap A)
Adds agent.Config.CloneFromHubURL — when set and Config.RepoPath does not yet exist on disk, agent.New clones the leaf repo from the hub HTTP URL before opening it. Matching project-codes between leaf and hub are required for downstream NATS sync subjects to align, so callers that need to bootstrap a leaf against an existing hub no longer have to import libfossil directly for the pre-agent clone step.
Also exposes agent.CloneRepo(ctx, repoPath, hubURL) as a standalone helper for callers that need a clone independent of agent construction.
Existing behavior is preserved: when CloneFromHubURL is empty, agent.New errors on missing RepoPath as before (covered by TestAgentNew_RejectsMissingRepoWithoutCloneURL).
Closes part of #137 (gap A — bones internal/coord/leaf.go).
* feat(hub): subscribe to fossil-sync NATS subject in NewHub (#137 gap B)
NewHub now connects a local NATS client to its embedded server and subscribes to "fossil.<project-code>.sync" so peer leaf agents can push xfer requests to the hub over NATS — alongside the existing HTTP path. Previously, achieving this required running a leaf-agent in serve mode on top of the hub (Pull=false, Push=false, Autosync=AutosyncOff, ServeNATSEnabled=true), which transitively pulled libfossil into the consumer's import graph.
The subscriber is enabled by default. Read-only HTTP-only mirrors that shouldn't accept NATS pushes can opt out via Config.DisableFossilSyncOverNATS.
Note on naming: the issue brief proposed Config.ServeFossilSyncOverNATS defaulting to true. Go bool zero-value semantics make default-true awkward without pointers, so the inverted-name shape (DisableX, defaults false = enabled) was used instead.
Closes part of #137 (gap B — bones internal/coord/hub.go).
* feat(hub): FileToCommit.Perm + CommitOpts.Time pass-through (#137 gap C)
Adds two additive fields to the public hub commit surface so consumers no longer need libfossil-direct access to preserve executable bits or seed reproducible commit timestamps:
- hub.FileToCommit.Perm — "x" marks the file executable on the resulting manifest's F-card. Empty preserves prior behavior. Other libfossil perm markers pass through verbatim. - hub.CommitOpts.Time — explicit commit timestamp; zero defaults to time.Now().UTC() at commit time, matching the prior behavior.
Tests cover round-tripping the executable bit via libfossil.Repo.ListFiles, honoring an explicit time via Timeline, and falling back to "now" on the zero value.
Closes part of #137 (gap C — bones internal/hub/hub.go seed path).
* ci: include #137 sim tests in the serve-tests allowlist
Adds TestAgentNew_ and TestHubNATSFossilSync_ to the sim serve-tests regex in both Makefile and test.yml so the new clone-if-absent and hub-NATS-subscriber tests actually run in CI alongside TestHubLeafE2E. (user: [email protected], size: 4095) [annotate] [blame] [check-ins using]
package sim import ( "bytes" "context" "path/filepath" "testing" "time" libfossil "github.com/danmestas/libfossil" _ "github.com/danmestas/libfossil/db/driver/modernc" "github.com/nats-io/nats.go" "github.com/danmestas/EdgeSync/hub" "github.com/danmestas/EdgeSync/leaf/agent" ) // TestHubNATSFossilSync_LeafPushLandsOnHub proves that a hub created via // hub.NewHub (with no leaf-agent serve-mode helper running on top) accepts // fossil-sync xfer requests over NATS and applies them to the hub repo. // // This is the contract that lets downstream consumers drop libfossil from // their go.mod — previously they had to spin up a leaf-agent in serve mode // (Pull=false, Push=false, Autosync=AutosyncOff, ServeNATSEnabled=true) // to get a NATS subscriber on fossil.<code>.sync, transitively pulling // libfossil into their import graph. func TestHubNATSFossilSync_LeafPushLandsOnHub(t *testing.T) { if testing.Short() { t.Skip("hub bootstrap is too slow for -short") } dir := t.TempDir() hubPath := filepath.Join(dir, "hub.fossil") leafPath := filepath.Join(dir, "leaf.fossil") ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) h, err := hub.NewHub(ctx, hub.Config{RepoPath: hubPath}) if err != nil { t.Fatalf("hub.NewHub: %v", err) } t.Cleanup(func() { _ = h.Stop() }) if h.FossilSyncSubject() == "" { t.Fatal("hub.FossilSyncSubject() empty: subscriber should be enabled by default") } if err := h.AddUser(hub.User{Login: "leaf", Caps: "asghbcofkimnqru"}); err != nil { t.Fatalf("hub.AddUser: %v", err) } go func() { _ = h.ServeHTTP(ctx) }() if !waitForHTTP(t, "http://"+h.HTTPAddr()+"/", 2*time.Second) { t.Fatal("hub HTTP not ready within 2s") } hubURL := "http://" + h.HTTPAddr() + "/" cloneCtx, cloneCancel := context.WithTimeout(ctx, 15*time.Second) leafRepo, _, err := libfossil.Clone(cloneCtx, leafPath, libfossil.NewHTTPTransport(hubURL), libfossil.CloneOpts{ User: "leaf", }) cloneCancel() if err != nil { t.Fatalf("libfossil.Clone: %v", err) } t.Cleanup(func() { _ = leafRepo.Close() }) projectCode, err := leafRepo.Config("project-code") if err != nil { t.Fatalf("leaf project-code: %v", err) } if _, _, err := leafRepo.Commit(libfossil.CommitOpts{ Files: []libfossil.FileToCommit{{Name: "leaf-nats-push.txt", Content: []byte("hello-via-nats\n")}}, Comment: "leaf push over nats", User: "leaf", }); err != nil { t.Fatalf("leaf commit: %v", err) } nc, err := nats.Connect(h.NATSURL(), nats.Timeout(2*time.Second)) if err != nil { t.Fatalf("connect leaf NATS client to hub: %v", err) } t.Cleanup(nc.Close) transport := agent.NewNATSTransport(nc, projectCode, 5*time.Second, "fossil") syncCtx, syncCancel := context.WithTimeout(ctx, 15*time.Second) res, err := leafRepo.Sync(syncCtx, transport, libfossil.SyncOpts{ Push: true, Pull: true, ProjectCode: projectCode, User: "leaf", PeerID: "leaf-test", }) syncCancel() if err != nil { t.Fatalf("leaf Sync over NATS to hub: %v", err) } if res == nil { t.Fatal("leaf Sync returned nil result") } got, err := h.Read(ctx, "leaf-nats-push.txt") if err != nil { t.Fatalf("hub.Read(leaf-nats-push.txt): %v", err) } if !bytes.Equal(got, []byte("hello-via-nats\n")) { t.Errorf("hub got %q, want %q", got, "hello-via-nats\n") } } // TestHubNATSFossilSync_DisableSkipsSubscriber proves that // Config.DisableFossilSyncOverNATS turns the subscriber off. Used by // callers that want a read-only HTTP-only mirror. func TestHubNATSFossilSync_DisableSkipsSubscriber(t *testing.T) { if testing.Short() { t.Skip("hub bootstrap is too slow for -short") } dir := t.TempDir() h, err := hub.NewHub(context.Background(), hub.Config{ RepoPath: filepath.Join(dir, "hub.fossil"), DisableFossilSyncOverNATS: true, }) if err != nil { t.Fatalf("hub.NewHub: %v", err) } t.Cleanup(func() { _ = h.Stop() }) if h.FossilSyncSubject() != "" { t.Errorf("FossilSyncSubject() = %q with DisableFossilSyncOverNATS=true; want empty", h.FossilSyncSubject()) } }