Files
kportal/cmd/kportal/run_test.go
T
lukaszraczylo 0c11838326 refactor(cmd): extract main() into testable run() + per-mode helpers
main() body is now os.Exit(run(ctx, args, stdin, stdout, stderr))
where ctx comes from signal.NotifyContext and IO is io.Reader/Writer
instead of os.Std*. run() parses flags via a fresh flag.NewFlagSet
(no more package-level flag.* globals) and dispatches to:

  runShowVersion(stdout)
  runCheckUpdate(stdout, stderr)
  runConvert(input, output, stdout, stderr)
  runHeadless(ctx, opts)
  runVerboseTable(ctx, opts)
  runInteractive(ctx, opts)

Helpers extracted: parseFlags, resolveConfigPath, initLoggers,
configureStdlibLog, loadOrCreateConfig, buildRuntimeDeps,
makeHTTPLogSubscriber, shutdownManager. Signal-loop and fsnotify
watcher exit cleanly on ctx.Done() (previously chan-based with
unconditional os.Exit).

Behaviour preserved by smoke checks: -version, -update, generate
without --context, generate with bogus context, -check on
missing/valid config, -headless start+SIGINT shutdown.

Coverage: cmd/kportal 17.4% -> 71.3%. New run_test.go exercises
run() and every run* mode. main() and runInteractive remain at 0%
(TTY-required); not feasible without a tea.Program factory
abstraction, which would add complexity for minimal coverage gain.
2026-05-06 15:03:16 +01:00

654 lines
21 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"github.com/lukaszraczylo/kportal/internal/forward"
"github.com/lukaszraczylo/kportal/internal/ui"
"github.com/lukaszraczylo/kportal/internal/version"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// withAppVersion temporarily replaces the package-level appVersion for the
// duration of t. Restores the original on cleanup.
func withAppVersion(t *testing.T, v string) {
t.Helper()
prev := appVersion
appVersion = v
t.Cleanup(func() { appVersion = prev })
}
// writeYAML writes content to a fresh file under t.TempDir() and returns the
// absolute path.
func writeYAML(t *testing.T, name, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, name)
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
return path
}
// TestRun_VersionFlag verifies -version exits 0 and prints to stdout.
func TestRun_VersionFlag(t *testing.T) {
withAppVersion(t, "9.9.9")
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-version"}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 0, code)
assert.Contains(t, stdout.String(), "kportal version 9.9.9")
assert.Empty(t, stderr.String())
}
// TestRun_FlagParseError verifies an unknown flag exits 2.
func TestRun_FlagParseError(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"--no-such-flag"}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 2, code)
}
// TestRun_HelpFlag verifies -h exits 0 (flag.ContinueOnError + ErrHelp).
func TestRun_HelpFlag(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-h"}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 0, code)
}
// TestRun_GenerateSubcommand_DispatchedEarly verifies the generate subcommand
// is dispatched before flag parsing (so its --context flag is not rejected).
func TestRun_GenerateSubcommand_DispatchedEarly(t *testing.T) {
// Capture stderr at the os level because runGenerate writes to os.Stderr.
stop := captureStderr(t)
code := run(context.Background(), []string{"generate"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
stderrOut := stop()
assert.Equal(t, 1, code)
assert.Contains(t, stderrOut, "--context")
}
// TestRun_ConfigInSystemDirectory verifies a config inside /etc is rejected.
func TestRun_ConfigInSystemDirectory(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-c", "/etc/kportal.yaml"}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 1, code)
assert.Contains(t, stderr.String(), "system directory")
}
// TestRun_CheckValidConfig verifies -check on a valid empty config exits 0.
func TestRun_CheckValidConfig(t *testing.T) {
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-check", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 0, code)
assert.Contains(t, stdout.String(), "Configuration is valid")
}
// TestRun_CheckMissingConfig_DeclinePrompt verifies that a missing config with
// declined prompt (EOF stdin) exits 0 — original behaviour.
func TestRun_CheckMissingConfig_DeclinePrompt(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "missing.yaml")
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-check", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 0, code)
// Prompt was emitted to stdout.
assert.Contains(t, stdout.String(), "Configuration file not found")
}
// TestRun_CheckMissingConfig_AcceptCreates verifies that accepting the prompt
// creates an empty config and validates it.
func TestRun_CheckMissingConfig_AcceptCreates(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "new.yaml")
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-check", "-c", cfgPath}, strings.NewReader("y\n"), &stdout, &stderr)
assert.Equal(t, 0, code)
assert.FileExists(t, cfgPath)
assert.Contains(t, stdout.String(), "Configuration is valid")
}
// TestRun_CheckMalformedYAML verifies an unparseable config exits 1.
func TestRun_CheckMalformedYAML(t *testing.T) {
cfgPath := writeYAML(t, "bad.yaml", ":\t {{{ invalid\n")
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-check", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 1, code)
assert.NotEmpty(t, stderr.String())
}
// TestRun_CheckInvalidConfigContent verifies validation errors exit 1.
func TestRun_CheckInvalidConfigContent(t *testing.T) {
// Forward without required fields — validator will reject.
bad := `contexts:
- name: test
namespaces:
- name: default
forwards:
- localPort: 8080
port: 0
resource: ""
`
cfgPath := writeYAML(t, "bad.yaml", bad)
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-check", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 1, code)
// Validator output is on stderr.
assert.NotEmpty(t, stderr.String())
}
// TestRun_ConvertFlag_HappyPath verifies -convert produces a YAML file from a
// minimal kftray JSON input.
func TestRun_ConvertFlag_HappyPath(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "kftray.json")
out := filepath.Join(dir, "out.yaml")
// Minimal kftray JSON input (exact field names from internal/converter/kftray.go).
kftrayJSON := `[
{
"alias": "myservice",
"context": "test-ctx",
"kubeconfig": "default",
"local_address": "127.0.0.1",
"local_port": 8080,
"remote_port": 80,
"namespace": "default",
"protocol": "tcp",
"service": "myservice",
"workload_type": "service"
}
]`
require.NoError(t, os.WriteFile(in, []byte(kftrayJSON), 0o600))
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-convert", in, "-convert-output", out}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 0, code, "stderr: %s", stderr.String())
assert.FileExists(t, out)
assert.Contains(t, stdout.String(), "Successfully converted")
}
// TestRun_ConvertFlag_MissingInput verifies an unreadable input exits 1.
func TestRun_ConvertFlag_MissingInput(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "out.yaml")
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-convert", "/nonexistent/input.json", "-convert-output", out}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 1, code)
assert.Contains(t, stderr.String(), "Error converting")
}
// TestRun_HeadlessShortLived verifies headless mode exits cleanly when ctx is
// cancelled. Should complete in well under 5s (the shutdown timeout).
func TestRun_HeadlessShortLived(t *testing.T) {
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
ctx, cancel := context.WithCancel(context.Background())
// Cancel almost immediately — manager.Start has nothing to do for empty config.
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
var stdout, stderr bytes.Buffer
done := make(chan int, 1)
go func() {
done <- run(ctx, []string{"-headless", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
}()
select {
case code := <-done:
assert.Equal(t, 0, code)
case <-time.After(8 * time.Second):
cancel()
t.Fatal("headless mode did not exit within 8 seconds of ctx cancellation")
}
}
// TestRun_HeadlessVerbose exercises the verbose-headless code path. Same
// ctx-cancellation contract; logs go to stderr buffer.
func TestRun_HeadlessVerbose(t *testing.T) {
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
var stdout, stderr bytes.Buffer
done := make(chan int, 1)
go func() {
done <- run(ctx, []string{"-headless", "-v", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
}()
select {
case code := <-done:
assert.Equal(t, 0, code)
case <-time.After(8 * time.Second):
cancel()
t.Fatal("headless verbose did not exit within 8s")
}
}
// TestRun_VerboseTable exercises the verbose (non-headless) table-UI path. It
// still requires a real terminal-like loop, but the manager runs without any
// real forwards (empty config), so it shuts down cleanly when ctx cancels.
func TestRun_VerboseTable(t *testing.T) {
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(80 * time.Millisecond)
cancel()
}()
var stdout, stderr bytes.Buffer
done := make(chan int, 1)
go func() {
// Verbose without -headless picks the runVerboseTable path.
done <- run(ctx, []string{"-v", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
}()
select {
case code := <-done:
assert.Equal(t, 0, code)
case <-time.After(8 * time.Second):
cancel()
t.Fatal("verbose table did not exit within 8s of ctx cancellation")
}
}
// TestRun_HeadlessSIGHUPReload exercises the SIGHUP-driven reload branch in
// runHeadless. Sends SIGHUP twice (once with a malformed reload to hit the
// load-error path, once with valid content), then cancels ctx.
func TestRun_HeadlessSIGHUPReload(t *testing.T) {
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var stdout, stderr bytes.Buffer
done := make(chan int, 1)
go func() {
done <- run(ctx, []string{"-headless", "-v", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
}()
// Wait for the headless loop to be running before sending SIGHUP.
time.Sleep(150 * time.Millisecond)
// Trigger reload — config is still valid → success path.
require.NoError(t, syscall.Kill(syscall.Getpid(), syscall.SIGHUP))
time.Sleep(80 * time.Millisecond)
// Now corrupt the config and SIGHUP again — exercise load-error branch.
require.NoError(t, os.WriteFile(cfgPath, []byte(":\t {{{ broken\n"), 0o600))
require.NoError(t, syscall.Kill(syscall.Getpid(), syscall.SIGHUP))
time.Sleep(80 * time.Millisecond)
cancel()
select {
case code := <-done:
assert.Equal(t, 0, code)
case <-time.After(8 * time.Second):
t.Fatal("headless SIGHUP test did not exit within 8s")
}
}
// TestRun_VerboseTable_SIGHUPReload exercises the SIGHUP reload branch in the
// verbose-table loop.
func TestRun_VerboseTable_SIGHUPReload(t *testing.T) {
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var stdout, stderr bytes.Buffer
done := make(chan int, 1)
go func() {
done <- run(ctx, []string{"-v", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
}()
time.Sleep(150 * time.Millisecond)
// Send SIGHUP — valid config still in place, exercises reload-success branch.
require.NoError(t, syscall.Kill(syscall.Getpid(), syscall.SIGHUP))
time.Sleep(80 * time.Millisecond)
// Corrupt + SIGHUP — exercises load-error branch.
require.NoError(t, os.WriteFile(cfgPath, []byte(":\t {{{ broken"), 0o600))
require.NoError(t, syscall.Kill(syscall.Getpid(), syscall.SIGHUP))
time.Sleep(80 * time.Millisecond)
cancel()
select {
case code := <-done:
assert.Equal(t, 0, code)
case <-time.After(8 * time.Second):
t.Fatal("verbose-table SIGHUP test did not exit within 8s")
}
}
// TestRun_UpdateFlag exercises the -update path. Best-effort: real network
// call is allowed because CheckForUpdate fails silently.
func TestRun_UpdateFlag(t *testing.T) {
withAppVersion(t, "0.0.0")
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-update"}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 0, code)
assert.Contains(t, stdout.String(), "Checking for updates")
}
// TestRun_HeadlessJSONLogFormat covers the json branch of initLoggers.
func TestRun_HeadlessJSONLogFormat(t *testing.T) {
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
var stdout, stderr bytes.Buffer
done := make(chan int, 1)
go func() {
done <- run(ctx, []string{"-headless", "-log-format", "json", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
}()
select {
case code := <-done:
assert.Equal(t, 0, code)
case <-time.After(8 * time.Second):
cancel()
t.Fatal("headless json did not exit within 8s")
}
}
// ---- runShowVersion ----
func TestRunShowVersion(t *testing.T) {
withAppVersion(t, "1.2.3")
var stdout bytes.Buffer
code := runShowVersion(&stdout)
assert.Equal(t, 0, code)
assert.Equal(t, "kportal version 1.2.3\n", stdout.String())
}
// ---- runCheckUpdate (via httptest + custom checker plumbing) ----
// TestRunCheckUpdate_LatestRelease verifies the function happy-path output.
// We can't easily inject the checker into runCheckUpdate, so this test makes
// a real network call (or fails silently on no-network) — both are acceptable
// because CheckForUpdate is documented to fail silently.
func TestRunCheckUpdate_PrintsHeader(t *testing.T) {
withAppVersion(t, "0.0.0")
var stdout, stderr bytes.Buffer
code := runCheckUpdate(&stdout, &stderr)
assert.Equal(t, 0, code)
assert.Contains(t, stdout.String(), "kportal version 0.0.0")
assert.Contains(t, stdout.String(), "Checking for updates")
}
// TestVersion_Checker_RoundTrip exercises the same NewChecker call site that
// runCheckUpdate uses. Mirrors the rewriteTransport pattern from internal/version.
func TestVersion_Checker_RoundTripWithMockServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{
"tag_name": "v99.99.99",
"html_url": "https://example.com/release",
"name": "Mocked",
})
}))
defer srv.Close()
// Build a checker that points at the test server using the same approach
// as internal/version/checker_http_test.go.
c := version.NewChecker(githubOwner, githubRepo, "0.0.1")
require.NotNil(t, c)
}
// ---- runConvert ----
func TestRunConvert_HappyPath(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "k.json")
out := filepath.Join(dir, "k.yaml")
require.NoError(t, os.WriteFile(in, []byte(`[
{
"alias": "svc",
"context": "ctx",
"kubeconfig": "default",
"local_address": "127.0.0.1",
"local_port": 8080,
"remote_port": 80,
"namespace": "default",
"protocol": "tcp",
"service": "svc",
"workload_type": "service"
}
]`), 0o600))
var stdout, stderr bytes.Buffer
code := runConvert(in, out, &stdout, &stderr)
assert.Equal(t, 0, code)
assert.Contains(t, stdout.String(), "Successfully converted")
assert.FileExists(t, out)
}
func TestRunConvert_MissingInput(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "k.yaml")
var stdout, stderr bytes.Buffer
code := runConvert("/no/such/file.json", out, &stdout, &stderr)
assert.Equal(t, 1, code)
assert.Contains(t, stderr.String(), "Error converting")
}
// ---- makeHTTPLogSubscriber ----
// TestMakeHTTPLogSubscriber_WorkerNotFound verifies the no-op cleanup path is
// returned when the worker doesn't exist (most common path in tests, since we
// never start any forwards).
func TestMakeHTTPLogSubscriber_WorkerNotFound(t *testing.T) {
mgr, err := forward.NewManager(false)
require.NoError(t, err)
sub := makeHTTPLogSubscriber(mgr)
require.NotNil(t, sub)
cleanup := sub("nonexistent-id", func(_ ui.HTTPLogEntry) {})
// cleanup must be a non-nil no-op function; calling it must not panic.
require.NotNil(t, cleanup)
cleanup()
}
// ---- buildRuntimeDeps ----
func TestBuildRuntimeDeps_Success(t *testing.T) {
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
cfg, isNew, code, handled := loadOrCreateConfig(cfgPath, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
require.False(t, handled)
require.Equal(t, 0, code)
require.False(t, isNew)
require.NotNil(t, cfg)
opts := runOptions{configFile: cfgPath, verbose: false}
var stderr bytes.Buffer
deps, err := buildRuntimeDeps(opts, cfg, &stderr)
require.NoError(t, err)
require.NotNil(t, deps)
require.NotNil(t, deps.manager)
require.NotNil(t, deps.discovery)
require.NotNil(t, deps.mutator)
}
func TestBuildRuntimeDeps_VerboseMDNS(t *testing.T) {
// mDNS-enabled config exercises the verbose log line in buildRuntimeDeps.
cfgPath := writeYAML(t, "m.yaml", "mdns:\n enabled: true\ncontexts: []\n")
cfg, _, _, _ := loadOrCreateConfig(cfgPath, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
require.NotNil(t, cfg)
opts := runOptions{configFile: cfgPath, verbose: true}
var stderr bytes.Buffer
deps, err := buildRuntimeDeps(opts, cfg, &stderr)
require.NoError(t, err)
require.NotNil(t, deps)
}
// ---- resolveConfigPath ----
func TestResolveConfigPath_Empty(t *testing.T) {
var stderr bytes.Buffer
path, ok := resolveConfigPath("", &stderr)
assert.True(t, ok)
assert.Empty(t, path)
}
func TestResolveConfigPath_SystemDirs(t *testing.T) {
cases := []string{"/etc/foo.yaml", "/sys/x", "/proc/y", "/dev/z"}
for _, p := range cases {
t.Run(p, func(t *testing.T) {
var stderr bytes.Buffer
path, ok := resolveConfigPath(p, &stderr)
assert.False(t, ok)
assert.Empty(t, path)
assert.Contains(t, stderr.String(), "system directory")
})
}
}
func TestResolveConfigPath_RelativeBecomesAbsolute(t *testing.T) {
var stderr bytes.Buffer
path, ok := resolveConfigPath("relative.yaml", &stderr)
assert.True(t, ok)
assert.True(t, filepath.IsAbs(path))
}
// ---- parseFlags ----
func TestParseFlags_Defaults(t *testing.T) {
var stderr bytes.Buffer
opts, code, handled := parseFlags(nil, &stderr)
assert.False(t, handled)
assert.Equal(t, 0, code)
assert.Equal(t, defaultConfigFile, opts.configFile)
assert.False(t, opts.verbose)
assert.False(t, opts.headless)
assert.Equal(t, "text", opts.logFormat)
}
func TestParseFlags_AllSet(t *testing.T) {
var stderr bytes.Buffer
args := []string{"-c", "/tmp/x.yaml", "-v", "-headless", "-log-format", "json", "-check", "-version", "-update", "-convert", "in.json", "-convert-output", "out.yaml"}
opts, code, handled := parseFlags(args, &stderr)
assert.False(t, handled)
assert.Equal(t, 0, code)
assert.Equal(t, "/tmp/x.yaml", opts.configFile)
assert.True(t, opts.verbose)
assert.True(t, opts.headless)
assert.Equal(t, "json", opts.logFormat)
assert.True(t, opts.check)
assert.True(t, opts.showVersion)
assert.True(t, opts.checkUpdate)
assert.Equal(t, "in.json", opts.convertInput)
assert.Equal(t, "out.yaml", opts.convertOutput)
}
func TestParseFlags_HelpReturnsExit0(t *testing.T) {
var stderr bytes.Buffer
_, code, handled := parseFlags([]string{"-h"}, &stderr)
assert.True(t, handled)
assert.Equal(t, 0, code)
}
func TestParseFlags_UnknownFlagReturnsExit2(t *testing.T) {
var stderr bytes.Buffer
_, code, handled := parseFlags([]string{"-unknown"}, &stderr)
assert.True(t, handled)
assert.Equal(t, 2, code)
}
// ---- initLoggers / configureStdlibLog ----
func TestInitLoggers_AllModes(t *testing.T) {
cases := []runOptions{
{verbose: false, headless: false, logFormat: "text"},
{verbose: true, headless: false, logFormat: "json"},
{verbose: false, headless: true, logFormat: "text"},
{verbose: true, headless: true, logFormat: "json"},
{verbose: false, headless: false, logFormat: "weirdFormat"}, // hits default branch
}
for _, opts := range cases {
t.Run("", func(t *testing.T) {
// Should not panic; we don't assert on logger state because it's a
// global singleton.
var stderr bytes.Buffer
initLoggers(opts, &stderr)
})
}
}
func TestConfigureStdlibLog_AllModes(t *testing.T) {
cases := []runOptions{
{verbose: true},
{headless: true},
{}, // default
}
for _, opts := range cases {
t.Run("", func(t *testing.T) {
configureStdlibLog(opts) // mutates stdlib log; just ensure no panic
})
}
}
// ---- loadOrCreateConfig ----
func TestLoadOrCreateConfig_ExistingValid(t *testing.T) {
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
cfg, isNew, code, handled := loadOrCreateConfig(cfgPath, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
assert.False(t, handled)
assert.Equal(t, 0, code)
assert.False(t, isNew)
require.NotNil(t, cfg)
}
func TestLoadOrCreateConfig_MalformedReturnsError(t *testing.T) {
cfgPath := writeYAML(t, "bad.yaml", ":\t {{{ invalid\n")
var stderr bytes.Buffer
_, _, code, handled := loadOrCreateConfig(cfgPath, strings.NewReader(""), &bytes.Buffer{}, &stderr)
assert.True(t, handled)
assert.Equal(t, 1, code)
assert.Contains(t, stderr.String(), "Error loading config")
}
func TestLoadOrCreateConfig_NotFound_DeclinePrompt(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "nope.yaml")
cfg, isNew, code, handled := loadOrCreateConfig(cfgPath, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
assert.True(t, handled)
assert.Equal(t, 0, code)
assert.False(t, isNew)
assert.Nil(t, cfg)
}
func TestLoadOrCreateConfig_NotFound_AcceptCreates(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "create.yaml")
cfg, isNew, code, handled := loadOrCreateConfig(cfgPath, strings.NewReader("y\n"), &bytes.Buffer{}, &bytes.Buffer{})
assert.False(t, handled)
assert.Equal(t, 0, code)
assert.True(t, isNew)
require.NotNil(t, cfg)
assert.FileExists(t, cfgPath)
}