6440e3f679
- 迁移 000044:production_kb_bindings + release_item_executions.target_knowledge_base_id - 系统设置新增「生产知识库绑定(对内/对外)」:从生产 WeKnora 实际列表选库, 保存时真校验(不存在拒收;生产端不可达只提示并如实回报),仅管理员可改 - 执行计划按绑定解析目标库并冻结进 release_item_executions;空目标直接拒收; 已发布过的条目保留原目标(重试/回滚回到同一处) - DocumentBundle.RemapKnowledgeBase:knowledges/chunks/knowledge_tags/wiki_pages/ wiki_folders 的 knowledge_base_id 与 ES 文档字段改写为目标库,重算表/文档摘要并重新封装 - 契约比对改为「声明的映射对」(KnowledgeBaseMapping),其余字段继续 fail-closed - 契约检查器 / 目标身份 / 验证器全部走映射后的目标库;对象通道无需改写 - Compose 拆分:数据面接线 compose.release-sim.yaml + 两道门禁 compose.release-gates.yaml - 测试:RemapKnowledgeBase 全通道/无操作/fail-closed、映射契约、绑定接口、RBAC、真实 PG 集成
166 lines
6.9 KiB
Go
166 lines
6.9 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
postgresstore "knowledge-governance/internal/platform/postgres"
|
|
"knowledge-governance/internal/platform/weknora"
|
|
)
|
|
|
|
type bindingStoreFake struct {
|
|
internal string
|
|
external string
|
|
saver string
|
|
// replan records what the last save asked for, so tests can assert that the
|
|
// effective (env-fallback aware) targets were used.
|
|
replanEffective [2]string
|
|
replanResult postgresstore.RoutingBindingReplanResult
|
|
replanCallCount int
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (fake *bindingStoreFake) SaveRoutingKBBindingsWithReplan(_ context.Context, internal, external, effectiveInternal, effectiveExternal, updatedBy string, _ time.Time) (postgresstore.RoutingBindingReplanResult, error) {
|
|
fake.internal, fake.external, fake.saver = internal, external, updatedBy
|
|
fake.replanEffective = [2]string{effectiveInternal, effectiveExternal}
|
|
fake.replanCallCount++
|
|
return fake.replanResult, 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)
|
|
}
|
|
}
|
|
|
|
// A binding change must also carry the 待入库 re-plan counts and pass the
|
|
// EFFECTIVE targets (env fallback included) so an unbound side still routes.
|
|
func TestRoutingBindingsSaveReportsReplanAndUsesEffectiveTargets(t *testing.T) {
|
|
store := &bindingStoreFake{replanResult: postgresstore.RoutingBindingReplanResult{Replanned: 3, RetiredDuplicates: 1}}
|
|
lister := &kbListerFake{bases: []weknora.KnowledgeBase{{ID: "kb-in", Name: "对内库"}, {ID: "kb-ex", Name: "对外库"}}}
|
|
handler := bindingsTestHandler(t, store, lister, "env-internal", "env-external")
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/routing-bindings", strings.NewReader(`{"internal_kb_id":"kb-in","external_kb_id":""}`)))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("save must succeed: %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if store.replanCallCount != 1 {
|
|
t.Fatalf("save must go through the replanning path once, got %d", store.replanCallCount)
|
|
}
|
|
if store.replanEffective != [2]string{"kb-in", "env-external"} {
|
|
t.Fatalf("effective targets = %#v, want the env fallback on the unbound side", store.replanEffective)
|
|
}
|
|
var envelope struct {
|
|
Data struct {
|
|
Replanned int `json:"replanned"`
|
|
RetiredDuplicates int `json:"retired_duplicates"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if envelope.Data.Replanned != 3 || envelope.Data.RetiredDuplicates != 1 {
|
|
t.Fatalf("replan counts = %#v", envelope.Data)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|