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

170 lines
7.7 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 TestListReviewCandidatesReturnsSafeCurrentGateProjection(t *testing.T) {
now := time.Date(2026, 9, 6, 8, 0, 0, 0, time.UTC)
db := &recordingDB{
row: reviewCountRow{count: 1},
rows: &reviewCandidateRows{values: []reviewCandidateRow{{
publicationID: "tp-safe", title: "Synthetic review item", sourceType: "manual",
currentVisibility: "internal", acceptanceRunID: "job-safe", knowledgeState: "passed",
chunksState: "passed", retrievalState: "manual_required", gateStatus: "ready", gateCode: "ready",
reviewState: "pending", updatedAt: now,
}}},
}
page, err := NewStore(db).ListReviewCandidates(context.Background(), governance.ReviewCandidateFilter{ReviewState: "pending", Limit: 20, Offset: 0})
if err != nil {
t.Fatalf("ListReviewCandidates() error = %v", err)
}
if page.Total != 1 || len(page.Items) != 1 {
t.Fatalf("page = %#v", page)
}
item := page.Items[0]
if item.PublicationID != "tp-safe" || item.GateStatus != governance.ReviewGateReady || item.ReviewState != governance.ReviewPending || item.RetrievalHit != governance.AcceptanceManualRequired {
t.Fatalf("item = %#v", item)
}
joined := strings.ToLower(db.queryRowSQL + "\n" + db.querySQL)
for _, required := range []string{
"from acceptance_results", "distinct on (publication_id)", "knowledge_exists", "chunks_present",
"retrieval_hit", "manual_required", "from publication_reviews", "publication_updated_at", "acceptance_run_id",
} {
if !strings.Contains(joined, required) {
t.Errorf("review queue SQL missing %q", required)
}
}
for _, forbidden := range []string{"knowledge_id", "test_kb_id", "local_spool_ref", "stable_external_id", "canonical_uri_hash", "raw_record_summary", "secret_ref", "base_url"} {
if strings.Contains(joined, forbidden) {
t.Errorf("review queue SQL includes forbidden projection %q", forbidden)
}
}
}
func TestListReviewCandidatesRejectsInvalidFilter(t *testing.T) {
_, err := NewStore(&recordingDB{}).ListReviewCandidates(context.Background(), governance.ReviewCandidateFilter{ReviewState: "invalid", Limit: 20})
if !errors.Is(err, ErrInvalidReviewFilter) {
t.Fatalf("error = %v, want ErrInvalidReviewFilter", err)
}
}
func TestReviewPublicationAppendsCurrentAcceptanceBoundDecision(t *testing.T) {
now := time.Date(2026, 9, 6, 8, 10, 0, 0, time.UTC)
db := &recordingDB{row: publicationReviewRow{
id: "rv-safe", publicationID: "tp-safe", decision: "approved", visibility: "internal",
reviewerRef: "engineer-1", decisionCode: "approved_for_release", acceptanceRunID: "job-safe",
publicationUpdatedAt: now.Add(-time.Minute), createdAt: now,
}}
review, err := NewStore(db).ReviewPublication(context.Background(), governance.ReviewPublicationRequest{
PublicationID: "tp-safe", Decision: governance.ReviewApproved, DecidedVisibility: governance.VisibilityInternal,
ReviewerRef: "engineer-1", DecisionCode: "approved_for_release", Now: now,
})
if err != nil {
t.Fatalf("ReviewPublication() error = %v", err)
}
if review.ID != "rv-safe" || review.Decision != governance.ReviewApproved || review.AcceptanceRunID != "job-safe" {
t.Fatalf("review = %#v", review)
}
sql := strings.ToLower(db.queryRowSQL)
for _, required := range []string{
"insert into publication_reviews", "acceptance_run_id", "publication_updated_at",
"gate_status = 'ready'", "on conflict", "returning",
} {
if !strings.Contains(sql, required) {
t.Errorf("review SQL missing %q", required)
}
}
}
func TestReviewPublicationRejectsInvalidDecisionContract(t *testing.T) {
requests := []governance.ReviewPublicationRequest{
{PublicationID: "tp-safe", Decision: governance.ReviewApproved, DecidedVisibility: governance.VisibilityInternal, ReviewerRef: "engineer-1", DecisionCode: "needs_correction", Now: time.Now()},
{PublicationID: "tp-safe", Decision: governance.ReviewRejected, DecidedVisibility: governance.VisibilityInternal, ReviewerRef: "engineer-1", DecisionCode: "approved_for_release", Now: time.Now()},
{PublicationID: "tp-safe", Decision: governance.ReviewApproved, DecidedVisibility: governance.VisibilityInternal, ReviewerRef: "bad\nactor", DecisionCode: "approved_for_release", Now: time.Now()},
}
for _, request := range requests {
if _, err := NewStore(&recordingDB{}).ReviewPublication(context.Background(), request); !errors.Is(err, ErrInvalidPublicationReview) {
t.Fatalf("request=%#v error=%v, want ErrInvalidPublicationReview", request, err)
}
}
}
func TestReviewPublicationReturnsNotEligibleWhenGateRejects(t *testing.T) {
db := &recordingDB{row: sourceScanErrorRow{err: pgx.ErrNoRows}}
_, err := NewStore(db).ReviewPublication(context.Background(), governance.ReviewPublicationRequest{
PublicationID: "tp-safe", Decision: governance.ReviewApproved, DecidedVisibility: governance.VisibilityInternal,
ReviewerRef: "engineer-1", DecisionCode: "approved_for_release", Now: time.Now(),
})
if !errors.Is(err, ErrPublicationReviewNotEligible) {
t.Fatalf("error=%v, want ErrPublicationReviewNotEligible", err)
}
}
type reviewCountRow struct{ count int }
func (r reviewCountRow) Scan(dest ...any) error {
*dest[0].(*int) = r.count
return nil
}
type publicationReviewRow struct {
id, publicationID, decision, visibility, reviewerRef, decisionCode string
acceptanceRunID string
publicationUpdatedAt, createdAt time.Time
}
func (r publicationReviewRow) Scan(dest ...any) error {
*dest[0].(*string), *dest[1].(*string), *dest[2].(*string), *dest[3].(*string) = r.id, r.publicationID, r.decision, r.visibility
*dest[4].(*string), *dest[5].(*string), *dest[6].(*string) = r.reviewerRef, r.decisionCode, r.acceptanceRunID
*dest[7].(*time.Time), *dest[8].(*time.Time) = r.publicationUpdatedAt, r.createdAt
return nil
}
type reviewCandidateRow struct {
publicationID, title, sourceType, currentVisibility string
acceptanceRunID, knowledgeState, chunksState string
retrievalState, gateStatus, gateCode, reviewState string
decidedVisibility, decisionCode, reviewerRef string
reviewedAt *time.Time
updatedAt time.Time
}
type reviewCandidateRows struct {
values []reviewCandidateRow
index int
}
func (r *reviewCandidateRows) Close() {}
func (r *reviewCandidateRows) Err() error { return nil }
func (r *reviewCandidateRows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} }
func (r *reviewCandidateRows) FieldDescriptions() []pgconn.FieldDescription { return nil }
func (r *reviewCandidateRows) RawValues() [][]byte { return nil }
func (r *reviewCandidateRows) Conn() *pgx.Conn { return nil }
func (r *reviewCandidateRows) Next() bool {
if r.index >= len(r.values) {
return false
}
r.index++
return true
}
func (r *reviewCandidateRows) 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.sourceType, v.currentVisibility
*dest[4].(*string), *dest[5].(*string), *dest[6].(*string), *dest[7].(*string) = v.acceptanceRunID, v.knowledgeState, v.chunksState, v.retrievalState
*dest[8].(*string), *dest[9].(*string), *dest[10].(*string) = v.gateStatus, v.gateCode, v.reviewState
*dest[11].(*string), *dest[12].(*string), *dest[13].(*string) = v.decidedVisibility, v.decisionCode, v.reviewerRef
*dest[14].(**time.Time), *dest[15].(*time.Time) = v.reviewedAt, v.updatedAt
return nil
}
func (r *reviewCandidateRows) Values() ([]any, error) { return nil, nil }