package postgres import ( "context" "errors" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "knowledge-governance/internal/governance" ) // 批量审核:逐条复用单条审核的写入路径,且**如实回报**哪几条当时不可审核 // (例如人工刚驳回过的那条不会被机器翻回去)。 func TestReviewPublicationsBatchDecidesEligibleAndReportsSkipped(t *testing.T) { databaseURL := isolatedIntegrationDatabaseURL(t, os.Getenv("TEST_DATABASE_URL")) if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := pgxpool.New(ctx, databaseURL) if err != nil { t.Fatal(err) } defer pool.Close() if err := ApplyMigrations(ctx, pool); err != nil { t.Fatal(err) } store := NewStore(pool) now := time.Date(2026, 10, 10, 15, 0, 0, 0, time.UTC) if _, err := pool.Exec(ctx, `INSERT INTO source_connections (id, source_type, name) VALUES ('conn-manual', 'manual', 'Manual')`); err != nil { t.Fatal(err) } insertReviewFixture(t, ctx, pool, 12, "tp-batch-a", now) insertReviewFixture(t, ctx, pool, 13, "tp-batch-b", now) insertReviewFixture(t, ctx, pool, 14, "tp-batch-c", now) // 人工先把 c 驳回:机器不得覆盖。 if _, err := store.ReviewPublication(ctx, governance.ReviewPublicationRequest{ PublicationID: "tp-batch-c", Decision: governance.ReviewRejected, DecidedVisibility: governance.VisibilityInternal, ReviewerRef: "admin", DecisionCode: "needs_correction", Now: now.Add(time.Minute), }); err != nil { t.Fatal(err) } // 人工发起的批量批准:可审核的写进去,不可审核的(这里是根本不存在的条目)如实跳报。 // 注意 c 也在其中——人工当然可以推翻自己先前的驳回,那是人工决策,不是机器覆盖。 result, err := store.ReviewPublicationsBatch(ctx, governance.ReviewPublicationsBatchRequest{ PublicationIDs: []string{"tp-batch-a", "tp-batch-b", "tp-batch-missing"}, Decision: governance.ReviewApproved, DecidedVisibility: governance.VisibilityInternal, ReviewerRef: "engineer-1", DecisionCode: "approved_for_release", Now: now.Add(2 * time.Minute), }) if err != nil { t.Fatalf("ReviewPublicationsBatch() error = %v", err) } if result.Selected != 3 || result.Decided != 2 || result.Skipped != 1 { t.Fatalf("batch result = %#v", result) } if len(result.SkippedIDs) != 1 || result.SkippedIDs[0] != "tp-batch-missing" { t.Fatalf("skipped ids = %#v", result.SkippedIDs) } // 机器发起的批量批准:人工驳回过的那条必须被跳过(永远不覆盖人工结论)。 systemResult, err := store.ReviewPublicationsBatch(ctx, governance.ReviewPublicationsBatchRequest{ PublicationIDs: []string{"tp-batch-c"}, Decision: governance.ReviewApproved, DecidedVisibility: governance.VisibilityInternal, ReviewerRef: "system:auto", DecisionCode: "approved_for_release", Now: now.Add(3 * time.Minute), }) if err != nil { t.Fatalf("system batch error = %v", err) } if systemResult.Decided != 0 || systemResult.Skipped != 1 { t.Fatalf("system batch must not override a human decision: %#v", systemResult) } var approvedCount int if err := pool.QueryRow(ctx, `SELECT COUNT(*)::integer FROM publication_reviews WHERE publication_id IN ('tp-batch-a','tp-batch-b') AND decision='approved' AND reviewer_ref='engineer-1'`).Scan(&approvedCount); err != nil { t.Fatal(err) } if approvedCount != 2 { t.Fatalf("approved reviews = %d, want 2", approvedCount) } // c 的人工驳回必须原样保留。 var decision, reviewer string if err := pool.QueryRow(ctx, `SELECT decision, reviewer_ref FROM publication_reviews WHERE publication_id='tp-batch-c' ORDER BY created_at DESC, id DESC LIMIT 1`).Scan(&decision, &reviewer); err != nil { t.Fatal(err) } if decision != "rejected" || reviewer != "admin" { t.Fatalf("human decision was overwritten: %q by %q", decision, reviewer) } // 非法输入整批拒绝(含重复 id)。 if _, err := store.ReviewPublicationsBatch(ctx, governance.ReviewPublicationsBatchRequest{ PublicationIDs: []string{"tp-batch-a", "tp-batch-a"}, Decision: governance.ReviewApproved, DecidedVisibility: governance.VisibilityInternal, ReviewerRef: "engineer-1", DecisionCode: "approved_for_release", Now: now, }); !errors.Is(err, ErrInvalidPublicationReview) { t.Fatalf("duplicate ids error = %v, want ErrInvalidPublicationReview", err) } } // 建批即锁定:一次调用就该得到 sealed 批次(草稿态不再单独占一步),且锁定会逐项复核门禁。 func TestCreateReleaseBatchSealsImmediately(t *testing.T) { databaseURL := isolatedIntegrationDatabaseURL(t, os.Getenv("TEST_DATABASE_URL")) if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := pgxpool.New(ctx, databaseURL) if err != nil { t.Fatal(err) } defer pool.Close() if err := ApplyMigrations(ctx, pool); err != nil { t.Fatal(err) } store := NewStore(pool) now := time.Date(2026, 10, 10, 16, 0, 0, 0, time.UTC) if _, err := pool.Exec(ctx, `INSERT INTO source_connections (id, source_type, name) VALUES ('conn-manual', 'manual', 'Manual')`); err != nil { t.Fatal(err) } insertReviewFixture(t, ctx, pool, 2, "tp-seal-a", now) if _, err := store.ReviewPublication(ctx, governance.ReviewPublicationRequest{ PublicationID: "tp-seal-a", Decision: governance.ReviewApproved, DecidedVisibility: governance.VisibilityInternal, ReviewerRef: "engineer-1", DecisionCode: "approved_for_release", Now: now.Add(time.Minute), }); err != nil { t.Fatal(err) } batch, err := store.CreateReleaseBatch(ctx, governance.CreateReleaseBatchRequest{ Name: "建批即锁定", CreatedBy: "engineer-1", PublicationIDs: []string{"tp-seal-a"}, SealImmediately: true, Now: now.Add(2 * time.Minute), }) if err != nil { t.Fatalf("seal-on-create must succeed: %v", err) } if batch.State != governance.ReleaseBatchSealed { t.Fatalf("batch state = %q, want sealed", batch.State) } if batch.ReleaseChangeSetID == "" || batch.SealedBy == "" { t.Fatalf("sealed batch must carry frozen manifest and sealer: %#v", batch) } }