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