Files
syncbot/internal/syncer/syncer_test.go
T
iceBear67 a006483bbc
ci / test (push) Canceled after 0s
docker / build (push) Canceled after 0s
init
2026-08-14 07:13:22 +00:00

323 lines
8.2 KiB
Go

package syncer
import (
"context"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"syncbot/internal/config"
"syncbot/internal/gitx"
)
// harness wires up a real src repo, a real bare dst repo and a Syncer, so the
// tests exercise the actual git plumbing rather than a mock of it.
type harness struct {
t *testing.T
src string
dst string
job config.Job
sync *Syncer
}
func newHarness(t *testing.T) *harness {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
root := t.TempDir()
src := filepath.Join(root, "src")
dst := filepath.Join(root, "dst.git")
home := filepath.Join(root, "home")
for _, d := range []string{src, dst, home} {
if err := os.MkdirAll(d, 0o700); err != nil {
t.Fatal(err)
}
}
git(t, src, "init", "--quiet", "-b", "main")
git(t, dst, "init", "--bare", "--quiet", "-b", "main")
// A bare repo refuses to have the branch its HEAD points at deleted, which
// would otherwise make the allow_empty case untestable. GitHub behaves the
// same way for its default branch; see the README's troubleshooting notes.
git(t, dst, "config", "receive.denyDeleteCurrent", "ignore")
h := &harness{
t: t,
src: src,
dst: dst,
job: config.Job{
Name: "test",
Src: config.Endpoint{URL: src},
Dst: config.Endpoint{URL: dst},
Dir: filepath.Join(root, "mirror.git"),
Interval: time.Minute,
Timeout: 2 * time.Minute,
MaxBackoff: time.Minute,
Refs: config.DefaultRefs,
Prune: true,
Force: true,
},
sync: &Syncer{Log: slog.New(slog.NewTextHandler(io.Discard, nil)), Home: home},
}
h.commit("first")
return h
}
func (h *harness) commit(msg string) {
h.t.Helper()
path := filepath.Join(h.src, "file.txt")
if err := os.WriteFile(path, []byte(msg+"\n"), 0o600); err != nil {
h.t.Fatal(err)
}
git(h.t, h.src, "add", "-A")
git(h.t, h.src, "commit", "--quiet", "-m", msg)
}
func (h *harness) run() Result {
h.t.Helper()
res, err := h.sync.Sync(context.Background(), h.job)
if err != nil {
h.t.Fatalf("sync: %v", err)
}
return res
}
func (h *harness) refs(dir string) gitx.Refs {
h.t.Helper()
out := git(h.t, dir, "for-each-ref", "--format=%(objectname)\t%(refname)")
refs := gitx.Refs{}
for _, line := range strings.Split(out, "\n") {
if obj, ref, ok := strings.Cut(strings.TrimSpace(line), "\t"); ok {
refs[ref] = obj
}
}
return refs
}
// assertMirrored checks that dst holds exactly what src holds.
func (h *harness) assertMirrored() {
h.t.Helper()
src, dst := h.refs(h.src), h.refs(h.dst)
if !src.Equal(dst) {
h.t.Fatalf("dst does not mirror src\n src: %v\n dst: %v", src, dst)
}
}
func TestFirstSyncCopiesEverything(t *testing.T) {
h := newHarness(t)
git(t, h.src, "tag", "-a", "v1.0.0", "-m", "release")
git(t, h.src, "branch", "feature/x")
res := h.run()
if !res.Fetched || !res.Pushed {
t.Fatalf("want fetch and push on first sync, got %+v", res)
}
h.assertMirrored()
dst := h.refs(h.dst)
for _, want := range []string{"refs/heads/main", "refs/heads/feature/x", "refs/tags/v1.0.0"} {
if _, ok := dst[want]; !ok {
t.Errorf("dst missing %s (has %v)", want, dst)
}
}
}
func TestNoChangesDoesNothing(t *testing.T) {
h := newHarness(t)
h.run()
res := h.run()
if res.Fetched {
t.Error("fetched despite src being unchanged")
}
if res.Pushed {
t.Error("pushed despite dst already being in sync")
}
}
func TestNewCommitPropagates(t *testing.T) {
h := newHarness(t)
h.run()
h.commit("second")
res := h.run()
if !res.Fetched || !res.Pushed {
t.Fatalf("want fetch and push after a new commit, got %+v", res)
}
h.assertMirrored()
}
func TestDeletedBranchIsPruned(t *testing.T) {
h := newHarness(t)
git(t, h.src, "branch", "temp")
h.run()
if _, ok := h.refs(h.dst)["refs/heads/temp"]; !ok {
t.Fatal("setup: dst should have refs/heads/temp")
}
git(t, h.src, "branch", "-D", "temp")
h.run()
if _, ok := h.refs(h.dst)["refs/heads/temp"]; ok {
t.Error("refs/heads/temp still on dst after being deleted upstream")
}
h.assertMirrored()
}
func TestForcePushAfterRewrite(t *testing.T) {
h := newHarness(t)
h.commit("second")
h.run()
// Rewrite history the way a rebase or an amended commit would.
git(t, h.src, "reset", "--hard", "--quiet", "HEAD~1")
h.commit("rewritten")
res := h.run()
if !res.Pushed {
t.Fatal("want a push after history was rewritten")
}
h.assertMirrored()
}
// The bot keeps no state between runs, so damage done directly to dst must heal
// on the next cycle even though src has not moved.
func TestDestinationDriftIsRepaired(t *testing.T) {
h := newHarness(t)
git(t, h.src, "branch", "keep")
h.run()
git(t, h.dst, "update-ref", "-d", "refs/heads/keep")
if _, ok := h.refs(h.dst)["refs/heads/keep"]; ok {
t.Fatal("setup: refs/heads/keep should be gone from dst")
}
res := h.run()
if res.Fetched {
t.Error("fetched even though src had not changed")
}
if !res.Pushed {
t.Fatal("want a push to repair dst")
}
h.assertMirrored()
}
func TestEmptySourceIsRefused(t *testing.T) {
h := newHarness(t)
h.run()
// Simulate a source that answers but has nothing to offer — a revoked
// token or a wrong URL looks exactly like this.
empty := filepath.Join(t.TempDir(), "empty.git")
git(t, t.TempDir(), "init", "--bare", "--quiet", empty)
h.job.Src = config.Endpoint{URL: empty}
_, err := h.sync.Sync(context.Background(), h.job)
if err == nil {
t.Fatal("want an error when an empty source would wipe dst")
}
if !strings.Contains(err.Error(), "allow_empty") {
t.Errorf("error should point at allow_empty, got: %v", err)
}
if len(h.refs(h.dst)) == 0 {
t.Error("dst was wiped despite the guard")
}
// With the guard lifted the wipe goes through, as documented.
h.job.AllowEmpty = true
if _, err := h.sync.Sync(context.Background(), h.job); err != nil {
t.Fatalf("sync with allow_empty: %v", err)
}
if n := len(h.refs(h.dst)); n != 0 {
t.Errorf("dst should be empty with allow_empty = true, has %d refs", n)
}
}
func TestRefsFilterLimitsWhatIsMirrored(t *testing.T) {
h := newHarness(t)
git(t, h.src, "tag", "v1")
h.job.Refs = []string{"refs/heads/*"}
h.run()
dst := h.refs(h.dst)
if _, ok := dst["refs/heads/main"]; !ok {
t.Error("branches should be mirrored")
}
if _, ok := dst["refs/tags/v1"]; ok {
t.Error("tags should not be mirrored when refs excludes them")
}
}
func TestUnreachableSourceReportsError(t *testing.T) {
h := newHarness(t)
h.job.Src = config.Endpoint{URL: filepath.Join(t.TempDir(), "does-not-exist.git")}
h.job.Timeout = 30 * time.Second
if _, err := h.sync.Sync(context.Background(), h.job); err == nil {
t.Fatal("want an error for an unreachable source")
}
}
func TestTimeoutIsEnforced(t *testing.T) {
h := newHarness(t)
h.job.Timeout = time.Nanosecond
_, err := h.sync.Sync(context.Background(), h.job)
if err == nil {
t.Fatal("want an error when the timeout expires")
}
}
func TestNeedsPush(t *testing.T) {
local := gitx.Refs{"refs/heads/main": "aaa"}
cases := []struct {
name string
local gitx.Refs
dst gitx.Refs
prune bool
want bool
}{
{"identical", local, gitx.Refs{"refs/heads/main": "aaa"}, true, false},
{"moved", local, gitx.Refs{"refs/heads/main": "bbb"}, true, true},
{"missing on dst", local, gitx.Refs{}, true, true},
{"extra on dst, pruning", local, gitx.Refs{"refs/heads/main": "aaa", "refs/heads/x": "c"}, true, true},
{"extra on dst, not pruning", local, gitx.Refs{"refs/heads/main": "aaa", "refs/heads/x": "c"}, false, false},
{"both empty", gitx.Refs{}, gitx.Refs{}, true, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := needsPush(tc.local, tc.dst, tc.prune); got != tc.want {
t.Errorf("needsPush = %v, want %v", got, tc.want)
}
})
}
}
func git(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_CONFIG_GLOBAL="+os.DevNull,
"GIT_CONFIG_SYSTEM="+os.DevNull,
"GIT_AUTHOR_NAME=syncbot test",
"GIT_AUTHOR_EMAIL=test@example.invalid",
"GIT_COMMITTER_NAME=syncbot test",
"GIT_COMMITTER_EMAIL=test@example.invalid",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return string(out)
}