130 lines
5.1 KiB
Go
130 lines
5.1 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
|
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
|
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
|
)
|
|
|
|
func TestAsyncCancellationPreservesActualOperationOutcome(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
report deployment.Report
|
|
err error
|
|
want string
|
|
}{
|
|
{name: "already_completed", report: deployment.Report{Status: "success"}, want: "success"},
|
|
{name: "failed", report: deployment.Report{Status: "failed"}, err: errors.New("部署收尾失败"), want: "failed"},
|
|
{name: "partially_completed", report: deployment.Report{Status: "partial"}, err: context.Canceled, want: "partial_success"},
|
|
{name: "cancelled", err: context.Canceled, want: "cancelled"},
|
|
{name: "recovery_unconfirmed", report: deployment.Report{Status: "failed", RecoveryStatus: "unconfirmed"}, err: errors.Join(context.Canceled, errors.New("回滚未确认")), want: "failed"},
|
|
{name: "recovery_failed", report: deployment.Report{Status: "failed", RecoveryStatus: "failed"}, err: errors.Join(context.Canceled, errors.New("回滚失败")), want: "failed"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
started := make(chan struct{})
|
|
server, tasks, _, cleanup := newTaskAPITestServer(t, func(ctx context.Context, _ string) (certificate.IssueResult, deployment.Report, error) {
|
|
close(started)
|
|
<-ctx.Done()
|
|
return certificate.IssueResult{}, test.report, test.err
|
|
})
|
|
defer cleanup()
|
|
response := httptest.NewRecorder()
|
|
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
|
|
task := submittedTask(t, response)
|
|
select {
|
|
case <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("任务没有开始")
|
|
}
|
|
response = httptest.NewRecorder()
|
|
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodDelete, "/api/v1/tasks/"+task.ID, nil))
|
|
if response.Code != http.StatusAccepted {
|
|
t.Fatalf("取消未被接收:%s", response.Body.String())
|
|
}
|
|
waitTaskWorkersForTest(t, server)
|
|
stored, err := tasks.GetTask(t.Context(), task.ID)
|
|
if err != nil || stored.Status != test.want {
|
|
t.Fatalf("取消请求覆盖了实际结果:%+v %v", stored, err)
|
|
}
|
|
attempts, err := tasks.ListAttempts(t.Context(), task.ID)
|
|
if err != nil || len(attempts) != 1 || attempts[0].Status != stored.Status {
|
|
t.Fatalf("任务与尝试不一致:%+v %v", attempts, err)
|
|
}
|
|
if test.want == "success" && !strings.Contains(stored.Message, "未撤销") {
|
|
t.Fatal("必须提示取消未撤销已完成操作")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type failingCompletionTaskRepository struct {
|
|
repository.TaskRepository
|
|
operationFinished *atomic.Bool
|
|
failRead bool
|
|
}
|
|
|
|
func (r failingCompletionTaskRepository) GetTask(ctx context.Context, id string) (repository.Task, error) {
|
|
if r.failRead && r.operationFinished.Load() {
|
|
return repository.Task{}, errors.New("模拟收尾状态读取失败")
|
|
}
|
|
return r.TaskRepository.GetTask(ctx, id)
|
|
}
|
|
|
|
func (r failingCompletionTaskRepository) FinishTaskAttempt(ctx context.Context, item repository.TaskCompletion) error {
|
|
if !r.failRead {
|
|
return errors.New("模拟收尾事务保存失败")
|
|
}
|
|
return r.TaskRepository.FinishTaskAttempt(ctx, item)
|
|
}
|
|
|
|
func TestAsyncCompletionFailureDoesNotPublishFalseSuccess(t *testing.T) {
|
|
for _, failRead := range []bool{true, false} {
|
|
name := "atomic_write_failure"
|
|
if failRead {
|
|
name = "state_read_failure"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
var finished atomic.Bool
|
|
server, tasks, _, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) {
|
|
finished.Store(true)
|
|
return certificate.IssueResult{}, deployment.Report{Status: "success"}, nil
|
|
})
|
|
defer cleanup()
|
|
server.tasks = failingCompletionTaskRepository{TaskRepository: tasks, operationFinished: &finished, failRead: failRead}
|
|
response := httptest.NewRecorder()
|
|
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
|
|
task := submittedTask(t, response)
|
|
waitTaskWorkersForTest(t, server)
|
|
stored, err := tasks.GetTask(t.Context(), task.ID)
|
|
if err != nil || stored.Status == "success" {
|
|
t.Fatalf("无法可靠持久化却标记成功:%+v %v", stored, err)
|
|
}
|
|
attempts, err := tasks.ListAttempts(t.Context(), task.ID)
|
|
if err != nil || len(attempts) != 1 || attempts[0].Status == "success" {
|
|
t.Fatalf("尝试被单独保存为成功:%+v %v", attempts, err)
|
|
}
|
|
if failRead {
|
|
if stored.Status != "failed" || attempts[0].Status != "failed" {
|
|
t.Fatal("状态读取失败必须闭合为失败")
|
|
}
|
|
} else {
|
|
if !isActiveTaskStatus(stored.Status) || stored.FinishedAt != nil || attempts[0].FinishedAt != nil {
|
|
t.Fatal("事务失败必须保留可由启动恢复接管的中断状态")
|
|
}
|
|
if count, err := tasks.RecoverInterruptedTasks(t.Context(), time.Now().UTC()); err != nil || count != 1 {
|
|
t.Fatalf("中断记录无法恢复:%d %v", count, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|