From ba3d24ca426f61ff1116d3009cce3896496b65d6 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 17:02:22 +0200 Subject: [PATCH] End-to-end tests for freshen and fetch (closes #66) 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 --- internal/cli/fetch_test.go | 130 ++++++++++++++++++++++ internal/cli/freshen_test.go | 202 ++++++++++++++++++++++++++--------- 2 files changed, 282 insertions(+), 50 deletions(-) diff --git a/internal/cli/fetch_test.go b/internal/cli/fetch_test.go index 82ad178..0a0bc5f 100644 --- a/internal/cli/fetch_test.go +++ b/internal/cli/fetch_test.go @@ -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 diff --git a/internal/cli/freshen_test.go b/internal/cli/freshen_test.go index eeb9ee3..375220c 100644 --- a/internal/cli/freshen_test.go +++ b/internal/cli/freshen_test.go @@ -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