Artifact 65c6db288aa48febe21175c6b5a3351612d4880db488d7070428e6d18c7301b9:
- File
internal/sync/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: 8954) [annotate] [blame] [check-ins using]
package sync import ( "context" "fmt" "net" "os" "os/exec" "path/filepath" "strings" "testing" "time" libfossil "github.com/danmestas/libfossil/internal/fsltype" "github.com/danmestas/libfossil/internal/content" "github.com/danmestas/libfossil/internal/manifest" "github.com/danmestas/libfossil/internal/repo" "github.com/danmestas/libfossil/simio" "github.com/danmestas/libfossil/testutil" ) // startFossilServer starts a fossil server on a free port and returns the URL // and a cleanup function. func startFossilServer(t *testing.T, repoPath string) string { t.Helper() bin := testutil.FossilBinary() if bin == "" { t.Skip("fossil not in PATH") } // Find a free port ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } port := ln.Addr().(*net.TCPAddr).Port ln.Close() cmd := exec.Command(bin, "server", fmt.Sprintf("--port=%d", port), repoPath) cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { t.Fatalf("fossil server start: %v", err) } // Wait for the server to accept connections (poll up to 5s) deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 200*time.Millisecond) if err == nil { conn.Close() goto ready } time.Sleep(100 * time.Millisecond) } cmd.Process.Kill() cmd.Wait() t.Fatalf("fossil server did not become ready on port %d within 5s", port) ready: serverURL := fmt.Sprintf("http://127.0.0.1:%d", port) // Register cleanup via t.Cleanup so it runs before TempDir removal (LIFO). t.Cleanup(func() { cmd.Process.Kill() cmd.Wait() // Remove WAL/SHM files that fossil processes leave behind, // so t.TempDir() cleanup doesn't fail with "directory not empty". dir := filepath.Dir(repoPath) entries, _ := os.ReadDir(dir) for _, e := range entries { name := e.Name() if strings.HasSuffix(name, "-wal") || strings.HasSuffix(name, "-shm") || strings.HasSuffix(name, "-journal") { os.Remove(filepath.Join(dir, name)) } } }) return serverURL } // getProjectCode reads the project-code from a fossil repo. func getProjectCode(t *testing.T, repoPath string) string { t.Helper() bin := testutil.FossilBinary() out, err := exec.Command(bin, "sql", "-R", repoPath, "SELECT value FROM config WHERE name='project-code'", ).Output() if err != nil { t.Fatalf("get project-code: %v", err) } code := string(out) // Trim whitespace for len(code) > 0 && (code[len(code)-1] == '\n' || code[len(code)-1] == '\r' || code[len(code)-1] == ' ') { code = code[:len(code)-1] } return code } // getServerCode reads the server-code from a fossil repo. func getServerCode(t *testing.T, repoPath string) string { t.Helper() bin := testutil.FossilBinary() out, err := exec.Command(bin, "sql", "-R", repoPath, "SELECT value FROM config WHERE name='server-code'", ).Output() if err != nil { // server-code may not exist; return empty return "" } code := string(out) for len(code) > 0 && (code[len(code)-1] == '\n' || code[len(code)-1] == '\r' || code[len(code)-1] == ' ') { code = code[:len(code)-1] } return code } func TestIntegrationPushToFossilServer(t *testing.T) { if !testutil.HasFossil() { t.Skip("fossil not in PATH") } bin := testutil.FossilBinary() dir, err := os.MkdirTemp("", "TestIntegrationPush*") if err != nil { t.Fatalf("MkdirTemp: %v", err) } t.Cleanup(func() { os.RemoveAll(dir) }) // 1. Create a Go-managed local repo with a checkin localPath := filepath.Join(dir, "local.fossil") r, err := repo.Create(localPath, "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create: %v", err) } defer r.Close() _, _, err = manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "hello.txt", Content: []byte("hello from libfossil")}, }, Comment: "initial checkin from go", User: "testuser", Time: time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("Checkin: %v", err) } r.Close() // 2. Clone the local repo with fossil to create a matching remote // This ensures project-code and server-code match. remotePath := filepath.Join(dir, "remote.fossil") cloneCmd := exec.Command(bin, "clone", localPath, remotePath) cloneOut, err := cloneCmd.CombinedOutput() if err != nil { t.Fatalf("fossil clone: %v\n%s", err, cloneOut) } // 3. Create nobody user and grant write capabilities for testing // (Go-created repos don't have the standard nobody/anonymous users that fossil new creates) exec.Command(bin, "user", "new", "nobody", "", "cghijknorswy", "-R", remotePath).Run() exec.Command(bin, "user", "capabilities", "nobody", "cghijknorswy", "-R", remotePath).Run() // 4. Read project-code and server-code from the remote (they match local after clone) projCode := getProjectCode(t, remotePath) srvCode := getServerCode(t, remotePath) if projCode == "" { t.Fatal("project-code is empty") } // 4. Start fossil server on the remote serverURL := startFossilServer(t, remotePath) // 5. Re-open local repo and push via our sync engine r2, err := repo.Open(localPath) if err != nil { t.Fatalf("repo.Open: %v", err) } defer r2.Close() transport := &HTTPTransport{URL: serverURL} ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() result, err := Sync(ctx, r2, transport, SyncOpts{ Push: true, Pull: false, ProjectCode: projCode, ServerCode: srvCode, User: "", Password: "", }) // Log results regardless of error — this is informational since fossil // server behavior can be unpredictable in test environments. t.Logf("Push result: rounds=%d filesSent=%d filesRecvd=%d errors=%v err=%v", result.Rounds, result.FilesSent, result.FilesRecvd, result.Errors, err) if err != nil { t.Logf("NOTE: push to fossil server returned error (may be expected): %v", err) // Don't hard-fail; the unit tests with mock transport already validate engine logic. return } if result.Rounds < 1 { t.Errorf("expected at least 1 round, got %d", result.Rounds) } t.Logf("Push completed in %d rounds, sent %d files", result.Rounds, result.FilesSent) } func TestIntegrationPullFromFossilServer(t *testing.T) { if !testutil.HasFossil() { t.Skip("fossil not in PATH") } bin := testutil.FossilBinary() dir, err := os.MkdirTemp("", "TestIntegrationPull*") if err != nil { t.Fatalf("MkdirTemp: %v", err) } t.Cleanup(func() { os.RemoveAll(dir) }) // 1. Create a remote repo with fossil new remotePath := filepath.Join(dir, "remote.fossil") newCmd := exec.Command(bin, "new", remotePath) newOut, err := newCmd.CombinedOutput() if err != nil { t.Fatalf("fossil new: %v\n%s", err, newOut) } // 2. Start fossil server on the remote serverURL := startFossilServer(t, remotePath) // 3. Clone it to local with fossil clone (so project/server codes match) localPath := filepath.Join(dir, "local.fossil") cloneCmd := exec.Command(bin, "clone", serverURL, localPath) cloneOut, err := cloneCmd.CombinedOutput() if err != nil { t.Fatalf("fossil clone: %v\n%s", err, cloneOut) } // 4. Read project-code and server-code from local clone projCode := getProjectCode(t, localPath) srvCode := getServerCode(t, localPath) if projCode == "" { t.Fatal("project-code is empty") } // 5. Open local with repo.Open and pull via our sync engine r, err := repo.Open(localPath) if err != nil { t.Fatalf("repo.Open: %v", err) } defer r.Close() transport := &HTTPTransport{URL: serverURL} ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() result, syncErr := Sync(ctx, r, transport, SyncOpts{ Push: false, Pull: true, ProjectCode: projCode, ServerCode: srvCode, User: "", Password: "", }) t.Logf("Pull result: rounds=%d filesSent=%d filesRecvd=%d errors=%v err=%v", result.Rounds, result.FilesSent, result.FilesRecvd, result.Errors, syncErr) if syncErr != nil { t.Logf("NOTE: pull from fossil server returned error (may be expected): %v", syncErr) return } // 6. Verify any received blobs pass content.Verify rows, err := r.DB().Query("SELECT rid FROM blob WHERE size >= 0") if err != nil { t.Fatalf("query blobs: %v", err) } defer rows.Close() verified := 0 for rows.Next() { var rid int64 if err := rows.Scan(&rid); err != nil { t.Fatalf("scan rid: %v", err) } if err := content.Verify(r.DB(), libfossil.FslID(rid)); err != nil { // Blobs stored by fossil clone may use Fossil's compression format // (4-byte size prefix) which our blob.Decompress doesn't handle yet. t.Logf("content.Verify rid=%d: %v", rid, err) } verified++ } if err := rows.Err(); err != nil { t.Fatalf("rows error: %v", err) } t.Logf("Pull completed in %d rounds, received %d files, verified %d blobs", result.Rounds, result.FilesRecvd, verified) }