34bb0b5096
Batch-write surface and pagination consistency:
* weknora doc delete <doc-id> [<doc-id>...] — positional multi-id, default
keep-going on failure. Single -y confirms entire batch.
* weknora session delete <session-id> [<session-id>...] — same shape.
* weknora chunk delete <chunk-id> [<chunk-id>...] --doc <doc-id> — multi-id
with shared --doc parent.
* Multi-id partial-failure rolls up as operation.failed (exit 1), not
server.error (exit 7) — failures are operation outcomes, not transient
transport issues, and the retry-with-backoff hint for server.* would
mislead callers.
* weknora api <path> --paginate — auto-walks offset pagination and merges
pages into a single {data, total} JSON response.
* Paginate truncation fix across 6 list/follower call sites.
* All doc / search / chunk / session / kb list commands migrated to
FormatOptions API.
Multi-id RunE only emits the {ok, failed} envelope when the operation
actually ran — pre-flight failures (e.g. confirmation_required) leave
stdout empty per the wire contract.
doc upload's missing-positional-or-flag check is wrapped as FlagError so
the exit code (2) matches the convention used by other commands that
require a positional argument directly.
307 lines
11 KiB
Go
307 lines
11 KiB
Go
package doc
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"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/testutil"
|
|
)
|
|
|
|
// fakeDeleteSvc captures calls and returns canned errors.
|
|
// errFor maps id → error for per-id failure injection (used in multi-id tests).
|
|
type fakeDeleteSvc struct {
|
|
err error
|
|
errFor map[string]error
|
|
got string
|
|
calls int
|
|
// deleted tracks all successfully deleted ids (multi-id tests).
|
|
deleted []string
|
|
}
|
|
|
|
func (f *fakeDeleteSvc) DeleteKnowledge(_ context.Context, id string) error {
|
|
f.calls++
|
|
f.got = id
|
|
if f.errFor != nil {
|
|
if err, ok := f.errFor[id]; ok {
|
|
return err
|
|
}
|
|
f.deleted = append(f.deleted, id)
|
|
return nil
|
|
}
|
|
if f.err != nil {
|
|
return f.err
|
|
}
|
|
f.deleted = append(f.deleted, id)
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Single-id tests — runDelete uses the simpler {id, deleted} payload.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestDelete_Success_WithForce(t *testing.T) {
|
|
out, _ := iostreams.SetForTest(t)
|
|
svc := &fakeDeleteSvc{}
|
|
opts := &DeleteOptions{Yes: true}
|
|
// Force=true short-circuits the confirm path; the prompter must not be
|
|
// consulted, so any value works.
|
|
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Answer: false}, "doc_abc"))
|
|
|
|
assert.Equal(t, "doc_abc", svc.got)
|
|
assert.Equal(t, 1, svc.calls)
|
|
assert.Contains(t, out.String(), "✓")
|
|
assert.Contains(t, out.String(), "doc_abc")
|
|
}
|
|
|
|
func TestDelete_Success_JSON(t *testing.T) {
|
|
out, _ := iostreams.SetForTest(t)
|
|
svc := &fakeDeleteSvc{}
|
|
opts := &DeleteOptions{Yes: true}
|
|
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{Answer: true}, "doc_abc"))
|
|
|
|
got := out.String()
|
|
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `{"id":"doc_abc"`), "expected bare object; got %q", got)
|
|
assert.Contains(t, got, `"deleted":true`)
|
|
assert.NotContains(t, got, `"ok":`)
|
|
}
|
|
|
|
func TestDelete_NotFound_404(t *testing.T) {
|
|
_, _ = iostreams.SetForTest(t)
|
|
svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")}
|
|
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, "doc_missing")
|
|
require.Error(t, err)
|
|
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
|
}
|
|
|
|
func TestDelete_HTTPError_500(t *testing.T) {
|
|
_, _ = iostreams.SetForTest(t)
|
|
svc := &fakeDeleteSvc{err: errors.New("HTTP error 500: internal")}
|
|
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, "doc_x")
|
|
require.Error(t, err)
|
|
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
// Single-id delete WrapHTTP-classifies the SDK error; HTTP 500 → server.error.
|
|
// (The multi-id path rolls up failures as operation.failed; this is the
|
|
// single-id path so it stays server.error.)
|
|
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
|
|
}
|
|
|
|
func TestDelete_ConfirmYes(t *testing.T) {
|
|
out, _ := iostreams.SetForTestWithTTY(t)
|
|
svc := &fakeDeleteSvc{}
|
|
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Answer: true}, "doc_abc")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, svc.calls, "user said yes ⇒ delete proceeds")
|
|
assert.Contains(t, out.String(), "✓")
|
|
}
|
|
|
|
func TestDelete_ConfirmNo(t *testing.T) {
|
|
_, errBuf := iostreams.SetForTestWithTTY(t)
|
|
svc := &fakeDeleteSvc{}
|
|
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Answer: false}, "doc_abc")
|
|
require.Error(t, err)
|
|
assert.Equal(t, 0, svc.calls, "user said no ⇒ SDK must NOT be called")
|
|
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
|
|
assert.Contains(t, errBuf.String(), "Aborted.")
|
|
}
|
|
|
|
// TestDelete_AgentPrompterErrors covers the path where the prompter itself
|
|
// returns an error (e.g. AgentPrompter, broken stdin). runDelete maps this to
|
|
// CodeInputMissingFlag so the user sees "pass --force" in the hint.
|
|
func TestDelete_AgentPrompterErrors(t *testing.T) {
|
|
_, _ = iostreams.SetForTestWithTTY(t)
|
|
svc := &fakeDeleteSvc{}
|
|
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Err: errors.New("no tty")}, "doc_abc")
|
|
require.Error(t, err)
|
|
assert.Equal(t, 0, svc.calls)
|
|
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
|
|
}
|
|
|
|
// TestDelete_NoYes_NonTTY_RequiresConfirmation: when stdout isn't a TTY
|
|
// (typical agent pipe / CI), the destructive-write protocol requires
|
|
// explicit -y/--yes. The CLI exits 10 with input.confirmation_required,
|
|
// never silently proceeds. See cli/README.md "Exit codes".
|
|
func TestDelete_NoYes_NonTTY_RequiresConfirmation(t *testing.T) {
|
|
_, _ = iostreams.SetForTest(t)
|
|
svc := &fakeDeleteSvc{}
|
|
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Err: errors.New("no tty")}, "doc_abc")
|
|
require.Error(t, err)
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
|
assert.Equal(t, 0, svc.calls, "non-TTY without -y must not call DeleteKnowledge")
|
|
assert.Equal(t, 10, cmdutil.ExitCode(err))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Multi-id tests (runMultiDelete, keep-going semantics)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestRunMultiDelete_AllSucceed(t *testing.T) {
|
|
_, _ = iostreams.SetForTest(t)
|
|
svc := &fakeDeleteSvc{}
|
|
res, err := runMultiDelete(
|
|
context.Background(),
|
|
&DeleteOptions{Yes: true},
|
|
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON},
|
|
svc,
|
|
&testutil.ConfirmPrompter{Answer: true},
|
|
[]string{"a", "b", "c"},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"a", "b", "c"}, res.OK)
|
|
assert.Empty(t, res.Failed)
|
|
assert.Equal(t, 3, svc.calls)
|
|
}
|
|
|
|
func TestRunMultiDelete_KeepGoingOnError(t *testing.T) {
|
|
_, _ = iostreams.SetForTest(t)
|
|
svc := &fakeDeleteSvc{errFor: map[string]error{"doc_b": errors.New("not found")}}
|
|
res, err := runMultiDelete(
|
|
context.Background(),
|
|
&DeleteOptions{Yes: true},
|
|
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON},
|
|
svc,
|
|
&testutil.ConfirmPrompter{Answer: true},
|
|
[]string{"doc_a", "doc_b", "doc_c"},
|
|
)
|
|
require.Error(t, err, "partial failure must return non-nil error (exit 1)")
|
|
assert.Equal(t, 3, svc.calls, "all ids must be attempted (keep-going)")
|
|
require.Len(t, res.OK, 2)
|
|
require.Len(t, res.Failed, 1)
|
|
assert.Equal(t, "doc_b", res.Failed[0].ID)
|
|
assert.Equal(t, "not found", res.Failed[0].Message)
|
|
// OK list must contain only successful ids
|
|
assert.Contains(t, res.OK, "doc_a")
|
|
assert.Contains(t, res.OK, "doc_c")
|
|
}
|
|
|
|
func TestRunMultiDelete_AllFail(t *testing.T) {
|
|
_, _ = iostreams.SetForTest(t)
|
|
svc := &fakeDeleteSvc{errFor: map[string]error{
|
|
"x": errors.New("HTTP error 404: not found"),
|
|
"y": errors.New("HTTP error 403: forbidden"),
|
|
}}
|
|
res, err := runMultiDelete(
|
|
context.Background(),
|
|
&DeleteOptions{Yes: true},
|
|
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON},
|
|
svc,
|
|
&testutil.ConfirmPrompter{Answer: true},
|
|
[]string{"x", "y"},
|
|
)
|
|
require.Error(t, err)
|
|
assert.Empty(t, res.OK)
|
|
assert.Len(t, res.Failed, 2)
|
|
}
|
|
|
|
func TestRunMultiDelete_ConfirmBatch_NonTTY_RequiresConfirmation(t *testing.T) {
|
|
_, _ = iostreams.SetForTest(t) // non-TTY
|
|
svc := &fakeDeleteSvc{}
|
|
_, err := runMultiDelete(
|
|
context.Background(),
|
|
&DeleteOptions{Yes: false},
|
|
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON},
|
|
svc,
|
|
&testutil.ConfirmPrompter{Answer: false},
|
|
[]string{"a", "b"},
|
|
)
|
|
require.Error(t, err)
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
|
assert.Equal(t, 0, svc.calls, "must not call DeleteKnowledge without confirmation")
|
|
}
|
|
|
|
func TestRunMultiDelete_ConfirmBatch_TTY_UserAborts(t *testing.T) {
|
|
_, errBuf := iostreams.SetForTestWithTTY(t)
|
|
svc := &fakeDeleteSvc{}
|
|
_, err := runMultiDelete(
|
|
context.Background(),
|
|
&DeleteOptions{Yes: false},
|
|
&cmdutil.FormatOptions{Mode: cmdutil.FormatText},
|
|
svc,
|
|
&testutil.ConfirmPrompter{Answer: false},
|
|
[]string{"a", "b", "c"},
|
|
)
|
|
require.Error(t, err)
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
|
|
assert.Contains(t, errBuf.String(), "Aborted.")
|
|
assert.Equal(t, 0, svc.calls, "user aborted ⇒ SDK must NOT be called")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Emit tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestEmitMultiDelete_JSON(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
res := &MultiDeleteResult{
|
|
OK: []string{"a", "b"},
|
|
Failed: []FailedItem{{ID: "c", Message: "x"}},
|
|
}
|
|
err := emitMultiDelete(res, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &buf)
|
|
require.NoError(t, err)
|
|
|
|
var got MultiDeleteResult
|
|
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
|
|
assert.Equal(t, []string{"a", "b"}, got.OK)
|
|
require.Len(t, got.Failed, 1)
|
|
assert.Equal(t, "c", got.Failed[0].ID)
|
|
}
|
|
|
|
func TestEmitMultiDelete_Text(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
res := &MultiDeleteResult{
|
|
OK: []string{"a"},
|
|
Failed: []FailedItem{{ID: "b", Message: "boom"}},
|
|
}
|
|
err := emitMultiDelete(res, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &buf)
|
|
require.NoError(t, err)
|
|
|
|
out := buf.String()
|
|
assert.Contains(t, out, "OK a")
|
|
assert.Contains(t, out, "FAIL b: boom")
|
|
}
|
|
|
|
func TestEmitMultiDelete_TextEmpty(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
res := &MultiDeleteResult{OK: []string{"x", "y"}}
|
|
err := emitMultiDelete(res, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &buf)
|
|
require.NoError(t, err)
|
|
|
|
out := buf.String()
|
|
assert.Contains(t, out, "OK x")
|
|
assert.Contains(t, out, "OK y")
|
|
assert.NotContains(t, out, "FAIL")
|
|
}
|
|
|
|
func TestEmitMultiDelete_UnsupportedFormat(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
res := &MultiDeleteResult{}
|
|
err := emitMultiDelete(res, &cmdutil.FormatOptions{Mode: "yaml"}, &buf)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "yaml")
|
|
}
|