Files
knowledge-governance/internal/platform/postgres/p2_identity_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

101 lines
6.0 KiB
Go

package postgres
import (
"context"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestP2StableIdentityAndProvenMove freezes docs plan P2:
// identity_confidence is never downgraded from stable_id; the canonical
// compare_catalog_change_set function classifies a 1:1 content-hash relocation
// as proven move evidence, and never merges ambiguous copies.
func TestP2StableIdentityAndProvenMove(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)
}
hash := strings.Repeat("c", 64)
if _, err := pool.Exec(ctx, `
INSERT INTO source_connections(id,source_type,name,enabled) VALUES('conn-id','pp','ID PP',true);
INSERT INTO source_catalog_snapshots(id,source_connection_id,snapshot_sha256,source_label,node_count,folder_count,file_count,observed_at)
VALUES('snap-old','conn-id',repeat('1',64),'ID PP',2,1,1,'2026-09-11T00:00:00Z'),
('snap-new','conn-id',repeat('2',64),'ID PP',2,1,1,'2026-09-12T00:00:00Z'),
('snap-copy','conn-id',repeat('3',64),'ID PP',3,1,2,'2026-09-13T00:00:00Z');
INSERT INTO source_catalog_nodes(snapshot_id,node_id,parent_node_id,node_type,name,relative_path,source_locator_hash,size_bytes,direct_child_count,subtree_file_count,subtree_bytes,content_sha256)
VALUES
('snap-old','root',NULL,'folder','ID PP','',repeat('4',64),NULL,1,1,10,NULL),
('snap-old','f-old','root','file','手册.pdf','旧目录/手册.pdf',repeat('5',64),10,0,1,10,repeat('c',64)),
('snap-new','root',NULL,'folder','ID PP','',repeat('4',64),NULL,1,1,10,NULL),
('snap-new','f-new','root','file','手册改名.pdf','新目录/手册改名.pdf',repeat('6',64),10,0,1,10,repeat('c',64)),
('snap-copy','root',NULL,'folder','ID PP','',repeat('4',64),NULL,2,2,20,NULL),
('snap-copy','f-new','root','file','手册改名.pdf','新目录/手册改名.pdf',repeat('6',64),10,0,1,10,repeat('c',64)),
('snap-copy','f-copy','root','file','副本.pdf','新目录/副本.pdf',repeat('7',64),10,0,1,10,repeat('c',64));
INSERT INTO source_catalog_change_sets(id,source_connection_id,from_snapshot_id,to_snapshot_id,created_at)
VALUES('cs-move','conn-id','snap-old','snap-new',NOW()),
('cs-copy','conn-id','snap-new','snap-copy',NOW());
SELECT compare_catalog_change_set('cs-move');
SELECT compare_catalog_change_set('cs-copy');`); err != nil {
t.Fatal(err)
}
// Proven move: the disappeared old locator is a delete carrying move evidence,
// and the reappearing new locator is an add carrying the same content hash.
var deleteReason, deleteOldHash string
if err := pool.QueryRow(ctx, `SELECT reason_code,COALESCE(old_content_sha256::text,'') FROM source_catalog_change_items WHERE change_set_id='cs-move' AND action='delete'`).Scan(&deleteReason, &deleteOldHash); err != nil || deleteReason != "content_hash_move" || deleteOldHash != hash {
t.Fatalf("cs-move delete=%q hash=%q err=%v", deleteReason, deleteOldHash, err)
}
var addReason, addNewHash string
if err := pool.QueryRow(ctx, `SELECT reason_code,COALESCE(new_content_sha256::text,'') FROM source_catalog_change_items WHERE change_set_id='cs-move' AND action='add'`).Scan(&addReason, &addNewHash); err != nil || addReason != "content_hash_move" || addNewHash != hash {
t.Fatalf("cs-move add=%q hash=%q err=%v", addReason, addNewHash, err)
}
// Counts were recalculated by the function.
var addCount, deleteCount int
if err := pool.QueryRow(ctx, `SELECT add_count,delete_count FROM source_catalog_change_sets WHERE id='cs-move'`).Scan(&addCount, &deleteCount); err != nil || addCount != 1 || deleteCount != 1 {
t.Fatalf("cs-move counts add=%d delete=%d err=%v", addCount, deleteCount, err)
}
// Ambiguous copy: hash appears twice on the new side → NOT a proven move;
// the copy is a plain add and the original stays untouched.
var copyReason string
if err := pool.QueryRow(ctx, `SELECT reason_code FROM source_catalog_change_items WHERE change_set_id='cs-copy' AND action='add' AND new_path LIKE '%副本%'`).Scan(&copyReason); err != nil || copyReason != "locator" {
t.Fatalf("ambiguous copy reason=%q err=%v", copyReason, err)
}
var copyDeletes int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM source_catalog_change_items WHERE change_set_id='cs-copy' AND action='delete'`).Scan(&copyDeletes); err != nil || copyDeletes != 0 {
t.Fatalf("ambiguous copy must not delete the original: %d err=%v", copyDeletes, err)
}
// identity_confidence upsert: stable_id never downgrades; path can rise.
if _, err := pool.Exec(ctx, `
INSERT INTO source_connections(id,source_type,name,enabled) VALUES('conn-id2','pp','ID PP2',true) ON CONFLICT DO NOTHING;
INSERT INTO source_items(id,source_connection_id,stable_external_id,canonical_uri_hash,normalized_path,title,item_type,revision,visibility,visibility_origin,identity_confidence)
VALUES('item-stable','conn-id','ext-stable',repeat('f',64),'稳.pdf','稳.pdf','document','v1','internal','pp_public_tag','stable_id') ON CONFLICT DO NOTHING;`); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO source_items(id,source_connection_id,stable_external_id,canonical_uri_hash,normalized_path,title,item_type,revision,visibility,visibility_origin,source_change,identity_confidence)
VALUES('item-stable','conn-id','ext-stable',repeat('f',64),'','稳.pdf','document','v1','internal','manual','added','content_hash')
ON CONFLICT (source_connection_id, stable_external_id) DO UPDATE
SET updated_at = source_items.updated_at,
identity_confidence = CASE WHEN source_items.identity_confidence='stable_id' THEN source_items.identity_confidence ELSE EXCLUDED.identity_confidence END;`); err != nil {
t.Fatal(err)
}
var confidence string
if err := pool.QueryRow(ctx, `SELECT identity_confidence FROM source_items WHERE id='item-stable'`).Scan(&confidence); err != nil || confidence != "stable_id" {
t.Fatalf("stable_id downgraded to %q err=%v", confidence, err)
}
}