dcdc8d1ddd
全栈知识治理平台首次入库(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 干净
229 lines
10 KiB
Go
229 lines
10 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
|
|
"knowledge-governance/internal/governance"
|
|
)
|
|
|
|
func TestCreateReleaseBatchAtomicallyLocksOnlyApprovedCurrentItems(t *testing.T) {
|
|
now := time.Date(2026, 9, 6, 9, 0, 0, 0, time.UTC)
|
|
db := &recordingDB{row: releaseBatchRow{id: "rb-safe", name: "Synthetic batch", state: "draft", policy: "release-gate-v1", createdBy: "engineer-1", itemCount: 3, createdAt: now}}
|
|
batch, err := NewStore(db).CreateReleaseBatch(context.Background(), governance.CreateReleaseBatchRequest{
|
|
Name: "Synthetic batch", CreatedBy: "engineer-1", PublicationIDs: []string{"tp-a", "tp-b"}, DeletePublicationIDs: []string{"tp-c"}, Now: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateReleaseBatch() error = %v", err)
|
|
}
|
|
if batch.ID != "rb-safe" || batch.State != governance.ReleaseBatchDraft || batch.ItemCount != 3 {
|
|
t.Fatalf("batch = %#v", batch)
|
|
}
|
|
sql := strings.ToLower(db.queryRowSQL)
|
|
for _, required := range []string{
|
|
"jsonb_array_elements_text", "publication_reviews", "decision = 'approved'",
|
|
"acceptance_run_id", "publication_updated_at", "knowledge_exists = 'passed'",
|
|
"chunks_present = 'passed'", "retrieval_hit in ('passed', 'manual_required')",
|
|
"insert into release_batches", "insert into release_batch_items", "count(distinct publication_id)", "returning",
|
|
"release_delete_ready", "'delete' as action", "eligible.action",
|
|
} {
|
|
if !strings.Contains(sql, required) {
|
|
t.Errorf("create batch SQL missing %q", required)
|
|
}
|
|
}
|
|
if strings.Contains(sql, "insert into jobs") || strings.Contains(sql, "release.probe") {
|
|
t.Fatal("P5 batch creation must not enqueue release jobs")
|
|
}
|
|
}
|
|
|
|
func TestCreateReleaseBatchRejectsInvalidOrDuplicateInput(t *testing.T) {
|
|
now := time.Now()
|
|
requests := []governance.CreateReleaseBatchRequest{
|
|
{Name: "", CreatedBy: "engineer-1", PublicationIDs: []string{"tp-a"}, Now: now},
|
|
{Name: "Batch", CreatedBy: "", PublicationIDs: []string{"tp-a"}, Now: now},
|
|
{Name: "Batch", CreatedBy: "engineer-1", PublicationIDs: nil, Now: now},
|
|
{Name: "Batch", CreatedBy: "engineer-1", PublicationIDs: []string{"tp-a", "tp-a"}, Now: now},
|
|
{Name: "Batch", CreatedBy: "engineer-1", PublicationIDs: []string{"tp-a"}, DeletePublicationIDs: []string{"tp-a"}, Now: now},
|
|
}
|
|
for _, request := range requests {
|
|
if _, err := NewStore(&recordingDB{}).CreateReleaseBatch(context.Background(), request); !errors.Is(err, ErrInvalidReleaseBatch) {
|
|
t.Fatalf("request=%#v error=%v, want ErrInvalidReleaseBatch", request, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateReleaseBatchReturnsNotEligibleWhenAnyItemFailsGate(t *testing.T) {
|
|
db := &recordingDB{row: sourceScanErrorRow{err: pgx.ErrNoRows}}
|
|
_, err := NewStore(db).CreateReleaseBatch(context.Background(), governance.CreateReleaseBatchRequest{
|
|
Name: "Batch", CreatedBy: "engineer-1", PublicationIDs: []string{"tp-a"}, Now: time.Now(),
|
|
})
|
|
if !errors.Is(err, ErrReleaseBatchNotEligible) {
|
|
t.Fatalf("error=%v, want ErrReleaseBatchNotEligible", err)
|
|
}
|
|
}
|
|
|
|
func TestSealReleaseBatchRevalidatesEveryLockedItemWithoutEnqueue(t *testing.T) {
|
|
now := time.Date(2026, 9, 6, 9, 10, 0, 0, time.UTC)
|
|
db := &recordingDB{row: releaseBatchRow{id: "rb-safe", name: "Synthetic batch", state: "sealed", policy: "release-gate-v1", createdBy: "engineer-1", sealedBy: "engineer-2", itemCount: 2, createdAt: now.Add(-time.Minute), sealedAt: &now}}
|
|
batch, err := NewStore(db).SealReleaseBatch(context.Background(), governance.SealReleaseBatchRequest{BatchID: "rb-safe", SealedBy: "engineer-2", Now: now})
|
|
if err != nil {
|
|
t.Fatalf("SealReleaseBatch() error = %v", err)
|
|
}
|
|
if batch.State != governance.ReleaseBatchSealed || batch.SealedBy != "engineer-2" || batch.SealedAt == nil {
|
|
t.Fatalf("batch = %#v", batch)
|
|
}
|
|
sql := strings.ToLower(db.queryRowSQL)
|
|
for _, required := range []string{
|
|
"join release_batch_items", "join publication_reviews",
|
|
"ready.review_id = item.review_id", "ready.acceptance_run_id = item.acceptance_run_id", "ready.publication_updated_at = item.publication_updated_at",
|
|
"publication.ingest_state = 'uploaded'", "publication.processing_state = 'completed'",
|
|
"publication.retrieval_state = 'unpublished'", "state = 'sealed'", "state = 'draft'",
|
|
"release_delete_ready", "item.action = 'upsert'", "item.action = 'delete'",
|
|
} {
|
|
if !strings.Contains(sql, required) {
|
|
t.Errorf("seal SQL missing %q", required)
|
|
}
|
|
}
|
|
if strings.Contains(sql, "insert into jobs") {
|
|
t.Fatal("P5 seal must not enqueue a production release")
|
|
}
|
|
}
|
|
|
|
func TestSealReleaseBatchReturnsGateConflict(t *testing.T) {
|
|
db := &recordingDB{row: sourceScanErrorRow{err: pgx.ErrNoRows}}
|
|
_, err := NewStore(db).SealReleaseBatch(context.Background(), governance.SealReleaseBatchRequest{BatchID: "rb-safe", SealedBy: "engineer-1", Now: time.Now()})
|
|
if !errors.Is(err, ErrReleaseBatchGateConflict) {
|
|
t.Fatalf("error=%v, want ErrReleaseBatchGateConflict", err)
|
|
}
|
|
}
|
|
|
|
func TestListReleaseBatchesAndDetailReturnSafeProjection(t *testing.T) {
|
|
now := time.Date(2026, 9, 6, 9, 20, 0, 0, time.UTC)
|
|
db := &recordingDB{
|
|
row: releaseCountRow{count: 1},
|
|
rows: &releaseBatchRows{values: []releaseBatchRow{{id: "rb-safe", name: "Batch", state: "sealed", policy: "release-gate-v1", createdBy: "engineer-1", sealedBy: "engineer-2", itemCount: 1, createdAt: now, sealedAt: &now}}},
|
|
}
|
|
page, err := NewStore(db).ListReleaseBatches(context.Background(), governance.ReleaseBatchFilter{State: "sealed", Limit: 20})
|
|
if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].State != governance.ReleaseBatchSealed {
|
|
t.Fatalf("page=%#v error=%v", page, err)
|
|
}
|
|
joined := strings.ToLower(db.queryRowSQL + "\n" + db.querySQL)
|
|
if !strings.Contains(joined, "from release_batches") || !strings.Contains(joined, "state = $1") {
|
|
t.Fatalf("batch list SQL=%s", joined)
|
|
}
|
|
|
|
detailDB := &releaseBatchReadDB{
|
|
recordingDB: &recordingDB{},
|
|
row: releaseBatchRow{id: "rb-safe", name: "Batch", state: "sealed", policy: "release-gate-v1", createdBy: "engineer-1", sealedBy: "engineer-2", itemCount: 1, createdAt: now, sealedAt: &now},
|
|
rows: &releaseBatchItemRows{values: []releaseBatchItemRow{{publicationID: "tp-safe", title: "Synthetic", visibility: "internal", action: "delete"}}},
|
|
}
|
|
detail, err := NewStore(detailDB).GetReleaseBatch(context.Background(), "rb-safe")
|
|
if err != nil || len(detail.Items) != 1 || detail.Items[0].Title != "Synthetic" || detail.Items[0].Action != "delete" {
|
|
t.Fatalf("detail=%#v error=%v", detail, err)
|
|
}
|
|
joined = strings.ToLower(detailDB.queryRowSQL + "\n" + detailDB.querySQL)
|
|
for _, forbidden := range []string{"knowledge_id", "test_kb_id", "acceptance_run_id", "publication_updated_at", "stable_external_id", "local_spool_ref"} {
|
|
if strings.Contains(joined, forbidden) {
|
|
t.Errorf("batch read SQL includes forbidden projection %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListReleaseBatchesRejectsInvalidFilter(t *testing.T) {
|
|
_, err := NewStore(&recordingDB{}).ListReleaseBatches(context.Background(), governance.ReleaseBatchFilter{State: "released", Limit: 20})
|
|
if !errors.Is(err, ErrInvalidReleaseBatchFilter) {
|
|
t.Fatalf("error=%v, want ErrInvalidReleaseBatchFilter", err)
|
|
}
|
|
}
|
|
|
|
type releaseBatchRow struct {
|
|
id, name, state, policy, createdBy, sealedBy string
|
|
itemCount int
|
|
createdAt time.Time
|
|
sealedAt *time.Time
|
|
}
|
|
|
|
func (r releaseBatchRow) Scan(dest ...any) error {
|
|
*dest[0].(*string), *dest[1].(*string), *dest[2].(*string), *dest[3].(*string) = r.id, r.name, r.state, r.policy
|
|
*dest[4].(*string), *dest[5].(*string), *dest[6].(*int) = r.createdBy, r.sealedBy, r.itemCount
|
|
*dest[7].(*time.Time), *dest[8].(**time.Time) = r.createdAt, r.sealedAt
|
|
return nil
|
|
}
|
|
|
|
type releaseCountRow struct{ count int }
|
|
|
|
func (r releaseCountRow) Scan(dest ...any) error {
|
|
*dest[0].(*int) = r.count
|
|
return nil
|
|
}
|
|
|
|
type releaseBatchRows struct {
|
|
values []releaseBatchRow
|
|
index int
|
|
}
|
|
|
|
func (r *releaseBatchRows) Close() {}
|
|
func (r *releaseBatchRows) Err() error { return nil }
|
|
func (r *releaseBatchRows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} }
|
|
func (r *releaseBatchRows) FieldDescriptions() []pgconn.FieldDescription { return nil }
|
|
func (r *releaseBatchRows) RawValues() [][]byte { return nil }
|
|
func (r *releaseBatchRows) Conn() *pgx.Conn { return nil }
|
|
func (r *releaseBatchRows) Next() bool {
|
|
if r.index >= len(r.values) {
|
|
return false
|
|
}
|
|
r.index++
|
|
return true
|
|
}
|
|
func (r *releaseBatchRows) Scan(dest ...any) error { return r.values[r.index-1].Scan(dest...) }
|
|
func (r *releaseBatchRows) Values() ([]any, error) { return nil, nil }
|
|
|
|
type releaseBatchItemRow struct{ publicationID, title, visibility, action string }
|
|
|
|
type releaseBatchItemRows struct {
|
|
values []releaseBatchItemRow
|
|
index int
|
|
}
|
|
|
|
func (r *releaseBatchItemRows) Close() {}
|
|
func (r *releaseBatchItemRows) Err() error { return nil }
|
|
func (r *releaseBatchItemRows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} }
|
|
func (r *releaseBatchItemRows) FieldDescriptions() []pgconn.FieldDescription { return nil }
|
|
func (r *releaseBatchItemRows) RawValues() [][]byte { return nil }
|
|
func (r *releaseBatchItemRows) Conn() *pgx.Conn { return nil }
|
|
func (r *releaseBatchItemRows) Next() bool {
|
|
if r.index >= len(r.values) {
|
|
return false
|
|
}
|
|
r.index++
|
|
return true
|
|
}
|
|
func (r *releaseBatchItemRows) Scan(dest ...any) error {
|
|
v := r.values[r.index-1]
|
|
*dest[0].(*string), *dest[1].(*string), *dest[2].(*string), *dest[3].(*string) = v.publicationID, v.title, v.visibility, v.action
|
|
return nil
|
|
}
|
|
func (r *releaseBatchItemRows) Values() ([]any, error) { return nil, nil }
|
|
|
|
type releaseBatchReadDB struct {
|
|
*recordingDB
|
|
row pgx.Row
|
|
rows pgx.Rows
|
|
}
|
|
|
|
func (d *releaseBatchReadDB) QueryRow(_ context.Context, sql string, args ...any) pgx.Row {
|
|
d.queryRowSQL, d.queryRowArgs = sql, args
|
|
return d.row
|
|
}
|
|
|
|
func (d *releaseBatchReadDB) Query(_ context.Context, sql string, args ...any) (pgx.Rows, error) {
|
|
d.querySQL, d.queryArgs = sql, args
|
|
return d.rows, nil
|
|
}
|