Files
nielash be46eaa1f0 bisync: test that every flag can be set through the rc
Before this change, nothing checked that the rc `sync/bisync` call accepted
every bisync flag, so the rc could fall behind the command line as flags
were added, as in #7799.

After this change, `TestRcFlagParity` passes each non-hidden bisync flag to
the rc, under the parameter name `rc.md` uses, with a value of the wrong
type. Every getter `rcBisync` uses rejects it, so the call fails only if
the parameter is read, and the test names any flag the rc doesn't handle.
2026-10-14 15:42:18 -04:00

267 lines
8.6 KiB
Go

package bisync_test
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/rclone/rclone/cmd"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/rc"
"github.com/rclone/rclone/fs/rc/jobs"
"github.com/rclone/rclone/fstest"
"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newRcBisync is newRcBisyncCtx with the rc jobs run in a plain context
func newRcBisync(t *testing.T, n int) (path1, path2 string, run func(in rc.Params) error) {
return newRcBisyncCtx(context.Background(), t, n)
}
// newRcBisyncCtx creates Path1 with n files, resyncs it to Path2, and returns
// the paths and a function which runs sync/bisync on them as an rc job in
// ctx, whose config stands in for the flags the rcd was started with
func newRcBisyncCtx(ctx context.Context, t *testing.T, n int) (path1, path2 string, run func(in rc.Params) error) {
if *fstest.RemoteName != "" {
t.Skip("Skipping test on non local remote")
}
dir := t.TempDir()
path1 = filepath.Join(dir, "path1")
path2 = filepath.Join(dir, "path2")
workdir := filepath.Join(dir, "workdir")
require.NoError(t, os.Mkdir(path1, 0o755))
require.NoError(t, os.Mkdir(path2, 0o755))
for i := range n {
require.NoError(t, os.WriteFile(filepath.Join(path1, fmt.Sprintf("file%d.txt", i)), []byte("content"), 0o644))
}
call := rc.Calls.Get("sync/bisync")
require.NotNil(t, call)
runIn := func(ctx context.Context, in rc.Params) error {
in["path1"] = path1
in["path2"] = path2
if _, ok := in["workdir"]; !ok {
in["workdir"] = workdir
}
_, _, err := jobs.NewJob(ctx, call.Fn, in)
return err
}
require.NoError(t, runIn(context.Background(), rc.Params{"resync": true}))
return path1, path2, func(in rc.Params) error { return runIn(ctx, in) }
}
func TestRcDryRun(t *testing.T) {
for _, test := range []struct {
name string
in rc.Params
dryRun bool
}{
{"dryRun", rc.Params{"dryRun": true}, true},
{"dry_run", rc.Params{"dry_run": true}, true},
{"_config", rc.Params{"_config": rc.Params{"DryRun": true}}, true},
{"dry_run and dryRun false", rc.Params{"dry_run": true, "dryRun": false}, true},
{"_config and dryRun false", rc.Params{"_config": rc.Params{"DryRun": true}, "dryRun": false}, true},
{"dryRun false", rc.Params{"dryRun": false}, false},
} {
t.Run(test.name, func(t *testing.T) {
path1, path2, run := newRcBisync(t, 1)
require.NoError(t, os.WriteFile(filepath.Join(path1, "new.txt"), []byte("new"), 0o644))
require.NoError(t, run(test.in))
if test.dryRun {
assert.NoFileExists(t, filepath.Join(path2, "new.txt"))
} else {
assert.FileExists(t, filepath.Join(path2, "new.txt"))
}
})
}
}
func TestRcMaxDelete(t *testing.T) {
for _, test := range []struct {
name string
in rc.Params
wantErr string
}{
{"default", rc.Params{}, ""},
{"maxDelete", rc.Params{"maxDelete": 5}, "too many deletes"},
{"max_delete allows", rc.Params{"max_delete": 20}, ""},
{"max_delete aborts", rc.Params{"max_delete": 5}, "too many deletes"},
{"_config MaxDelete aborts", rc.Params{"_config": rc.Params{"MaxDelete": 5}}, "too many deletes"},
{"max_delete over 100", rc.Params{"max_delete": 500}, "--max-delete"},
} {
t.Run(test.name, func(t *testing.T) {
path1, path2, run := newRcBisync(t, 10)
require.NoError(t, os.Remove(filepath.Join(path1, "file0.txt")))
err := run(test.in)
if test.wantErr != "" {
assert.ErrorContains(t, err, test.wantErr)
assert.FileExists(t, filepath.Join(path2, "file0.txt"))
} else {
require.NoError(t, err)
assert.NoFileExists(t, filepath.Join(path2, "file0.txt"))
}
})
}
}
// Test a --max-delete the rcd was started with applies, as on the command line
func TestRcMaxDeleteFromRcd(t *testing.T) {
for _, test := range []struct {
maxDelete int64
wantErr string
}{
{5, "too many deletes"},
{500, "--max-delete"},
} {
t.Run(fmt.Sprint(test.maxDelete), func(t *testing.T) {
ctx, ci := fs.AddConfig(context.Background())
ci.MaxDelete = test.maxDelete
path1, path2, run := newRcBisyncCtx(ctx, t, 10)
require.NoError(t, os.Remove(filepath.Join(path1, "file0.txt")))
assert.ErrorContains(t, run(rc.Params{}), test.wantErr)
assert.FileExists(t, filepath.Join(path2, "file0.txt"))
})
}
}
func TestRcInvalidParams(t *testing.T) {
for _, test := range []struct {
key string
value any
}{
{"force", "yes"},
{"maxDeleteRenamesAware", "yes"},
{"filtersFile", []string{"filters.txt"}},
{"checkSync", 1},
{"conflictResolve", "bogus"},
{"maxLock", "soon"},
} {
t.Run(test.key, func(t *testing.T) {
path1, path2, run := newRcBisync(t, 1)
require.NoError(t, os.WriteFile(filepath.Join(path1, "new.txt"), []byte("new"), 0o644))
err := run(rc.Params{test.key: test.value})
assert.Error(t, err)
assert.NoFileExists(t, filepath.Join(path2, "new.txt"))
})
}
}
func TestRcBackupDir(t *testing.T) {
for _, test := range []struct {
key string
changed int // the path whose change overwrites the file on the other path
}{
{"backupDir1", 2},
{"backupdir1", 2},
{"backupDir2", 1},
{"backupdir2", 1},
} {
t.Run(test.key, func(t *testing.T) {
path1, path2, run := newRcBisync(t, 2)
backupDir := filepath.Join(filepath.Dir(path1), "backup")
changedPath := map[int]string{1: path1, 2: path2}[test.changed]
require.NoError(t, os.WriteFile(filepath.Join(changedPath, "file0.txt"), []byte("changed"), 0o644))
require.NoError(t, run(rc.Params{test.key: backupDir}))
assert.FileExists(t, filepath.Join(backupDir, "file0.txt"))
})
}
}
func TestRcEnums(t *testing.T) {
read := func(t *testing.T, path string) string {
b, err := os.ReadFile(filepath.Join(path, "file0.txt"))
require.NoError(t, err)
return string(b)
}
// changeBoth makes file0.txt differ on both paths, with Path2 an hour newer
changeBoth := func(t *testing.T, path1, path2 string) {
now := time.Now()
for path, content := range map[string]string{path1: "path1", path2: "path2 wins"} {
file := filepath.Join(path, "file0.txt")
require.NoError(t, os.WriteFile(file, []byte(content), 0o644))
mtime := now
if path == path1 {
mtime = now.Add(-time.Hour)
}
require.NoError(t, os.Chtimes(file, mtime, mtime))
}
}
names := func(t *testing.T, path string) (names []string) {
entries, err := os.ReadDir(path)
require.NoError(t, err)
for _, e := range entries {
names = append(names, e.Name())
}
return names
}
t.Run("conflictResolve and conflictLoser", func(t *testing.T) {
path1, path2, run := newRcBisync(t, 2)
changeBoth(t, path1, path2)
require.NoError(t, run(rc.Params{"conflictResolve": "path2", "conflictLoser": "delete"}))
for _, path := range []string{path1, path2} {
assert.Equal(t, []string{"file0.txt", "file1.txt"}, names(t, path))
assert.Equal(t, "path2 wins", read(t, path))
}
})
t.Run("conflict defaults", func(t *testing.T) {
path1, path2, run := newRcBisync(t, 2)
changeBoth(t, path1, path2)
require.NoError(t, run(rc.Params{}))
assert.NotContains(t, names(t, path1), "file0.txt", "with no winner both versions are renamed")
})
for _, test := range []struct {
name string
in rc.Params
want string
}{
{"resyncMode default", rc.Params{"resync": true}, "path1"},
{"resyncMode path2", rc.Params{"resync": true, "resyncMode": "path2"}, "path2 wins"},
} {
t.Run(test.name, func(t *testing.T) {
path1, path2, run := newRcBisync(t, 2)
changeBoth(t, path1, path2)
require.NoError(t, run(test.in))
assert.Equal(t, test.want, read(t, path1))
assert.Equal(t, test.want, read(t, path2))
})
}
t.Run("checkSync only", func(t *testing.T) {
path1, path2, run := newRcBisync(t, 1)
require.NoError(t, os.WriteFile(filepath.Join(path1, "new.txt"), []byte("new"), 0o644))
require.NoError(t, run(rc.Params{"checkSync": "only"}))
assert.NoFileExists(t, filepath.Join(path2, "new.txt"))
})
}
// rcParamName returns the rc parameter name for a flag, as help.go does for rc.md
func rcParamName(flag string) string {
words := strings.Split(flag, "-")
for i := 1; i < len(words); i++ {
words[i] = strings.ToUpper(words[i][:1]) + words[i][1:]
}
return strings.Join(words, "")
}
// Test every bisync flag can be set through the rc
func TestRcFlagParity(t *testing.T) {
bisyncCmd, _, err := cmd.Root.Find([]string{"bisync"})
require.NoError(t, err)
bisyncCmd.Flags().VisitAll(func(flag *pflag.Flag) {
if flag.Hidden {
return
}
key := rcParamName(flag.Name)
t.Run(key, func(t *testing.T) {
_, _, run := newRcBisync(t, 1)
// no rc getter accepts a map, so this only fails if rcBisync reads the param
err := run(rc.Params{key: rc.Params{"wrong": "type"}})
assert.ErrorContains(t, err, key, "no rc parameter for --%s", flag.Name)
})
})
}