Files
knowledge-governance/internal/workflows/testweknora/validate_test.go
T
姚章浩 acf053faf3 feat(release): 建批时冻结 + 验收通过自动批准 —— 把"每份文档 8 步"砍掉两步并去掉两次卡死
背景:发布管理把"逐条核验"的仪式强加给每一份文档。实测一份文档要 9~11 次交互,
且有两处顺序陷阱(冻结后批准 → 建批 422;生成即固化 → 批准被永久钉住)。

P0-1 建批时冻结:
- CreateReleaseBatch 在未指定版本时,自己把当前实时计划冻结成一份**已确认**版本并绑定
  (同一事务;同摘要复用最近版本,planned 或 confirmed 都可复用,因此同一份计划不堆版本)。
- 语义不变:批一旦建立,清单/摘要/验收轮次/目标库绑定全部不可变、回滚按它走;变的只是
  "谁在什么时候按固化键"——从操作员的手动两步变成建批这一下。
- 效果:去掉「生成新版本」「确认冻结」两步;消灭"批准晚于冻结 → 422"这类顺序陷阱;
  前端不再传版本 id,也不再有"所选知识不在冻结清单里"的阻断。
- 计划→清单项的映射抽成 ChangeSetItemsFromPlan 供两处共用,否则两处算出的摘要不同,
  "同一计划复用同一版本"永远不命中。

P0-2 验收通过自动批准:
- 验收三项结论达发布门槛(knowledge_exists/chunks_present=passed,retrieval_hit 与门槛同
  口径)时,由 worker 写入 reviewer_ref='system:auto' 的批准记录,替代"每份文档点一次批准"。
- 策略 TEST_AUTO_REVIEW_POLICY = off | internal(默认,仅对内)| all;值不认识拒绝启动。
  只影响审核这一步:生产授权、双闸与发布前快照门禁不受影响。界面上只读展示当前策略。
- 安全边界(写死在审核 SQL 里):系统只允许写"第一条"审核——人工一旦表过态,机器不得用一条
  更晚的 approved 把人工结论翻回去;人工随时可再审。集成测试去掉该约束即 FAIL(已实测)。
- 自动批准自身的任何失败都不影响验收结论,只上报。

顺带(P1-1 前半,已被 P0-1 取代的部分已删除):
- 写操作失败把服务端 {"error": ...} 的原因带出来,并对已知原因给出下一步(不再只给 HTTP 状态码)。
- 当前的"不在已冻结清单里"标注与就地修复按钮随 P0-1 一起移除(该失败模式已不存在)。

测试:bind_time_freeze_integration_test.go(绑定已确认版本/同计划复用/冻结后不可变/
动作不符仍 NotEligible)、auto_review_integration_test.go(系统只写第一条、人工可再审)、
validate_test.go(对内自动/对外不自动/策略关闭/验收未过不批/自身失败不影响验收)、
auto_review_test.go、config_test 及前端能力解析测试。
实测耗时:本机 Go 全量 26 包通过、前端 178 条通过。
2026-10-10 15:05:20 +08:00

212 lines
8.8 KiB
Go

package testweknora
import (
"context"
"errors"
"testing"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
postgresstore "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/platform/weknora"
)
func TestValidateHandlerAppendsDeterministicChecksAndManualRetrieval(t *testing.T) {
client := &validateClientStub{
knowledge: weknora.Knowledge{ID: "knowledge-1", KnowledgeBaseID: "kb-1"},
chunkTotal: 3,
}
store := &validateStoreStub{publication: postgresstore.PublicationForValidation{
ID: "publication-1", TestKBID: "kb-1", KnowledgeID: "knowledge-1",
}}
now := time.Date(2026, time.September, 4, 4, 10, 0, 0, time.UTC)
handler, err := NewValidateHandler(ValidateHandlerConfig{
Store: store, Client: client, RunID: "run-1", PolicyVersion: "structural-v1",
Now: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
if err := handler.Handle(context.Background(), jobs.Job{Payload: []byte(`{"publication_id":"publication-1"}`)}); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if len(store.appended) != 3 {
t.Fatalf("appended = %d, want 3: %#v", len(store.appended), store.appended)
}
byType := map[string]governance.AcceptanceResult{}
for _, result := range store.appended {
byType[result.CheckType] = result
}
if byType["knowledge_exists"].State != governance.AcceptancePassed {
t.Fatalf("knowledge_exists = %#v", byType["knowledge_exists"])
}
if byType["chunks_present"].State != governance.AcceptancePassed {
t.Fatalf("chunks_present = %#v", byType["chunks_present"])
}
if byType["retrieval_hit"].State != governance.AcceptanceManualRequired {
t.Fatalf("retrieval_hit = %#v", byType["retrieval_hit"])
}
for _, result := range store.appended {
if result.PublicationID != "publication-1" || result.RunID != "run-1" || result.ObservedAt != now {
t.Fatalf("acceptance metadata = %#v", result)
}
}
}
func TestValidateHandlerFailsChecksWithoutAborting(t *testing.T) {
client := &validateClientStub{
knowledge: weknora.Knowledge{ID: "knowledge-mismatch", KnowledgeBaseID: "kb-other"},
chunkTotal: 0,
}
store := &validateStoreStub{publication: postgresstore.PublicationForValidation{
ID: "publication-1", TestKBID: "kb-1", KnowledgeID: "knowledge-1",
}}
handler, err := NewValidateHandler(ValidateHandlerConfig{
Store: store, Client: client, RunID: "run-2", PolicyVersion: "structural-v1",
Now: func() time.Time { return time.Date(2026, time.September, 4, 4, 11, 0, 0, time.UTC) },
})
if err != nil {
t.Fatal(err)
}
if err := handler.Handle(context.Background(), jobs.Job{Payload: []byte(`{"publication_id":"publication-1"}`)}); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if len(store.appended) != 3 {
t.Fatalf("appended = %d, want 3", len(store.appended))
}
byType := map[string]governance.AcceptanceResult{}
for _, result := range store.appended {
byType[result.CheckType] = result
}
if byType["knowledge_exists"].State != governance.AcceptanceFailed || byType["chunks_present"].State != governance.AcceptanceFailed {
t.Fatalf("failed checks = %#v", store.appended)
}
if byType["retrieval_hit"].State != governance.AcceptanceManualRequired {
t.Fatalf("retrieval still manual = %#v", byType["retrieval_hit"])
}
}
type validateStoreStub struct {
publication postgresstore.PublicationForValidation
appended []governance.AcceptanceResult
reviews []governance.ReviewPublicationRequest
reviewErr error
}
func (store *validateStoreStub) LoadPublicationForValidation(context.Context, string) (postgresstore.PublicationForValidation, error) {
return store.publication, nil
}
func (store *validateStoreStub) AppendAcceptanceResult(_ context.Context, result governance.AcceptanceResult) error {
store.appended = append(store.appended, result)
return nil
}
func (store *validateStoreStub) ReviewPublication(_ context.Context, request governance.ReviewPublicationRequest) (governance.PublicationReview, error) {
if store.reviewErr != nil {
return governance.PublicationReview{}, store.reviewErr
}
store.reviews = append(store.reviews, request)
return governance.PublicationReview{ID: "rv-auto", PublicationID: request.PublicationID}, nil
}
type validateClientStub struct {
knowledge weknora.Knowledge
chunkTotal int64
}
func (client *validateClientStub) GetKnowledge(context.Context, string) (weknora.Knowledge, error) {
return client.knowledge, nil
}
func (client *validateClientStub) ListChunks(context.Context, string, int, int, []string) ([]weknora.Chunk, int64, error) {
chunks := make([]weknora.Chunk, 0, client.chunkTotal)
for i := int64(0); i < client.chunkTotal; i++ {
chunks = append(chunks, weknora.Chunk{ID: "chunk", KnowledgeID: "knowledge-1"})
}
return chunks, client.chunkTotal, nil
}
// 自动批准:验收通过的知识由系统写入批准,替代"每份文档人工点一次批准"。
// 关键边界:对外不自动、策略可关、验收没过不批、自动批准自己失败也绝不把验收判成失败。
func TestValidateHandlerAutoApprovesVerifiedInternalPublications(t *testing.T) {
run := func(t *testing.T, scope string, policy governance.AutoReviewPolicy, knowledgeBase, knowledgeID string, reviewErr error) (*validateStoreStub, error) {
t.Helper()
client := &validateClientStub{
knowledge: weknora.Knowledge{ID: knowledgeID, KnowledgeBaseID: knowledgeBase},
chunkTotal: 3,
}
store := &validateStoreStub{
publication: postgresstore.PublicationForValidation{ID: "publication-1", TestKBID: "kb-1", KnowledgeID: "knowledge-1", TargetScope: scope},
reviewErr: reviewErr,
}
now := time.Date(2026, time.October, 10, 8, 0, 0, 0, time.UTC)
handler, err := NewValidateHandler(ValidateHandlerConfig{
Store: store, Client: client, RunID: "run-1", PolicyVersion: "structural-v1",
AutoReviewPolicy: policy, Now: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
return store, handler.Handle(context.Background(), jobs.Job{ID: "job-1", Payload: []byte(`{"publication_id":"publication-1"}`)})
}
store, err := run(t, "internal", governance.AutoReviewInternal, "kb-1", "knowledge-1", nil)
if err != nil {
t.Fatalf("Handle() error = %v", err)
}
if len(store.reviews) != 1 {
t.Fatalf("auto reviews = %d, want 1", len(store.reviews))
}
review := store.reviews[0]
if review.ReviewerRef != "system:auto" || review.Decision != governance.ReviewApproved || review.DecidedVisibility != governance.VisibilityInternal || review.DecisionCode != "approved_for_release" {
t.Fatalf("auto review = %#v", review)
}
if review.Now.IsZero() {
t.Fatalf("auto review must carry the observation time")
}
// 对外:默认不自动批准。
external, err := run(t, "external", governance.AutoReviewInternal, "kb-1", "knowledge-1", nil)
if err != nil || len(external.reviews) != 0 {
t.Fatalf("external auto reviews = %d error = %v", len(external.reviews), err)
}
// 策略关闭。
off, err := run(t, "internal", governance.AutoReviewOff, "kb-1", "knowledge-1", nil)
if err != nil || len(off.reviews) != 0 {
t.Fatalf("off auto reviews = %d error = %v", len(off.reviews), err)
}
// 验收没过(知识存在性校验失败):不批准。
failed, err := run(t, "internal", governance.AutoReviewInternal, "kb-other", "knowledge-1", nil)
if err != nil || len(failed.reviews) != 0 {
t.Fatalf("failed-acceptance auto reviews = %d error = %v", len(failed.reviews), err)
}
// 自动批准自己失败(例如已被人工审过)不能让验收失败。
conflicted, err := run(t, "internal", governance.AutoReviewInternal, "kb-1", "knowledge-1", postgresstore.ErrPublicationReviewNotEligible)
if err != nil {
t.Fatalf("review-not-eligible must not fail validation: %v", err)
}
if len(conflicted.reviews) != 0 || len(conflicted.appended) != 3 {
t.Fatalf("conflicted reviews = %d appended = %d", len(conflicted.reviews), len(conflicted.appended))
}
// 其他错误也只是上报,不影响验收结论。
reported := 0
client := &validateClientStub{knowledge: weknora.Knowledge{ID: "knowledge-1", KnowledgeBaseID: "kb-1"}, chunkTotal: 3}
store2 := &validateStoreStub{publication: postgresstore.PublicationForValidation{ID: "publication-1", TestKBID: "kb-1", KnowledgeID: "knowledge-1", TargetScope: "internal"}, reviewErr: errors.New("boom")}
handler, err := NewValidateHandler(ValidateHandlerConfig{
Store: store2, Client: client, RunID: "run-1", PolicyVersion: "structural-v1",
AutoReviewPolicy: governance.AutoReviewInternal, Report: func(error) { reported++ },
Now: func() time.Time { return time.Now().UTC() },
})
if err != nil {
t.Fatal(err)
}
if err := handler.Handle(context.Background(), jobs.Job{ID: "job-2", Payload: []byte(`{"publication_id":"publication-1"}`)}); err != nil {
t.Fatalf("auto review failure must not fail validation: %v", err)
}
if reported != 1 {
t.Fatalf("reported = %d, want 1", reported)
}
}