package httpapi import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "net/url" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "knowledge-governance/internal/governance" postgresstore "knowledge-governance/internal/platform/postgres" ) // isolatedUISettingsURL 给本用例一个独立 schema。 // // 这个用例会「创建候选并激活」——激活会把当前的 active 设置挤成 retired。若直接跑在 // 共享库(尤其是线上治理库)上,它会静默改掉真实的刷新策略与端点:2026-10-10 实测踩到, // 真实那条 uis-d40ea1fc… 被 retired、测试负载(30/60/3600/14400 + 示例端点)被激活, // 已用备份恢复。参照 internal/monitoring 的做法加 schema 隔离,杜绝再次发生。 func isolatedUISettingsURL(t *testing.T) string { t.Helper() databaseURL := os.Getenv("TEST_DATABASE_URL") if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not set") } parsed, err := url.Parse(databaseURL) if err != nil { t.Fatal(err) } digest := sha256.Sum256([]byte(t.Name())) schema := "kgtest_" + hex.EncodeToString(digest[:8]) ctx := context.Background() admin, err := pgxpool.New(ctx, databaseURL) if err != nil { t.Fatal(err) } defer admin.Close() if _, err := admin.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schema); err != nil { t.Fatal(err) } t.Cleanup(func() { cleanup, err := pgxpool.New(context.Background(), databaseURL) if err == nil { _, _ = cleanup.Exec(context.Background(), "DROP SCHEMA IF EXISTS "+schema+" CASCADE") cleanup.Close() } }) query := parsed.Query() query.Set("search_path", schema) parsed.RawQuery = query.Encode() return parsed.String() } func TestUISettingsWriteReadBackIntegration(t *testing.T) { ctx := context.Background() pool, err := pgxpool.New(ctx, isolatedUISettingsURL(t)) if err != nil { t.Fatal(err) } defer pool.Close() if err := postgresstore.ApplyMigrations(ctx, pool); err != nil { t.Fatal(err) } now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) handler, err := NewHandler(Config{Store: postgresstore.NewStore(pool), Now: func() time.Time { return now }}) if err != nil { t.Fatal(err) } create := httptest.NewRecorder() createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/ui-settings/candidates", bytes.NewBufferString(`{"document_refresh_seconds":30,"resource_refresh_seconds":60,"stalled_after_seconds":3600,"finalizing_after_seconds":14400,"ingestion_concurrency":2,"test_weknora_base_url":"https://weknora.test.example:8443/weknora","production_weknora_base_url":"https://weknora.prod.example:8443/weknora","production_tenant_id":"10000","production_knowledge_base_id":"kb-prod-1"}`)) createRequest.Header.Set("X-Governance-Action", "ui-settings") createRequest.Header.Set("Idempotency-Key", "op-e2e-uis-create") handler.ServeHTTP(create, createRequest) if create.Code != http.StatusCreated { t.Fatalf("create status=%d body=%s", create.Code, create.Body.String()) } var candidate struct { Data governance.UISettings `json:"data"` } if err := json.NewDecoder(create.Body).Decode(&candidate); err != nil || candidate.Data.State != "validated" { t.Fatalf("candidate=%#v error=%v", candidate, err) } if candidate.Data.ProductionWeKnoraBaseURL != "https://weknora.prod.example:8443/weknora" || candidate.Data.TestWeKnoraBaseURL != "https://weknora.test.example:8443/weknora" || candidate.Data.ProductionTenantID != "10000" || candidate.Data.ProductionKnowledgeBaseID != "kb-prod-1" { t.Fatalf("service fields lost in the web write path: %#v", candidate.Data) } activate := httptest.NewRecorder() activateRequest := httptest.NewRequest(http.MethodPost, "/api/v1/ui-settings/"+candidate.Data.ID+"/activate", bytes.NewBufferString(`{}`)) activateRequest.Header.Set("X-Governance-Action", "ui-settings") activateRequest.Header.Set("Idempotency-Key", "op-e2e-uis-activate") handler.ServeHTTP(activate, activateRequest) if activate.Code != http.StatusOK { t.Fatalf("activate status=%d body=%s", activate.Code, activate.Body.String()) } get := httptest.NewRecorder() handler.ServeHTTP(get, httptest.NewRequest(http.MethodGet, "/api/v1/ui-settings", nil)) if get.Code != http.StatusOK { t.Fatalf("get status=%d body=%s", get.Code, get.Body.String()) } var active struct { Data governance.UISettings `json:"data"` } if err := json.NewDecoder(get.Body).Decode(&active); err != nil || active.Data.ID != candidate.Data.ID || active.Data.State != "active" || active.Data.DocumentRefreshSeconds != 30 { t.Fatalf("active=%#v error=%v", active, err) } if active.Data.ProductionWeKnoraBaseURL != "https://weknora.prod.example:8443/weknora" || active.Data.TestWeKnoraBaseURL != "https://weknora.test.example:8443/weknora" || active.Data.ProductionTenantID != "10000" || active.Data.ProductionKnowledgeBaseID != "kb-prod-1" { t.Fatalf("active service fields not served back to the console: %#v", active.Data) } var persistedEndpoint, persistedTestEndpoint, persistedTenant, persistedKnowledgeBase string if err := pool.QueryRow(ctx, `SELECT production_weknora_base_url,test_weknora_base_url,production_tenant_id,production_knowledge_base_id FROM ui_setting_revisions WHERE state='active'`).Scan(&persistedEndpoint, &persistedTestEndpoint, &persistedTenant, &persistedKnowledgeBase); err != nil || persistedEndpoint != "https://weknora.prod.example:8443/weknora" || persistedTestEndpoint != "https://weknora.test.example:8443/weknora" || persistedTenant != "10000" || persistedKnowledgeBase != "kb-prod-1" { t.Fatalf("persisted service fields=%q/%q/%q/%q error=%v", persistedEndpoint, persistedTestEndpoint, persistedTenant, persistedKnowledgeBase, err) } var activeCount, retiredCount int if err := pool.QueryRow(ctx, `SELECT COUNT(*) FILTER(WHERE state='active'),COUNT(*) FILTER(WHERE state='retired') FROM ui_setting_revisions`).Scan(&activeCount, &retiredCount); err != nil || activeCount != 1 || retiredCount != 1 { t.Fatalf("active=%d retired=%d error=%v", activeCount, retiredCount, err) } }