Files
knowledge-governance/internal/httpapi/routing_bindings_test.go
T
姚章浩 021e91eae4 feat(settings): bind internal/external knowledge bases from the UI
Routing targets move from env-only to a UI-managed binding (migration
000034 routing_kb_bindings, single active row):
- GET/PUT /api/v1/routing-bindings + /options: env defaults act as the
  bootstrap fallback; a UI binding wins and takes effect immediately
  (per-request resolution, no restart), and clearing falls back to env
- PUT validates against the connected WeKnora instance (real knowledge
  base list via the scoped API key) and rejects same-KB bindings;
  saves require the admin role
- route/catalog capabilities now resolve through the binding so both
  annotation paths share one source of truth
- system settings > 服务配置 gains a 知识库绑定 panel: dropdown picker
  (or raw ID when WeKnora is unreachable) showing bound name, origin
  (UI vs env default) and last author
- contract tests: OpenAPI, handler matrix (fallback, validation,
  same-KB rejection, capability resolution), frontend fetch/save layer
2026-09-30 10:54:07 +08:00

122 lines
4.6 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"knowledge-governance/internal/platform/weknora"
)
type bindingStoreFake struct {
internal string
external string
saver string
}
func (fake *bindingStoreFake) LoadRoutingKBBindings(context.Context) (string, string, string, error) {
return fake.internal, fake.external, fake.saver, nil
}
func (fake *bindingStoreFake) SaveRoutingKBBindings(_ context.Context, internal, external, updatedBy string) error {
fake.internal, fake.external, fake.saver = internal, external, updatedBy
return nil
}
type kbListerFake struct {
bases []weknora.KnowledgeBase
err error
}
func (fake *kbListerFake) ListKnowledgeBases(context.Context) ([]weknora.KnowledgeBase, error) {
return fake.bases, fake.err
}
func bindingsTestHandler(t *testing.T, store RoutingBindingStore, lister KnowledgeBaseLister, envInternal, envExternal string) http.Handler {
t.Helper()
handler, err := NewHandler(Config{
Store: &fakeJobStore{}, Monitor: nil,
InternalRoutingKnowledgeBaseID: envInternal, ExternalRoutingKnowledgeBaseID: envExternal,
RoutingBindings: store, KnowledgeBases: lister,
Now: func() time.Time { return time.Date(2026, time.September, 13, 8, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("NewHandler: %v", err)
}
return handler
}
func TestRoutingBindingsReadFallsBackToEnv(t *testing.T) {
store := &bindingStoreFake{internal: "", external: "fd-ext"}
handler := bindingsTestHandler(t, store, &kbListerFake{bases: []weknora.KnowledgeBase{{ID: "fd-ext", Name: "对外库"}}}, "sn-internal", "env-external")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/routing-bindings", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d body %s", recorder.Code, recorder.Body.String())
}
var envelope struct {
Data routingBindingView `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
view := envelope.Data
if view.InternalKBID != "sn-internal" || !view.InternalEnvFallback || view.InternalBoundByUI {
t.Fatalf("internal side = %#v", view)
}
if view.ExternalKBID != "fd-ext" || !view.ExternalBoundByUI || view.ExternalKBName != "对外库" {
t.Fatalf("external side = %#v", view)
}
if !view.WeKnoraConnected {
t.Fatalf("connected must be true: %#v", view)
}
}
func TestRoutingBindingsSaveValidatesAgainstWeKnora(t *testing.T) {
store := &bindingStoreFake{}
lister := &kbListerFake{bases: []weknora.KnowledgeBase{{ID: "kb-in", Name: "对内库"}, {ID: "kb-ex", Name: "对外库"}}}
handler := bindingsTestHandler(t, store, lister, "", "")
body := `{"internal_kb_id":"kb-in","external_kb_id":"missing"}`
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/routing-bindings", strings.NewReader(body)))
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "对外绑定的知识库") {
t.Fatalf("unknown target must be rejected: %d %s", recorder.Code, recorder.Body.String())
}
if store.internal != "" {
t.Fatalf("rejected save must not persist: %#v", store)
}
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/routing-bindings", strings.NewReader(`{"internal_kb_id":"kb-in","external_kb_id":"kb-in"}`)))
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "不同的知识库") {
t.Fatalf("same-kb binding must be rejected: %d %s", recorder.Code, recorder.Body.String())
}
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/routing-bindings", strings.NewReader(`{"internal_kb_id":"kb-in","external_kb_id":"kb-ex"}`)))
if recorder.Code != http.StatusOK {
t.Fatalf("valid binding must save: %d %s", recorder.Code, recorder.Body.String())
}
if store.internal != "kb-in" || store.external != "kb-ex" {
t.Fatalf("store = %#v", store)
}
}
func TestRouteCatalogFilesUsesUIBinding(t *testing.T) {
store := &bindingStoreFake{internal: "ui-internal", external: "ui-external"}
handler := bindingsTestHandler(t, store, &kbListerFake{}, "env-internal", "env-external")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/source-catalog/routing-capabilities", nil))
var envelope struct {
Data map[string]bool `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if !envelope.Data["internal_enabled"] || !envelope.Data["external_enabled"] {
t.Fatalf("capabilities = %#v", envelope.Data)
}
}