From f6dde5acb63880e009f0e3da9b609e263eba68fa Mon Sep 17 00:00:00 2001 From: Samuel N Cui Date: Sat, 11 Jul 2026 20:50:30 +0800 Subject: [PATCH] test: add library end-to-end coverage --- AGENTS.md | 2 +- TESTING.md | 29 ++++++++-- e2e_test.go | 2 +- library_e2e_test.go | 132 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 library_e2e_test.go diff --git a/AGENTS.md b/AGENTS.md index ecea6bd..2212f68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ The current change set addresses these areas: - select the longest matching mountpoint with directory-boundary checks; - use `path/filepath` for host file-system paths; - centralize platform-aware path ordering in `comparePath`; -- add semantic, race, cross-platform, and CLI end-to-end coverage. +- add semantic, race, cross-platform, CLI end-to-end, and library end-to-end coverage. The working tree may already contain staged or unstaged changes. Preserve them and do not reset, rewrite, or discard unrelated work. diff --git a/TESTING.md b/TESTING.md index 899b8a0..6738941 100644 --- a/TESTING.md +++ b/TESTING.md @@ -28,9 +28,9 @@ Run the suite with the race detector: go test -race ./... ``` -## End-to-end test +## End-to-end tests -The end-to-end test builds the `acp` command, copies a directory tree through the CLI, and verifies: +The command end-to-end test builds the `acp` command, copies a directory tree through the CLI, and verifies: - regular and empty file contents; - nested destination paths; @@ -38,13 +38,32 @@ The end-to-end test builds the `acp` command, copies a directory tree through th - JSON report decoding; - SHA256 generation for every copied file. -Run only this test with: +Run only the command test with: ```sh -go test -run '^TestACPCopyE2E$' -v . +go test -run '^TestACPCommandE2E$' -v . ``` -The test is skipped when `go test` is run with `-short`. +The library end-to-end test uses the public Go API directly with two destinations and verifies: + +- the complete index, prepare, copy, cleanup, and event pipeline; +- regular, empty, and nested file contents at both destinations; +- successful report status and destination sets; +- file size and exact SHA256 values. + +Run only the library test with: + +```sh +go test -run '^TestACPLibraryE2E$' -v . +``` + +Run both end-to-end tests with: + +```sh +go test -run '^TestACP(Command|Library)E2E$' -v . +``` + +Both tests are skipped when `go test` is run with `-short`. ## Focused tests diff --git a/e2e_test.go b/e2e_test.go index 0e17a21..a9600c9 100644 --- a/e2e_test.go +++ b/e2e_test.go @@ -14,7 +14,7 @@ import ( "github.com/samuelncui/acp" ) -func TestACPCopyE2E(t *testing.T) { +func TestACPCommandE2E(t *testing.T) { if testing.Short() { t.Skip("skipping end-to-end test in short mode") } diff --git a/library_e2e_test.go b/library_e2e_test.go new file mode 100644 index 0000000..566b3b2 --- /dev/null +++ b/library_e2e_test.go @@ -0,0 +1,132 @@ +package acp_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + "time" + + "github.com/samuelncui/acp" +) + +func TestACPLibraryE2E(t *testing.T) { + if testing.Short() { + t.Skip("skipping end-to-end test in short mode") + } + + tempDir := t.TempDir() + source := filepath.Join(tempDir, "source") + targets := []string{ + filepath.Join(tempDir, "target-a"), + filepath.Join(tempDir, "target-b"), + } + for _, target := range targets { + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target directory %q: %v", target, err) + } + } + + files := map[string][]byte{ + "plain.txt": []byte("library copy\n"), + "empty.txt": nil, + filepath.Join("nested", "data"): {5, 4, 3, 2, 1}, + } + for relativePath, data := range files { + path := filepath.Join(source, relativePath) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("create source directory: %v", err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("write source file %q: %v", relativePath, err) + } + } + + handler, getter := acp.NewReportGetter() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + copyer, err := acp.New( + ctx, + acp.WildcardJob(acp.Source(source), acp.Target(targets...)), + acp.WithHash(true), + acp.Overwrite(true), + acp.WithEventHandler(handler), + ) + if err != nil { + t.Fatalf("create copyer: %v", err) + } + + done := make(chan struct{}) + go func() { + copyer.Wait() + close(done) + }() + select { + case <-done: + case <-ctx.Done(): + t.Fatalf("wait for copyer: %v", ctx.Err()) + } + + for _, target := range targets { + copyRoot := filepath.Join(target, filepath.Base(source)) + for relativePath, want := range files { + got, err := os.ReadFile(filepath.Join(copyRoot, relativePath)) + if err != nil { + t.Fatalf("read copied file %q from %q: %v", relativePath, target, err) + } + if !bytes.Equal(got, want) { + t.Fatalf("copied file %q from %q = %v, want %v", relativePath, target, got, want) + } + } + } + + report := getter() + if len(report.Errors) != 0 { + t.Fatalf("report errors = %v", report.Errors) + } + if len(report.Jobs) != len(files) { + t.Fatalf("report jobs = %d, want %d", len(report.Jobs), len(files)) + } + + jobs := make(map[string]*acp.Job, len(report.Jobs)) + for _, job := range report.Jobs { + jobs[job.FullPath] = job + } + for relativePath, data := range files { + sourcePath := filepath.Join(source, relativePath) + job, ok := jobs[sourcePath] + if !ok { + t.Fatalf("report job not found for %q", sourcePath) + } + if job.Status != acp.JobStatusFinished { + t.Fatalf("job %q status = %q", sourcePath, job.Status) + } + if len(job.FailTargets) != 0 { + t.Fatalf("job %q failures = %v", sourcePath, job.FailTargets) + } + if job.Size != int64(len(data)) { + t.Fatalf("job %q size = %d, want %d", sourcePath, job.Size, len(data)) + } + + wantTargets := make(map[string]struct{}, len(targets)) + for _, target := range targets { + wantTargets[filepath.Join(target, filepath.Base(source), relativePath)] = struct{}{} + } + if len(job.SuccessTargets) != len(wantTargets) { + t.Fatalf("job %q success targets = %v", sourcePath, job.SuccessTargets) + } + for _, target := range job.SuccessTargets { + if _, ok := wantTargets[target]; !ok { + t.Fatalf("job %q unexpected success target %q", sourcePath, target) + } + } + + sum := sha256.Sum256(data) + if want := hex.EncodeToString(sum[:]); job.SHA256 != want { + t.Fatalf("job %q SHA256 = %q, want %q", sourcePath, job.SHA256, want) + } + } +}