Artifact 6a1f62b6f8f47eda15183448206c6df44782385d85e36a3892dd354d6e4f4aac:
- File
internal/sync/cluster_integration_test.go
— part of check-in
[6b66ea6b2d]
at
2026-05-12 20:01:20
on branch trunk
— feat: CreateOpts.ProjectCode — accept explicit project-code on Create (#31)
* feat: CreateOpts.ProjectCode — accept explicit project-code on Create
Downstream EdgeSync coordinates a shared project-code across sibling Fossil repos created on different machines so its NATS sync subject ("<prefix>.<project-code>.sync") matches across leaves. Today every Create generates a fresh project-code, so commits never propagate.
Add CreateOpts.ProjectCode: empty preserves current behavior; non-empty must be 40-char lowercase hex (^[0-9a-f]{40}$) — the same format generated project-codes use. Validation runs at the top of libfossil.Create before any disk I/O, so invalid input cannot leave a half-written file behind. Server-code stays auto-generated.
Threaded through repo.Create / repo.CreateWithEnv / db.SeedConfig. SeedConfig validates as defense-in-depth (public package, multiple callers possible).
Clone accepting ProjectCode is out of scope; will follow separately if EdgeSync needs it.
* test: round-trip explicit ProjectCode through fossil rebuild
Asserts that vanilla `fossil` (2.28) accepts a Go-side repo created with an explicit ProjectCode, and that `fossil sql` reads back the same value we set. Closes the gap where the existing TestCreate_FossilValidation only covered the generated-project-code path. (user: [email protected], size: 5440) [annotate] [blame] [check-ins using]
package sync import ( "context" "fmt" "path/filepath" "testing" "github.com/danmestas/libfossil/internal/blob" "github.com/danmestas/libfossil/internal/repo" "github.com/danmestas/libfossil/simio" "github.com/danmestas/libfossil/internal/xfer" _ "github.com/danmestas/libfossil/internal/testdriver" ) // TestPushOnly_DrainsServerGimmes verifies that when the client pushes // without pulling (SyncOpts{Push:true, Pull:false}), the server still // requests blobs it does not have via gimme cards and the client services // them across multiple rounds. // // Regression: in v0.4.0 the server only emitted gimme cards when pullOK // was true (driven by the client's pull card). Push-only clients exited // after one round even when the server was missing artifacts, leaving // the server with an incomplete blob store. func TestPushOnly_DrainsServerGimmes(t *testing.T) { clientPath := filepath.Join(t.TempDir(), "client.fossil") clientRepo, err := repo.Create(clientPath, "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create client: %v", err) } t.Cleanup(func() { clientRepo.Close() }) const numBlobs = 50 for i := range numBlobs { content := []byte(fmt.Sprintf("push-only-blob-%04d", i)) if _, _, err := blob.Store(clientRepo.DB(), content); err != nil { t.Fatalf("blob.Store[%d]: %v", i, err) } } serverPath := filepath.Join(t.TempDir(), "server.fossil") serverRepo, err := repo.Create(serverPath, "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create server: %v", err) } t.Cleanup(func() { serverRepo.Close() }) transport := &MockTransport{ Handler: func(req *xfer.Message) *xfer.Message { resp, err := HandleSync(context.Background(), serverRepo, req) if err != nil { t.Fatalf("HandleSync: %v", err) } return resp }, } var projCode, srvCode string clientRepo.DB().QueryRow("SELECT value FROM config WHERE name='project-code'").Scan(&projCode) clientRepo.DB().QueryRow("SELECT value FROM config WHERE name='server-code'").Scan(&srvCode) result, err := Sync(context.Background(), clientRepo, transport, SyncOpts{ Push: true, Pull: false, ProjectCode: projCode, ServerCode: srvCode, }) if err != nil { t.Fatalf("Sync: %v", err) } var serverCount int if err := serverRepo.DB().QueryRow("SELECT count(*) FROM blob WHERE size >= 0").Scan(&serverCount); err != nil { t.Fatalf("count blobs: %v", err) } t.Logf("push-only sync completed: rounds=%d sent=%d server_blobs=%d", result.Rounds, result.FilesSent, serverCount) if serverCount < numBlobs { t.Fatalf("server has %d blobs, want >= %d (push-only did not drain server gimmes)", serverCount, numBlobs) } if result.FilesSent < numBlobs { t.Fatalf("FilesSent = %d, want >= %d (multi-round push-only failed to send all)", result.FilesSent, numBlobs) } // Multi-round behaviour: the protocol needs at least 2 rounds (igot then file). if result.Rounds < 2 { t.Fatalf("Rounds = %d, want >= 2 (push-only converged before draining gimmes)", result.Rounds) } } // TestClusterSync_RoundTrip creates a client repo with 200 blobs, syncs them // to an empty server repo via MockTransport wrapping HandleSync, and verifies // all 200 blobs arrive within 10 rounds. func TestClusterSync_RoundTrip(t *testing.T) { // --- Setup: client repo with 200 blobs --- clientPath := filepath.Join(t.TempDir(), "client.fossil") clientRepo, err := repo.Create(clientPath, "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create client: %v", err) } t.Cleanup(func() { clientRepo.Close() }) for i := range 200 { content := []byte(fmt.Sprintf("cluster-roundtrip-blob-%04d", i)) _, _, err := blob.Store(clientRepo.DB(), content) if err != nil { t.Fatalf("blob.Store[%d]: %v", i, err) } } // --- Setup: empty server repo --- serverPath := filepath.Join(t.TempDir(), "server.fossil") serverRepo, err := repo.Create(serverPath, "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create server: %v", err) } t.Cleanup(func() { serverRepo.Close() }) // --- Transport: MockTransport wrapping HandleSync --- transport := &MockTransport{ Handler: func(req *xfer.Message) *xfer.Message { resp, err := HandleSync(context.Background(), serverRepo, req) if err != nil { t.Fatalf("HandleSync: %v", err) } return resp }, } // --- Sync: client pushes to server --- var projCode, srvCode string clientRepo.DB().QueryRow("SELECT value FROM config WHERE name='project-code'").Scan(&projCode) clientRepo.DB().QueryRow("SELECT value FROM config WHERE name='server-code'").Scan(&srvCode) result, err := Sync(context.Background(), clientRepo, transport, SyncOpts{ Push: true, Pull: true, ProjectCode: projCode, ServerCode: srvCode, }) if err != nil { t.Fatalf("Sync: %v", err) } // --- Verify: all 200 blobs arrived --- var serverCount int err = serverRepo.DB().QueryRow("SELECT count(*) FROM blob WHERE size >= 0").Scan(&serverCount) if err != nil { t.Fatalf("count blobs: %v", err) } t.Logf("sync completed: rounds=%d sent=%d recv=%d server_blobs=%d", result.Rounds, result.FilesSent, result.FilesRecvd, serverCount) if serverCount < 200 { t.Fatalf("server has %d blobs, want >= 200", serverCount) } // --- Verify: converged within 10 rounds --- if result.Rounds > 10 { t.Fatalf("took %d rounds, want <= 10", result.Rounds) } }