470 lines
22 KiB
Go
470 lines
22 KiB
Go
package repository
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"path/filepath"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||
"git.pcux.cn/xuans/Scert/backend/internal/database"
|
||
)
|
||
|
||
func newTaskRepositoryTest(t *testing.T) (*SQLTaskRepository, *sql.DB) {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
cfg := config.Default(dir)
|
||
cfg.Database.DSN = filepath.Join(dir, "data", "test.db")
|
||
db, err := database.Open(context.Background(), cfg.Database)
|
||
if err != nil {
|
||
t.Fatalf("打开测试数据库失败:%v", err)
|
||
}
|
||
if err := database.Migrate(context.Background(), db.DB, "sqlite"); err != nil {
|
||
db.Close()
|
||
t.Fatalf("迁移测试数据库失败:%v", err)
|
||
}
|
||
for _, id := range []string{"domain-1", "domain-2", "domain-3"} {
|
||
if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO domains (id, name, certificate_type, sans_json, status)
|
||
VALUES (?, ?, 'single', '[]', 'pending')`, id, id+".example.test"); err != nil {
|
||
db.Close()
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
return NewSQLTaskRepository(db.DB), db.DB
|
||
}
|
||
|
||
func TestSQLTaskRepositoryLifecycle(t *testing.T) {
|
||
repository, db := newTaskRepositoryTest(t)
|
||
defer db.Close()
|
||
ctx := context.Background()
|
||
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
||
task := Task{
|
||
ID: "task-1", DomainID: "domain-1", TaskType: "certificate_issue",
|
||
Status: "queued", Stage: "queued", Message: "等待执行", CreatedAt: now, UpdatedAt: now,
|
||
}
|
||
if err := repository.CreateTask(ctx, task); err != nil {
|
||
t.Fatalf("创建任务失败:%v", err)
|
||
}
|
||
got, err := repository.GetTask(ctx, task.ID)
|
||
if err != nil {
|
||
t.Fatalf("读取任务失败:%v", err)
|
||
}
|
||
if got.ID != task.ID || got.Status != "queued" || got.Stage != "queued" {
|
||
t.Fatalf("读取任务内容错误:%+v", got)
|
||
}
|
||
started := now.Add(time.Minute)
|
||
if err := repository.UpdateTask(ctx, task.ID, "running", "certificate_issue", "正在申请证书", &started, nil); err != nil {
|
||
t.Fatalf("更新任务失败:%v", err)
|
||
}
|
||
active, err := repository.FindActiveTaskByDomain(ctx, task.DomainID, task.TaskType)
|
||
if err != nil || active.Status != "running" {
|
||
t.Fatalf("查询活跃任务失败:%+v %v", active, err)
|
||
}
|
||
finished := started.Add(time.Minute)
|
||
if err := repository.UpdateTask(ctx, task.ID, "success", "certificate_issue", "执行成功", &started, &finished); err != nil {
|
||
t.Fatalf("完成任务失败:%v", err)
|
||
}
|
||
if _, err := repository.FindActiveTaskByDomain(ctx, task.DomainID, task.TaskType); err != sql.ErrNoRows {
|
||
t.Fatalf("已完成任务不应被视为活跃任务:%v", err)
|
||
}
|
||
attempt := TaskAttempt{
|
||
ID: "attempt-1", TaskID: task.ID, AttemptNumber: 1, Stage: "certificate_issue",
|
||
Status: "success", Message: "执行成功", StartedAt: &started, FinishedAt: &finished, CreatedAt: finished,
|
||
}
|
||
if err := repository.CreateAttempt(ctx, attempt); err != nil {
|
||
t.Fatalf("创建任务尝试失败:%v", err)
|
||
}
|
||
if err := repository.FinishAttempt(ctx, attempt.ID, "success", "执行成功", &finished); err != nil {
|
||
t.Fatalf("完成任务尝试失败:%v", err)
|
||
}
|
||
attempts, err := repository.ListAttempts(ctx, task.ID)
|
||
if err != nil || len(attempts) != 1 || attempts[0].Status != "success" {
|
||
t.Fatalf("读取任务尝试失败:%+v %v", attempts, err)
|
||
}
|
||
items, err := repository.ListTasks(ctx, task.DomainID, 10)
|
||
if err != nil || len(items) != 1 {
|
||
t.Fatalf("读取任务列表失败:%+v %v", items, err)
|
||
}
|
||
}
|
||
|
||
func TestSQLTaskRepositoryPersistsDeploymentResultsByAttempt(t *testing.T) {
|
||
repository, db := newTaskRepositoryTest(t)
|
||
defer db.Close()
|
||
ctx := context.Background()
|
||
if err := repository.CreateTask(ctx, Task{
|
||
ID: "task-deployment-results", DomainID: "domain-1", TaskType: "certificate_issue",
|
||
Status: "running", Stage: "certificate_issue", Message: "正在执行证书工作流",
|
||
}); err != nil {
|
||
t.Fatalf("创建部署任务失败:%v", err)
|
||
}
|
||
if err := repository.CreateAttempt(ctx, TaskAttempt{
|
||
ID: "attempt-deployment-results", TaskID: "task-deployment-results", AttemptNumber: 1,
|
||
Stage: "certificate_issue", Status: "running", Message: "正在执行证书工作流",
|
||
}); err != nil {
|
||
t.Fatalf("创建部署任务尝试失败:%v", err)
|
||
}
|
||
created := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)
|
||
if err := repository.RecordTaskDeploymentResults(ctx, "task-deployment-results", "attempt-deployment-results", 1, []TaskDeploymentResult{
|
||
{ResultIndex: 0, ServerID: "server-1", Server: "第一台服务器", Status: "success", Message: "部署成功", CreatedAt: created},
|
||
{ResultIndex: 1, ServerID: "server-2", Server: "第二台服务器", Status: "failed", Message: "部署失败", CreatedAt: created},
|
||
}); err != nil {
|
||
t.Fatalf("保存部署结果失败:%v", err)
|
||
}
|
||
items, err := repository.ListTaskDeploymentResults(ctx, "task-deployment-results")
|
||
if err != nil || len(items) != 2 || items[0].AttemptNumber != 1 || items[1].ServerID != "server-2" || items[1].Status != "failed" {
|
||
t.Fatalf("部署结果读取错误:%+v %v", items, err)
|
||
}
|
||
if err := repository.RecordTaskDeploymentResults(ctx, "task-deployment-results", "attempt-deployment-results", 1, []TaskDeploymentResult{
|
||
{ResultIndex: 0, ServerID: "server-1", Server: "第一台服务器", Status: "success", Message: "重新部署成功", CreatedAt: created},
|
||
}); err != nil {
|
||
t.Fatalf("替换部署结果失败:%v", err)
|
||
}
|
||
items, err = repository.ListTaskDeploymentResults(ctx, "task-deployment-results")
|
||
if err != nil || len(items) != 1 || items[0].Message != "重新部署成功" {
|
||
t.Fatalf("部署结果替换错误:%+v %v", items, err)
|
||
}
|
||
}
|
||
func TestSQLTaskRepositoryRejectsInvalidStatus(t *testing.T) {
|
||
repository, db := newTaskRepositoryTest(t)
|
||
defer db.Close()
|
||
err := repository.CreateTask(context.Background(), Task{
|
||
ID: "task-invalid", DomainID: "domain-1", TaskType: "certificate_issue", Status: "invalid", Stage: "queued",
|
||
})
|
||
if err == nil {
|
||
t.Fatal("无效任务状态应被拒绝")
|
||
}
|
||
}
|
||
|
||
func TestSQLTaskRepositoryRejectsConcurrentActiveTaskOfSameType(t *testing.T) {
|
||
repository, db := newTaskRepositoryTest(t)
|
||
defer db.Close()
|
||
ctx := context.Background()
|
||
if err := repository.CreateTask(ctx, Task{
|
||
ID: "task-active", DomainID: "domain-1", TaskType: "certificate_validate",
|
||
Status: "running", Stage: "certificate_validate", Message: "正在校验证书",
|
||
}); err != nil {
|
||
t.Fatalf("创建活动任务失败:%v", err)
|
||
}
|
||
err := repository.CreateTask(ctx, Task{
|
||
ID: "task-duplicate", DomainID: "domain-1", TaskType: "certificate_validate",
|
||
Status: "queued", Stage: "queued", Message: "等待校验证书",
|
||
})
|
||
if !errors.Is(err, ErrTaskAlreadyRunning) {
|
||
t.Fatalf("同类活动任务应被拒绝:%v", err)
|
||
}
|
||
if _, err := repository.GetTask(ctx, "task-duplicate"); !errors.Is(err, sql.ErrNoRows) {
|
||
t.Fatalf("拒绝并发任务后不应留下重复任务:%v", err)
|
||
}
|
||
}
|
||
|
||
func TestSQLTaskRepositoryRejectsConcurrentManagementRenewal(t *testing.T) {
|
||
repository, db := newTaskRepositoryTest(t)
|
||
defer db.Close()
|
||
ctx := context.Background()
|
||
if err := repository.CreateTask(ctx, Task{
|
||
ID: "management-renewal-active", DomainID: ManagementResourceID, TaskType: "management_renewal",
|
||
Status: "running", Stage: "certificate_issue",
|
||
}); err != nil {
|
||
t.Fatalf("创建管理证书活动任务失败:%v", err)
|
||
}
|
||
err := repository.CreateTask(ctx, Task{
|
||
ID: "management-renewal-duplicate", DomainID: ManagementResourceID, TaskType: "management_renewal",
|
||
Status: "queued", Stage: "queued",
|
||
})
|
||
if !errors.Is(err, ErrTaskAlreadyRunning) {
|
||
t.Fatalf("管理证书活动任务应被拒绝:%v", err)
|
||
}
|
||
if _, err := repository.GetTask(ctx, "management-renewal-duplicate"); !errors.Is(err, sql.ErrNoRows) {
|
||
t.Fatalf("拒绝管理证书并发任务后不应留下重复任务:%v", err)
|
||
}
|
||
}
|
||
|
||
func TestSQLTaskRepositoryCancelMarksRunningAttempt(t *testing.T) {
|
||
repository, db := newTaskRepositoryTest(t)
|
||
defer db.Close()
|
||
ctx := context.Background()
|
||
now := time.Now().UTC()
|
||
if err := repository.CreateTask(ctx, Task{
|
||
ID: "task-cancel", DomainID: "domain-1", TaskType: "certificate_issue",
|
||
Status: "running", Stage: "certificate_issue", Message: "正在执行", CreatedAt: now, UpdatedAt: now,
|
||
}); err != nil {
|
||
t.Fatalf("创建取消测试任务失败:%v", err)
|
||
}
|
||
if err := repository.CreateAttempt(ctx, TaskAttempt{
|
||
ID: "attempt-cancel", TaskID: "task-cancel", AttemptNumber: 1,
|
||
Stage: "certificate_issue", Status: "running", Message: "正在执行", StartedAt: &now, CreatedAt: now,
|
||
}); err != nil {
|
||
t.Fatalf("创建取消测试尝试失败:%v", err)
|
||
}
|
||
finished := now.Add(time.Second)
|
||
if err := repository.CancelTask(ctx, "task-cancel", "任务已由用户取消", &finished); err != nil {
|
||
t.Fatalf("取消任务失败:%v", err)
|
||
}
|
||
task, err := repository.GetTask(ctx, "task-cancel")
|
||
if err != nil || task.Status != "cancelled" || task.FinishedAt == nil {
|
||
t.Fatalf("取消后的任务状态错误:%+v %v", task, err)
|
||
}
|
||
attempts, err := repository.ListAttempts(ctx, "task-cancel")
|
||
if err != nil || len(attempts) != 1 || attempts[0].Status != "cancelled" || attempts[0].FinishedAt == nil {
|
||
t.Fatalf("取消后的任务尝试状态错误:%+v %v", attempts, err)
|
||
}
|
||
if err := repository.FinishAttempt(ctx, "attempt-cancel", "success", "晚到的成功结果", &finished); err != nil {
|
||
t.Fatalf("重复完成已取消尝试不应失败:%v", err)
|
||
}
|
||
attempts, err = repository.ListAttempts(ctx, "task-cancel")
|
||
if err != nil || attempts[0].Status != "cancelled" {
|
||
t.Fatalf("已取消尝试不应被晚到结果覆盖:%+v %v", attempts, err)
|
||
}
|
||
}
|
||
|
||
func TestSQLTaskRepositoryCancelMarksAllActiveAttempts(t *testing.T) {
|
||
repository, db := newTaskRepositoryTest(t)
|
||
defer db.Close()
|
||
ctx := context.Background()
|
||
now := time.Now().UTC()
|
||
activeStatuses := []string{"queued", "running", "retry_wait", "cancelling"}
|
||
domainIDs := []string{"domain-1", "domain-2", "domain-3", "domain-1"}
|
||
for index, status := range activeStatuses {
|
||
taskID := "task-cancel-active-" + status
|
||
attemptID := "attempt-cancel-active-" + status
|
||
if err := repository.CreateTask(ctx, Task{
|
||
ID: taskID, DomainID: domainIDs[index], TaskType: "certificate_issue",
|
||
Status: status, Stage: "certificate_issue", Message: "\u6b63\u5728\u6267\u884c", CreatedAt: now, UpdatedAt: now,
|
||
}); err != nil {
|
||
t.Fatalf("create %s task: %v", status, err)
|
||
}
|
||
if err := repository.CreateAttempt(ctx, TaskAttempt{
|
||
ID: attemptID, TaskID: taskID, AttemptNumber: 1,
|
||
Stage: "certificate_issue", Status: status, Message: "\u6b63\u5728\u6267\u884c", StartedAt: &now, CreatedAt: now,
|
||
}); err != nil {
|
||
t.Fatalf("create %s attempt: %v", status, err)
|
||
}
|
||
|
||
finished := now.Add(time.Second)
|
||
if err := repository.CancelTask(ctx, taskID, "\u4efb\u52a1\u5df2\u7531\u7528\u6237\u53d6\u6d88", &finished); err != nil {
|
||
t.Fatalf("cancel %s task: %v", status, err)
|
||
}
|
||
attempts, err := repository.ListAttempts(ctx, taskID)
|
||
if err != nil || len(attempts) != 1 || attempts[0].Status != "cancelled" || attempts[0].FinishedAt == nil {
|
||
t.Fatalf("cancel %s attempt state: %+v %v", status, attempts, err)
|
||
}
|
||
}
|
||
}
|
||
func TestSQLTaskRepositoryDoesNotOverwriteCancelledTask(t *testing.T) {
|
||
repository, db := newTaskRepositoryTest(t)
|
||
defer db.Close()
|
||
ctx := context.Background()
|
||
now := time.Now().UTC()
|
||
if err := repository.CreateTask(ctx, Task{
|
||
ID: "task-state", DomainID: "domain-1", TaskType: "certificate_issue",
|
||
Status: "running", Stage: "certificate_issue", Message: "正在执行", CreatedAt: now, UpdatedAt: now,
|
||
}); err != nil {
|
||
t.Fatalf("创建状态测试任务失败:%v", err)
|
||
}
|
||
finished := now.Add(time.Second)
|
||
if err := repository.CancelTask(ctx, "task-state", "任务已由用户取消", &finished); err != nil {
|
||
t.Fatalf("取消状态测试任务失败:%v", err)
|
||
}
|
||
if err := repository.UpdateTask(ctx, "task-state", "success", "certificate_issue", "晚到的成功结果", &now, &finished); err == nil {
|
||
t.Fatal("已取消任务不应被更新为成功")
|
||
}
|
||
task, err := repository.GetTask(ctx, "task-state")
|
||
if err != nil || task.Status != "cancelled" {
|
||
t.Fatalf("已取消任务状态被覆盖:%+v %v", task, err)
|
||
}
|
||
}
|
||
|
||
func TestSQLTaskRepositoryRecoversInterruptedTasksAndAttempts(t *testing.T) {
|
||
repository, db := newTaskRepositoryTest(t)
|
||
defer db.Close()
|
||
ctx := context.Background()
|
||
created := time.Date(2026, 8, 30, 9, 0, 0, 0, time.UTC)
|
||
for _, item := range []Task{
|
||
{ID: "task-queued", DomainID: "domain-1", TaskType: "certificate_issue", Status: "queued", Stage: "queued", Message: "\u7b49\u5f85\u6267\u884c", CreatedAt: created, UpdatedAt: created},
|
||
{ID: "task-running", DomainID: "domain-2", TaskType: "certificate_issue", Status: "running", Stage: "certificate_issue", Message: "\u6b63\u5728\u6267\u884c", CreatedAt: created, UpdatedAt: created},
|
||
{ID: "task-success", DomainID: "domain-3", TaskType: "certificate_issue", Status: "success", Stage: "certificate_issue", Message: "\u5df2\u5b8c\u6210", CreatedAt: created, UpdatedAt: created},
|
||
} {
|
||
if err := repository.CreateTask(ctx, item); err != nil {
|
||
t.Fatalf("\u521b\u5efa\u6062\u590d\u6d4b\u8bd5\u4efb\u52a1\u5931\u8d25\uff1a%v", err)
|
||
}
|
||
}
|
||
if err := repository.CreateAttempt(ctx, TaskAttempt{
|
||
ID: "attempt-running", TaskID: "task-running", AttemptNumber: 1, Stage: "certificate_issue", Status: "running", Message: "\u6b63\u5728\u6267\u884c", CreatedAt: created,
|
||
}); err != nil {
|
||
t.Fatalf("\u521b\u5efa\u6062\u590d\u6d4b\u8bd5\u5c1d\u8bd5\u5931\u8d25\uff1a%v", err)
|
||
}
|
||
if err := repository.CreateAttempt(ctx, TaskAttempt{
|
||
ID: "attempt-success", TaskID: "task-success", AttemptNumber: 1, Stage: "certificate_issue", Status: "success", Message: "\u5df2\u5b8c\u6210", CreatedAt: created,
|
||
}); err != nil {
|
||
t.Fatalf("\u521b\u5efa\u5df2\u5b8c\u6210\u6d4b\u8bd5\u5c1d\u8bd5\u5931\u8d25\uff1a%v", err)
|
||
}
|
||
if err := repository.CreateAttempt(ctx, TaskAttempt{
|
||
ID: "attempt-success-stale", TaskID: "task-success", AttemptNumber: 2, Stage: "certificate_issue", Status: "running", Message: "\u5386\u53f2\u5c1d\u8bd5", CreatedAt: created,
|
||
}); err != nil {
|
||
t.Fatalf("\u521b\u5efa\u5386\u53f2\u6d3b\u52a8\u5c1d\u8bd5\u5931\u8d25\uff1a%v", err)
|
||
}
|
||
finished := time.Date(2026, 8, 30, 10, 0, 0, 0, time.UTC)
|
||
count, err := repository.RecoverInterruptedTasks(ctx, finished)
|
||
if err != nil || count != 2 {
|
||
t.Fatalf("\u6062\u590d\u4e2d\u65ad\u4efb\u52a1\u6570\u91cf\u9519\u8bef\uff1a%d %v", count, err)
|
||
}
|
||
for _, id := range []string{"task-queued", "task-running"} {
|
||
item, getErr := repository.GetTask(ctx, id)
|
||
if getErr != nil || item.Status != "failed" || item.Stage != "service_restart" || item.FinishedAt == nil || !item.FinishedAt.Equal(finished) {
|
||
t.Fatalf("\u4e2d\u65ad\u4efb\u52a1\u6062\u590d\u7ed3\u679c\u9519\u8bef\uff1a%+v %v", item, getErr)
|
||
}
|
||
}
|
||
completed, err := repository.GetTask(ctx, "task-success")
|
||
if err != nil || completed.Status != "success" {
|
||
t.Fatalf("\u5df2\u5b8c\u6210\u4efb\u52a1\u4e0d\u5e94\u88ab\u6062\u590d\u903b\u8f91\u4fee\u6539\uff1a%+v %v", completed, err)
|
||
}
|
||
attempts, err := repository.ListAttempts(ctx, "task-running")
|
||
if err != nil || len(attempts) != 1 || attempts[0].Status != "failed" || attempts[0].FinishedAt == nil || !attempts[0].FinishedAt.Equal(finished) {
|
||
t.Fatalf("\u8fd0\u884c\u4e2d\u4efb\u52a1\u5c1d\u8bd5\u6062\u590d\u7ed3\u679c\u9519\u8bef\uff1a%+v %v", attempts, err)
|
||
}
|
||
completedAttempts, err := repository.ListAttempts(ctx, "task-success")
|
||
if err != nil || len(completedAttempts) != 2 || completedAttempts[0].Status != "success" || completedAttempts[1].Status != "running" {
|
||
t.Fatalf("\u5df2\u5b8c\u6210\u4efb\u52a1\u5c1d\u8bd5\u4e0d\u5e94\u88ab\u6062\u590d\u903b\u8f91\u4fee\u6539\uff1a%+v %v", completedAttempts, err)
|
||
}
|
||
var auditCount int
|
||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs WHERE action = 'task_recovery'`).Scan(&auditCount); err != nil || auditCount != 2 {
|
||
t.Fatalf("\u4e2d\u65ad\u4efb\u52a1\u5ba1\u8ba1\u6570\u91cf\u9519\u8bef\uff1a%d %v", auditCount, err)
|
||
}
|
||
rows, err := db.QueryContext(ctx, `SELECT resource_id, status, task_id, scope_type, project_id FROM audit_logs WHERE action = 'task_recovery' ORDER BY resource_id`)
|
||
if err != nil {
|
||
t.Fatalf("\u8bfb\u53d6\u4e2d\u65ad\u4efb\u52a1\u5ba1\u8ba1\u5931\u8d25\uff1a%v", err)
|
||
}
|
||
defer rows.Close()
|
||
for _, expectedID := range []string{"task-queued", "task-running"} {
|
||
if !rows.Next() {
|
||
t.Fatalf("\u7f3a\u5c11\u4e2d\u65ad\u4efb\u52a1\u5ba1\u8ba1\uff1a%s", expectedID)
|
||
}
|
||
var resourceID, status, taskID, scopeType, projectID string
|
||
if err := rows.Scan(&resourceID, &status, &taskID, &scopeType, &projectID); err != nil {
|
||
t.Fatalf("\u8bfb\u53d6\u4e2d\u65ad\u4efb\u52a1\u5ba1\u8ba1\u5931\u8d25\uff1a%v", err)
|
||
}
|
||
if resourceID != expectedID || status != "failed" || taskID != expectedID || scopeType != "project" {
|
||
t.Fatalf("\u4e2d\u65ad\u4efb\u52a1\u5ba1\u8ba1\u5185\u5bb9\u9519\u8bef:id=%s status=%s task=%s scope=%s project=%s", resourceID, status, taskID, scopeType, projectID)
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
t.Fatalf("\u8bfb\u53d6\u4e2d\u65ad\u4efb\u52a1\u5ba1\u8ba1\u6d41\u5931\u8d25\uff1a%v", err)
|
||
}
|
||
count, err = repository.RecoverInterruptedTasks(ctx, finished.Add(time.Minute))
|
||
if err != nil || count != 0 {
|
||
t.Fatalf("\u670d\u52a1\u91cd\u542f\u6062\u590d\u4e0d\u5e94\u91cd\u590d\u6267\u884c\uff1a%d %v", count, err)
|
||
}
|
||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs WHERE action = 'task_recovery'`).Scan(&auditCount); err != nil || auditCount != 2 {
|
||
t.Fatalf("\u91cd\u590d\u6062\u590d\u4e0d\u5e94\u65b0\u589e\u5ba1\u8ba1\uff1a%d %v", auditCount, err)
|
||
}
|
||
}
|
||
|
||
func TestSQLTaskRepositoryRecoveryDoesNotTouchHistoricalRestartTaskAttempts(t *testing.T) {
|
||
repository, db := newTaskRepositoryTest(t)
|
||
defer db.Close()
|
||
ctx := context.Background()
|
||
finished := time.Date(2026, 8, 30, 10, 0, 0, 0, time.UTC)
|
||
|
||
for _, item := range []Task{
|
||
{ID: "task-active-recovery", DomainID: "domain-1", TaskType: "certificate_issue", Status: "running", Stage: "certificate_issue"},
|
||
{ID: "task-historical-restart", DomainID: "domain-2", TaskType: "certificate_deploy", Status: "failed", Stage: "service_restart", Message: "历史恢复失败", FinishedAt: &finished},
|
||
} {
|
||
if err := repository.CreateTask(ctx, item); err != nil {
|
||
t.Fatalf("创建恢复边界任务失败:%v", err)
|
||
}
|
||
}
|
||
for _, attempt := range []TaskAttempt{
|
||
{ID: "attempt-active-recovery", TaskID: "task-active-recovery", AttemptNumber: 1, Stage: "certificate_issue", Status: "running"},
|
||
{ID: "attempt-historical-restart", TaskID: "task-historical-restart", AttemptNumber: 1, Stage: "certificate_deploy", Status: "running"},
|
||
} {
|
||
if err := repository.CreateAttempt(ctx, attempt); err != nil {
|
||
t.Fatalf("创建恢复边界任务尝试失败:%v", err)
|
||
}
|
||
}
|
||
|
||
if count, err := repository.RecoverInterruptedTasks(ctx, finished); err != nil || count != 1 {
|
||
t.Fatalf("恢复中断任务数量错误:%d %v", count, err)
|
||
}
|
||
recovered, err := repository.ListAttempts(ctx, "task-active-recovery")
|
||
if err != nil || len(recovered) != 1 || recovered[0].Status != "failed" {
|
||
t.Fatalf("活动任务尝试未被恢复:%+v %v", recovered, err)
|
||
}
|
||
historical, err := repository.ListAttempts(ctx, "task-historical-restart")
|
||
if err != nil || len(historical) != 1 || historical[0].Status != "running" || historical[0].FinishedAt != nil {
|
||
t.Fatalf("历史恢复任务尝试被错误修改:%+v %v", historical, err)
|
||
}
|
||
}
|
||
|
||
func TestSQLTaskRepositoryRecoveryAuditFailureRollsBack(t *testing.T) {
|
||
repository, db := newTaskRepositoryTest(t)
|
||
defer db.Close()
|
||
ctx := context.Background()
|
||
if err := repository.CreateTask(ctx, Task{
|
||
ID: "task-recovery-audit-failure", DomainID: "domain-1", TaskType: "certificate_issue",
|
||
Status: "running", Stage: "certificate_issue", Message: "\u6b63\u5728\u6267\u884c",
|
||
}); err != nil {
|
||
t.Fatalf("\u521b\u5efa\u5ba1\u8ba1\u56de\u6eda\u6d4b\u8bd5\u4efb\u52a1\u5931\u8d25\uff1a%v", err)
|
||
}
|
||
if _, err := db.ExecContext(ctx, `CREATE TRIGGER reject_task_recovery_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(ABORT, 'audit-failure'); END`); err != nil {
|
||
t.Fatalf("\u521b\u5efa\u5ba1\u8ba1\u5931\u8d25\u89e6\u53d1\u5668\u5931\u8d25\uff1a%v", err)
|
||
}
|
||
defer db.ExecContext(context.Background(), `DROP TRIGGER reject_task_recovery_audit`)
|
||
if count, err := repository.RecoverInterruptedTasks(ctx, time.Date(2026, 8, 30, 10, 0, 0, 0, time.UTC)); err == nil || count != 0 {
|
||
t.Fatalf("\u5ba1\u8ba1\u5931\u8d25\u65f6\u5e94\u56de\u6eda\u6062\u590d\uff1a%d %v", count, err)
|
||
}
|
||
item, err := repository.GetTask(ctx, "task-recovery-audit-failure")
|
||
if err != nil || item.Status != "running" || item.Stage != "certificate_issue" {
|
||
t.Fatalf("\u5ba1\u8ba1\u5931\u8d25\u540e\u4efb\u52a1\u72b6\u6001\u672a\u56de\u6eda\uff1a%+v %v", item, err)
|
||
}
|
||
var auditCount int
|
||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs`).Scan(&auditCount); err != nil || auditCount != 0 {
|
||
t.Fatalf("\u5ba1\u8ba1\u5931\u8d25\u540e\u4e0d\u5e94\u4fdd\u7559\u65e5\u5fd7\uff1a%d %v", auditCount, err)
|
||
}
|
||
}
|
||
|
||
func TestSQLTaskRepositoryRejectsConcurrentDuplicateIdempotencyKeys(t *testing.T) {
|
||
repository, db := newTaskRepositoryTest(t)
|
||
defer db.Close()
|
||
|
||
ctx := context.Background()
|
||
start := make(chan struct{})
|
||
results := make(chan error, 2)
|
||
for _, taskID := range []string{"task-idempotency-a", "task-idempotency-b"} {
|
||
go func(taskID string) {
|
||
<-start
|
||
results <- repository.CreateTask(ctx, Task{
|
||
ID: taskID, DomainID: "domain-1", TaskType: "certificate_issue",
|
||
Status: "queued", Stage: "queued", Message: "test",
|
||
IdempotencyKey: "duplicate-key-20260929",
|
||
})
|
||
}(taskID)
|
||
}
|
||
close(start)
|
||
|
||
var succeeded, failed int
|
||
for range 2 {
|
||
if err := <-results; err == nil {
|
||
succeeded++
|
||
} else {
|
||
failed++
|
||
}
|
||
}
|
||
if succeeded != 1 || failed != 1 {
|
||
t.Fatalf("unexpected concurrent idempotency result: success=%d failure=%d", succeeded, failed)
|
||
}
|
||
|
||
items, err := repository.ListTasks(ctx, "domain-1", 10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(items) != 1 || items[0].IdempotencyKey != "duplicate-key-20260929" {
|
||
t.Fatalf("duplicate idempotency key created multiple tasks: %+v", items)
|
||
}
|
||
stored, err := repository.FindTaskByIdempotencyKey(ctx, "domain-1", "duplicate-key-20260929")
|
||
if err != nil || stored.ID != items[0].ID {
|
||
t.Fatalf("idempotency lookup mismatch: %+v %v", stored, err)
|
||
}
|
||
}
|