Files
knowledge-governance/internal/workflows/release/handler_test.go
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

210 lines
8.6 KiB
Go

package release
import (
"context"
"errors"
"fmt"
"reflect"
"testing"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
)
func TestHandlerStopsAfterPermanentItemFailureAndSchedulesAutomaticRollback(t *testing.T) {
store := &fakeStore{items: []PlanItem{{PublicationID: "tp-1"}, {PublicationID: "tp-2"}, {PublicationID: "tp-3"}}}
publisher := &fakePublisher{publish: map[string]publishResult{
"tp-1": {result: ItemResult{BundleSHA256: repeatHex('a'), ProductionRevision: "prod-1"}},
"tp-2": {err: &ItemError{Code: ErrorCodeContractMismatch, Retryable: false}},
"tp-3": {result: ItemResult{BundleSHA256: repeatHex('b'), ProductionRevision: "prod-3"}},
}}
handler, err := NewHandler(store, publisher, func() time.Time { return time.Date(2026, 9, 6, 7, 0, 0, 0, time.UTC) })
if err != nil {
t.Fatal(err)
}
job := releaseJob("release.publish", 1, 3)
if err := handler(context.Background(), job); err != nil {
t.Fatal(err)
}
if len(store.completed) != 1 || store.completed[0].PublicationID != "tp-1" {
t.Fatalf("completed=%#v", store.completed)
}
if len(store.failed) != 2 || store.failed[0].ErrorCode != ErrorCodeContractMismatch || store.failed[1].PublicationID != "tp-3" || store.failed[1].ErrorCode != ErrorCodeStoppedAfterFailure {
t.Fatalf("failed=%#v", store.failed)
}
if store.finishedExecutionID != "rx-safe" || store.automaticRollbackExecutionID != "rx-safe" || publisher.publishCalls != 2 {
t.Fatalf("finished=%q rollback=%q publishCalls=%d", store.finishedExecutionID, store.automaticRollbackExecutionID, publisher.publishCalls)
}
}
func TestHandlerPersistsProgressAtFiftyItemBatchBoundaries(t *testing.T) {
items := make([]PlanItem, 0, 105)
results := make(map[string]publishResult, 105)
for index := 0; index < 105; index++ {
id := fmt.Sprintf("tp-%03d", index)
items = append(items, PlanItem{PublicationID: id})
results[id] = publishResult{result: ItemResult{BundleSHA256: repeatHex('a'), ProductionRevision: "prod-1"}}
}
store := &fakeStore{items: items}
handler, err := NewHandler(store, &fakePublisher{publish: results}, time.Now)
if err != nil {
t.Fatal(err)
}
if err := handler(context.Background(), releaseJob("release.publish", 1, 3)); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(store.startedBatches, [][2]int{{1, 3}, {2, 3}, {3, 3}}) {
t.Fatalf("started batches=%v", store.startedBatches)
}
}
func TestHandlerPublishesDeleteActionAsVerifiedAbsentRevision(t *testing.T) {
store := &fakeStore{items: []PlanItem{{PublicationID: "tp-delete", Action: "delete", PreviousProductionRevision: "prod-current"}}}
publisher := &fakePublisher{delete: map[string]publishResult{
"tp-delete": {result: ItemResult{BundleSHA256: repeatHex('d'), PreviousProductionRevision: "prod-current", ProductionRevision: "absent"}},
}}
handler, err := NewHandler(store, publisher, func() time.Time { return time.Date(2026, 9, 10, 4, 0, 0, 0, time.UTC) })
if err != nil {
t.Fatal(err)
}
if err := handler(context.Background(), jobs.Job{ID: "job-delete", Type: "release.publish", Payload: []byte(`{"execution_id":"rx-delete","release_batch_id":"rb-delete","operation":"publish"}`), Attempt: 1, MaxAttempts: 3}); err != nil {
t.Fatal(err)
}
if publisher.deleteCalls != 1 || publisher.publishCalls != 0 || len(store.completed) != 1 || store.completed[0].ProductionRevision != "absent" {
t.Fatalf("deleteCalls=%d publishCalls=%d completed=%#v", publisher.deleteCalls, publisher.publishCalls, store.completed)
}
}
func TestHandlerReturnsOnlySanitizedRetryErrorBeforeLastAttempt(t *testing.T) {
store := &fakeStore{items: []PlanItem{{PublicationID: "tp-1"}}}
publisher := &fakePublisher{publish: map[string]publishResult{
"tp-1": {err: &ItemError{Code: ErrorCodeTargetUnavailable, Retryable: true, Cause: errors.New("raw endpoint and credential text")}},
}}
handler, _ := NewHandler(store, publisher, time.Now)
err := handler(context.Background(), releaseJob("release.publish", 1, 3))
if !errors.Is(err, ErrReleaseRetryable) || err.Error() != ErrReleaseRetryable.Error() {
t.Fatalf("error=%v", err)
}
if len(store.failed) != 0 || store.finishedExecutionID != "" {
t.Fatalf("failed=%#v finished=%q", store.failed, store.finishedExecutionID)
}
}
func TestHandlerFailsRetryableItemsWithFixedCodeOnLastAttempt(t *testing.T) {
store := &fakeStore{items: []PlanItem{{PublicationID: "tp-1"}}}
publisher := &fakePublisher{publish: map[string]publishResult{
"tp-1": {err: &ItemError{Code: ErrorCodeTargetUnavailable, Retryable: true}},
}}
handler, _ := NewHandler(store, publisher, time.Now)
if err := handler(context.Background(), releaseJob("release.publish", 3, 3)); err != nil {
t.Fatal(err)
}
if len(store.failed) != 1 || store.failed[0].ErrorCode != ErrorCodeTargetUnavailable || store.finishedExecutionID != "rx-safe" {
t.Fatalf("failed=%#v finished=%q", store.failed, store.finishedExecutionID)
}
}
func TestHandlerDispatchesRollbackAndRejectsInvalidPayload(t *testing.T) {
store := &fakeStore{items: []PlanItem{{PublicationID: "tp-1"}}}
publisher := &fakePublisher{rollback: map[string]publishResult{"tp-1": {result: ItemResult{ProductionRevision: "prod-old"}}}}
handler, _ := NewHandler(store, publisher, time.Now)
if err := handler(context.Background(), releaseJob("release.rollback", 1, 3)); err != nil {
t.Fatal(err)
}
if publisher.rollbackCalls != 1 || publisher.publishCalls != 0 {
t.Fatalf("publish=%d rollback=%d", publisher.publishCalls, publisher.rollbackCalls)
}
bad := releaseJob("release.publish", 1, 3)
bad.Payload = []byte(`{"execution_id":"../bad"}`)
if err := handler(context.Background(), bad); !errors.Is(err, ErrInvalidReleaseJob) {
t.Fatalf("error=%v", err)
}
// Production rollback payloads carry prior_execution_id; the strict
// decoder must accept it (regression: release jobs failed permanently as
// invalid_release_job the moment rollback executions included the field).
withPrior := releaseJob("release.rollback", 1, 3)
withPrior.Payload = []byte(`{"execution_id":"rx-safe","release_batch_id":"rb-safe","operation":"rollback","prior_execution_id":"rx-prior"}`)
if err := handler(context.Background(), withPrior); err != nil {
t.Fatalf("rollback payload with prior_execution_id rejected: %v", err)
}
}
func releaseJob(jobType string, attempt, maxAttempts int) jobs.Job {
payload := []byte(`{"execution_id":"rx-safe","release_batch_id":"rb-safe","operation":"publish"}`)
if jobType == "release.rollback" {
payload = []byte(`{"execution_id":"rx-safe","release_batch_id":"rb-safe","operation":"rollback"}`)
}
return jobs.Job{ID: "job-safe", Type: jobType, Payload: payload, Attempt: attempt, MaxAttempts: maxAttempts}
}
type fakeStore struct {
items []PlanItem
completed []CompleteItemRequest
failed []FailItemRequest
finishedExecutionID string
automaticRollbackExecutionID string
startedBatches [][2]int
}
func (s *fakeStore) StartExecution(context.Context, StartExecutionRequest) ([]PlanItem, error) {
return s.items, nil
}
func (s *fakeStore) CompleteItem(_ context.Context, request CompleteItemRequest) error {
s.completed = append(s.completed, request)
return nil
}
func (s *fakeStore) FailItem(_ context.Context, request FailItemRequest) error {
s.failed = append(s.failed, request)
return nil
}
func (s *fakeStore) FinishExecution(_ context.Context, executionID string, _ time.Time) error {
s.finishedExecutionID = executionID
return nil
}
func (s *fakeStore) ScheduleAutomaticRollback(_ context.Context, executionID string, _ time.Time) error {
s.automaticRollbackExecutionID = executionID
return nil
}
func (s *fakeStore) BeginBatch(_ context.Context, _ string, batch, total int, _ time.Time) error {
s.startedBatches = append(s.startedBatches, [2]int{batch, total})
return nil
}
type publishResult struct {
result ItemResult
err error
}
type fakePublisher struct {
publish, rollback, delete map[string]publishResult
publishCalls, rollbackCalls, deleteCalls int
}
func (p *fakePublisher) Publish(_ context.Context, item PlanItem) (ItemResult, error) {
p.publishCalls++
value := p.publish[item.PublicationID]
return value.result, value.err
}
func (p *fakePublisher) Rollback(_ context.Context, item PlanItem) (ItemResult, error) {
p.rollbackCalls++
value := p.rollback[item.PublicationID]
return value.result, value.err
}
func (p *fakePublisher) Delete(_ context.Context, item PlanItem) (ItemResult, error) {
p.deleteCalls++
value := p.delete[item.PublicationID]
return value.result, value.err
}
func repeatHex(value byte) string {
result := make([]byte, 64)
for index := range result {
result[index] = value
}
return string(result)
}
var _ = governance.ReleaseOperationPublish