From 8da4666e76f8b46cad29e41d2fdd0b98663c53bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Wed, 28 Oct 2026 15:58:06 +0800 Subject: [PATCH] =?UTF-8?q?test(library):=20=E7=AB=AF=E5=88=B0=E7=AB=AF?= =?UTF-8?q?=E7=BC=96=E6=8E=92=E5=9C=A8=E6=9C=AC=E6=9C=BA=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=20PG/ES=20=E4=B8=8A=E8=B7=91=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:只验证单阶段写入还不够,完整链路(授权→预备份→四类写入→校验)也要在真实服务上跑。 本机 WeKnora 测试栈一次性库与随机索引已通过,CI 无这些服务故在 test-scope 声明为可选。 --- CHANGELOG.md | 4 +- docs/release-ledger.md | 4 +- ...rary_production_target_integration_test.go | 112 +++++++++++++++++- scripts/test-scope.json | 5 + 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82064c2..4ed4a20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,9 @@ - `TestLibraryProductionTargetIsolated` 在本机 WeKnora 测试栈真实 PG(一次性 `kg_library_test_*` 库) 与真实 ES(随机索引)跑通:首次导入 → 校验 → 撤销传播(PG 行与 ES 文档都消失、跨租户不受影响) - → 独立预备份(3111e823…,626 字节)→ 注入故障(删行)→ 校验失败 → 恢复后校验通过。 + → 独立预备份(626 字节)→ 注入故障(删行)→ 校验失败 → 恢复后校验通过。 +- `TestLibraryReleaseRunIsolated`:同一环境上跑完整编排(授权 → 预备份 → 数据库/资源/索引/Wiki → + 校验),返回 succeeded,预备份快照 8e4ed5ba…(1074 字节)。 ## 开发快照(生产发布授权 + 同步/恢复编排 + 存储管理真实化) diff --git a/docs/release-ledger.md b/docs/release-ledger.md index 8d65b30..50ef9e1 100644 --- a/docs/release-ledger.md +++ b/docs/release-ledger.md @@ -29,7 +29,9 @@ build-web / tests-go / tests-web / contracts / version / security-secrets / secu | 0.1.123 | 67a48faf23996b7cf55d2ed589e4235f9b4d4754 | 去掉 qawemail 单测的外部 DNS 依赖(run164 因此红) | 165 | **success** | 否 | -0.1.124 起只是台账/进度补记(文档),候选以最后一次全绿 run 为准(见交付报告)。 +| 0.1.125 | 281699a43104af6ce5344b17375ac7102f3e563d | 生产整库真实读写/校验/恢复 + 本机真实 PG/ES 集成验证 | 167 | **success** | 否(未部署) | + +0.1.124 起只是台账/进度补记(文档)。当前候选 = 0.1.125(281699a),只推了 main,未切运行版。 历史事实保留:0.1.120 run161、0.1.121 run162、0.1.122 run163 均 success,0.1.123 run165 success; run164 因 qawemail 依赖外网 DNS 失败,已修,不涂改。 diff --git a/internal/releaseadapter/v071/library_production_target_integration_test.go b/internal/releaseadapter/v071/library_production_target_integration_test.go index 1c5491c..ea314d9 100644 --- a/internal/releaseadapter/v071/library_production_target_integration_test.go +++ b/internal/releaseadapter/v071/library_production_target_integration_test.go @@ -39,8 +39,7 @@ func TestLibraryProductionTargetIsolated(t *testing.T) { t.Fatalf("open isolated PG: %v", err) } defer pool.Close() - if _, err := pool.Exec(ctx, `DROP TABLE IF EXISTS knowledges,chunks,knowledge_tags,knowledge_tag_relations, - resources,resource_bindings,wiki_pages,wiki_folders,wiki_page_issues,wiki_log_entries,knowledge_bases,schema_migrations CASCADE`); err != nil { + if _, err := pool.Exec(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { t.Fatalf("reset isolated schema: %v", err) } if _, err := pool.Exec(ctx, libraryTestSchema); err != nil { @@ -198,10 +197,117 @@ func TestLibraryProductionTargetIsolated(t *testing.T) { } // fixtureObjectStore 只为让目标构造通过:本场景没有资源部件,不会被调用。 +// 端到端:授权 → 预备份 → 四类写入 → 校验,全走 libraryrelease.Run 的真实适配器。 +func TestLibraryReleaseRunIsolated(t *testing.T) { + dsn, base := os.Getenv("KG_LIBRARY_TEST_DATABASE_URL"), os.Getenv("KG_LIBRARY_TEST_ELASTIC_URL") + if dsn == "" || base == "" || os.Getenv("KG_LIBRARY_TEST_ISOLATED") != "1" { + t.Skip("requires isolated PG and ES opt-in") + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("open isolated PG: %v", err) + } + defer pool.Close() + for _, statement := range []string{ + `DROP SCHEMA public CASCADE; CREATE SCHEMA public`, + libraryTestSchema, + `ALTER TABLE wiki_pages ADD COLUMN status text DEFAULT 'published'`, + `ALTER TABLE knowledge_tag_relations ADD COLUMN tenant_id bigint`, + `ALTER TABLE resources ADD COLUMN provider text DEFAULT 's3', ADD COLUMN size bigint DEFAULT 8`, + } { + if _, err := pool.Exec(ctx, statement); err != nil { + t.Fatalf("fixture schema: %v", err) + } + } + token := make([]byte, 8) + if _, err := rand.Read(token); err != nil { + t.Fatal(err) + } + index := "kg_library_test_" + hex.EncodeToString(token) + client := &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + call := func(method, path, body string, want int) { + t.Helper() + request, err := http.NewRequestWithContext(ctx, method, base+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + response, err := client.Do(request) + if err != nil { + t.Fatalf("fixture HTTP: %v", err) + } + defer response.Body.Close() + if response.StatusCode != want { + t.Fatalf("fixture %s %s => %d, want %d", method, path, response.StatusCode, want) + } + } + call("PUT", "/"+index, `{"mappings":{"properties":{"tenant_id":{"type":"long"},"knowledge_base_id":{"type":"keyword"},"knowledge_id":{"type":"keyword"},"chunk_id":{"type":"keyword"},"content":{"type":"text"},"embedding":{"type":"dense_vector","dims":2}}}}`, 200) + defer func() { + call("DELETE", "/"+index, "", 200) + call("HEAD", "/"+index, "", 404) + }() + archiveRoot := t.TempDir() + if err := os.Chmod(archiveRoot, 0700); err != nil { + t.Fatal(err) + } + archive, err := librarysnapshot.Open(archiveRoot, 64*1024*1024) + if err != nil { + t.Fatal(err) + } + defer archive.Close() + snapshotID := sealTargetFixture(t, archive, "internal", []string{"doc-safe"}, []string{"chunk-safe"}, "wiki-safe") + // 生产"当前状态"也要自洽:库里有分块,索引里就必须有对应文档, + // 否则预备份的覆盖校验会(正确地)拒绝。 + call("PUT", "/"+index+"/_doc/chunk-safe?refresh=true", + `{"tenant_id":10000,"knowledge_base_id":"kb-safe","knowledge_id":"doc-safe","chunk_id":"chunk-safe","source_id":"chunk-safe","content":"chunk","embedding":[0.1,0.2]}`, 201) + // 库里已有夹具种子行(doc-safe/chunk-safe),只需保证索引侧自洽。 + + target, err := NewProductionLibraryTarget(ctx, ProductionLibraryConfig{ + Postgres: dsn, SearchURL: base, SearchIndex: index, Objects: fixtureObjectStore{}, + ObjectsReader: fixtureObjectStore{}, ArchiveRoot: archiveRoot, MaxDocuments: 100, HTTPClient: client, + }, ProductionScope{TenantID: 10000, KnowledgeBaseID: "kb-safe"}) + if err != nil { + t.Fatalf("target: %v", err) + } + defer target.Close() + mapping, err := target.Search().MappingSHA256(ctx) + if err != nil { + t.Fatal(err) + } + backups, err := NewProductionBackupStore(ProductionBackupConfig{ + Pool: target.Pool(), Search: target.Search(), Objects: fixtureObjectStore{}, ArchiveRoot: archiveRoot, + Scope: "internal", Identity: target.Identity(), MaxDocuments: 100, + Compatibility: librarysnapshot.Compatibility{WeKnoraVersion: "v0.7.1", SchemaVersion: 74, + RetrieverEngine: "elasticsearch_v8", EmbeddingModel: "text-embedding-v4", + EmbeddingDimension: 1024, IndexMappingSHA256: mapping}, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + request := libraryrelease.Request{Target: "internal", SnapshotID: snapshotID, + ParamsDigest: "digest-e2e", Now: now, + Grant: libraryrelease.Grant{Target: "internal", SnapshotID: snapshotID, ParamsDigest: "digest-e2e", + ExpiresAt: now.Add(10 * time.Minute), AuthorizedBy: "acceptance"}} + outcome := libraryrelease.Run(ctx, request, target, backups, nil) + if outcome.State != "succeeded" { + t.Fatalf("run outcome = %+v", outcome) + } + if outcome.Backup.SnapshotID == "" { + t.Fatalf("run must create a backup first: %+v", outcome) + } + t.Logf("e2e run succeeded snapshot=%s backup=%s bytes=%d", snapshotID, outcome.Backup.SnapshotID, outcome.Backup.Bytes) +} + type fixtureObjectStore struct{} +// Open 返回与夹具资源行 size=8 一致的字节,供预备份读取对象。 func (fixtureObjectStore) Open(context.Context, string, string) (io.ReadCloser, error) { - return nil, errors.New("fixture object store is read-only here") + return io.NopCloser(strings.NewReader("12345678")), nil } func (fixtureObjectStore) Put(context.Context, string, string, io.Reader, int64) error { diff --git a/scripts/test-scope.json b/scripts/test-scope.json index f2719bf..80e0103 100644 --- a/scripts/test-scope.json +++ b/scripts/test-scope.json @@ -168,6 +168,11 @@ "test": "TestLocalMirrorPeriodicSyncIsDurableAndDueOnly", "reason": "仅显式一次性隔离PG验证本地副本/持久同步或专门只读源夹具;默认CI不连接共享与生产库,另行实际执行隔离验收" }, + { + "package": "knowledge-governance/internal/releaseadapter/v071", + "test": "TestLibraryReleaseRunIsolated", + "reason": "完整编排(授权→预备份→四类写入→校验)跑真实一次性隔离 PG 与随机 ES 索引;默认 CI 无这些服务,本机 WeKnora 测试栈已实跑通过" + }, { "package": "knowledge-governance/internal/releaseadapter/v071", "test": "TestLibraryProductionTargetIsolated",