d0a45d563f
界面验收第三遍(禁止导入 → 需下架)暴露两处真问题: 1) 需下架车道死掉:判据 `retrieval_state='enabled'`,而 retrieval_state 在这套状态机里 描述「是否发布到生产」(unpublished/disabling/disabled/failed),全仓没有任何代码写过 'enabled',线上 5/5 发布单元都是 unpublished → 车道恒 0,界面「仍在知识库里的文件会 出现在这里」的承诺不成立。改为与设计注释一致的口径:仍在测试库存活(completed + 有 knowledge_id + 未 disabling/disabled)+ 来源项 policy_state='blocked'。 线上对照:旧判据 0 → 新判据 1,命中被禁止导入的 DH-AG95夹爪适配-指令协议.md; 修后页面显示「需下架(1)· 对内知识库 · 1 份仍在检索中」。新增集成测试覆盖 存活/未禁止/移除中/已移除/未上传五种情形(真实 Postgres + 独立 schema)。 2) 集成测试改到线上设置(直接踩到并已恢复):ui_settings_integration_test.go 只判断 TEST_DATABASE_URL 是否存在、无 schema 隔离,而它「创建候选并激活」——跑在共享库上会把 真实 active 设置挤成 retired。已先备份(backups/ui-settings/…pre-restore.jsonl),再用 单事务(先 retire 再 activate,uq_ui_setting_active 只允许一条 active)恢复 uis-d40ea1fc…(15/30/1800/7200 + https://weknora-test.dobot.cn/),并复核线上值。 根因修复:给该用例加 per-test schema 隔离,复跑后线上设置不再被改动。 同时把两处与事实不符的文案改实话(WeKnoraMonitor.tsx 需下架说明、SourceCatalogBrowser.tsx 禁止导入说明):移除走 test.remove 工作流(删除测试库知识条目,可再入库),入口当前由 变更集删除路径触发,页面尚无独立按钮——条目级列表与下架按钮列为待排期。
128 lines
6.0 KiB
Go
128 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|