package tools_test import ( "context" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // substitutionFileList matches the tool's internal list — used to drive fake server routing. var substitutionFileList = []string{ "go.mod", "Taskfile.yml", "Dockerfile", ".gitea/workflows/cd.yml", "README.md", ".context/PROJECT.md", } // contentWithPlaceholder is a template file body that contains the placeholder. const contentWithPlaceholder = "# __PROJECT_NAME__\nmodule __MODULE_PATH__\n" func encodedContent(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } // fileContentsJSON returns a JSON FileContents object for the given path. func fileContentsJSON(path string) string { enc := encodedContent(contentWithPlaceholder) return fmt.Sprintf(`{"path":%q,"sha":"sha-%s","size":40,"content":%q,"encoding":"base64"}`, path, strings.ReplaceAll(path, "/", "-"), enc) } // fileWriteResultJSON returns a minimal FileWriteResult JSON. func fileWriteResultJSON(path string) string { return fmt.Sprintf(`{"content":{"path":%q,"sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`, path) } // newTemplateRepoJSON returns a JSON Repo marked as template. func newTemplateRepoJSON(name string, isTemplate bool) string { return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`, name, name, name, name, isTemplate) } // newGeneratedRepoJSON returns the JSON for the newly generated repo. func newGeneratedRepoJSON(name string) string { return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`, name, name, name, name) } func newCreateProjectTool(srvURL string) *tools.CreateProjectFromTemplate { c := gitea.NewClient(srvURL, "tok") a := allowlist.New([]string{"mathias"}) return tools.NewCreateProjectFromTemplate(c, a, "mathias", "template-go-web") } // TestCreateProjectHappyPath: all 6 files served and substituted. func TestCreateProjectHappyPath(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { // Template repo lookup case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web": _, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true))) // Destination repo lookup — 404 means it doesn't exist yet case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc": w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message":"not found"}`)) // Generate case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate": w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc"))) // File contents GET — handle all 6 substitution files case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"): filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/") _, _ = w.Write([]byte(fileContentsJSON(filePath))) // File contents PUT — handle all 6 substitution files case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"): filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(fileWriteResultJSON(filePath))) default: t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() tool := newCreateProjectTool(srv.URL) result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc","description":"A new service"}`)) require.NoError(t, err) var out struct { FullName string `json:"full_name"` HTMLURL string `json:"html_url"` CloneURL string `json:"clone_url"` DefaultBranch string `json:"default_branch"` FilesSubstituted []string `json:"files_substituted"` PartialFailure string `json:"partial_failure,omitempty"` } require.NoError(t, json.Unmarshal(result, &out)) assert.Equal(t, "mathias/new-svc", out.FullName) assert.Equal(t, "http://gitea.example.com/mathias/new-svc", out.HTMLURL) assert.Equal(t, "main", out.DefaultBranch) assert.ElementsMatch(t, substitutionFileList, out.FilesSubstituted) assert.Empty(t, out.PartialFailure) } // TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network. func TestCreateProjectNameRegexFailure(t *testing.T) { tool := tools.NewCreateProjectFromTemplate( gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-web", ) _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`)) require.Error(t, err) assert.ErrorIs(t, err, gitea.ErrValidation) } // TestCreateProjectAllowlistRejects: owner not in allowlist returns error. func TestCreateProjectAllowlistRejects(t *testing.T) { tool := tools.NewCreateProjectFromTemplate( gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-web", ) _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`)) require.Error(t, err) assert.Contains(t, err.Error(), "allowlist") } // TestCreateProjectTemplateNotTemplate: template repo exists but is not marked as template. func TestCreateProjectTemplateNotTemplate(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // Template lookup returns a non-template repo. if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web" { _, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", false))) return } t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) w.WriteHeader(http.StatusNotFound) })) defer srv.Close() tool := newCreateProjectTool(srv.URL) _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`)) require.Error(t, err) assert.ErrorIs(t, err, gitea.ErrValidation) } // TestCreateProjectDestinationExists: destination repo already exists. func TestCreateProjectDestinationExists(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web": _, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true))) case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc": // Destination exists — return 200. _, _ = w.Write([]byte(newTemplateRepoJSON("new-svc", false))) default: t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() tool := newCreateProjectTool(srv.URL) _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`)) require.Error(t, err) assert.ErrorIs(t, err, gitea.ErrConflict) } // TestCreateProjectMidPassSubstitutionFailure: the 4th file (.gitea/workflows/cd.yml) PUT fails; // the first 3 are substituted, partial_failure is populated, no Go error is returned. func TestCreateProjectMidPassSubstitutionFailure(t *testing.T) { // Files that should succeed (index 0-2 in substitutionFileList). successFiles := map[string]bool{ "go.mod": true, "Taskfile.yml": true, "Dockerfile": true, } // The 4th file (index 3) is .gitea/workflows/cd.yml — its PUT returns 500. failFile := ".gitea/workflows/cd.yml" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web": _, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true))) case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc": w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message":"not found"}`)) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate": w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc"))) case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"): filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/") _, _ = w.Write([]byte(fileContentsJSON(filePath))) case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"): filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/") if filePath == failFile { // Simulate upstream 500. w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"message":"internal server error"}`)) return } if !successFiles[filePath] { t.Errorf("unexpected PUT for file: %s", filePath) w.WriteHeader(http.StatusNotFound) return } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(fileWriteResultJSON(filePath))) default: t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() tool := newCreateProjectTool(srv.URL) result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`)) // Best-effort: no Go error returned, partial state in result. require.NoError(t, err) var out struct { FullName string `json:"full_name"` FilesSubstituted []string `json:"files_substituted"` PartialFailure string `json:"partial_failure,omitempty"` } require.NoError(t, json.Unmarshal(result, &out)) // First 3 files should be in FilesSubstituted. assert.Len(t, out.FilesSubstituted, 3) assert.Contains(t, out.FilesSubstituted, "go.mod") assert.Contains(t, out.FilesSubstituted, "Taskfile.yml") assert.Contains(t, out.FilesSubstituted, "Dockerfile") assert.NotContains(t, out.FilesSubstituted, failFile) // partial_failure should be non-empty. assert.NotEmpty(t, out.PartialFailure, "partial_failure should be populated on mid-pass failure") }