package cli import ( "bytes" "context" "os" "path/filepath" "strings" "testing" "gitea.maximumdirect.net/eric/distributor/internal/state" "gitea.maximumdirect.net/eric/distributor/internal/storage" "gitea.maximumdirect.net/eric/distributor/internal/testutil" ) func TestExecuteReconcileStateAppliesByDefault(t *testing.T) { _, destinationRoot, configPath := writeReconcileStateLocalFixture(t) if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("# Report\nSunny.\n"), 0o600); err != nil { t.Fatalf("write managed output: %v", err) } if err := os.WriteFile(filepath.Join(destinationRoot, "extra.txt"), []byte("unmanaged"), 0o600); err != nil { t.Fatalf("write unmanaged output: %v", err) } var stdout, stderr bytes.Buffer code := Execute(context.Background(), []string{ "reconcile-state", "--config", configPath, "--pipeline", "reports", "--destination", "archive", }, &stdout, &stderr) if code != exitOK { t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String()) } if !strings.Contains(stdout.String(), "status=changed") { t.Fatalf("stdout = %q, want changed status", stdout.String()) } catalog := readLocalCatalogState(t, filepath.Join(destinationRoot, storage.StateFileName)) if got := strings.Join(state.CatalogManagedOutputPaths(catalog), ","); got != "report.md,html.txt" { t.Fatalf("state outputs = %q, want report.md,html.txt", got) } assertLocalFile(t, filepath.Join(destinationRoot, "extra.txt"), "unmanaged") if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestExecuteReconcileStateDryRunReportsWithoutWriting(t *testing.T) { _, destinationRoot, configPath := writeReconcileStateLocalFixture(t) if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("# Report\nSunny.\n"), 0o600); err != nil { t.Fatalf("write managed output: %v", err) } var stdout, stderr bytes.Buffer code := Execute(context.Background(), []string{ "reconcile-state", "--config", configPath, "--pipeline", "reports", "--destination", "archive", "--dry-run", }, &stdout, &stderr) if code != exitOK { t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String()) } if !strings.Contains(stdout.String(), "status=would_change") { t.Fatalf("stdout = %q, want would_change status", stdout.String()) } catalog := readLocalCatalogState(t, filepath.Join(destinationRoot, storage.StateFileName)) if got := strings.Join(state.CatalogManagedOutputPaths(catalog), ","); got != "report.md,summary.txt,html.txt" { t.Fatalf("state outputs = %q, want original outputs", got) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestExecuteReconcileStateJSONReport(t *testing.T) { _, destinationRoot, configPath := writeReconcileStateLocalFixture(t) if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("# Report\nSunny.\n"), 0o600); err != nil { t.Fatalf("write managed output: %v", err) } if err := os.WriteFile(filepath.Join(destinationRoot, "extra.txt"), []byte("unmanaged"), 0o600); err != nil { t.Fatalf("write unmanaged output: %v", err) } var stdout, stderr bytes.Buffer code := Execute(context.Background(), []string{ "reconcile-state", "--config", configPath, "--pipeline", "reports", "--destination", "archive", "--dry-run", "--format", "json", }, &stdout, &stderr) if code != exitOK { t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String()) } envelope := decodeEnvelope(t, &stdout) if envelope["command"] != "reconcile-state" || envelope["ok"] != true { t.Fatalf("envelope = %#v, want reconcile-state ok", envelope) } result := envelopeResult(t, envelope) if result["would_change"] != true || result["changed"] != false || result["dry_run"] != true { t.Fatalf("result = %#v, want dry-run pending change", result) } missing, ok := result["missing_managed_outputs"].([]any) if !ok || len(missing) != 1 { t.Fatalf("missing outputs = %#v, want one", result["missing_managed_outputs"]) } unmanaged, ok := result["unmanaged_entries"].([]any) if !ok || len(unmanaged) != 1 { t.Fatalf("unmanaged entries = %#v, want one", result["unmanaged_entries"]) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestExecuteReconcileStateRejectsInvalidFlags(t *testing.T) { tests := []struct { name string args []string wantStderr string }{ { name: "missing config", args: []string{"reconcile-state", "--pipeline", "reports", "--destination", "archive"}, wantStderr: "requires --config", }, { name: "missing pipeline", args: []string{"reconcile-state", "--config", "config.yml", "--destination", "archive"}, wantStderr: "requires --pipeline", }, { name: "missing destination", args: []string{"reconcile-state", "--config", "config.yml", "--pipeline", "reports"}, wantStderr: "requires --destination", }, { name: "invalid format", args: []string{"reconcile-state", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive", "--format", "xml"}, wantStderr: "format must be text or json", }, { name: "positional", args: []string{"reconcile-state", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive", "extra"}, wantStderr: "does not accept positional arguments", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var stdout, stderr bytes.Buffer code := Execute(context.Background(), tt.args, &stdout, &stderr) if code != exitUsage { t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitUsage, stderr.String()) } if stdout.Len() != 0 { t.Fatalf("stdout = %q, want empty", stdout.String()) } if !strings.Contains(stderr.String(), tt.wantStderr) { t.Fatalf("stderr = %q, want substring %q", stderr.String(), tt.wantStderr) } }) } } func writeReconcileStateLocalFixture(t *testing.T) (string, string, string) { t.Helper() sourceRoot := t.TempDir() destinationRoot := t.TempDir() manifest := testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{}) writeCatalogDestinationState(t, destinationRoot, manifest, true) if err := os.WriteFile(filepath.Join(destinationRoot, "html.txt"), []byte("other"), 0o600); err != nil { t.Fatalf("write other owner output: %v", err) } configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot) return sourceRoot, destinationRoot, configPath } func assertLocalFile(t *testing.T, path, want string) { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("read file %s: %v", path, err) } if string(data) != want { t.Fatalf("file %s = %q, want %q", path, data, want) } }