cc8254f862
Two intertwined mainstream-alignment moves bundled because they share
the migration target (every command's --json path):
1. Drop --dry-run entirely. Survey of comparable API-wrapper CLIs
(gh, aws, stripe, lark): none expose --dry-run. The mainstream that
does (kubectl/git/helm/ansible) operates on declarative manifests
or local state where the preview is materially different from the
executed action. WeKnora's CLI just echoed the same parameters
that would have gone on the wire — the preview added no real
signal over `--help` + reading the call site. Removes:
- root --dry-run persistent flag + cmdutil/dryrun.go
- DryRun fields + EmitDryRun calls in 12 write commands
- format.Envelope.DryRun field
- 8 corresponding *_test.go cases
- --dry-run mention from README.md and CHANGELOG.md
- "dry_run":false from 16 golden envelopes
2. Migrate every --json output to bare data:
- New format.WriteJSON / WriteJSONFiltered helpers
(cli/internal/format/bare.go) share filterArrayItems /
filterObjectKeys / writeJQ with the (still-live for now) envelope
filter helpers.
- Read commands (kb/doc/session list+view, search chunks/docs/
sessions/kb, auth list/status, agent list/view, context list,
doctor) emit bare arrays / objects on stdout.
- Write commands (kb create/edit/delete/pin/empty, doc upload/
upload_recursive/delete, session delete, auth login/logout/
refresh/token, link/unlink, context add/use/remove, agent
invoke, chat, api, version) emit bare result objects. Risk
classification dropped — the resource + exit code already
convey the action.
Per-command shape changes:
list / search → []T (was {ok, data:{items:[…]}})
view → T (was {ok, data:T, _meta:…})
create / edit → T
delete / pin / etc. → {id, …action result…}
doctor → {summary, checks}
api → {status, headers, body}
_meta dropped on the read path:
pagination (page/page_size/total/has_more) — agents iterate with
--all-pages or accept --limit (gh CLI parity);
kb_id / context echo — caller already knows what it asked for.
Acceptance contract goldens regenerated for the new bare shape.
Error envelope on stdout (PrintErrorEnvelope) stays live for now —
the envelope-infra deletion lands in the next commit.
261 lines
8.8 KiB
Go
261 lines
8.8 KiB
Go
package doc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"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"
|
|
sdk "github.com/Tencent/WeKnora/client"
|
|
)
|
|
|
|
// fakeUploadSvc captures call arguments and returns canned responses.
|
|
type fakeUploadSvc struct {
|
|
resp *sdk.Knowledge
|
|
err error
|
|
urlResp *sdk.Knowledge
|
|
urlErr error
|
|
got struct {
|
|
kbID, filePath, customName, channel string
|
|
metadata map[string]string
|
|
enableMultimodel *bool
|
|
urlReq sdk.CreateKnowledgeFromURLRequest
|
|
}
|
|
}
|
|
|
|
func (f *fakeUploadSvc) CreateKnowledgeFromFile(
|
|
_ context.Context,
|
|
kbID, filePath string,
|
|
metadata map[string]string,
|
|
enableMultimodel *bool,
|
|
customFileName, channel string,
|
|
) (*sdk.Knowledge, error) {
|
|
f.got.kbID = kbID
|
|
f.got.filePath = filePath
|
|
f.got.metadata = metadata
|
|
f.got.enableMultimodel = enableMultimodel
|
|
f.got.customName = customFileName
|
|
f.got.channel = channel
|
|
return f.resp, f.err
|
|
}
|
|
|
|
func (f *fakeUploadSvc) CreateKnowledgeFromURL(
|
|
_ context.Context,
|
|
kbID string,
|
|
req sdk.CreateKnowledgeFromURLRequest,
|
|
) (*sdk.Knowledge, error) {
|
|
f.got.kbID = kbID
|
|
f.got.urlReq = req
|
|
return f.urlResp, f.urlErr
|
|
}
|
|
|
|
// writeTempFile creates a regular file under t.TempDir() with sample content.
|
|
func writeTempFile(t *testing.T, name string) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), name)
|
|
require.NoError(t, os.WriteFile(path, []byte("hello world"), 0o644))
|
|
return path
|
|
}
|
|
|
|
func TestUpload_Success_Human(t *testing.T) {
|
|
out, _ := iostreams.SetForTest(t)
|
|
path := writeTempFile(t, "report.pdf")
|
|
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_99", FileName: "report.pdf"}}
|
|
opts := &UploadOptions{}
|
|
require.NoError(t, runUpload(context.Background(), opts, nil, svc, "kb_xxx", path))
|
|
|
|
assert.Equal(t, "kb_xxx", svc.got.kbID)
|
|
assert.Equal(t, path, svc.got.filePath)
|
|
assert.Equal(t, "", svc.got.customName, "no --name ⇒ empty (server uses base name)")
|
|
assert.Equal(t, uploadChannel, svc.got.channel)
|
|
assert.Nil(t, svc.got.metadata)
|
|
assert.Nil(t, svc.got.enableMultimodel)
|
|
|
|
got := out.String()
|
|
for _, want := range []string{"✓", "Uploaded", "report.pdf", "doc_99"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("output missing %q in:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUpload_Success_CustomName(t *testing.T) {
|
|
_, _ = iostreams.SetForTest(t)
|
|
path := writeTempFile(t, "q3.pdf")
|
|
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_88", FileName: "q3.pdf"}}
|
|
opts := &UploadOptions{Name: "Q3 Marketing Report.pdf"}
|
|
require.NoError(t, runUpload(context.Background(), opts, nil, svc, "kb_xxx", path))
|
|
assert.Equal(t, "Q3 Marketing Report.pdf", svc.got.customName)
|
|
}
|
|
|
|
func TestUpload_Success_JSON(t *testing.T) {
|
|
out, _ := iostreams.SetForTest(t)
|
|
path := writeTempFile(t, "a.md")
|
|
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_77", FileName: "a.md"}}
|
|
opts := &UploadOptions{}
|
|
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.JSONOptions{}, svc, "kb_xxx", path))
|
|
|
|
got := out.String()
|
|
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `{"id":"doc_77"`), "expected bare Knowledge object; got %q", got)
|
|
assert.Contains(t, got, `"file_name":"a.md"`)
|
|
assert.NotContains(t, got, `"ok":`)
|
|
}
|
|
|
|
func TestUpload_HTTPError_500(t *testing.T) {
|
|
_, _ = iostreams.SetForTest(t)
|
|
path := writeTempFile(t, "x.txt")
|
|
svc := &fakeUploadSvc{err: errors.New("HTTP error 500: internal")}
|
|
err := runUpload(context.Background(), &UploadOptions{}, nil, svc, "kb_xxx", path)
|
|
require.Error(t, err)
|
|
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
|
|
}
|
|
|
|
func TestUpload_HTTPError_409Conflict(t *testing.T) {
|
|
_, _ = iostreams.SetForTest(t)
|
|
path := writeTempFile(t, "dup.pdf")
|
|
svc := &fakeUploadSvc{err: errors.New("HTTP error 409: file exists")}
|
|
err := runUpload(context.Background(), &UploadOptions{}, nil, svc, "kb_xxx", path)
|
|
require.Error(t, err)
|
|
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
assert.Equal(t, cmdutil.CodeResourceAlreadyExists, typed.Code)
|
|
}
|
|
|
|
func TestValidateUploadPath_NotFound(t *testing.T) {
|
|
missing := filepath.Join(t.TempDir(), "does-not-exist.pdf")
|
|
err := validateUploadPath(missing)
|
|
require.Error(t, err)
|
|
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
assert.Equal(t, cmdutil.CodeUploadFileNotFound, typed.Code)
|
|
}
|
|
|
|
func TestValidateUploadPath_DirectoryRejected(t *testing.T) {
|
|
dir := t.TempDir() // already exists, is a dir
|
|
err := validateUploadPath(dir)
|
|
require.Error(t, err)
|
|
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
|
assert.Contains(t, typed.Message, "not a regular file")
|
|
}
|
|
|
|
func TestValidateUploadPath_RegularFileAccepted(t *testing.T) {
|
|
path := writeTempFile(t, "ok.txt")
|
|
require.NoError(t, validateUploadPath(path))
|
|
}
|
|
|
|
func TestValidateUploadPath_SymlinkToFileAccepted(t *testing.T) {
|
|
target := writeTempFile(t, "target.txt")
|
|
link := filepath.Join(t.TempDir(), "link.txt")
|
|
if err := os.Symlink(target, link); err != nil {
|
|
t.Skipf("symlink not supported on this platform: %v", err)
|
|
}
|
|
// os.Stat (not Lstat) should follow the symlink and report regular file.
|
|
require.NoError(t, validateUploadPath(link))
|
|
}
|
|
|
|
// --from-url tests (4-N1).
|
|
|
|
func TestUploadFromURL_Success_Human(t *testing.T) {
|
|
out, _ := iostreams.SetForTest(t)
|
|
svc := &fakeUploadSvc{urlResp: &sdk.Knowledge{ID: "doc_url_1", FileName: "whitepaper.pdf"}}
|
|
opts := &UploadOptions{FromURL: "https://example.com/whitepaper.pdf"}
|
|
require.NoError(t, runUploadFromURL(context.Background(), opts, nil, svc, "kb_xxx"))
|
|
|
|
assert.Equal(t, "kb_xxx", svc.got.kbID)
|
|
assert.Equal(t, "https://example.com/whitepaper.pdf", svc.got.urlReq.URL)
|
|
assert.Equal(t, "api", svc.got.urlReq.Channel)
|
|
assert.Contains(t, out.String(), "Ingested")
|
|
assert.Contains(t, out.String(), "doc_url_1")
|
|
}
|
|
|
|
func TestUploadFromURL_WithName_Passes_AsFileName(t *testing.T) {
|
|
_, _ = iostreams.SetForTest(t)
|
|
svc := &fakeUploadSvc{urlResp: &sdk.Knowledge{ID: "doc_url_2"}}
|
|
opts := &UploadOptions{FromURL: "https://example.com/article.html", Name: "Q3 Article"}
|
|
require.NoError(t, runUploadFromURL(context.Background(), opts, nil, svc, "kb_xxx"))
|
|
assert.Equal(t, "Q3 Article", svc.got.urlReq.FileName,
|
|
"--name must be forwarded as FileName (server uses it for file-vs-crawl mode hint)")
|
|
}
|
|
|
|
func TestUploadFromURL_JSON_BareObject(t *testing.T) {
|
|
out, _ := iostreams.SetForTest(t)
|
|
svc := &fakeUploadSvc{urlResp: &sdk.Knowledge{ID: "doc_url_3", FileName: "ok.pdf"}}
|
|
jopts := &cmdutil.JSONOptions{}
|
|
require.NoError(t, runUploadFromURL(context.Background(),
|
|
&UploadOptions{FromURL: "https://example.com/ok.pdf"}, jopts, svc, "kb_xxx"))
|
|
got := out.String()
|
|
assert.Contains(t, got, `"id":"doc_url_3"`)
|
|
assert.NotContains(t, got, `"ok":`)
|
|
assert.NotContains(t, got, `"risk":`)
|
|
}
|
|
|
|
func TestUploadFromURL_DuplicateURLMaps_resource_already_exists(t *testing.T) {
|
|
_, _ = iostreams.SetForTest(t)
|
|
svc := &fakeUploadSvc{
|
|
urlResp: &sdk.Knowledge{ID: "doc_existing"},
|
|
urlErr: sdk.ErrDuplicateURL,
|
|
}
|
|
err := runUploadFromURL(context.Background(),
|
|
&UploadOptions{FromURL: "https://example.com/dup.pdf"}, nil, svc, "kb_xxx")
|
|
require.Error(t, err)
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
assert.Equal(t, cmdutil.CodeResourceAlreadyExists, typed.Code)
|
|
}
|
|
|
|
func TestValidateUploadFlags_FromURL_OK(t *testing.T) {
|
|
require.NoError(t, validateUploadFlags(&UploadOptions{FromURL: "https://example.com/x.pdf"}, nil))
|
|
}
|
|
|
|
func TestValidateUploadFlags_FromURL_WithPositional_Rejected(t *testing.T) {
|
|
err := validateUploadFlags(&UploadOptions{FromURL: "https://example.com/x.pdf"}, []string{"/tmp/x.pdf"})
|
|
require.Error(t, err)
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
|
}
|
|
|
|
func TestValidateUploadFlags_FromURL_WithRecursive_Rejected(t *testing.T) {
|
|
err := validateUploadFlags(&UploadOptions{FromURL: "https://example.com/x.pdf", Recursive: true}, nil)
|
|
require.Error(t, err)
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
|
}
|
|
|
|
func TestValidateUploadFlags_FromURL_BadScheme(t *testing.T) {
|
|
err := validateUploadFlags(&UploadOptions{FromURL: "file:///etc/passwd"}, nil)
|
|
require.Error(t, err)
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
|
}
|
|
|
|
func TestValidateUploadFlags_FromURL_NoHost(t *testing.T) {
|
|
err := validateUploadFlags(&UploadOptions{FromURL: "https://"}, nil)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestValidateUploadFlags_NoPathOrURL_Rejected(t *testing.T) {
|
|
err := validateUploadFlags(&UploadOptions{}, nil)
|
|
require.Error(t, err)
|
|
var typed *cmdutil.Error
|
|
require.ErrorAs(t, err, &typed)
|
|
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
|
}
|