Files
knowledge-governance/internal/platform/postgres/release_batches_test.go
T
姚章浩 acf053faf3 feat(release): 建批时冻结 + 验收通过自动批准 —— 把"每份文档 8 步"砍掉两步并去掉两次卡死
背景:发布管理把"逐条核验"的仪式强加给每一份文档。实测一份文档要 9~11 次交互,
且有两处顺序陷阱(冻结后批准 → 建批 422;生成即固化 → 批准被永久钉住)。

P0-1 建批时冻结:
- CreateReleaseBatch 在未指定版本时,自己把当前实时计划冻结成一份**已确认**版本并绑定
  (同一事务;同摘要复用最近版本,planned 或 confirmed 都可复用,因此同一份计划不堆版本)。
- 语义不变:批一旦建立,清单/摘要/验收轮次/目标库绑定全部不可变、回滚按它走;变的只是
  "谁在什么时候按固化键"——从操作员的手动两步变成建批这一下。
- 效果:去掉「生成新版本」「确认冻结」两步;消灭"批准晚于冻结 → 422"这类顺序陷阱;
  前端不再传版本 id,也不再有"所选知识不在冻结清单里"的阻断。
- 计划→清单项的映射抽成 ChangeSetItemsFromPlan 供两处共用,否则两处算出的摘要不同,
  "同一计划复用同一版本"永远不命中。

P0-2 验收通过自动批准:
- 验收三项结论达发布门槛(knowledge_exists/chunks_present=passed,retrieval_hit 与门槛同
  口径)时,由 worker 写入 reviewer_ref='system:auto' 的批准记录,替代"每份文档点一次批准"。
- 策略 TEST_AUTO_REVIEW_POLICY = off | internal(默认,仅对内)| all;值不认识拒绝启动。
  只影响审核这一步:生产授权、双闸与发布前快照门禁不受影响。界面上只读展示当前策略。
- 安全边界(写死在审核 SQL 里):系统只允许写"第一条"审核——人工一旦表过态,机器不得用一条
  更晚的 approved 把人工结论翻回去;人工随时可再审。集成测试去掉该约束即 FAIL(已实测)。
- 自动批准自身的任何失败都不影响验收结论,只上报。

顺带(P1-1 前半,已被 P0-1 取代的部分已删除):
- 写操作失败把服务端 {"error": ...} 的原因带出来,并对已知原因给出下一步(不再只给 HTTP 状态码)。
- 当前的"不在已冻结清单里"标注与就地修复按钮随 P0-1 一起移除(该失败模式已不存在)。

测试:bind_time_freeze_integration_test.go(绑定已确认版本/同计划复用/冻结后不可变/
动作不符仍 NotEligible)、auto_review_integration_test.go(系统只写第一条、人工可再审)、
validate_test.go(对内自动/对外不自动/策略关闭/验收未过不批/自身失败不影响验收)、
auto_review_test.go、config_test 及前端能力解析测试。
实测耗时:本机 Go 全量 26 包通过、前端 178 条通过。
2026-10-10 15:05:20 +08:00

233 lines
11 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}}
// 显式传入已确认版本:这个用例检查的是建批 SQL 的门禁条件,桩 db 不提供实时计划查询;
// "不带版本 id → 服务端自动冻结"的路径由 bind_time_freeze_integration_test.go 用真库覆盖。
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"}, ReleaseChangeSetID: "rcs-safe", 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)
}
}
}
// 桩 db 查不到"已确认版本",建批必须拒绝——这条守卫是"批次只能绑定已确认清单"的入口。
// 「门禁不通过 → ErrReleaseBatchNotEligible」的映射由 bind_time_freeze_integration_test.go 用真库钉住。
func TestCreateReleaseBatchRejectsUnconfirmedChangeSet(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"}, ReleaseChangeSetID: "rcs-safe", Now: time.Now(),
})
if !errors.Is(err, ErrReleaseChangeSetUnconfirmed) {
t.Fatalf("error=%v, want ErrReleaseChangeSetUnconfirmed", 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
}