package giteaticket import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "sync" "testing" ) func validTicketInput() TicketInput { return TicketInput{ Title: "实现云端固件版本解析 API", Body: `## 溯源 - Parent: FEAT_OTA_001 ## 任务上下文 云端需要能够解析上传的固件包,提取版本号与依赖关系。 ## 验收标准 - [ ] 上传 .bin 固件文件后返回解析结果 JSON - [ ] 解析结果包含 version, dependencies 字段 ## NFRs - 响应时间 P99 < 500ms ## 技术实现思路 使用 Go 读取固件文件头部 metadata。`, Labels: []string{"type/story", "domain/cloud", "priority/high", "status/todo"}, ParentReferenceID: "FEAT_OTA_001", EstimatedHours: 8, } } // ── parseInput tests ── func TestParseInputValid(t *testing.T) { in := validTicketInput() data, _ := json.Marshal(in) ticket, err := parseInput(string(data)) if err != nil { t.Fatalf("unexpected error: %v", err) } if ticket.Title != in.Title { t.Errorf("title mismatch: got %q", ticket.Title) } if len(ticket.Labels) != 4 { t.Errorf("expected 4 labels, got %d", len(ticket.Labels)) } if ticket.ParentReferenceID != "FEAT_OTA_001" { t.Errorf("parent_reference_id mismatch: got %q", ticket.ParentReferenceID) } if ticket.EstimatedHours != 8 { t.Errorf("estimated_hours mismatch: got %d", ticket.EstimatedHours) } } func TestParseInputEmpty(t *testing.T) { _, err := parseInput("") if err == nil { t.Fatal("expected error for empty input") } } func TestParseInputInvalidJSON(t *testing.T) { _, err := parseInput("{bad json}") if err == nil { t.Fatal("expected error for invalid JSON") } } // ── validate tests ── func TestValidateMissingTitle(t *testing.T) { in := validTicketInput() in.Title = "" err := validate(&in) if err == nil || !strings.Contains(err.Error(), "title") { t.Fatalf("expected title error, got: %v", err) } } func TestValidateMissingBody(t *testing.T) { in := validTicketInput() in.Body = "" err := validate(&in) if err == nil || !strings.Contains(err.Error(), "body") { t.Fatalf("expected body error, got: %v", err) } } func TestValidateMissingLabels(t *testing.T) { in := validTicketInput() in.Labels = nil err := validate(&in) if err == nil || !strings.Contains(err.Error(), "labels") { t.Fatalf("expected labels error, got: %v", err) } } func TestValidateMissingParentRef(t *testing.T) { in := validTicketInput() in.ParentReferenceID = "" err := validate(&in) if err == nil || !strings.Contains(err.Error(), "parent_reference_id") { t.Fatalf("expected parent_reference_id error, got: %v", err) } } func TestValidateOptionalEstimatedHours(t *testing.T) { in := validTicketInput() in.EstimatedHours = 0 err := validate(&in) if err != nil { t.Fatalf("estimated_hours should be optional, got: %v", err) } } // ── buildIssueBody tests ── func TestBuildIssueBodyContainsMetadata(t *testing.T) { in := validTicketInput() body := buildIssueBody(&in) if !strings.Contains(body, "FEAT_OTA_001") { t.Error("body missing parent_reference_id") } if !strings.Contains(body, "8 小时") { t.Error("body missing estimated_hours") } if !strings.Contains(body, "type/story") { t.Error("body missing labels") } if !strings.Contains(body, "SAFe 元数据") { t.Error("body missing metadata section header") } } func TestBuildIssueBodyNoHours(t *testing.T) { in := validTicketInput() in.EstimatedHours = 0 body := buildIssueBody(&in) if strings.Contains(body, "预估工时") { t.Error("body should not contain estimated hours when 0") } } // ── labelColor tests ── func TestLabelColor(t *testing.T) { tests := []struct { name string expected string }{ {"type/story", "#0075ca"}, {"domain/cloud", "#7057ff"}, {"priority/high", "#d73a4a"}, {"priority/low", "#e4e669"}, {"status/todo", "#0e8a16"}, {"custom-label", "#ededed"}, } for _, tc := range tests { got := labelColor(tc.name) if got != tc.expected { t.Errorf("labelColor(%q) = %q, want %q", tc.name, got, tc.expected) } } } // ── Name / Description tests ── func TestNameAndDescription(t *testing.T) { tool := New(Config{}, nil) if tool.Name() != "create_gitea_ticket" { t.Errorf("unexpected name: %s", tool.Name()) } if tool.Description() == "" { t.Error("description should not be empty") } } // ── Integration test with mock Gitea server ── func TestCallWithMockGitea(t *testing.T) { var mu sync.Mutex var createdIssue map[string]interface{} labelIDCounter := int64(100) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify auth header auth := r.Header.Get("Authorization") if auth != "token test-token" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } switch { // GET /api/v1/repos/owner/repo/labels case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/labels"): w.Header().Set("Content-Type", "application/json") // Return one existing label json.NewEncoder(w).Encode([]giteaLabelResp{ {ID: 1, Name: "type/story"}, }) // POST /api/v1/repos/owner/repo/labels case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/labels"): var payload map[string]string json.NewDecoder(r.Body).Decode(&payload) mu.Lock() labelIDCounter++ id := labelIDCounter mu.Unlock() w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(giteaLabelResp{ ID: id, Name: payload["name"], }) // POST /api/v1/repos/owner/repo/issues case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/issues"): json.NewDecoder(r.Body).Decode(&createdIssue) w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(giteaIssueResp{ ID: 42, Number: 42, HTMLURL: "https://gitea.example.com/owner/repo/issues/42", Title: createdIssue["title"].(string), State: "open", CreatedAt: "2026-03-11T10:00:00Z", }) default: http.Error(w, "not found", http.StatusNotFound) } })) defer server.Close() tool := New(Config{ BaseURL: server.URL, Token: "test-token", Owner: "owner", Repo: "repo", }, nil) in := validTicketInput() data, _ := json.Marshal(in) result, err := tool.Call(context.Background(), string(data)) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(result, "#42") { t.Error("result missing issue number") } if !strings.Contains(result, "创建成功") { t.Error("result missing success message") } if !strings.Contains(result, "FEAT_OTA_001") { t.Error("result missing parent reference") } if !strings.Contains(result, "8 小时") { t.Error("result missing estimated hours") } // Verify the issue body sent to Gitea contains SAFe metadata if body, ok := createdIssue["body"].(string); ok { if !strings.Contains(body, "SAFe 元数据") { t.Error("issue body missing SAFe metadata") } if !strings.Contains(body, "FEAT_OTA_001") { t.Error("issue body missing parent reference") } } else { t.Error("createdIssue body not captured") } } func TestCallMissingConfig(t *testing.T) { tool := New(Config{}, nil) in := validTicketInput() data, _ := json.Marshal(in) _, err := tool.Call(context.Background(), string(data)) if err == nil || !strings.Contains(err.Error(), "missing Gitea configuration") { t.Fatalf("expected config error, got: %v", err) } } func TestCallInvalidInput(t *testing.T) { tool := New(Config{ BaseURL: "http://localhost", Token: "t", Owner: "o", Repo: "r", }, nil) _, err := tool.Call(context.Background(), "not json") if err == nil { t.Fatal("expected error for invalid input") } } func TestCallValidationError(t *testing.T) { tool := New(Config{ BaseURL: "http://localhost", Token: "t", Owner: "o", Repo: "r", }, nil) in := validTicketInput() in.Title = "" data, _ := json.Marshal(in) _, err := tool.Call(context.Background(), string(data)) if err == nil || !strings.Contains(err.Error(), "title") { t.Fatalf("expected title validation error, got: %v", err) } } // Test that labels resolution creates missing labels func TestCallCreatesNewLabels(t *testing.T) { createdLabels := []string{} var mu sync.Mutex server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/labels"): w.Header().Set("Content-Type", "application/json") // No existing labels json.NewEncoder(w).Encode([]giteaLabelResp{}) case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/labels"): var payload map[string]string json.NewDecoder(r.Body).Decode(&payload) mu.Lock() createdLabels = append(createdLabels, payload["name"]) mu.Unlock() w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(giteaLabelResp{ ID: int64(len(createdLabels)), Name: payload["name"], }) case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/issues"): w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(giteaIssueResp{ ID: 1, Number: 1, HTMLURL: "http://test/1", Title: "t", State: "open", }) default: http.Error(w, "not found", http.StatusNotFound) } })) defer server.Close() tool := New(Config{ BaseURL: server.URL, Token: "tok", Owner: "o", Repo: "r", }, nil) in := validTicketInput() data, _ := json.Marshal(in) _, err := tool.Call(context.Background(), string(data)) if err != nil { t.Fatalf("unexpected error: %v", err) } mu.Lock() defer mu.Unlock() if len(createdLabels) != 4 { t.Errorf("expected 4 labels created, got %d: %v", len(createdLabels), createdLabels) } } func TestFormatResult(t *testing.T) { issue := &giteaIssueResp{ Number: 99, Title: "测试任务", State: "open", HTMLURL: "https://gitea.example.com/issues/99", } ticket := &TicketInput{ ParentReferenceID: "ENAB_KAFKA_001", Labels: []string{"type/enabler"}, EstimatedHours: 4, } result := formatResult(issue, ticket) if !strings.Contains(result, "#99") { t.Error("missing issue number") } if !strings.Contains(result, "ENAB_KAFKA_001") { t.Error("missing parent reference") } if !strings.Contains(result, "4 小时") { t.Error("missing estimated hours") } }