Scert/backend/internal/repository/task_repository_test.go

470 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}