diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go index 688720c..520b242 100644 --- a/cmd/summer/main_test.go +++ b/cmd/summer/main_test.go @@ -27,7 +27,7 @@ func TestToolCommandNames(t *testing.T) { helpWants := map[string][]string{ "make:plugin": {"vendor.plugin"}, - "new": {"--framework", "name"}, + "new": {"--framework", "--no-plugin", "--no-git", "name"}, "make:model": {"[plugin] [name]", "no-migration"}, "make:migration": {"[plugin] [name]"}, "make:command": {"[plugin] [name]"}, @@ -155,6 +155,76 @@ func TestMakeCommandsViaCLI(t *testing.T) { } } +// TestNewCommandViaCLI runs summer new through the CLI root in a parent +// holding a summercms symlink to this checkout. +func TestNewCommandViaCLI(t *testing.T) { + repo, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + parent := t.TempDir() + if err := os.Symlink(repo, filepath.Join(parent, "summercms")); err != nil { + t.Fatal(err) + } + t.Chdir(parent) + + run := func(args ...string) (string, error) { + var buf bytes.Buffer + root, err := bonfire.NewRoot("summer", toolCommands(), &buf) + if err != nil { + t.Fatal(err) + } + root.SetArgs(args) + err = root.Execute() + return buf.String(), err + } + + if out, err := run("new"); err == nil { + t.Fatalf("new without a name succeeded:\n%s", out) + } + if out, err := run("new", "Demo"); err == nil { + t.Fatalf("new Demo succeeded:\n%s", out) + } + if _, err := os.Lstat(filepath.Join(parent, "sm-Demo-app")); !os.IsNotExist(err) { + t.Fatalf("new Demo left sm-Demo-app: %v", err) + } + + out, err := run("new", "demo", "--no-git") + if err != nil { + t.Fatalf("new demo --no-git: %v\n%s", err, out) + } + app := filepath.Join(parent, "sm-demo-app") + for _, rel := range []string{"summer.yaml", "main.go", "plugins/demo/routes.go"} { + if _, err := os.Stat(filepath.Join(app, rel)); err != nil { + t.Fatalf("%s missing: %v", rel, err) + } + } + if _, err := os.Lstat(filepath.Join(app, ".git")); !os.IsNotExist(err) { + t.Fatalf("--no-git created .git: %v", err) + } + env, err := os.ReadFile(filepath.Join(app, ".env")) + if err != nil { + t.Fatal(err) + } + var key string + for _, line := range strings.Split(string(env), "\n") { + if v, ok := strings.CutPrefix(line, "SUMMER_APP__KEY="); ok { + key = v + } + } + if len(key) != 44 { + t.Fatalf("SUMMER_APP__KEY = %q, want a 44-character key", key) + } + for _, want := range []string{"created " + app, "Next steps, inside sm-demo-app:", "summer migrate", "curl http://127.0.0.1:8080/"} { + if !strings.Contains(out, want) { + t.Fatalf("output lacks %q:\n%s", want, out) + } + } + if out, err := run("new", "demo", "--no-git"); err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("second new demo: %v\n%s", err, out) + } +} + func copyHelloApp(t *testing.T) string { t.Helper() src, err := filepath.Abs(filepath.Join("..", "..", "examples", "hello")) diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 89cfd45..924d684 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -272,8 +272,15 @@ func TestMakeAndAddPluginOnHelloCopy(t *testing.T) { if err != nil { t.Fatal(err) } - if !bytes.Contains(work, []byte("plugins/demo")) { - t.Fatalf("go.work missing plugins/demo:\n%s", work) + if !bytes.Contains(work, []byte("./plugins/demo")) { + t.Fatalf("go.work missing ./plugins/demo:\n%s", work) + } + // plugin:add writes go.work use paths relative to the go.work directory, + // so a committed go.work carries no machine-specific path. + for _, line := range strings.Split(string(work), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "/") { + t.Fatalf("go.work has an absolute use path %q:\n%s", line, work) + } } appMod, err := os.ReadFile(filepath.Join(dir, "go.mod")) if err != nil { @@ -324,6 +331,28 @@ func TestMakeAndAddPluginOnHelloCopy(t *testing.T) { } } +// TestMakePluginRoutesUnchanged: plain make:plugin still renders the +// no-route stub; only summer new opts into the starter route. +func TestMakePluginRoutesUnchanged(t *testing.T) { + dir := copyHelloApp(t) + pluginDir, err := MakePlugin(t.Context(), dir, "golem15.demo") + if err != nil { + t.Fatal(err) + } + routes, err := os.ReadFile(filepath.Join(pluginDir, "routes.go")) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(routes, []byte("func (p *Plugin) Routes(pact.Router) error { return nil }")) { + t.Fatalf("routes.go is not the no-route stub:\n%s", routes) + } + for _, unwanted := range []string{`"net/http"`, `"encoding/json"`, "r.Get("} { + if bytes.Contains(routes, []byte(unwanted)) { + t.Fatalf("plain routes.go contains %s:\n%s", unwanted, routes) + } + } +} + func TestAddPluginRejectsConflictingID(t *testing.T) { dir := copyHelloApp(t) ctx := t.Context() diff --git a/internal/build/new_test.go b/internal/build/new_test.go index afb79c4..b565bfa 100644 --- a/internal/build/new_test.go +++ b/internal/build/new_test.go @@ -2,6 +2,8 @@ package build import ( "bytes" + "go/token" + "io/fs" "os" "os/exec" "path/filepath" @@ -12,19 +14,109 @@ import ( // testAppKey is a fixed, valid app key (base64 of 32 bytes) for hermetic tests. const testAppKey = "c3VtbWVyY21zLXRlc3Qta2V5LTMyLWJ5dGVzLWxvbmc=" +// repoRoot is the absolute path of this framework checkout. +func repoRoot(t *testing.T) string { + t.Helper() + repo, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + return repo +} + +// parentWithFramework returns a temp parent holding a summercms symlink to +// this checkout, so NewApp finds the framework as a sibling. +func parentWithFramework(t *testing.T) string { + t.Helper() + parent := t.TempDir() + if err := os.Symlink(repoRoot(t), filepath.Join(parent, "summercms")); err != nil { + t.Fatal(err) + } + return parent +} + +// fakeFramework writes dir/go.mod declaring module and returns dir. +func fakeFramework(t *testing.T, dir, module string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(dir, "go.mod"), "module "+module+"\n\ngo 1.27.0\n") + return dir +} + +// stubBinaryFramework replaces frameworkFromBinary for the test. +func stubBinaryFramework(t *testing.T, dir string) { + t.Helper() + orig := frameworkFromBinary + frameworkFromBinary = func() string { return dir } + t.Cleanup(func() { frameworkFromBinary = orig }) +} + +// gitOut runs git in dir and returns its trimmed output. +func gitOut(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.CommandContext(t.Context(), "git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +// requireGit skips when git is not installed. +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } +} + +// isolateGit points git at an empty configuration (no system or global +// file, temp HOME) and clears every identity variable, while keeping the Go +// caches where they are so the starter plugin's go mod tidy stays offline. +func isolateGit(t *testing.T) { + t.Helper() + requireGit(t) + envOut, err := exec.CommandContext(t.Context(), "go", "env", "GOMODCACHE", "GOCACHE", "GOPATH", "GOENV").Output() + if err != nil { + t.Fatalf("go env: %v", err) + } + vals := strings.Split(strings.TrimSpace(string(envOut)), "\n") + if len(vals) != 4 { + t.Fatalf("go env output %q", envOut) + } + for i, k := range []string{"GOMODCACHE", "GOCACHE", "GOPATH", "GOENV"} { + t.Setenv(k, vals[i]) + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + // Recent git runs auto maintenance detached after a commit; it would + // write into .git while the temp dir is being removed. + t.Setenv("GIT_CONFIG_COUNT", "2") + t.Setenv("GIT_CONFIG_KEY_0", "maintenance.auto") + t.Setenv("GIT_CONFIG_VALUE_0", "false") + t.Setenv("GIT_CONFIG_KEY_1", "gc.auto") + t.Setenv("GIT_CONFIG_VALUE_1", "0") + for _, k := range []string{"GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "EMAIL", "GIT_DIR", "GIT_WORK_TREE"} { + t.Setenv(k, "") + os.Unsetenv(k) + } +} + // TestNewAppSmoke writes an app next to a symlinked framework checkout, // builds it and lists the starter route. func TestNewAppSmoke(t *testing.T) { if testing.Short() { t.Skip("runs go mod tidy and go build") } - repo, err := filepath.Abs(filepath.Join("..", "..")) - if err != nil { - t.Fatal(err) - } parent := t.TempDir() fw := filepath.Join(parent, "summercms") - if err := os.Symlink(repo, fw); err != nil { + if err := os.Symlink(repoRoot(t), fw); err != nil { t.Fatal(err) } ctx := t.Context() @@ -73,3 +165,480 @@ func TestNewAppSmoke(t *testing.T) { } } } + +// TestNewNoPluginBuilds builds an app created with --no-plugin: an empty +// plugin list must compile. +func TestNewNoPluginBuilds(t *testing.T) { + if testing.Short() { + t.Skip("runs go build") + } + parent := parentWithFramework(t) + ctx := t.Context() + dir, err := NewApp(ctx, NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoGit: true, NoPlugin: true}, nil) + if err != nil { + t.Fatal(err) + } + gen := readAppFile(t, dir, "plugins.gen.go") + if !bytes.Contains(gen, []byte("var PluginIDs = []string{}")) { + t.Fatalf("plugins.gen.go has plugins:\n%s", gen) + } + var out bytes.Buffer + if err := App(ctx, dir, &out); err != nil { + t.Fatalf("build: %v\n%s", err, out.String()) + } + if _, err := os.Stat(filepath.Join(dir, "bin", "demo")); err != nil { + t.Fatalf("binary missing: %v", err) + } +} + +func TestNewValidateAppName(t *testing.T) { + var keywords []string + for tok := token.Token(0); tok < 256; tok++ { + if tok.IsKeyword() { + keywords = append(keywords, tok.String()) + } + } + if len(keywords) != 25 { + t.Fatalf("found %d Go keywords, want 25: %v", len(keywords), keywords) + } + for _, name := range []string{"", "Demo", "1x", "a-b", "a.b", "a/b", "a_b", "demo app", "main", "internal"} { + if err := ValidateAppName(name); err == nil { + t.Errorf("ValidateAppName(%q) = nil, want error", name) + } + } + for _, kw := range keywords { + err := ValidateAppName(kw) + if err == nil || !strings.Contains(err.Error(), "keyword") { + t.Errorf("ValidateAppName(%q) = %v, want a keyword error", kw, err) + } + } + for _, name := range []string{"main", "internal"} { + if err := ValidateAppName(name); err == nil || !strings.Contains(err.Error(), "reserved") { + t.Errorf("ValidateAppName(%q) = %v, want a reserved error", name, err) + } + } + for _, name := range []string{"init", "string", "vendor", "testdata", "a1", "demo"} { + if err := ValidateAppName(name); err != nil { + t.Errorf("ValidateAppName(%q) = %v, want nil", name, err) + } + } + // NewApp applies the same rule before touching the filesystem. + parent := t.TempDir() + if _, err := NewApp(t.Context(), NewOptions{Name: "func", Parent: parent, AppKey: testAppKey, NoGit: true}, nil); err == nil { + t.Fatal("NewApp accepted a keyword name") + } + assertDirEntries(t, parent, nil) +} + +// assertDirEntries requires dir to hold exactly want (names, sorted). +func assertDirEntries(t *testing.T, dir string, want []string) { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var got []string + for _, e := range entries { + got = append(got, e.Name()) + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("%s holds %v, want %v", dir, got, want) + } +} + +func TestNewRefusesExistingTarget(t *testing.T) { + cases := []struct { + name string + setup func(t *testing.T, target string) + check func(t *testing.T, target string) + }{ + { + name: "directory", + setup: func(t *testing.T, target string) { + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(target, "keep.txt"), "mine\n") + }, + check: func(t *testing.T, target string) { + assertDirEntries(t, target, []string{"keep.txt"}) + if got := string(readAppFile(t, target, "keep.txt")); got != "mine\n" { + t.Fatalf("keep.txt = %q", got) + } + }, + }, + { + name: "file", + setup: func(t *testing.T, target string) { write(t, target, "mine\n") }, + check: func(t *testing.T, target string) { + data, err := os.ReadFile(target) + if err != nil || string(data) != "mine\n" { + t.Fatalf("file = %q, %v", data, err) + } + }, + }, + { + name: "symlink to a directory", + setup: func(t *testing.T, target string) { + dest := filepath.Join(filepath.Dir(target), "elsewhere") + if err := os.Mkdir(dest, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("elsewhere", target); err != nil { + t.Fatal(err) + } + }, + check: func(t *testing.T, target string) { + if link, err := os.Readlink(target); err != nil || link != "elsewhere" { + t.Fatalf("symlink = %q, %v", link, err) + } + assertDirEntries(t, filepath.Join(filepath.Dir(target), "elsewhere"), nil) + }, + }, + { + name: "dangling symlink", + setup: func(t *testing.T, target string) { _ = os.Symlink("missing", target) }, + check: func(t *testing.T, target string) { + if link, err := os.Readlink(target); err != nil || link != "missing" { + t.Fatalf("symlink = %q, %v", link, err) + } + if _, err := os.Stat(filepath.Join(filepath.Dir(target), "missing")); !os.IsNotExist(err) { + t.Fatalf("dangling symlink target was created: %v", err) + } + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + parent := parentWithFramework(t) + target := filepath.Join(parent, "sm-demo-app") + tc.setup(t, target) + _, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoGit: true}, nil) + if err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("NewApp error = %v, want already exists", err) + } + tc.check(t, target) + }) + } +} + +func TestNewFrameworkResolution(t *testing.T) { + const fwModule = "git.golem15.com/golem15/summercms" + + t.Run("flag wins over siblings", func(t *testing.T) { + stubBinaryFramework(t, "") + parent := t.TempDir() + fakeFramework(t, filepath.Join(parent, "summercms"), fwModule) + flagDir := fakeFramework(t, filepath.Join(t.TempDir(), "fw"), fwModule) + got, err := resolveFramework(parent, flagDir) + if err != nil || got != flagDir { + t.Fatalf("resolveFramework = %q, %v; want %q", got, err, flagDir) + } + }) + t.Run("relative flag resolves against the parent", func(t *testing.T) { + stubBinaryFramework(t, "") + parent := t.TempDir() + want := fakeFramework(t, filepath.Join(parent, "vendor", "fw"), fwModule) + got, err := resolveFramework(parent, "vendor/fw") + if err != nil || got != want { + t.Fatalf("resolveFramework = %q, %v; want %q", got, err, want) + } + }) + t.Run("wrong module in the flag is an error, not a fall-through", func(t *testing.T) { + stubBinaryFramework(t, repoRoot(t)) + parent := t.TempDir() + fakeFramework(t, filepath.Join(parent, "summercms"), fwModule) + flagDir := fakeFramework(t, filepath.Join(t.TempDir(), "other"), "example.com/other") + got, err := resolveFramework(parent, flagDir) + if err == nil || !strings.Contains(err.Error(), "--framework") || !strings.Contains(err.Error(), "example.com/other") { + t.Fatalf("resolveFramework = %q, %v; want a --framework module error", got, err) + } + missing := filepath.Join(t.TempDir(), "nothing") + if got, err := resolveFramework(parent, missing); err == nil { + t.Fatalf("resolveFramework(missing flag) = %q, want error", got) + } + }) + t.Run("summercms before summercms.go", func(t *testing.T) { + stubBinaryFramework(t, "") + parent := t.TempDir() + want := fakeFramework(t, filepath.Join(parent, "summercms"), fwModule) + fakeFramework(t, filepath.Join(parent, "summercms.go"), fwModule) + got, err := resolveFramework(parent, "") + if err != nil || got != want { + t.Fatalf("resolveFramework = %q, %v; want %q", got, err, want) + } + }) + t.Run("sibling with another module is skipped", func(t *testing.T) { + stubBinaryFramework(t, "") + parent := t.TempDir() + fakeFramework(t, filepath.Join(parent, "summercms"), "example.com/notframework") + want := fakeFramework(t, filepath.Join(parent, "summercms.go"), fwModule) + got, err := resolveFramework(parent, "") + if err != nil || got != want { + t.Fatalf("resolveFramework = %q, %v; want %q", got, err, want) + } + }) + t.Run("binary source is used last", func(t *testing.T) { + bin := fakeFramework(t, filepath.Join(t.TempDir(), "src"), fwModule) + stubBinaryFramework(t, bin) + parent := t.TempDir() + got, err := resolveFramework(parent, "") + if err != nil || got != bin { + t.Fatalf("resolveFramework = %q, %v; want %q", got, err, bin) + } + // A sibling still wins over the binary source. + sib := fakeFramework(t, filepath.Join(parent, "summercms.go"), fwModule) + if got, err := resolveFramework(parent, ""); err != nil || got != sib { + t.Fatalf("resolveFramework = %q, %v; want sibling %q", got, err, sib) + } + }) + t.Run("binary source with another module is ignored", func(t *testing.T) { + stubBinaryFramework(t, fakeFramework(t, filepath.Join(t.TempDir(), "src"), "example.com/other")) + if got, err := resolveFramework(t.TempDir(), ""); err == nil { + t.Fatalf("resolveFramework = %q, want error", got) + } + }) + t.Run("nothing found names --framework and writes nothing", func(t *testing.T) { + stubBinaryFramework(t, "") + parent := t.TempDir() + _, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoGit: true}, nil) + if err == nil || !strings.Contains(err.Error(), "--framework") { + t.Fatalf("NewApp error = %v, want one naming --framework", err) + } + assertDirEntries(t, parent, nil) + }) +} + +func TestNewRefusesTargetInsideFramework(t *testing.T) { + stubBinaryFramework(t, "") + fw := fakeFramework(t, filepath.Join(t.TempDir(), "summercms"), frameworkModule) + for _, tc := range []struct { + name, parent, flag string + }{ + {"framework root", fw, "."}, + {"subdirectory", filepath.Join(fw, "apps"), ".."}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := os.MkdirAll(tc.parent, 0o755); err != nil { + t.Fatal(err) + } + before, err := os.ReadDir(tc.parent) + if err != nil { + t.Fatal(err) + } + _, err = NewApp(t.Context(), NewOptions{Name: "demo", Parent: tc.parent, Framework: tc.flag, AppKey: testAppKey, NoGit: true}, nil) + if err == nil || !strings.Contains(err.Error(), "inside the framework checkout") { + t.Fatalf("NewApp error = %v, want inside the framework checkout", err) + } + after, err := os.ReadDir(tc.parent) + if err != nil { + t.Fatal(err) + } + if len(after) != len(before) { + t.Fatalf("%s changed: %d entries before, %d after", tc.parent, len(before), len(after)) + } + }) + } +} + +func TestNewCleansUpOnFailure(t *testing.T) { + stubBinaryFramework(t, "") + // The framework go.mod is valid but the checkout has no packages, so the + // starter plugin's go mod tidy fails after the target was created. + t.Setenv("GOPROXY", "off") + parent := t.TempDir() + fw := fakeFramework(t, filepath.Join(parent, "summercms"), frameworkModule) + write(t, filepath.Join(parent, "notes.txt"), "keep me\n") + + var out bytes.Buffer + _, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, Framework: fw, AppKey: testAppKey, NoGit: true}, &out) + if err == nil { + t.Fatalf("NewApp succeeded against an empty framework\n%s", out.String()) + } + if !strings.Contains(err.Error(), "removed it") { + t.Fatalf("error %v does not say the target was removed", err) + } + if _, err := os.Lstat(filepath.Join(parent, "sm-demo-app")); !os.IsNotExist(err) { + t.Fatalf("sm-demo-app remains after a failed run: %v", err) + } + assertDirEntries(t, parent, []string{"notes.txt", "summercms"}) + if got := string(readAppFile(t, parent, "notes.txt")); got != "keep me\n" { + t.Fatalf("notes.txt = %q", got) + } + assertDirEntries(t, fw, []string{"go.mod"}) +} + +func TestNewRequiresValidAppKey(t *testing.T) { + stubBinaryFramework(t, "") + // No framework is reachable: a key error must come first. + parent := t.TempDir() + for _, key := range []string{"", "AAAA", "c2hvcnQ=", "not base64 at all!", testAppKey[:43]} { + _, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: key, NoGit: true}, nil) + if err == nil || !strings.Contains(err.Error(), "application key") { + t.Errorf("key %q: error = %v, want an application key error", key, err) + } + } + assertDirEntries(t, parent, nil) +} + +func TestNewEnvFileMode(t *testing.T) { + parent := parentWithFramework(t) + dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoGit: true, NoPlugin: true}, nil) + if err != nil { + t.Fatal(err) + } + for rel, want := range map[string]fs.FileMode{".env": 0o600, ".env.example": 0o644, "compose.yaml": 0o644} { + st, err := os.Stat(filepath.Join(dir, rel)) + if err != nil { + t.Fatal(err) + } + if got := st.Mode().Perm(); got != want { + t.Errorf("%s mode = %o, want %o", rel, got, want) + } + } + env := string(readAppFile(t, dir, ".env")) + if !strings.Contains(env, "SUMMER_APP__KEY="+testAppKey+"\n") { + t.Fatalf(".env lacks the key:\n%s", env) + } +} + +func TestNewAgentsSymlink(t *testing.T) { + parent := parentWithFramework(t) + dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoGit: true, NoPlugin: true}, nil) + if err != nil { + t.Fatal(err) + } + link, err := os.Readlink(filepath.Join(dir, "AGENTS.md")) + if err != nil || link != "CLAUDE.md" { + t.Fatalf("AGENTS.md -> %q, %v; want CLAUDE.md", link, err) + } + if !bytes.Equal(readAppFile(t, dir, "AGENTS.md"), readAppFile(t, dir, "CLAUDE.md")) { + t.Fatal("AGENTS.md does not resolve to CLAUDE.md") + } +} + +func TestNewGitFirstCommit(t *testing.T) { + isolateGit(t) + parent := parentWithFramework(t) + var out bytes.Buffer + dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey}, &out) + if err != nil { + t.Fatalf("NewApp: %v\n%s", err, out.String()) + } + if got := gitOut(t, dir, "rev-list", "--count", "HEAD"); got != "1" { + t.Fatalf("commit count = %s, want 1", got) + } + if got := gitOut(t, dir, "symbolic-ref", "--short", "HEAD"); got != "main" { + t.Fatalf("branch = %s, want main", got) + } + if got := gitOut(t, dir, "log", "-1", "--format=%an <%ae>|%cn <%ce>"); got != "SummerCMS |SummerCMS " { + t.Fatalf("identity = %s", got) + } + if !strings.Contains(out.String(), "warning: git user.name and user.email not configured") { + t.Fatalf("no fallback identity warning:\n%s", out.String()) + } + // The fallback is passed per command, never written to the repository. + for _, key := range []string{"user.name", "user.email"} { + cmd := exec.CommandContext(t.Context(), "git", "config", key) + cmd.Dir = dir + if res, err := cmd.CombinedOutput(); err == nil { + t.Fatalf("git config %s is set after summer new: %s", key, res) + } + } + tracked := strings.Split(gitOut(t, dir, "ls-files"), "\n") + has := func(name string) bool { + for _, f := range tracked { + if f == name { + return true + } + } + return false + } + for _, want := range []string{".env.example", ".gitignore", "go.work", "main.go", "plugins.gen.go", "AGENTS.md", "CLAUDE.md", "summer.yaml", "plugins/demo/plugin.go"} { + if !has(want) { + t.Errorf("%s is not tracked: %v", want, tracked) + } + } + if has(".env") { + t.Errorf(".env is tracked: %v", tracked) + } + if got := gitOut(t, dir, "status", "--porcelain"); got != "" { + t.Errorf("working tree not clean after the first commit:\n%s", got) + } + if got := gitOut(t, dir, "log", "-1", "--format=%B"); strings.Contains(got, "Co-Authored-By") || got != "Create sm-demo-app with summer new" { + t.Errorf("commit message = %q", got) + } +} + +func TestNewGitUsesConfiguredIdentity(t *testing.T) { + isolateGit(t) + t.Setenv("GIT_AUTHOR_NAME", "Ada Lovelace") + t.Setenv("GIT_AUTHOR_EMAIL", "ada@example.com") + t.Setenv("GIT_COMMITTER_NAME", "Ada Lovelace") + t.Setenv("GIT_COMMITTER_EMAIL", "ada@example.com") + parent := parentWithFramework(t) + var out bytes.Buffer + dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoPlugin: true}, &out) + if err != nil { + t.Fatalf("NewApp: %v\n%s", err, out.String()) + } + if got := gitOut(t, dir, "log", "-1", "--format=%an <%ae>|%cn <%ce>"); got != "Ada Lovelace |Ada Lovelace " { + t.Fatalf("identity = %s", got) + } + if strings.Contains(out.String(), "warning") { + t.Fatalf("unexpected warning with a configured identity:\n%s", out.String()) + } +} + +func TestNewGitMissingIsWarning(t *testing.T) { + goBin, err := exec.LookPath("go") + if err != nil { + t.Fatal(err) + } + goBin, err = filepath.Abs(goBin) + if err != nil { + t.Fatal(err) + } + tools := t.TempDir() + if err := os.Symlink(goBin, filepath.Join(tools, "go")); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", tools) + if _, err := exec.LookPath("git"); err == nil { + t.Fatal("git still on PATH") + } + parent := parentWithFramework(t) + var out bytes.Buffer + dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey}, &out) + if err != nil { + t.Fatalf("NewApp without git: %v\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "warning: git not found on PATH") { + t.Fatalf("no git warning:\n%s", out.String()) + } + if _, err := os.Stat(filepath.Join(dir, "summer.yaml")); err != nil { + t.Fatalf("app incomplete: %v", err) + } + if _, err := os.Lstat(filepath.Join(dir, ".git")); !os.IsNotExist(err) { + t.Fatalf(".git exists without git: %v", err) + } +} + +func TestNewNoGit(t *testing.T) { + requireGit(t) + parent := parentWithFramework(t) + var out bytes.Buffer + dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoGit: true, NoPlugin: true}, &out) + if err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(filepath.Join(dir, ".git")); !os.IsNotExist(err) { + t.Fatalf("--no-git created .git: %v", err) + } + if strings.Contains(out.String(), "warning") { + t.Fatalf("--no-git printed a warning:\n%s", out.String()) + } + if !strings.Contains(out.String(), "created "+dir) { + t.Fatalf("output lacks the created line:\n%s", out.String()) + } +} diff --git a/modules/lagoon/keygen_test.go b/modules/lagoon/keygen_test.go index 6ba334c..6cf6dd4 100644 --- a/modules/lagoon/keygen_test.go +++ b/modules/lagoon/keygen_test.go @@ -39,7 +39,8 @@ func TestEncryptedKeyGeneratePrints32ByteBase64(t *testing.T) { } } -func TestGenerateAppKeyIs32ByteBase64(t *testing.T) { +// TestGenerateAppKey: 44 characters, 32 decoded bytes, distinct values. +func TestGenerateAppKey(t *testing.T) { a, err := GenerateAppKey() if err != nil { t.Fatal(err)