Files
Enterprise-Knowledge-Base/cli/cmd/link/link_test.go
T
nullkey e236be1ced fix(cli): correct KB id detection, SSE terminal-frame, and CI test isolation
Three defects surfaced during end-to-end RAG verification — the first two
block real chat usage, the third makes Linux CI flaky:

1. KB id detection — `IsKBID` was checking
   `strings.HasPrefix(s, "kb_")`, but WeKnora generates KB ids as bare
   UUIDs (internal/types/knowledge_base.go: `uuid.New().String()` stored
   in a `varchar(36)` column). Real ids therefore fell through to the
   name-resolution path:

     $ weknora chat ... --kb a32a63ff-fb36-4874-bcaa-30f48570a694
     Error: knowledge base not found: a32a63ff-...

   Switched the discriminator to a UUID regex
   (`^[0-9a-fA-F]{8}-…-[0-9a-fA-F]{12}$`). KB names are arbitrary
   user-supplied strings, so the canonical 8-4-4-4-12 form is an
   unambiguous signal. Mirrors gcloud `--project`'s id-vs-name detection.

2. SSE terminal-frame — the accumulator's `Append` was gating
   finalization on `r.Done`, but the server's KnowledgeQAStream protocol
   emits a leading `agent_query` frame with `done=true` to deliver
   session + message metadata *before* the answer fragments arrive:

     event: message
     data: {"response_type":"agent_query","content":"","done":true,…}

     event: message
     data: {"response_type":"answer","content":"你好","done":false}
     …
     event: message
     data: {"response_type":"complete","content":"","done":true}

   The accumulator therefore flipped to `finished=true` on frame #1 and
   discarded every subsequent answer fragment — `weknora chat … --json`
   returned `answer: ""` even though the LLM reported completion_tokens
   > 0. Fixed: terminate only on `response_type == complete`.
   References still captured opportunistically (they may arrive on a
   dedicated `references` event before the terminator).

3. doctor credential_storage CI isolation — the check probes the real
   OS keyring via `secrets.NewBestEffortStore()`: present on macOS dev
   machines → StatusOK; absent on Linux CI runners without libsecret /
   Gnome-Keyring → StatusWarn ("falling back to file store"). That
   host-dependence was leaking into two test classes that assumed
   StatusOK:

     * cmd/doctor/doctor_test.go: TestDoctor_AllOK and
       TestDoctor_NoConfig_StillRunsCredentialStorage already had a
       withCredStoreFactory seam but didn't use it. Added the pin.

     * acceptance/contract/envelope_test.go: doctor.success_offline
       and doctor.error_network golden cases. The contract test runs
       through the cobra tree in-process and shares cmd/doctor's
       package-level credStoreFactory var — but couldn't reach it
       because the existing seam was unexported.

   Fix: export `doctor.SetCredStoreFactoryForTest(fn) (restore func())`
   for out-of-package tests; acceptance/contract/helpers_test.go adds
   a TestMain that pins the factory to a MemStore-returning closure
   for the whole suite (MemStore is neither *FileStore nor a real
   keyring, so doctor's type-switch hits StatusOK). Production stays
   at secrets.NewBestEffortStore — only the test hook is now reachable
   from across packages.

Test fixtures and goldens that used the old `kb_xxx` literals or
`Done: true` terminators were rewritten to use real UUIDs and
`ResponseType: ResponseTypeComplete` respectively. Per-command --help
text and Long descriptions / Examples now show a UUID rather than
`kb_…` so users see the correct shape from the start. New
TestAccumulator_IgnoresAgentQueryDone pins the SSE terminator bug so
it can't regress.

Tests: 24 cli packages green on macOS dev + Linux/macOS/Windows CI
matrix. Verified end-to-end against a live WeKnora server: `weknora
chat "..." --kb <UUID> --no-stream --json` returns the full LLM answer
in the envelope, live token streaming in TTY mode works, and the
credential_storage check renders deterministic envelopes across hosts.
2026-05-12 13:20:42 +08:00

149 lines
4.1 KiB
Go

package linkcmd
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/projectlink"
sdk "github.com/Tencent/WeKnora/client"
)
func chdir(t *testing.T, dir string) {
t.Helper()
prev, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(dir))
t.Cleanup(func() { _ = os.Chdir(prev) })
}
func fakeKBServer(t *testing.T, kbs []sdk.KnowledgeBase) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/knowledge-bases", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(sdk.KnowledgeBaseListResponse{Success: true, Data: kbs})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func newFactory(currentCtx string, client *sdk.Client) *cmdutil.Factory {
cfg := &config.Config{
CurrentContext: currentCtx,
Contexts: map[string]config.Context{
currentCtx: {Host: "https://example"},
},
}
return &cmdutil.Factory{
Config: func() (*config.Config, error) { return cfg, nil },
Client: func() (*sdk.Client, error) {
if client == nil {
return nil, errors.New("client not configured")
}
return client, nil
},
}
}
func TestLink_ByID(t *testing.T) {
dir := t.TempDir()
chdir(t, dir)
out, _ := iostreams.SetForTest(t)
f := newFactory("default", nil)
opts := &Options{KB: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"}
require.NoError(t, runLink(context.Background(), opts, f))
linkPath := filepath.Join(dir, ".weknora", "project.yaml")
p, err := projectlink.Load(linkPath)
require.NoError(t, err)
assert.Equal(t, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", p.KBID)
assert.Equal(t, "default", p.Context)
assert.Contains(t, out.String(), "✓")
}
func TestLink_ByName(t *testing.T) {
dir := t.TempDir()
chdir(t, dir)
_, _ = iostreams.SetForTest(t)
srv := fakeKBServer(t, []sdk.KnowledgeBase{
{ID: "kb_a", Name: "foo"},
{ID: "kb_b", Name: "bar"},
})
cli := sdk.NewClient(srv.URL)
f := newFactory("default", cli)
opts := &Options{KB: "foo"}
require.NoError(t, runLink(context.Background(), opts, f))
p, err := projectlink.Load(filepath.Join(dir, ".weknora", "project.yaml"))
require.NoError(t, err)
assert.Equal(t, "kb_a", p.KBID)
}
func TestLink_KBNotFound(t *testing.T) {
dir := t.TempDir()
chdir(t, dir)
_, _ = iostreams.SetForTest(t)
srv := fakeKBServer(t, []sdk.KnowledgeBase{{ID: "kb_a", Name: "foo"}})
cli := sdk.NewClient(srv.URL)
f := newFactory("default", cli)
opts := &Options{KB: "missing"}
err := runLink(context.Background(), opts, f)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeKBNotFound, typed.Code)
}
func TestLink_OverwritesExisting(t *testing.T) {
dir := t.TempDir()
chdir(t, dir)
_, _ = iostreams.SetForTest(t)
// Pre-existing link.
linkPath := filepath.Join(dir, ".weknora", "project.yaml")
require.NoError(t, projectlink.Save(linkPath, &projectlink.Project{
Context: "default", KBID: "11111111-1111-4111-8111-111111111111",
}))
f := newFactory("default", nil)
opts := &Options{KB: "22222222-2222-4222-8222-222222222222"}
require.NoError(t, runLink(context.Background(), opts, f))
p, err := projectlink.Load(linkPath)
require.NoError(t, err)
assert.Equal(t, "22222222-2222-4222-8222-222222222222", p.KBID, "link should overwrite silently")
}
// TestLink_NonInteractive_NoKB exercises the non-TTY-without-flag error path.
// SetForTest gives us a non-TTY iostreams, so omitting --kb must error rather
// than hang on a prompt.
func TestLink_NonInteractive_NoKB(t *testing.T) {
dir := t.TempDir()
chdir(t, dir)
_, _ = iostreams.SetForTest(t)
f := newFactory("default", nil)
opts := &Options{} // no KB
err := runLink(context.Background(), opts, f)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeKBIDRequired, typed.Code)
}