Files
Enterprise-Knowledge-Base/cli/cmd/doctor/doctor_test.go
T
nullkey bb592a59a6 feat(cli): contract test suite + dependabot (PR-8)
cli/acceptance/contract/:
  envelope_test.go    — 16 envelope golden cases (9 commands × {success/error
                        variants}; 3 cases dropped with rationale: doctor.success
                        non-offline has unstable timing detail; auth_login.* needs
                        stdin/keyring scaffold deferred to v0.2; context_use.error
                        needs leaf-local --json deferred to follow-up)
  errorcodes_test.go  — single-direction AST scan of cli/cmd/ extracting first
                        arg of cmdutil.NewError / cmdutil.Wrapf calls;
                        ClassifyHTTPError dynamic-classify bridged via
                        cmdutil.ClassifyHTTPErrorOutputs() per spec §4.3.
  testdata/envelopes/ — 16 JSON golden files

helpers_test.go (PR-6 scaffold) extended:
  runCmd now wires cobra Out/Err sinks (version uses c.OutOrStdout) AND
  replicates cmd.Execute()'s error-envelope path so error-case goldens are
  populated. Without this, every error scenario's golden was 0 bytes.

cli/cmd/root.go: mapCobraError → MapCobraError, wantsJSONOutput → WantsJSONOutput
                 (exported so the contract test helper can replicate Execute()'s
                 envelope-printing path without calling Execute() itself).
                 root_test.go updated to use new exported names.

.github/dependabot.yml (新增):gomod /cli + github-actions weekly,gh-style
                              ignore semver-major to avoid noise. Open-source
                              dependency safety,independent of release cadence.

v0.1 不发布到任何分发平台 (release infra 推迟到发布窗口 milestone)。
2026-05-09 12:18:01 +08:00

211 lines
7.5 KiB
Go

package doctor
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/compat"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeServices struct {
systemInfo *sdk.SystemInfo
systemErr error
userResp *sdk.CurrentUserResponse
userErr error
pingErr error
systemInfoHits atomic.Int32 // count GetSystemInfo invocations
}
func (f *fakeServices) GetSystemInfo(ctx context.Context) (*sdk.SystemInfo, error) {
f.systemInfoHits.Add(1)
return f.systemInfo, f.systemErr
}
func (f *fakeServices) GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error) {
return f.userResp, f.userErr
}
func (f *fakeServices) PingBaseURL(ctx context.Context) error { return f.pingErr }
func goodUserResp() *sdk.CurrentUserResponse {
r := &sdk.CurrentUserResponse{}
r.Data.User = &sdk.AuthUser{ID: "u1"}
return r
}
func TestDoctor_AllOK(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
out, _ := iostreams.SetForTest(t)
svc := &fakeServices{
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
userResp: goodUserResp(),
}
r := runChecks(context.Background(), &Options{JSONOut: true}, svc, "1.0.0")
if !r.Summary.AllPassed {
t.Errorf("expected all_passed, got summary %+v", r.Summary)
}
if r.Summary.Passed != 4 {
t.Errorf("expected Passed=4 (one per check), got %+v", r.Summary)
}
if r.Summary.Failed != 0 || r.Summary.Skipped != 0 {
t.Errorf("expected 0 fail / 0 skip, got %+v", r.Summary)
}
emit(&Options{JSONOut: true}, r)
if !strings.Contains(out.String(), `"all_passed":true`) {
t.Errorf("envelope should embed all_passed=true, got %q", out.String())
}
}
func TestDoctor_BaseURLFails_DownstreamSkip(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
svc := &fakeServices{
pingErr: errors.New("connect refused"),
userResp: goodUserResp(),
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if r.Summary.Skipped != 2 {
t.Errorf("expected 2 skipped (auth_credential + server_version), got %d", r.Summary.Skipped)
}
if r.Checks[0].Status != StatusFail {
t.Errorf("base_url_reachable status = %q, want fail", r.Checks[0].Status)
}
if !strings.Contains(r.Checks[0].Hint, "auth login") {
t.Errorf("base_url fail hint should reference `auth login`, got %q", r.Checks[0].Hint)
}
if r.Checks[1].Status != StatusSkip {
t.Errorf("auth_credential status = %q, want skip", r.Checks[1].Status)
}
if r.Checks[2].Status != StatusSkip {
t.Errorf("server_version status = %q, want skip", r.Checks[2].Status)
}
// credential_storage 与网络无关,应该独立运行(不受 base_url fail 影响)
if r.Checks[3].Name != "credential_storage" {
t.Errorf("Checks[3] = %q, want credential_storage", r.Checks[3].Name)
}
}
func TestDoctor_Offline_OnlyKeyringChecked(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
svc := &fakeServices{}
r := runChecks(context.Background(), &Options{Offline: true}, svc, "1.0.0")
if r.Summary.Skipped < 3 {
t.Errorf("expected >=3 skip in offline mode, got %d", r.Summary.Skipped)
}
last := r.Checks[3]
if last.Name != "credential_storage" {
t.Errorf("last check = %q, want credential_storage", last.Name)
}
if last.Status == StatusSkip {
t.Error("credential_storage should NOT be skipped even in offline mode")
}
}
func TestDoctor_AuthFails_VersionSkipped(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
svc := &fakeServices{
userErr: errors.New("HTTP error 401: unauthenticated"),
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if r.Checks[0].Status != StatusOK {
t.Errorf("base_url should pass, got %q", r.Checks[0].Status)
}
if r.Checks[1].Status != StatusFail {
t.Errorf("auth_credential should fail, got %q", r.Checks[1].Status)
}
if r.Checks[2].Status != StatusSkip {
t.Errorf("server_version should skip due to auth fail, got %q", r.Checks[2].Status)
}
}
func TestDoctor_CacheHit_SkipsProbe(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
// Pre-populate fresh cache
if err := compat.SaveCache(&compat.Info{ServerVersion: "1.0.0", ProbedAt: time.Now()}); err != nil {
t.Fatalf("seed cache: %v", err)
}
svc := &fakeServices{userResp: goodUserResp()}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if r.Checks[2].Status != StatusOK {
t.Errorf("server_version should be ok via cache, got %q (%s)", r.Checks[2].Status, r.Checks[2].Details)
}
if svc.systemInfoHits.Load() != 0 {
t.Errorf("expected 0 GetSystemInfo calls (cache hit), got %d", svc.systemInfoHits.Load())
}
if !strings.Contains(r.Checks[2].Details, "cached") {
t.Errorf("details should mention cache, got %q", r.Checks[2].Details)
}
}
// TestDoctor_NoConfig_StillRunsCredentialStorage guards the package-doc
// promise that credential_storage runs even with zero configuration. Round-4
// reviewer surfaced that buildServices used to abort on f.Client() failure,
// silently violating the doc for any first-time user running `weknora doctor`
// to diagnose setup. The lazy-resolve fix means missing auth surfaces as
// auth_credential=fail, not a top-level command exit.
func TestDoctor_NoConfig_StillRunsCredentialStorage(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
f := cmdutil.New()
svc, err := buildServices(f)
if err != nil {
t.Fatalf("buildServices must succeed even with no config; got: %v", err)
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
// All 4 checks must run (no early-exit). Last one is credential_storage and
// has no network/auth dependency, so it must report ok.
if got := len(r.Checks); got != 4 {
t.Fatalf("expected 4 checks executed, got %d", got)
}
if r.Checks[3].Name != "credential_storage" {
t.Errorf("Checks[3] = %q, want credential_storage", r.Checks[3].Name)
}
if r.Checks[3].Status != StatusOK {
t.Errorf("credential_storage must run / pass even without auth, got %q (%s)",
r.Checks[3].Status, r.Checks[3].Details)
}
// base_url_reachable fails (no host); cascade then skips auth_credential
// and server_version. Whether auth/version are skip vs fail is an internal
// cascade detail; the user-visible promise is that 4 checks executed and
// the independent credential_storage one passed.
if r.Checks[0].Status != StatusFail {
t.Errorf("base_url must fail without host, got %q", r.Checks[0].Status)
}
if r.Checks[1].Status == StatusOK {
t.Errorf("auth_credential must NOT report ok without config, got %q", r.Checks[1].Status)
}
}
func TestDoctor_NoCache_BypassesCache(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
// Pre-populate fresh cache; --no-cache should ignore it
if err := compat.SaveCache(&compat.Info{ServerVersion: "9.9.9", ProbedAt: time.Now()}); err != nil {
t.Fatalf("seed cache: %v", err)
}
svc := &fakeServices{
userResp: goodUserResp(),
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
}
r := runChecks(context.Background(), &Options{NoCache: true}, svc, "1.0.0")
if svc.systemInfoHits.Load() != 1 {
t.Errorf("expected 1 GetSystemInfo call (--no-cache), got %d", svc.systemInfoHits.Load())
}
if !strings.Contains(r.Checks[2].Details, "1.0.0") {
t.Errorf("details should reflect probed version 1.0.0 not cached 9.9.9, got %q", r.Checks[2].Details)
}
}