package cli //nolint:testpackage // shares hermeticConfig and the capture helpers import ( "context" "database/sql" "encoding/json" "fmt" "os" "path/filepath" "testing" "time" "github.com/adrg/xdg" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/types" ) // pruneJSONDocument is the shape `prune --json` writes: the // PruneBlobsResult document, and nothing else. // //nolint:tagliatelle // snake_case is the established JSON output format type pruneJSONDocument struct { BlobsFound int `json:"blobs_found"` BlobsDeleted int `json:"blobs_deleted"` BytesFreed int64 `json:"bytes_freed"` } // stalePruneSnapshotID is seeded into the local index with no manifest // on the destination store, which is exactly what makes it stale. const stalePruneSnapshotID = "test-host_test_2026-04-01T09:00:00Z" // TestEntryPruneJSONStdoutIsExactlyOneDocument is the end-to-end // regression guard for issue #108: `vaultik prune --json | jq .` must // work with no other flags. // // It runs Entry over the process's real stdout descriptor, through // cobra and the fx graph, against a hermetic file:// destination store // — the same construction TestEntryJSONStdoutIsExactlyOneDocument uses // for `snapshot list`, with the pipe to jq replaced by a decoder. // // Both branches of the local-snapshot reconciliation are exercised // because the three stdout writes that broke this covered all of them: // one line per stale record and a summary when there were any, and a // "No stale local snapshots found." line when there were none. No input // avoided the contamination, so no single branch demonstrates the fix. // // Not parallel: it replaces os.Args, os.Stdout and the xdg globals. // //nolint:paralleltest // replaces os.Args, os.Stdout and the xdg globals func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) { for _, testCase := range []struct { name string seedStale bool description string }{ { name: "no stale local records", seedStale: false, description: "the empty-index branch used to print a 'No stale' line", }, { name: "stale local records present", seedStale: true, description: "the removal branch used to print a line per record " + "plus a summary", }, } { t.Run(testCase.name, func(t *testing.T) { configPath := writeHermeticPruneConfig(t, testCase.seedStale) previousArgs := os.Args t.Cleanup(func() { os.Args = previousArgs rootFlags = RootFlags{} }) os.Args = []string{ programName, flagConfig, configPath, cmdPrune, flagJSON, } stdout := captureProcessStdout(t, Entry) requireExactlyOneJSONDocument(t, stdout) var document pruneJSONDocument require.NoError(t, json.Unmarshal([]byte(stdout), &document), testCase.description) // A destination store with no blobs has none to prune. The // assertion that matters is the one above; this one keeps the // test honest about which document it decoded. assert.Equal(t, 0, document.BlobsFound) }) } } // writeHermeticPruneConfig builds a config over a temp directory and, if // seedStale is set, creates the index database up front with one // snapshot record that has no counterpart on the destination store. // Returns the config path. func writeHermeticPruneConfig(t *testing.T, seedStale bool) string { t.Helper() dir := t.TempDir() configPath := filepath.Join(dir, "config.yml") indexPath := filepath.Join(dir, "index.sqlite") contents := fmt.Sprintf(hermeticConfig, filepath.Join(dir, "source"), filepath.Join(dir, "store"), indexPath) require.NoError(t, os.WriteFile(configPath, []byte(contents), configFileMode)) // The PID lock lives under xdg.DataHome, which xdg resolves at // package init; point it at the temp dir so the test neither // touches nor collides with the real one. t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data")) xdg.Reload() t.Cleanup(xdg.Reload) if seedStale { seedStaleSnapshotRecord(t, indexPath) } return configPath } // seedStaleSnapshotRecord creates the index database at path and // inserts one completed snapshot into it. Nothing is written to the // destination store, so `prune` finds the record stale and removes it — // the branch that printed a line per record. func seedStaleSnapshotRecord(t *testing.T, path string) { t.Helper() ctx := context.Background() db, err := database.New(ctx, path) require.NoError(t, err) defer func() { require.NoError(t, db.Close()) }() startedAt := time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC) completedAt := startedAt.Add(time.Minute) snap := &database.Snapshot{ ID: types.SnapshotID(stalePruneSnapshotID), Hostname: "test-host", VaultikVersion: "test", StartedAt: startedAt, CompletedAt: &completedAt, } repos := database.NewRepositories(db) err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error { return repos.Snapshots.Create(ctx, tx, snap) }) require.NoError(t, err) }