Files
summercms/examples/hello/hello_test.go
Jakub Zych 8d4d7ce7a7 feat(bonfire): global flags, Artisan list and exit codes
- Global options -v/-vv/-vvv, -q, --ansi/--no-ansi, -n and --env on every
  command; PeekGlobals reads them before config loads, OnGlobals hooks
  re-apply them after parsing (diary.ApplyGlobals in the generated main)
- Bare binary, --help and list print the Artisan list with the version
  header from BuildVersion and namespace blocks
- Execute runs the root through fang (no version, man page or completions)
  and returns 0, 1 or 2; Fail renders errors before the root exists
- NewRoot rejects duplicate command names (ErrDuplicateCommand) and flags
  that reuse a global option (ErrReservedFlag)
- Generated main and summer use PeekGlobals, compass.Open with --env,
  diary.FromGlobals and bonfire.Execute; docs checker knows the globals
2026-10-21 12:13:23 +02:00

340 lines
10 KiB
Go

package main
import (
"bytes"
"context"
"crypto/sha256"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"slices"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/postcard"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/golem15/summercms/modules/towel"
)
func TestGreeterHelloPrintsLayeredConfig(t *testing.T) {
got := runHello(t, nil)
want := "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional events=ok collected=greeter handled=true"
assertGreeting(t, got, want)
}
func TestGreeterHelloSeesDevelopmentOverlay(t *testing.T) {
got := runHello(t, map[string]string{"SUMMER_ENV": "development"})
want := "name=hello-dev posts_per_page=10 debug=true extra=hello-from-optional events=ok collected=greeter handled=true"
assertGreeting(t, got, want)
}
func TestGreeterHelloSeesEnvVarOverrideOfPluginDefault(t *testing.T) {
got := runHello(t, map[string]string{
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE": "25",
})
want := "name=hello-app posts_per_page=25 debug=false extra=hello-from-optional events=ok collected=greeter handled=true"
assertGreeting(t, got, want)
}
func runHello(t *testing.T, env map[string]string) string {
t.Helper()
for k, v := range env {
t.Setenv(k, v)
}
var buf bytes.Buffer
if code := run([]string{"greeter:hello"}, &buf); code != 0 {
t.Fatalf("greeter:hello: exit %d\n%s", code, buf.String())
}
return strings.TrimSpace(buf.String())
}
func TestGreeterRunsWithoutOptionalPlugin(t *testing.T) {
got := runHelloIDs(t, []string{"golem15.hello", "golem15.greeter"}, nil)
want := "name=hello-app posts_per_page=10 debug=false extra= events=ok collected=greeter handled=true"
assertGreeting(t, got, want)
}
func runHelloIDs(t *testing.T, ids []string, env map[string]string) string {
t.Helper()
for k, v := range env {
t.Setenv(k, v)
}
cfg, err := compass.Load("config")
if err != nil {
t.Fatalf("Load: %v", err)
}
app := backpack.New(cfg)
plugins, err := party.Activate(app, ids)
if err != nil {
t.Fatalf("Activate: %v", err)
}
var commands []bonfire.Command
for _, plugin := range plugins {
if hasCommands, ok := plugin.(pact.HasCommands); ok {
commands = append(commands, hasCommands.Commands()...)
}
}
var buf bytes.Buffer
root, err := bonfire.NewRoot("hello", commands, &buf)
if err != nil {
t.Fatalf("NewRoot: %v", err)
}
root.SetArgs([]string{"greeter:hello"})
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
return strings.TrimSpace(buf.String())
}
func TestBuiltBinaryGreeterHello(t *testing.T) {
first := runSummerBuild(t)
h1 := generatedHash(t)
second := runSummerBuild(t)
h2 := generatedHash(t)
if h1 != h2 {
t.Fatalf("generated files changed on second build\nfirst:\n%s\nsecond:\n%s", first, second)
}
cmd := exec.Command("./bin/hello", "greeter:hello")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("bin/hello greeter:hello: %v\n%s", err, out)
}
got := strings.TrimSpace(string(out))
want := "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional events=ok collected=greeter handled=true"
assertGreeting(t, got, want)
}
func TestHelloPluginResolvesLang(t *testing.T) {
cfg, err := compass.Load("config")
if err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
if _, err := party.Activate(app, []string{"golem15.hello"}); err != nil {
t.Fatal(err)
}
tr, ok := app.Lookup[*phrasebook.Translator]()
if !ok {
t.Fatal("translator not published")
}
ctx := towel.WithLocale(context.Background(), "pl")
if got := tr.Get(ctx, "golem15.hello::lang.greeting", nil); got != "Cześć" {
t.Fatalf("towel locale = %q", got)
}
if got := tr.Get(context.Background(), "golem15.hello::lang.greeting", nil); got != "Hello" {
t.Fatalf("app locale default = %q", got)
}
if got := tr.GetIn("en", "golem15.hello::lang.labels.pluginName", nil); got != "Hello plugin" {
t.Fatalf("en = %q", got)
}
if got := tr.GetIn("pl", "golem15.hello::lang.labels.pluginName", nil); got != "Wtyczka Hello" {
t.Fatalf("pl = %q", got)
}
if got := tr.GetIn("en", "golem15.hello::lang.greeting", nil); got != "Hello" {
t.Fatalf("greeting = %q", got)
}
if got := tr.GetIn("pl-PL", "golem15.hello::lang.greeting", nil); got != "Cześć" {
t.Fatalf("pl-PL = %q", got)
}
if got := tr.GetIn("de", "golem15.hello::lang.greeting", nil); got != "Hello" {
t.Fatalf("fallback en = %q", got)
}
const missing = "golem15.hello::lang.does.not.exist"
if got := tr.GetIn("pl-PL", missing, nil); got != missing {
t.Fatalf("raw key = %q", got)
}
}
func TestHelloPluginSendsMail(t *testing.T) {
cfg, err := compass.Load("config")
if err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
if _, err := party.Activate(app, []string{"golem15.hello"}); err != nil {
t.Fatal(err)
}
mail, ok := app.Lookup[postcard.Mailer]()
if !ok {
t.Fatal("mailer not published")
}
if err := mail.Send(t.Context(), postcard.Message{
Template: "golem15.hello::mail.hello",
To: []string{"ada@example.test"},
Vars: map[string]any{"Name": "Ada", "URL": "https://example.test"},
}); err != nil {
t.Fatal(err)
}
if err := mail.Send(t.Context(), postcard.Message{
Template: "golem15.hello::mail.hello-en",
To: []string{"ada@example.test"},
Vars: map[string]any{"Name": "Ada", "URL": "https://example.test"},
}); err != nil {
t.Fatal(err)
}
err = mail.Send(t.Context(), postcard.Message{
Template: "golem15.hello::mail.does-not-exist",
To: []string{"ada@example.test"},
})
if err == nil || !strings.Contains(err.Error(), "golem15.hello::mail.does-not-exist") {
t.Fatalf("unknown template error = %v", err)
}
}
func TestTypedItemRoute(t *testing.T) {
cfg, err := compass.Load("config")
if err != nil {
t.Fatal(err)
}
application := backpack.New(cfg)
plugins, err := party.Activate(application, PluginIDs)
if err != nil {
t.Fatal(err)
}
h, err := surf.Assemble(application, plugins)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/items/nope", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("malformed id status = %d", rec.Code)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/items/99", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("unknown id status = %d", rec.Code)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/items/1", nil))
if rec.Code != http.StatusOK || rec.Body.String() != `{"id":1}` {
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/kinds/other", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("enum reject status = %d", rec.Code)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/kinds/widget", nil))
if rec.Code != http.StatusOK || rec.Body.String() != `{"kind":"widget"}` {
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
}
}
func TestHelpListsServe(t *testing.T) {
var buf bytes.Buffer
if code := run([]string{"--help"}, &buf); code != 0 {
t.Fatalf("--help: exit %d\n%s", code, buf.String())
}
help := buf.String()
for _, name := range []string{"Available commands:", "serve", "migrate", "migrate:status", "migrate:rollback"} {
if !strings.Contains(help, name) {
t.Fatalf("help missing %s:\n%s", name, help)
}
}
}
func TestMigrateWithoutDSNFailsLoudly(t *testing.T) {
var buf bytes.Buffer
if code := run([]string{"migrate"}, &buf); code != 1 {
t.Fatalf("migrate without a DSN: exit %d, want 1\n%s", code, buf.String())
}
runSummerBuild(t)
cmd := exec.Command("./bin/hello", "migrate")
var stderr bytes.Buffer
cmd.Stderr = &stderr
err := cmd.Run()
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 {
t.Fatalf("bin/hello migrate: %v, want exit 1\n%s", err, stderr.String())
}
if !strings.Contains(stderr.String(), "database.dsn") {
t.Fatalf("stderr does not name database.dsn:\n%s", stderr.String())
}
}
func TestBuiltBinaryUnknownCommandExitsNonZero(t *testing.T) {
runSummerBuild(t)
cmd := exec.Command("./bin/hello", "does:not-exist")
out, err := cmd.CombinedOutput()
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 2 {
t.Fatalf("unknown command: %v, want exit 2, output:\n%s", err, out)
}
}
// TestBuiltBinaryListsCommands: the bare binary prints the Artisan list with
// the version header and the plugin namespace, and global options are
// accepted before a command.
func TestBuiltBinaryListsCommands(t *testing.T) {
runSummerBuild(t)
cmd := exec.Command("./bin/hello")
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
t.Fatalf("bin/hello: %v\n%s%s", err, stdout.String(), stderr.String())
}
got := stdout.String()
for _, want := range []string{"SummerCMS hello", "Available commands:", "greeter:hello"} {
if !strings.Contains(got, want) {
t.Fatalf("list missing %q:\n%s", want, got)
}
}
if !slices.Contains(strings.Split(got, "\n"), " greeter") {
t.Fatalf("list has no greeter namespace line:\n%s", got)
}
verbose := exec.Command("./bin/hello", "-vv", "list")
if out, err := verbose.CombinedOutput(); err != nil {
t.Fatalf("bin/hello -vv list: %v\n%s", err, out)
}
}
func assertGreeting(t *testing.T, got, want string) {
t.Helper()
if !strings.Contains(got, "plugin\tstatus") || !strings.Contains(got, "golem15.greeter\tactive") {
t.Fatalf("missing non-tty table:\n%s", got)
}
if lastLine(got) != want {
t.Fatalf("greeting = %q, want %q\nfull:\n%s", lastLine(got), want, got)
}
}
func lastLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.LastIndex(s, "\n"); i >= 0 {
return s[i+1:]
}
return s
}
func runSummerBuild(t *testing.T) string {
t.Helper()
cmd := exec.Command("go", "run", "../../cmd/summer", "build")
cmd.Dir = "."
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("summer build: %v\n%s", err, out)
}
return string(out)
}
func generatedHash(t *testing.T) [32]byte {
t.Helper()
var buf bytes.Buffer
for _, name := range []string{"main.go", "plugins.gen.go"} {
body, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
buf.Write(body)
}
return sha256.Sum256(buf.Bytes())
}