Files
WeKnora/cli/cmd/kb/delete_test.go
T
nullkey da9faa9e07 feat(cli): add agent-first affordance — envelope, exit-10, --dry-run
Borrows the lark-cli agent-affordance model
(https://github.com/larksuite/cli/blob/main/AGENTS.md +
skills/lark-shared/SKILL.md) so weknora is designed to be agent-friendly:
error messages, output format, and flag design follow conventions agents
can rely on.

cli/AGENTS.md (operational reference for LLM agents invoking weknora):
  Public document covering envelope schema, exit-code protocol
  (0/1/2/10/130), stdout/stderr separation, and behavioral rules.
  Sensitive commands (\`context use\`, \`kb delete\`, \`doc delete\`, \`init\`)
  gain "AI agents:" paragraphs in their cobra Long descriptions so
  guidance shows in --help.

format.Envelope schema additions:
  Risk    per-operation classification (read / write / high-risk-write +
          action description), populated by write commands on both success
          and failure paths.
  Notice  system advisories (CLI update available, server-CLI version
          skew); type defined, emit sites land in v0.3.
  DryRun  marker for envelopes returned from --dry-run preview paths.

  RiskLevel constants realigned to lark's taxonomy: read / write /
  high-risk-write (was: read / mutating / destructive — not yet wired by
  any command).

  cmdutil.Error gains OperationRisk; PrintErrorEnvelope auto-attaches it
  to envelope.Risk so destructive failure paths surface uniformly.

Exit-10 confirmation protocol:
  New ErrorCode \`input.confirmation_required\` mapped to exit code 10 in
  cmdutil.ExitCode. ConfirmDestructive now returns this code (with
  OperationRisk attached) when stdout is non-TTY or --json was set, with
  -y/--yes absent. Previous behavior — silent proceed in non-TTY — was
  unsafe: scripts and agents could delete resources with no explicit
  approval. Three test cases re-pinned around the new contract.

  This is a wire-contract change for any caller who relied on silent
  proceed; v0.0/v0.1 had no destructive commands, so the blast radius is
  contained to v0.2 itself.

--dry-run global flag:
  cmd write paths (kb create/delete, doc upload/delete, api POST/PUT/PATCH/
  DELETE) check cmdutil.IsDryRun(cmd) and skip the SDK call, emitting an
  envelope with dry_run=true plus a Risk classification. Read commands
  ignore --dry-run by design (no side effect to preview). Human-mode
  prints \`[dry-run] would <action>\` to stdout.

Command discovery: agents introspect via the existing \`--help\` surface
(consistent with gh / kubectl / aws / gcloud / terraform — none of them
ship a CLI-tree self-description command). An earlier draft added a
\`weknora schema\` reflection command; dropped after a mainstream survey
found it has no stable analog (lark-cli's schema describes Lark API
methods, not its own CLI tree).

Tests: 27 cli packages pass at this commit. Added two new tests covering
envelope.risk and envelope._notice serialization.
2026-05-12 13:20:42 +08:00

176 lines
5.9 KiB
Go

package kb
import (
"context"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
)
// fakeDeleteSvc records what id was deleted.
type fakeDeleteSvc struct {
err error
gotID string
called bool
}
func (f *fakeDeleteSvc) DeleteKnowledgeBase(_ context.Context, id string) error {
f.called = true
f.gotID = id
return f.err
}
// confirmPrompter scripts a Confirm answer; Input/Password are unused here.
type confirmPrompter struct {
answer bool
err error
asked bool
}
func (c *confirmPrompter) Input(string, string) (string, error) { return "", prompt.ErrAgentNoPrompt }
func (c *confirmPrompter) Password(string) (string, error) { return "", prompt.ErrAgentNoPrompt }
func (c *confirmPrompter) Confirm(string, bool) (bool, error) {
c.asked = true
return c.answer, c.err
}
func TestDelete_Success_WithForce(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
p := &confirmPrompter{}
opts := &DeleteOptions{Yes: true}
require.NoError(t, runDelete(context.Background(), opts, svc, p, "kb_force"))
assert.True(t, svc.called)
assert.Equal(t, "kb_force", svc.gotID)
assert.False(t, p.asked, "--force must skip the confirm prompt")
assert.Contains(t, out.String(), "✓ Deleted")
assert.Contains(t, out.String(), "kb_force")
}
func TestDelete_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")}
p := &confirmPrompter{}
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, svc, p, "kb_missing")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
func TestDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
// SetForTest uses bytes.Buffer for Out — IsStdoutTTY() = false. Without
// -y/--yes, exit-10 protocol fires (lark-cli skill protocol; AGENTS.md):
// the CLI must NOT silently proceed in scripted contexts.
iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
p := &confirmPrompter{}
err := runDelete(context.Background(), &DeleteOptions{}, svc, p, "kb_nontty")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
assert.False(t, svc.called, "non-TTY without -y must not call DeleteKnowledgeBase")
assert.False(t, p.asked, "non-TTY ⇒ Confirm is never invoked")
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit code 10 per lark-cli skill protocol")
}
func TestDelete_JSONOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
p := &confirmPrompter{}
opts := &DeleteOptions{Yes: true, JSONOut: true}
require.NoError(t, runDelete(context.Background(), opts, svc, p, "kb_json"))
got := out.String()
assert.True(t, strings.HasPrefix(got, `{"ok":true`), "envelope should start with ok:true; got %q", got)
assert.Contains(t, got, `"id":"kb_json"`)
assert.Contains(t, got, `"deleted":true`)
assert.Contains(t, got, `"kb_id":"kb_json"`)
}
// The remaining tests cover the interactive confirm path which only fires
// under IsStdoutTTY() && !JSONOut — exercised via SetForTestWithTTY.
func TestDelete_ConfirmYes(t *testing.T) {
_, _ = iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &confirmPrompter{answer: true}
require.NoError(t, runDelete(context.Background(), &DeleteOptions{}, svc, p, "kb_yes"))
assert.True(t, p.asked, "confirm prompt should fire on TTY without --force")
assert.True(t, svc.called, "answer=yes ⇒ delete proceeds")
assert.Equal(t, "kb_yes", svc.gotID)
}
func TestDelete_ConfirmNo(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &confirmPrompter{answer: false}
err := runDelete(context.Background(), &DeleteOptions{}, svc, p, "kb_no")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
assert.True(t, p.asked)
assert.False(t, svc.called, "answer=no ⇒ delete must NOT run")
assert.Contains(t, errBuf.String(), "Aborted")
}
func TestDelete_ConfirmPrompterError(t *testing.T) {
_, _ = iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &confirmPrompter{err: prompt.ErrAgentNoPrompt}
err := runDelete(context.Background(), &DeleteOptions{}, svc, p, "kb_err")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code,
"prompter error should surface as missing-flag (pass --force)")
assert.False(t, svc.called)
}
func TestDelete_JSONOut_NoYes_RequiresConfirmation(t *testing.T) {
// Even on a TTY, --json indicates a scripted caller; cannot prompt.
// Exit-10 protocol must fire when -y is absent.
iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &confirmPrompter{}
opts := &DeleteOptions{JSONOut: true}
err := runDelete(context.Background(), opts, svc, p, "kb_jtty")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
assert.False(t, p.asked, "--json must skip the prompt even on TTY")
assert.False(t, svc.called, "--json without -y must not call DeleteKnowledgeBase")
assert.Equal(t, 10, cmdutil.ExitCode(err))
}
func TestDelete_JSONOut_WithYes_Proceeds(t *testing.T) {
// --json + -y is the agent happy-path: scripted caller with explicit
// approval. Must call SDK and emit envelope.
out, _ := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &confirmPrompter{}
opts := &DeleteOptions{Yes: true, JSONOut: true}
require.NoError(t, runDelete(context.Background(), opts, svc, p, "kb_jtty"))
assert.False(t, p.asked, "-y must skip the prompt")
assert.True(t, svc.called)
assert.Contains(t, out.String(), `"deleted":true`)
}