dcdc8d1ddd
全栈知识治理平台首次入库(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 干净
210 lines
8.6 KiB
Go
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
|