70131c6161
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 条通过。
119 lines
4.8 KiB
Go
119 lines
4.8 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"knowledge-governance/internal/governance"
|
|
"knowledge-governance/internal/platform/postgres"
|
|
)
|
|
|
|
// reviewBatchFakeStore:在既有 fakeJobStore 之上补出批量审核能力,验证批量端点的
|
|
// 幂等键要求、逐条结果回报与错误映射。
|
|
type reviewBatchFakeStore struct {
|
|
fakeJobStore
|
|
captured governance.ReviewPublicationsBatchRequest
|
|
result governance.ReviewPublicationsBatchResult
|
|
replayed bool
|
|
err error
|
|
}
|
|
|
|
func (s *reviewBatchFakeStore) ReviewPublicationsBatchWithOperation(_ context.Context, request governance.ReviewPublicationsBatchRequest, _ string) (governance.ReviewPublicationsBatchResult, bool, error) {
|
|
s.captured = request
|
|
return s.result, s.replayed, s.err
|
|
}
|
|
|
|
func TestBatchReviewCandidatesHandler(t *testing.T) {
|
|
store := &reviewBatchFakeStore{result: governance.ReviewPublicationsBatchResult{Selected: 3, Decided: 2, Skipped: 1, SkippedIDs: []string{"tp-c"}, Reviews: []governance.PublicationReview{}}}
|
|
handler, err := NewHandler(Config{Store: store})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 缺幂等键:写路由一律要求。
|
|
missing := httptest.NewRecorder()
|
|
handler.ServeHTTP(missing, httptest.NewRequest(http.MethodPost, "/api/v1/review-candidates/batch-decision", strings.NewReader(`{"publication_ids":["tp-a"],"decision":"approved","decided_visibility":"internal","decision_code":"approved_for_release"}`)))
|
|
if missing.Code != http.StatusBadRequest {
|
|
t.Fatalf("missing idempotency key status=%d", missing.Code)
|
|
}
|
|
|
|
// 空选择:直接拒绝。
|
|
empty := httptest.NewRecorder()
|
|
emptyRequest := httptest.NewRequest(http.MethodPost, "/api/v1/review-candidates/batch-decision", strings.NewReader(`{"publication_ids":[],"decision":"approved","decided_visibility":"internal","decision_code":"approved_for_release"}`))
|
|
emptyRequest.Header.Set("Idempotency-Key", "op-batch-1")
|
|
handler.ServeHTTP(empty, emptyRequest)
|
|
if empty.Code != http.StatusBadRequest {
|
|
t.Fatalf("empty selection status=%d", empty.Code)
|
|
}
|
|
|
|
// 正常:逐条结果原样回报,操作人由服务端盖章。
|
|
ok := httptest.NewRecorder()
|
|
okRequest := httptest.NewRequest(http.MethodPost, "/api/v1/review-candidates/batch-decision", strings.NewReader(`{"publication_ids":["tp-a","tp-b","tp-c"],"decision":"approved","decided_visibility":"internal","decision_code":"approved_for_release"}`))
|
|
okRequest.Header.Set("Idempotency-Key", "op-batch-2")
|
|
handler.ServeHTTP(ok, okRequest)
|
|
if ok.Code != http.StatusOK {
|
|
t.Fatalf("batch review status=%d body=%s", ok.Code, ok.Body.String())
|
|
}
|
|
var body struct {
|
|
Data struct {
|
|
Selected int `json:"selected"`
|
|
Decided int `json:"decided"`
|
|
Skipped int `json:"skipped"`
|
|
SkippedIDs []string `json:"skipped_ids"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(ok.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Data.Decided != 2 || body.Data.Skipped != 1 || len(body.Data.SkippedIDs) != 1 || body.Data.SkippedIDs[0] != "tp-c" {
|
|
t.Fatalf("batch result=%#v", body.Data)
|
|
}
|
|
if store.captured.ReviewerRef == "" {
|
|
t.Fatal("reviewer must be stamped by the server")
|
|
}
|
|
|
|
// 错误映射。
|
|
for _, testCase := range []struct {
|
|
name string
|
|
err error
|
|
status int
|
|
}{
|
|
{"invalid", postgres.ErrInvalidPublicationReview, http.StatusBadRequest},
|
|
{"conflict", postgres.ErrOperationRequestConflict, http.StatusConflict},
|
|
{"unexpected", errors.New("boom"), http.StatusInternalServerError},
|
|
} {
|
|
failing := &reviewBatchFakeStore{err: testCase.err}
|
|
failingHandler, err := NewHandler(Config{Store: failing})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/review-candidates/batch-decision", strings.NewReader(`{"publication_ids":["tp-a"],"decision":"approved","decided_visibility":"internal","decision_code":"approved_for_release"}`))
|
|
request.Header.Set("Idempotency-Key", "op-batch-3")
|
|
failingHandler.ServeHTTP(recorder, request)
|
|
if recorder.Code != testCase.status {
|
|
t.Fatalf("%s status=%d want=%d", testCase.name, recorder.Code, testCase.status)
|
|
}
|
|
}
|
|
|
|
// 未接线:诚实 501。
|
|
unwired, err := NewHandler(Config{Store: storeWithoutReviewBatch{Store: store}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
unwiredRecorder := httptest.NewRecorder()
|
|
unwiredRequest := httptest.NewRequest(http.MethodPost, "/api/v1/review-candidates/batch-decision", strings.NewReader(`{"publication_ids":["tp-a"],"decision":"approved","decided_visibility":"internal","decision_code":"approved_for_release"}`))
|
|
unwiredRequest.Header.Set("Idempotency-Key", "op-batch-4")
|
|
unwired.ServeHTTP(unwiredRecorder, unwiredRequest)
|
|
if unwiredRecorder.Code != http.StatusNotImplemented {
|
|
t.Fatalf("unwired status=%d", unwiredRecorder.Code)
|
|
}
|
|
}
|
|
|
|
type storeWithoutReviewBatch struct{ Store }
|