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