Files
knowledge-governance/internal/platform/postgres/review_batch_integration_test.go
T
姚章浩 70131c6161 feat(release): 批量审核 + 建批即锁定 —— 发布管理单文档交互降到 3~4 次
P0-3:
- 批量审核:新增 POST /api/v1/review-candidates/batch-decision,逐条复用单条审核的写入
  路径(同一套合法性校验与"最新验收轮次"绑定),逐条回报 decided/skipped/skipped_ids;
  不可审核的条目只计入 skipped,不打断整批,其他错误整批回滚(不留半成品)。
  界面新增「全选可批准」「批量批准选中」「批量驳回选中」。
- 建批即锁定:建批请求加 seal 字段,服务端在创建后立即执行既有的密封(仍逐项复核门禁,
  失败整批回滚),草稿态不再单独占一步;界面按钮改为「创建并锁定清单」,流程说明从 8 步改 7 步。
- 判据修正:「可批准」原先把已驳回的算进去(review_state !== 'approved'),改为只含 pending + 门禁通过。

语义边界(写进测试):批量批准按人工身份记录,人工可推翻自己先前的驳回;system:auto 发起的
批量批准对人工驳回过的那条必须跳过(Decided=0/Skipped=1)——机器永远不覆盖人工。

测试:review_batch_integration_test.go(批量决定/跳报/重复 id 拒绝/机器不覆盖人工、
建批即锁定)、review_batch_test.go(幂等键、400/409/500/501 映射、服务端盖操作人)、
reviewRelease.test.ts(批量请求体与逐条回报、建批带 seal)。前端 181 条通过。
2026-10-10 15:29:31 +08:00

142 lines
6.0 KiB
Go

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)
}
}