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) } }