Gate prune's local-cleanup output on --json, and make make build build (closes #108)
All checks were successful
check / check (push) Successful in 2m16s
All checks were successful
check / check (push) Successful in 2m16s
Closes #110. CleanupLocalSnapshots wrote three prose lines to stdout with no --json awareness, covering every branch, so `vaultik prune --json | jq` failed on any input. -q never helped either: printlnStdout and stdoutf write straight to v.Stdout and never consult v.UI, which is what SetQuiet affects. It now takes *PruneOptions, symmetric with its sibling phase PruneBlobs, and gates all three writes. Threading opts.JSON was chosen over moving the lines to log.Info, because internal/log/log.go defaults the level to Warn: log.Info would not have relocated them to stderr, it would have deleted them from a plain `vaultik prune`, and "Removing stale local record" narrates the deletion of local index rows. The stale-record count is deliberately not added to PruneBlobsResult - every field there is blob-scoped and produced by the phase that runs after this reconciliation, so adding it would change a published --json schema as a side effect of a stream fix. Note for anyone reading the --json contract: under --json the stale-record removal now produces no signal in either stream. stdout is correctly gated, stderr is level-pinned to Warn because --json sets Quiet, and the count is not in the document. That is inherited behaviour - PruneBlobs' own log.Info calls are equally invisible under --json - not something this change introduced, and it is tracked separately. make build exited 0 and produced nothing: .PHONY listed build with no build: rule, and a phony target with no prerequisites and no recipe is considered already satisfied, which turns what would be a hard error into a silent success. In a repo where `make build` is the documented way to build, a caller checking the exit code concluded the build worked. Now `build: vaultik`, verified in both directions - a clean build produces the binary, a deliberately broken one exits non-zero and produces none. All 19 .PHONY names were audited; build was the only one lacking a rule. TestPhonyTargetsAllHaveRules keeps that true for names added later, so the class is closed rather than the instance.
This commit was merged in pull request #111.
This commit is contained in:
165
internal/cli/entry_prune_json_test.go
Normal file
165
internal/cli/entry_prune_json_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user