acf053faf3
背景:发布管理把"逐条核验"的仪式强加给每一份文档。实测一份文档要 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 条通过。
233 lines
11 KiB
Go
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
|
|
}
|