The freshen tests built a fixture and never ran freshen. They now run gen and freshen through the command entry point on a temp dir: an unchanged tree leaves the manifest's entries as they were, and a modified file (also one edited without changing its size), a new or a deleted file gives a manifest that lists exactly the tree, with sizes, hashes and mtimes from disk, on which check passes. New fetch tests run the command against an httptest server: a nested tree lands complete, a hash mismatch exits non-zero and leaves no file, and a partly filled destination gets every listed file downloaded again and replaced, while a file the manifest does not list is left alone. Model: opus-5-5
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -235,6 +236,33 @@ func manifestOf(t *testing.T, files map[string][]byte) []byte {
|
||||
return scanToManifest(t, sourceFs)
|
||||
}
|
||||
|
||||
// filesUnder returns the content of every file under dir, by its path
|
||||
// relative to dir.
|
||||
func filesUnder(t *testing.T, dir string) map[string][]byte {
|
||||
t.Helper()
|
||||
|
||||
files := map[string][]byte{}
|
||||
|
||||
err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil || entry.IsDir() {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path) //nolint:gosec // test-controlled path
|
||||
files[filepath.ToSlash(rel)] = content
|
||||
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// testClient returns the client tests download with. Its retries wait
|
||||
// milliseconds rather than seconds.
|
||||
func testClient() retryingClient {
|
||||
@@ -848,6 +876,108 @@ func TestFetchEscapedPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchTree runs fetch on a tree with nested directories. Every file
|
||||
// the manifest lists must land under its own path with its own content,
|
||||
// and nothing else may be left in the destination.
|
||||
//
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchTree(t *testing.T) {
|
||||
files := map[string][]byte{
|
||||
"top.txt": []byte("at the top"),
|
||||
"sub/one.txt": []byte("one level down"),
|
||||
"sub/deeper/two.txt": []byte("two levels down"),
|
||||
"other/deep/est.txt": []byte("in a second directory"),
|
||||
}
|
||||
|
||||
server := httptest.NewServer(fetchTestHandler(manifestOf(t, files), files))
|
||||
defer server.Close()
|
||||
|
||||
dest := chdirTemp(t)
|
||||
|
||||
opts := testOpts([]string{testApp, cmdFetch, "-q", server.URL}, afero.NewOsFs())
|
||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||
|
||||
assert.Equal(t, files, filesUnder(t, dest))
|
||||
}
|
||||
|
||||
// TestFetchFailsOnHashMismatch runs fetch against a server that serves a
|
||||
// file with the size the manifest lists but different content. fetch must
|
||||
// exit non-zero and leave no file in the destination.
|
||||
//
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchFailsOnHashMismatch(t *testing.T) {
|
||||
listed := map[string][]byte{testDirFile: []byte("original")}
|
||||
served := map[string][]byte{testDirFile: []byte("tampered")}
|
||||
|
||||
server := httptest.NewServer(fetchTestHandler(manifestOf(t, listed), served))
|
||||
defer server.Close()
|
||||
|
||||
dest := chdirTemp(t)
|
||||
|
||||
opts := testOpts([]string{testApp, cmdFetch, "-q", server.URL}, afero.NewOsFs())
|
||||
assert.Equal(t, 1, runCLI(opts))
|
||||
assert.Empty(t, filesUnder(t, dest))
|
||||
}
|
||||
|
||||
// TestFetchIntoPartlyFilledDestination runs fetch where an interrupted
|
||||
// fetch of an older version of the tree left one file current, one file
|
||||
// out of date and one half written to its temp file, beside a file the
|
||||
// manifest does not list. fetch downloads every file the manifest lists,
|
||||
// those already present included, and replaces what is there; the file
|
||||
// the manifest does not list is left alone.
|
||||
//
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchIntoPartlyFilledDestination(t *testing.T) {
|
||||
files := map[string][]byte{
|
||||
"current.txt": []byte("already fetched"),
|
||||
"sub/changed.txt": []byte("new version"),
|
||||
"sub/partial.txt": []byte("cut off partway"),
|
||||
}
|
||||
unlisted := []byte("not in the manifest")
|
||||
|
||||
tree := fetchTestHandler(manifestOf(t, files), files)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
requested []string
|
||||
)
|
||||
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
|
||||
requested = append(requested, r.URL.Path)
|
||||
|
||||
mu.Unlock()
|
||||
|
||||
tree.ServeHTTP(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dest := chdirTemp(t)
|
||||
|
||||
require.NoError(t, os.MkdirAll("sub", 0o750))
|
||||
require.NoError(t, os.WriteFile("current.txt", files["current.txt"], 0o600))
|
||||
require.NoError(t, os.WriteFile("sub/changed.txt", []byte("old version"), 0o600))
|
||||
require.NoError(t,
|
||||
os.WriteFile(tempPathFor("sub/partial.txt"), []byte("cut off"), 0o600))
|
||||
require.NoError(t, os.WriteFile("unlisted.txt", unlisted, 0o600))
|
||||
|
||||
opts := testOpts([]string{testApp, cmdFetch, "-q", server.URL}, afero.NewOsFs())
|
||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||
|
||||
want := maps.Clone(files)
|
||||
want["unlisted.txt"] = unlisted
|
||||
assert.Equal(t, want, filesUnder(t, dest))
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
assert.ElementsMatch(t, []string{
|
||||
"/" + defaultManifestName, "/current.txt", "/sub/changed.txt", "/sub/partial.txt",
|
||||
}, requested)
|
||||
}
|
||||
|
||||
// TestFetchTimeoutFlag runs fetch with --timeout against a server that
|
||||
// never answers. Without the flag's limit the request would wait forever;
|
||||
// once fetch gives up on it, the server cancels fetch's context so that
|
||||
|
||||
+152
-50
@@ -2,11 +2,11 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
@@ -31,77 +32,178 @@ func (s stubFileInfo) ModTime() time.Time { return s.mtime }
|
||||
func (s stubFileInfo) IsDir() bool { return false }
|
||||
func (s stubFileInfo) Sys() any { return nil }
|
||||
|
||||
// setupFreshenDir populates /testdir with two files, scans it, and
|
||||
// writes the resulting manifest to /testdir/index.mf.
|
||||
func setupFreshenDir(t *testing.T, fs afero.Fs) {
|
||||
// setupFreshenDir writes files, by path and content, into a fresh temp
|
||||
// dir and runs gen on it. It returns the temp dir and the path of the
|
||||
// manifest gen wrote there.
|
||||
func setupFreshenDir(
|
||||
t *testing.T, fs afero.Fs, files map[string]string,
|
||||
) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "content1")
|
||||
writeTestFile(t, fs, "/testdir/file2.txt", "content2")
|
||||
root := t.TempDir()
|
||||
manifestPath := filepath.Join(root, defaultManifestName)
|
||||
|
||||
// Generate initial manifest
|
||||
opts := &mfer.ScannerOptions{Fs: fs}
|
||||
s := mfer.NewScannerWithOptions(opts)
|
||||
require.NoError(t, s.EnumeratePath(testDir, nil))
|
||||
for path, content := range files {
|
||||
require.NoError(t, fs.MkdirAll(filepath.Dir(filepath.Join(root, path)), 0o750))
|
||||
writeTestFile(t, fs, filepath.Join(root, path), content)
|
||||
}
|
||||
|
||||
var manifestBuf bytes.Buffer
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", manifestPath, root}, fs)
|
||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
|
||||
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||
|
||||
// Write manifest to filesystem
|
||||
require.NoError(t,
|
||||
afero.WriteFile(fs, "/testdir/index.mf", manifestBuf.Bytes(), 0o644))
|
||||
return root, manifestPath
|
||||
}
|
||||
|
||||
// runFreshen runs freshen on the manifest at manifestPath for the tree
|
||||
// at root and requires it to succeed.
|
||||
func runFreshen(t *testing.T, fs afero.Fs, root, manifestPath string) {
|
||||
t.Helper()
|
||||
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdFreshen, "-q", testFlagBase, root, manifestPath,
|
||||
}, fs)
|
||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
}
|
||||
|
||||
// manifestFiles returns the file entries of the manifest at path.
|
||||
func manifestFiles(t *testing.T, fs afero.Fs, path string) []*mfer.MFFilePath {
|
||||
t.Helper()
|
||||
|
||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||
Path: path,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return manifest.Files()
|
||||
}
|
||||
|
||||
// TestFreshenUnchanged freshens a tree that has not changed since gen
|
||||
// made its manifest: the manifest must list the same entries as before.
|
||||
func TestFreshenUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
setupFreshenDir(t, fs)
|
||||
fs := afero.NewOsFs()
|
||||
root, manifestPath := setupFreshenDir(t, fs,
|
||||
map[string]string{testFileTxt: "content1", testDirFile: "content2"})
|
||||
before := manifestFiles(t, fs, manifestPath)
|
||||
|
||||
// Parse manifest to verify
|
||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||
Path: "/testdir/index.mf",
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, manifest.Files(), 2)
|
||||
runFreshen(t, fs, root, manifestPath)
|
||||
|
||||
after := manifestFiles(t, fs, manifestPath)
|
||||
require.Len(t, after, len(before))
|
||||
|
||||
for i := range before {
|
||||
assert.True(t, proto.Equal(before[i], after[i]),
|
||||
"entry for %s changed", before[i].GetPath())
|
||||
}
|
||||
}
|
||||
|
||||
// assertManifestLists asserts that the manifest at manifestPath lists
|
||||
// exactly the files in want, each with the size and SHA-256 hash of its
|
||||
// content in want and the mtime of the file of that name under root.
|
||||
func assertManifestLists(
|
||||
t *testing.T, fs afero.Fs, root, manifestPath string, want map[string]string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
files := manifestFiles(t, fs, manifestPath)
|
||||
listed := make([]string, 0, len(files))
|
||||
|
||||
for _, f := range files {
|
||||
listed = append(listed, f.GetPath())
|
||||
|
||||
content, ok := want[f.GetPath()]
|
||||
if !ok {
|
||||
continue // reported by the ElementsMatch below
|
||||
}
|
||||
|
||||
digest := sha256.Sum256([]byte(content))
|
||||
hash, err := multihash.Encode(digest[:], multihash.SHA2_256)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(len(content)), f.GetSize(), f.GetPath())
|
||||
require.NotEmpty(t, f.GetHashes(), f.GetPath())
|
||||
assert.Equal(t, hash, f.GetHashes()[0].GetMultiHash(), f.GetPath())
|
||||
|
||||
info, err := fs.Stat(filepath.Join(root, f.GetPath()))
|
||||
require.NoError(t, err)
|
||||
|
||||
mtime, ok := entryMtime(f)
|
||||
assert.True(t, ok && mtime.Equal(info.ModTime()),
|
||||
"%s: manifest has mtime %v, file has %v",
|
||||
f.GetPath(), mtime, info.ModTime())
|
||||
}
|
||||
|
||||
assert.ElementsMatch(t, slices.Collect(maps.Keys(want)), listed)
|
||||
}
|
||||
|
||||
// TestFreshenWithChanges makes one change to a tree after gen made its
|
||||
// manifest, then freshens the manifest. The rewritten manifest must list
|
||||
// exactly the files now in the tree, and check must pass on the tree.
|
||||
func TestFreshenWithChanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
setupFreshenDir(t, fs)
|
||||
tree := map[string]string{testFileTxt: "content1", testDirFile: "content2"}
|
||||
|
||||
// Verify initial manifest has 2 files
|
||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||
Path: "/testdir/index.mf",
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, manifest.Files(), 2)
|
||||
// Every file a case writes gets this mtime, which differs from the one
|
||||
// gen recorded, so an edit that keeps the size is told apart by its
|
||||
// mtime whatever the filesystem's clock resolution.
|
||||
writtenMtime := time.Unix(1_700_000_000, 0)
|
||||
|
||||
// Add a new file
|
||||
writeTestFile(t, fs, "/testdir/file3.txt", "content3")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
write map[string]string // files to write, by path and content
|
||||
remove string // file to delete, if any
|
||||
}{
|
||||
{
|
||||
name: "modified file",
|
||||
write: map[string]string{testDirFile: "modified content2"},
|
||||
},
|
||||
{
|
||||
name: "modified file, same size",
|
||||
write: map[string]string{testDirFile: "CONTENT2"},
|
||||
},
|
||||
{
|
||||
name: "new file",
|
||||
write: map[string]string{"dir/new.txt": "content3"},
|
||||
},
|
||||
{
|
||||
name: "deleted file",
|
||||
remove: testFileTxt,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Modify file2 (change content and size)
|
||||
writeTestFile(t, fs, "/testdir/file2.txt", "modified content2")
|
||||
fs := afero.NewOsFs()
|
||||
root, manifestPath := setupFreshenDir(t, fs, tree)
|
||||
|
||||
// Remove file1
|
||||
require.NoError(t, fs.Remove(testFile1))
|
||||
// want is the tree as it is after the change.
|
||||
want := maps.Clone(tree)
|
||||
|
||||
// Note: The freshen operation would need to be run here
|
||||
// For now, we just verify the test setup is correct
|
||||
exists, _ := afero.Exists(fs, testFile1)
|
||||
assert.False(t, exists)
|
||||
for path, content := range tc.write {
|
||||
writeTestFile(t, fs, filepath.Join(root, path), content)
|
||||
require.NoError(t, fs.Chtimes(
|
||||
filepath.Join(root, path), writtenMtime, writtenMtime))
|
||||
want[path] = content
|
||||
}
|
||||
|
||||
exists, _ = afero.Exists(fs, "/testdir/file3.txt")
|
||||
assert.True(t, exists)
|
||||
if tc.remove != "" {
|
||||
require.NoError(t, fs.Remove(filepath.Join(root, tc.remove)))
|
||||
delete(want, tc.remove)
|
||||
}
|
||||
|
||||
content, _ := afero.ReadFile(fs, "/testdir/file2.txt")
|
||||
assert.Equal(t, "modified content2", string(content))
|
||||
runFreshen(t, fs, root, manifestPath)
|
||||
|
||||
assertManifestLists(t, fs, root, manifestPath, want)
|
||||
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdCheck, "-q", testFlagNoExtra, testFlagBase, root, manifestPath,
|
||||
}, fs)
|
||||
assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreshenLeavesManifestOutOfListing freshens a manifest kept in a
|
||||
|
||||
Reference in New Issue
Block a user