package handler import ( "testing" "time" "github.com/1024XEngineer/xinfra/server/internal/model" "github.com/1024XEngineer/xinfra/server/internal/service" ) func TestWayneStatusToString(t *testing.T) { for name, tc := range map[string]struct { input int want string }{ "success": {input: 1, want: model.TaskFinished}, "failed": {input: 0, want: model.TaskExecutionFailed}, "unknown": {input: -1, want: "unknown"}, "large number": {input: 99, want: "unknown"}, } { t.Run(name, func(t *testing.T) { got := wayneStatusToString(tc.input) if got != tc.want { t.Fatalf("wayneStatusToString(%d) = %q, want %q", tc.input, got, tc.want) } }) } } func TestParseWaynePublishTaskID(t *testing.T) { for name, tc := range map[string]struct { input string wantResID int64 wantHisID int64 wantOk bool }{ "valid": {input: "wayne:publish:123:456", wantResID: 123, wantHisID: 456, wantOk: true}, "large ids": {input: "wayne:publish:999999999:888888888", wantResID: 999999999, wantHisID: 888888888, wantOk: true}, "wrong prefix": {input: "awx:publish:123:456", wantOk: false}, "missing parts": {input: "wayne:publish:123", wantOk: false}, "extra parts": {input: "wayne:publish:123:456:789", wantOk: false}, "non-numeric": {input: "wayne:publish:abc:456", wantOk: false}, "non-numeric hist": {input: "wayne:publish:123:abc", wantOk: false}, "empty": {input: "", wantOk: false}, "random string": {input: "hello", wantOk: false}, } { t.Run(name, func(t *testing.T) { resID, hisID, ok := parseWaynePublishTaskID(tc.input) if ok != tc.wantOk { t.Fatalf("parseWaynePublishTaskID(%q) ok=%v, want %v", tc.input, ok, tc.wantOk) } if ok && (resID != tc.wantResID || hisID != tc.wantHisID) { t.Fatalf("parseWaynePublishTaskID(%q) = (%d, %d), want (%d, %d)", tc.input, resID, hisID, tc.wantResID, tc.wantHisID) } }) } } func TestWaynePublishTaskID(t *testing.T) { history := service.WayneDeploymentHistory{ResourceID: 42, ID: 100} got := waynePublishTaskID(history) want := "wayne:publish:42:100" if got != want { t.Fatalf("waynePublishTaskID() = %q, want %q", got, want) } } func TestWayneDeploymentTaskName(t *testing.T) { for name, tc := range map[string]struct { history service.WayneDeploymentHistory want string }{ "with name": { history: service.WayneDeploymentHistory{ResourceName: "my-service", ResourceID: 10}, want: "Wayne 服务部署 · my-service", }, "empty name uses ID": { history: service.WayneDeploymentHistory{ResourceName: "", ResourceID: 42}, want: "Wayne 服务部署 · 42", }, "whitespace name uses ID": { history: service.WayneDeploymentHistory{ResourceName: " ", ResourceID: 7}, want: "Wayne 服务部署 · 7", }, } { t.Run(name, func(t *testing.T) { got := wayneDeploymentTaskName(tc.history) if got != tc.want { t.Fatalf("wayneDeploymentTaskName() = %q, want %q", got, tc.want) } }) } } func TestTextForTaskStatus(t *testing.T) { for name, tc := range map[string]struct { status string want string }{ "pending": {status: model.TaskPending, want: "等待"}, "validating": {status: model.TaskValidating, want: "等待"}, "dispatching": {status: model.TaskDispatching, want: "等待"}, "running": {status: model.TaskRunning, want: "执行中"}, "registering": {status: model.TaskRegistering, want: "执行中"}, "canceling": {status: model.TaskCanceling, want: "执行中"}, "rollback_pending": {status: model.TaskRollbackPending, want: "回退中"}, "rolling_back": {status: model.TaskRollingBack, want: "回退中"}, "finished": {status: model.TaskFinished, want: "成功"}, "rolled_back": {status: model.TaskRolledBack, want: "已回退"}, "rollback_failed": {status: model.TaskRollbackFailed, want: "回退失败"}, "rollback_ack": {status: model.TaskRollbackAck, want: "已确认释放"}, "register_failed": {status: model.TaskRegisterFailed, want: "注册失败(实例保留)"}, "canceled": {status: model.TaskCanceled, want: "已取消"}, "execution_failed": {status: model.TaskExecutionFailed, want: "失败"}, "validation_failed": {status: model.TaskValidationFailed, want: "失败"}, "unknown": {status: "unknown_status", want: "失败"}, } { t.Run(name, func(t *testing.T) { got := textForTaskStatus(tc.status) if got != tc.want { t.Fatalf("textForTaskStatus(%q) = %q, want %q", tc.status, got, tc.want) } }) } } func TestClassForTaskStatus(t *testing.T) { for name, tc := range map[string]struct { status string want string }{ "finished": {status: model.TaskFinished, want: "ok"}, "rolled_back": {status: model.TaskRolledBack, want: "ok"}, "execution_failed": {status: model.TaskExecutionFailed, want: "err"}, "validation_failed": {status: model.TaskValidationFailed, want: "err"}, "canceled": {status: model.TaskCanceled, want: "err"}, "rollback_failed": {status: model.TaskRollbackFailed, want: "err"}, "rollback_ack": {status: model.TaskRollbackAck, want: "warn"}, "register_failed": {status: model.TaskRegisterFailed, want: "warn"}, "running": {status: model.TaskRunning, want: "warn"}, "dispatching": {status: model.TaskDispatching, want: "warn"}, "pending": {status: model.TaskPending, want: ""}, "unknown": {status: "unknown_status", want: ""}, } { t.Run(name, func(t *testing.T) { got := classForTaskStatus(tc.status) if got != tc.want { t.Fatalf("classForTaskStatus(%q) = %q, want %q", tc.status, got, tc.want) } }) } } func TestTextForWaynePublishStatus(t *testing.T) { for name, tc := range map[string]struct { status int want string }{ "success": {status: 1, want: "成功"}, "failed": {status: 0, want: "失败"}, "unknown": {status: -1, want: "未知"}, "large": {status: 99, want: "未知"}, } { t.Run(name, func(t *testing.T) { got := textForWaynePublishStatus(tc.status) if got != tc.want { t.Fatalf("textForWaynePublishStatus(%d) = %q, want %q", tc.status, got, tc.want) } }) } } func TestClassForWaynePublishStatus(t *testing.T) { for name, tc := range map[string]struct { status int want string }{ "success": {status: 1, want: "ok"}, "failed": {status: 0, want: "err"}, "unknown": {status: -1, want: ""}, } { t.Run(name, func(t *testing.T) { got := classForWaynePublishStatus(tc.status) if got != tc.want { t.Fatalf("classForWaynePublishStatus(%d) = %q, want %q", tc.status, got, tc.want) } }) } } func TestSplitStdoutLines(t *testing.T) { for name, tc := range map[string]struct { input string want []taskLogLine }{ "single line": { input: "hello world", want: []taskLogLine{{Time: "", Message: "hello world", Class: ""}}, }, "multiple lines": { input: "line1\nline2\nline3", want: []taskLogLine{ {Time: "", Message: "line1", Class: ""}, {Time: "", Message: "line2", Class: ""}, {Time: "", Message: "line3", Class: ""}, }, }, "skip blank lines": { input: "line1\n\n\nline2", want: []taskLogLine{ {Time: "", Message: "line1", Class: ""}, {Time: "", Message: "line2", Class: ""}, }, }, "trim cr": { input: "line1\r\nline2\r\n", want: []taskLogLine{ {Time: "", Message: "line1", Class: ""}, {Time: "", Message: "line2", Class: ""}, }, }, "error line": { input: "TASK FAILED: something went wrong", want: []taskLogLine{{Time: "", Message: "TASK FAILED: something went wrong", Class: "err"}}, }, "ok line": { input: "ok: [task 1] Apply role", want: []taskLogLine{{Time: "", Message: "ok: [task 1] Apply role", Class: "ok"}}, }, "changed line": { input: "changed: [host1] Task result changed", want: []taskLogLine{{Time: "", Message: "changed: [host1] Task result changed", Class: "tag-ok"}}, }, "empty input": { input: "", want: []taskLogLine{}, }, "only whitespace": { input: " \n \n ", want: []taskLogLine{}, }, } { t.Run(name, func(t *testing.T) { got := splitStdoutLines(tc.input) if len(got) != len(tc.want) { t.Fatalf("splitStdoutLines(%q) returned %d lines, want %d", tc.input, len(got), len(tc.want)) } for i := range got { if got[i] != tc.want[i] { t.Fatalf("splitStdoutLines(%q)[%d] = %+v, want %+v", tc.input, i, got[i], tc.want[i]) } } }) } } func TestClassForOutputLine(t *testing.T) { for name, tc := range map[string]struct { input string want string }{ "failed keyword": {input: "TASK FAILED: error occurred", want: "err"}, "fatal keyword": {input: "fatal: [host] unresolvable", want: "err"}, "error keyword": {input: "ERROR: something bad", want: "err"}, "error uppercase": {input: "ConnectionError: timeout", want: "err"}, "ok keyword": {input: "ok: [host1] Apply task", want: "ok"}, "successful": {input: "PLAY RECAP: successful", want: "ok"}, "success keyword": {input: "task completed with success", want: "ok"}, "changed keyword": {input: "changed: [host1] Executed task", want: "tag-ok"}, "plain line": {input: "some random output", want: ""}, "mixed case error": {input: "FAILED: task failed", want: "err"}, } { t.Run(name, func(t *testing.T) { got := classForOutputLine(tc.input) if got != tc.want { t.Fatalf("classForOutputLine(%q) = %q, want %q", tc.input, got, tc.want) } }) } } func TestIsTerminalStatus(t *testing.T) { terminalStatuses := []string{ model.TaskFinished, model.TaskCanceled, model.TaskExecutionFailed, model.TaskValidationFailed, model.TaskRegisterFailed, model.TaskRolledBack, model.TaskRollbackFailed, model.TaskRollbackAck, } for _, status := range terminalStatuses { t.Run("terminal_"+status, func(t *testing.T) { if !isTerminalStatus(status) { t.Fatalf("isTerminalStatus(%q) = false, want true", status) } }) } nonTerminalStatuses := []string{ model.TaskPending, model.TaskValidating, model.TaskDispatching, model.TaskRunning, model.TaskRegistering, model.TaskCanceling, model.TaskRollbackPending, model.TaskRollingBack, "unknown", "", } for _, status := range nonTerminalStatuses { t.Run("non_terminal_"+status, func(t *testing.T) { if isTerminalStatus(status) { t.Fatalf("isTerminalStatus(%q) = true, want false", status) } }) } } func TestFormatTaskLogTime(t *testing.T) { t.Run("zero time", func(t *testing.T) { got := formatTaskLogTime(time.Time{}) if got != "" { t.Fatalf("formatTaskLogTime(zero) = %q, want empty", got) } }) t.Run("normal time", func(t *testing.T) { ts := time.Date(2026, 7, 29, 14, 30, 45, 0, time.UTC) got := formatTaskLogTime(ts) if got != "14:30:45" { t.Fatalf("formatTaskLogTime() = %q, want %q", got, "14:30:45") } }) t.Run("midnight", func(t *testing.T) { ts := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) got := formatTaskLogTime(ts) if got != "00:00:00" { t.Fatalf("formatTaskLogTime() = %q, want %q", got, "00:00:00") } }) } func TestSortTaskLogSummaries(t *testing.T) { t.Run("empty", func(t *testing.T) { items := []taskLogSummary{} sortTaskLogSummaries(items) if len(items) != 0 { t.Fatalf("sortTaskLogSummaries(empty) produced %d items", len(items)) } }) t.Run("single", func(t *testing.T) { items := []taskLogSummary{{UpdatedAt: time.Now()}} sortTaskLogSummaries(items) if len(items) != 1 { t.Fatalf("sortTaskLogSummaries(single) produced %d items", len(items)) } }) t.Run("sorts descending by UpdatedAt", func(t *testing.T) { now := time.Now() items := []taskLogSummary{ {ID: "oldest", UpdatedAt: now.Add(-3 * time.Hour)}, {ID: "newest", UpdatedAt: now}, {ID: "middle", UpdatedAt: now.Add(-1 * time.Hour)}, } sortTaskLogSummaries(items) if items[0].ID != "newest" || items[1].ID != "middle" || items[2].ID != "oldest" { t.Fatalf("sortTaskLogSummaries order wrong: got IDs [%s, %s, %s]", items[0].ID, items[1].ID, items[2].ID) } }) t.Run("already sorted", func(t *testing.T) { now := time.Now() items := []taskLogSummary{ {ID: "a", UpdatedAt: now.Add(-2 * time.Hour)}, {ID: "b", UpdatedAt: now.Add(-1 * time.Hour)}, {ID: "c", UpdatedAt: now}, } sortTaskLogSummaries(items) if items[0].ID != "c" || items[1].ID != "b" || items[2].ID != "a" { t.Fatalf("sortTaskLogSummaries order wrong: got IDs [%s, %s, %s]", items[0].ID, items[1].ID, items[2].ID) } }) } func TestAwxTaskSummary(t *testing.T) { now := time.Now() task := model.DeliveryTask{ ID: "task-123", BusinessLineID: 5, Status: model.TaskFinished, InstanceName: "mysql-01", TargetID: 10, CreatedAt: now.Add(-1 * time.Hour), UpdatedAt: now, } summary := awxTaskSummary(task) if summary.ID != "awx:task-123" { t.Fatalf("awxTaskSummary ID = %q, want %q", summary.ID, "awx:task-123") } if summary.Source != "awx" { t.Fatalf("awxTaskSummary Source = %q, want %q", summary.Source, "awx") } if summary.Service != "mysql" { t.Fatalf("awxTaskSummary Service = %q, want %q", summary.Service, "mysql") } if summary.Name != "MySQL 标准化交付 · mysql-01" { t.Fatalf("awxTaskSummary Name = %q, want %q", summary.Name, "MySQL 标准化交付 · mysql-01") } if summary.Runner != "AWX Job Template #10" { t.Fatalf("awxTaskSummary Runner = %q, want %q", summary.Runner, "AWX Job Template #10") } if summary.Status != model.TaskFinished { t.Fatalf("awxTaskSummary Status = %q, want %q", summary.Status, model.TaskFinished) } if summary.StatusText != "成功" { t.Fatalf("awxTaskSummary StatusText = %q, want %q", summary.StatusText, "成功") } if summary.StatusClass != "ok" { t.Fatalf("awxTaskSummary StatusClass = %q, want %q", summary.StatusClass, "ok") } if summary.BusinessLineID != 5 { t.Fatalf("awxTaskSummary BusinessLineID = %d, want 5", summary.BusinessLineID) } if summary.ReferenceID != "task-123" { t.Fatalf("awxTaskSummary ReferenceID = %q, want %q", summary.ReferenceID, "task-123") } if !summary.CreatedAt.Equal(now.Add(-1 * time.Hour)) { t.Fatalf("awxTaskSummary CreatedAt = %v, want %v", summary.CreatedAt, now.Add(-1*time.Hour)) } if !summary.UpdatedAt.Equal(now) { t.Fatalf("awxTaskSummary UpdatedAt = %v, want %v", summary.UpdatedAt, now) } } func TestWayneTaskSummary(t *testing.T) { now := time.Now() history := service.WayneDeploymentHistory{ ID: 200, ResourceID: 42, ResourceName: "my-service", Status: 1, BusinessLineID: 3, CreatedAt: now, } summary := wayneTaskSummary(history) if summary.ID != "wayne:publish:42:200" { t.Fatalf("wayneTaskSummary ID = %q, want %q", summary.ID, "wayne:publish:42:200") } if summary.Source != "wayne" { t.Fatalf("wayneTaskSummary Source = %q, want %q", summary.Source, "wayne") } if summary.Service != "wayne-deployment" { t.Fatalf("wayneTaskSummary Service = %q, want %q", summary.Service, "wayne-deployment") } if summary.Name != "Wayne 服务部署 · my-service" { t.Fatalf("wayneTaskSummary Name = %q, want %q", summary.Name, "Wayne 服务部署 · my-service") } if summary.Runner != "Wayne Native API" { t.Fatalf("wayneTaskSummary Runner = %q, want %q", summary.Runner, "Wayne Native API") } if summary.Status != model.TaskFinished { t.Fatalf("wayneTaskSummary Status = %q, want %q", summary.Status, model.TaskFinished) } if summary.StatusText != "成功" { t.Fatalf("wayneTaskSummary StatusText = %q, want %q", summary.StatusText, "成功") } if summary.StatusClass != "ok" { t.Fatalf("wayneTaskSummary StatusClass = %q, want %q", summary.StatusClass, "ok") } if summary.BusinessLineID != 3 { t.Fatalf("wayneTaskSummary BusinessLineID = %d, want 3", summary.BusinessLineID) } if summary.ReferenceID != "200" { t.Fatalf("wayneTaskSummary ReferenceID = %q, want %q", summary.ReferenceID, "200") } } func TestParseActualTaskID(t *testing.T) { h := &TaskLogHandler{} for name, tc := range map[string]struct { input string want string }{ "awx task": {input: "awx:task-123", want: "task-123"}, "wayne task": {input: "wayne:publish:42:200", want: "wayne:42"}, "empty": {input: "", want: ""}, "unknown": {input: "unknown:id", want: ""}, "awx no prefix": {input: "awx:", want: ""}, } { t.Run(name, func(t *testing.T) { got := h.parseActualTaskID(tc.input) if got != tc.want { t.Fatalf("parseActualTaskID(%q) = %q, want %q", tc.input, got, tc.want) } }) } }