Files
knowledge-governance/internal/platform/postgres/p3_confirm_integration_test.go
T
zheromiku dcdc8d1ddd feat: 知识治理平台(含已验证发布/回滚契约)
全栈知识治理平台首次入库(P1-P10 + 发布链路模拟验证闭环):

- 治理核心:源目录/变更集/人工审核/发布批次/发布执行(fail-closed 闸口)
- 测试通道:WeKnora 测试 KB 上传/轮询/验收;poll 完成时写入
  parser_generation/index_generation,修复 manual/seed 路径发布闸口永久拦截
- 发布适配器 v071(本地 source=测试角色 / 云端 target=生产角色 模拟验证通过):
  * ES 导出/导入 mapping 自适应(.keyword 子字段、可选 tenant_id)
  * OSS locator 支持嵌套 scheme;chunks.seq_id 由目标序列分配
  * bundle 归档同身份重试原子替换;verifier 按 bundle chunk_type 计数
  * 回滚 job payload 支持 prior_execution_id(strict 解码)
- 三通道发布+回滚演练实测:publish 4/4 verified,检索命中,
  rollback 4/4 rolled_back,目标端 PG is_enabled=false、ES is_enabled=false
- 前端 A·Data-console 风格全站统一,120 用例通过
- 门禁:go vet / go test 全包通过,gofmt 干净
2026-09-30 10:54:06 +08:00

229 lines
9.9 KiB
Go

package postgres
import (
"context"
"os"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/governance"
)
func seedP3Fixture(t *testing.T, pool *pgxpool.Pool) string {
t.Helper()
ctx := context.Background()
_, seedErr := pool.Exec(ctx, `
INSERT INTO source_connections(id,source_type,name,enabled) VALUES('conn-p3','alist','P3 来源',true);
INSERT INTO content_objects(id,sha256,size_bytes,local_spool_ref,content_state) VALUES
('co-p3-a',repeat('a',64),10,'verified/a.pdf','verified'),
('co-p3-b',repeat('b',64),12,'verified/b.pdf','verified'),
('co-p3-c',repeat('c',64),20,'verified/c.pdf','verified');
INSERT INTO source_items(id,source_connection_id,stable_external_id,canonical_uri_hash,title,item_type,revision,visibility,visibility_origin)
VALUES('item-a','conn-p3','ext-a',repeat('d',64),'新增.pdf','document','r1','internal','manual'),
('item-b','conn-p3','ext-b',repeat('e',64),'更新.pdf','document','r2','internal','manual'),
('item-c','conn-p3','ext-c',repeat('f',64),'删除.pdf','document','r3','internal','manual'),
('item-d','conn-p3','ext-d',repeat('9',64),'未变.pdf','document','r1','internal','manual'),
('item-e','conn-p3','ext-e',repeat('8',64),'内部专属.pdf','document','r1','internal','manual');
INSERT INTO source_item_versions(id,source_item_id,revision,content_object_id,content_state) VALUES
('version-a','item-a','r1','co-p3-a','verified'),
('version-b','item-b','r2','co-p3-b','verified'),
('version-c','item-c','r3','co-p3-c','verified'),
('version-d','item-d','r1','co-p3-a','verified'),
('version-e','item-e','r1','co-p3-a','verified');
INSERT INTO source_catalog_snapshots(id,source_connection_id,snapshot_sha256,source_label,node_count,folder_count,file_count,observed_at)
VALUES('p3-old','conn-p3',repeat('1',64),'P3 来源',1,1,0,'2026-09-11T00:00:00Z'),
('p3-new','conn-p3',repeat('2',64),'P3 来源',6,1,5,'2026-09-12T00:00:00Z');
INSERT INTO source_catalog_nodes(snapshot_id,node_id,parent_node_id,node_type,name,relative_path,source_locator_hash,source_item_id,size_bytes,content_sha256)
VALUES
('p3-new','root',NULL,'folder','P3 来源','',repeat('3',64),NULL,NULL,NULL),
('p3-new','n-a','root','file','新增.pdf','新增.pdf',repeat('a',64),'item-a',10,repeat('a',64)),
('p3-new','n-b','root','file','更新.pdf','更新.pdf',repeat('b',64),'item-b',12,repeat('b',64)),
('p3-new','n-d','root','file','未变.pdf','未变.pdf',repeat('c',64),'item-d',10,repeat('a',64)),
('p3-new','n-e','root','file','内部专属.pdf','内部专属.pdf',repeat('e',64),'item-e',10,repeat('a',64));
INSERT INTO test_publications(id,source_item_version_id,test_kb_id,target_scope,ingest_state,processing_state,retrieval_state,knowledge_id,created_at,updated_at)
VALUES('tp-p3-c','version-c','kb-internal','internal','uploaded','completed','enabled','kn-c',NOW(),NOW());
INSERT INTO source_catalog_change_sets(id,source_connection_id,from_snapshot_id,to_snapshot_id,created_at)
VALUES('cs-p3','conn-p3','p3-old','p3-new',NOW());
INSERT INTO source_catalog_change_items(change_set_id,source_locator_hash,action,title,old_path,new_path,new_scope,new_source_item_id,old_source_item_id,created_at)
VALUES
('cs-p3',repeat('a',64),'add','新增.pdf','','新增.pdf','internal','item-a',NULL,NOW()),
('cs-p3',repeat('b',64),'update','更新.pdf','更新.pdf','更新.pdf','internal','item-b',NULL,NOW()),
('cs-p3',repeat('c',64),'delete','删除.pdf','删除.pdf','','',NULL,'item-c',NOW()),
('cs-p3',repeat('d',64),'no_change','未变.pdf','未变.pdf','未变.pdf','internal','item-d',NULL,NOW()),
('cs-p3',repeat('e',64),'add','内部专属.pdf','','内部专属.pdf','blocked','item-e',NULL,NOW());`)
if seedErr != nil {
t.Fatal(seedErr)
}
return "cs-p3"
}
func p3Seqs(t *testing.T, pool *pgxpool.Pool, changeSetID string) []int64 {
t.Helper()
rows, err := pool.Query(context.Background(), `SELECT item_seq FROM source_catalog_change_items WHERE change_set_id=$1 ORDER BY item_seq`, changeSetID)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var seqs []int64
for rows.Next() {
var seq int64
if err := rows.Scan(&seq); err != nil {
t.Fatal(err)
}
seqs = append(seqs, seq)
}
if len(seqs) != 5 {
t.Fatalf("expected 5 change items, got %d", len(seqs))
}
return seqs
}
func TestP3ConfirmExecutesSelectedChangeItems(t *testing.T) {
databaseURL := isolatedIntegrationDatabaseURL(t, os.Getenv("TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
if err := ApplyMigrations(ctx, pool); err != nil {
t.Fatal(err)
}
id := seedP3Fixture(t, pool)
store := NewStore(pool)
now := time.Date(2026, 9, 12, 3, 0, 0, 0, time.UTC)
// Jobs are zero before confirmation.
var jobsBefore int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM jobs`).Scan(&jobsBefore); err != nil || jobsBefore != 0 {
t.Fatalf("jobs before=%d err=%v", jobsBefore, err)
}
// Partial selection: add, update, delete, no_change, blocked-delete.
result, err := store.ConfirmCatalogChangeSet(ctx, governance.ConfirmCatalogChangeSetRequest{
ChangeSetID: id, ItemSeqs: p3Seqs(t, pool, "cs-p3"),
OperatorRef: "engineer-1", Reason: "确认P3差异执行", Now: now,
InternalTargetKBID: "kb-internal", ExternalTargetKBID: "kb-external",
})
if err != nil {
t.Fatal(err)
}
if result.ConfirmedItems != 4 || result.UploadsCreated != 2 || result.RemovalsRequested != 1 || result.NoChangeIgnored != 1 || result.SkippedNoArtifact != 0 || result.SkippedNoTarget != 0 {
t.Fatalf("result=%#v", result)
}
// blocked add became a removal request too (desired state blocked → disable path)
var uploads, removals int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM jobs WHERE type='test.upload'`).Scan(&uploads); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM jobs WHERE type='test.remove'`).Scan(&removals); err != nil {
t.Fatal(err)
}
if uploads != 2 || removals != 1 {
t.Fatalf("uploads=%d removals=%d", uploads, removals)
}
// Upload payloads carry the ChangeSet and item identity (P5 will make it a constraint).
var payloadOK bool
if err := pool.QueryRow(ctx, `SELECT COUNT(*)=2 FROM jobs WHERE type='test.upload' AND payload->>'change_set_id'='cs-p3'`).Scan(&payloadOK); err != nil || !payloadOK {
t.Fatalf("upload provenance missing ok=%v err=%v", payloadOK, err)
}
// Publications record their change provenance.
var provenance int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM test_publications WHERE change_set_id='cs-p3'`).Scan(&provenance); err != nil || provenance != 2 {
t.Fatalf("publication provenance=%d err=%v", provenance, err)
}
// Confirmations bind to the change set.
var confirmations int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM processing_confirmations WHERE change_set_id='cs-p3'`).Scan(&confirmations); err != nil || confirmations != 2 {
t.Fatalf("confirmations=%d err=%v", confirmations, err)
}
// Lifecycle moved to confirmed.
var state string
if err := pool.QueryRow(ctx, `SELECT state FROM source_catalog_change_sets WHERE id='cs-p3'`).Scan(&state); err != nil || state != "confirmed" {
t.Fatalf("state=%q err=%v", state, err)
}
// Re-confirming the same items replays stored counts; no new jobs or rows.
replaySeqs := p3Seqs(t, pool, "cs-p3")[:1]
replay, err := store.ConfirmCatalogChangeSet(ctx, governance.ConfirmCatalogChangeSetRequest{
ChangeSetID: id, ItemSeqs: replaySeqs,
OperatorRef: "engineer-2", Reason: "重复确认", Now: now.Add(time.Minute),
InternalTargetKBID: "kb-internal",
})
if err != nil {
t.Fatal(err)
}
if replay.UploadsCreated != 2 || replay.RemovalsRequested != 1 {
t.Fatalf("replay=%#v", replay)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM jobs WHERE type='test.upload'`).Scan(&uploads); err != nil || uploads != 2 {
t.Fatalf("replay created uploads=%d err=%v", uploads, err)
}
// Unknown locator belongs to nobody: rejected before any enqueue.
if _, err := store.ConfirmCatalogChangeSet(ctx, governance.ConfirmCatalogChangeSetRequest{
ChangeSetID: id, ItemSeqs: []int64{999999},
OperatorRef: "engineer-2", Reason: "越界确认", Now: now, InternalTargetKBID: "kb-internal",
}); err == nil {
t.Fatal("unknown item selection accepted")
}
}
func TestP3ConcurrentConfirmationsEnqueueOnce(t *testing.T) {
databaseURL := isolatedIntegrationDatabaseURL(t, os.Getenv("TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
if err := ApplyMigrations(ctx, pool); err != nil {
t.Fatal(err)
}
id := seedP3Fixture(t, pool)
store := NewStore(pool)
now := time.Date(2026, 9, 12, 4, 0, 0, 0, time.UTC)
const workers = 8
var wg sync.WaitGroup
errs := make([]error, workers)
for index := 0; index < workers; index++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
_, errs[index] = store.ConfirmCatalogChangeSet(ctx, governance.ConfirmCatalogChangeSetRequest{
ChangeSetID: id,
ItemSeqs: p3Seqs(t, pool, "cs-p3"),
OperatorRef: "engineer-1", Reason: "并发确认", Now: now.Add(time.Duration(index) * time.Second),
InternalTargetKBID: "kb-internal", ExternalTargetKBID: "kb-external",
})
}(index)
}
wg.Wait()
for index, err := range errs {
if err != nil {
t.Fatalf("worker %d err=%v", index, err)
}
}
// Exactly one enqueue per publication regardless of concurrent confirmations
// (FOR UPDATE on the set serializes; later workers replay from stored counts).
var uploads, removals int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM jobs WHERE type='test.upload'`).Scan(&uploads); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM jobs WHERE type='test.remove'`).Scan(&removals); err != nil {
t.Fatal(err)
}
if uploads != 2 || removals != 1 {
t.Fatalf("concurrent enqueue: uploads=%d removals=%d", uploads, removals)
}
}