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 }