Files
summercms/modules/surf/serve_summary_smoke_test.go
Jakub Zych 168d65cd1b feat(conga,dev): worker and scheduler reports, summer dev runs serve
- queue:work and schedule:run print a startup summary and a shutdown
  report on a terminal, log them as records otherwise, and return nil
  after a clean stop
- schedule:run --once ends with 'Ran N due entries (M failed)'
- summer dev runs bin/<app> serve with the forwarded global options,
  reports through Output, stops the old server before starting the new
  one and prints a dev report on Ctrl+C (exit 0)
- lagoon.DatabaseLabel describes a DSN without its secrets
- Long help and examples for queue:work, schedule:run, dev, docs:serve
2026-10-21 13:04:27 +02:00

236 lines
7.3 KiB
Go

package surf
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"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/conga"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/party"
)
// summaryPlugin is a plugin with an ID and required plugins.
type summaryPlugin struct {
id string
req []string
}
func (p summaryPlugin) ID() string { return p.id }
func (p summaryPlugin) Requires() []string { return p.req }
func (p summaryPlugin) Register(*backpack.App) error { return nil }
func (p summaryPlugin) Boot(*backpack.App) error { return nil }
// summaryApp is an app whose config holds a DSN with a user and password.
func summaryApp(t *testing.T) *backpack.App {
t.Helper()
dir := t.TempDir()
files := map[string]string{
"app.yaml": "name: acme\n",
"database.yaml": "dsn: postgres://summer_user:pa55-secret@db.internal:5433/acme_prod?sslmode=disable\n",
"mail.yaml": "driver: smtp\n",
"cache.yaml": "driver: redis\n",
"realtime.yaml": "driver: centrifugo\n",
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "staging", Environ: []string{"SUMMER_ENV=staging"}})
if err != nil {
t.Fatal(err)
}
return backpack.New(cfg)
}
func TestServeSummarySmoke(t *testing.T) {
plugins := []party.Plugin{
summaryPlugin{id: "acme.user"},
summaryPlugin{id: "acme.blog", req: []string{"acme.user"}},
}
facts := buildFacts(summaryApp(t), factInputs{
addr: "127.0.0.1:8096",
bin: "bin/acme",
plugins: plugins,
queues: []string{"default", "mail"},
worker: true,
adminPrefix: "/backend",
mounted: true,
admins: adminsNone,
pending: []lagoon.MigrationRef{
{Plugin: "summercms.cabana", ID: "0001"},
{Plugin: "acme.blog", ID: "0001_posts"},
{Plugin: "acme.blog", ID: "0002_tags"},
},
version: bonfire.Version{App: "v1.4.0", Framework: "v0.4.0"},
})
var block bytes.Buffer
out := bonfire.NewOutput(strings.NewReader(""), &block, &block)
printSummary(out, facts)
got := block.String()
for _, want := range []string{
"SummerCMS acme v1.4.0 (framework v0.4.0)",
"Environment staging",
"http://127.0.0.1:8096/",
"http://127.0.0.1:8096/backend",
"db.internal:5433/acme_prod",
"3 pending — run bin/acme migrate",
"2 (acme.user, acme.blog)",
"default, mail (worker in serve)",
"Mail smtp",
"Cache redis",
"Realtime centrifugo",
"Search null",
"no administrators yet",
} {
if !strings.Contains(got, want) {
t.Errorf("summary block lacks %q:\n%s", want, got)
}
}
if strings.Contains(got, "requires acme.user") {
t.Errorf("verbosity 0 prints per-plugin lines:\n%s", got)
}
verbose := facts.details(1)
var perPlugin bool
for _, d := range verbose {
if strings.TrimSpace(d.Label) == "acme.blog" && strings.Contains(d.Value, "requires acme.user; 2 pending migrations") {
perPlugin = true
}
}
if !perPlugin {
t.Errorf("verbosity 1 lacks the acme.blog line: %#v", verbose)
}
var logs bytes.Buffer
logSummary(context.Background(), slog.New(slog.NewJSONHandler(&logs, nil)), facts)
var summary map[string]any
listening := 0
for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") {
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("log line %q: %v", line, err)
}
switch rec["msg"] {
case summaryMessage:
summary = rec
case "listening on 127.0.0.1:8096":
listening++
}
}
if listening != 1 {
t.Fatalf("want one record \"listening on 127.0.0.1:8096\":\n%s", logs.String())
}
if summary == nil {
t.Fatalf("no %q record:\n%s", summaryMessage, logs.String())
}
for key, want := range map[string]any{
"environment": "staging",
"database": "db.internal:5433/acme_prod",
"migrations_pending": float64(3),
"plugins": float64(2),
"plugin_ids": "acme.user,acme.blog",
"mail": "smtp",
"cache": "redis",
"realtime": "centrifugo",
"search": "null",
} {
if summary[key] != want {
t.Errorf("summary record %s = %v, want %v", key, summary[key], want)
}
}
for _, secret := range []string{"pa55-secret", "summer_user"} {
if strings.Contains(got, secret) || strings.Contains(logs.String(), secret) {
t.Fatalf("summary leaks %q:\n%s\n%s", secret, got, logs.String())
}
}
if lagoon.DatabaseLabel("host=db port=5432 dbname=acme user=u password=p") != "db:5432/acme" {
t.Errorf("keyword DSN = %q", lagoon.DatabaseLabel("host=db port=5432 dbname=acme user=u password=p"))
}
if lagoon.DatabaseLabel("postgres://u:p@[::1:bad") != "configured" {
t.Errorf("unparsable DSN = %q, want configured", lagoon.DatabaseLabel("postgres://u:p@[::1:bad"))
}
probe := []bonfire.Command{{
Name: "probe:summary",
Run: func(_ context.Context, _ bonfire.Input, out bonfire.Output) error {
printSummary(out, facts)
return nil
},
}}
for _, tc := range []struct {
args []string
block bool
}{{nil, true}, {[]string{"-q"}, false}} {
var buf bytes.Buffer
if err := bonfire.Call(context.Background(), probe, "probe:summary", tc.args, &buf); err != nil {
t.Fatal(err)
}
if strings.Contains(buf.String(), "Environment") != tc.block {
t.Errorf("args %v: block printed = %v, want %v:\n%s", tc.args, !tc.block, tc.block, buf.String())
}
}
}
func TestShutdownReportSmoke(t *testing.T) {
report := shutdownReport{
Draining: 3,
Aborted: 1,
Served: 120,
Worker: &conga.WorkerStats{Done: 7, Failed: 2, HandedBack: 1},
Uptime: 3*time.Minute + 4*time.Second,
Errors: []error{errors.New("surf: drain requests: context deadline exceeded")},
}
var buf bytes.Buffer
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
bonfire.Details(out, "Shutdown report", report.details())
got := buf.String()
for _, want := range []string{
"Shutdown report",
"Requests 120 served, 2 drained, 1 aborted",
"Worker 7 done, 2 failed, 1 handed back to the queue",
"Uptime 3m4s",
"Errors surf: drain requests: context deadline exceeded",
} {
if !strings.Contains(got, want) {
t.Errorf("report lacks %q:\n%s", want, got)
}
}
if report.Err() == nil {
t.Fatal("a drain error must make the report fail")
}
var logs bytes.Buffer
logReport(context.Background(), slog.New(slog.NewJSONHandler(&logs, nil)), report)
var rec map[string]any
if err := json.Unmarshal(logs.Bytes(), &rec); err != nil {
t.Fatal(err)
}
if rec["msg"] != reportMessage || rec["level"] != "ERROR" || rec["served"] != float64(120) ||
rec["drained"] != float64(2) || rec["aborted"] != float64(1) || rec["jobs_done"] != float64(7) ||
rec["jobs_handed_back"] != float64(1) {
t.Fatalf("report record = %v", rec)
}
clean := shutdownReport{Served: 5, Uptime: time.Second}
if clean.Err() != nil {
t.Fatal("a stop without errors must exit 0")
}
buf.Reset()
bonfire.Details(out, "", clean.details())
if !strings.Contains(buf.String(), "Worker not running in serve") || !strings.Contains(buf.String(), "Errors none") {
t.Fatalf("clean report:\n%s", buf.String())
}
}