The template name was hardcoded into the binary at startup via
NewCreateProjectFromTemplate("mathias", "template-go-web"), so
generating from a different template (e.g. template-go-agent)
required a code change and restart. The constructor already
parameterised it correctly — the gap was at the tool's input
schema, which never exposed template_name to the caller.
Adds an optional template_name input field. When set, it overrides
the server-configured default for that call only; when omitted,
behavior is unchanged. Template owner stays server-configured —
only the repo name is per-call.
Server-side validation already verifies the resolved template
exists and is marked as a template repo, so no enum constraint
is added — keeps the door open for future templates (go-ml,
go-service, ...) without redeploys.
Adds TestCreateProjectTemplateNameOverride verifying the override
directs both the template lookup and the /generate POST.
Closes #24
323 lines
13 KiB
Go
323 lines
13 KiB
Go
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)
|
|
}
|
|
|
|
// TestCreateProjectTemplateNameOverride (issue #24): per-call template_name overrides the
|
|
// server-configured default, so the same binary can generate from template-go-web or
|
|
// template-go-agent without restart.
|
|
func TestCreateProjectTemplateNameOverride(t *testing.T) {
|
|
var templateLookups, generateCalls []string
|
|
|
|
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-agent":
|
|
templateLookups = append(templateLookups, "template-go-agent")
|
|
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-agent", true)))
|
|
|
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
|
templateLookups = append(templateLookups, "template-go-web")
|
|
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
|
|
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-agent":
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
|
|
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/generate"):
|
|
generateCalls = append(generateCalls, r.URL.Path)
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-agent")))
|
|
|
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
|
|
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/")
|
|
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
|
|
|
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
|
|
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/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()
|
|
|
|
// Server is configured with template-go-web as the default; call overrides to template-go-agent.
|
|
tool := newCreateProjectTool(srv.URL)
|
|
_, err := tool.Call(context.Background(), json.RawMessage(
|
|
`{"owner":"mathias","name":"new-agent","template_name":"template-go-agent"}`,
|
|
))
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, []string{"template-go-agent"}, templateLookups,
|
|
"override must direct the template lookup, not the server default")
|
|
require.Len(t, generateCalls, 1)
|
|
assert.Equal(t, "/api/v1/repos/mathias/template-go-agent/generate", generateCalls[0],
|
|
"override must direct the /generate call too")
|
|
}
|
|
|
|
// 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")
|
|
}
|