feat(11.1-05): add the walkthrough's admin controller, publish command and published_at migration

- make:admin-controller, make:command and make:migration output, finished:
  the Posts controller serves models.Post behind acme.blog.access_posts,
  WinterCMS-style form and list YAML embedded through pact.AdminAssets,
  blog:publish sets published_at by slug with a bound parameter
- the posts route lists published posts only, newest first
- Docker tests migrate an ICU pl-PL database, roll back published_at, serve
  the route and run blog:publish with a published and an opened database
- the page gains the admin controller, console command and added-column
  sections
This commit is contained in:
Jakub Zych
2026-09-30 23:35:44 +02:00
parent 41a3190956
commit dd82b8a2ad
17 changed files with 1223 additions and 59 deletions

View File

@@ -9,6 +9,7 @@ import (
"git.golem15.com/golem15/summercms/docs/examples/blog"
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
"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"
@@ -16,8 +17,8 @@ import (
)
// activate boots acme.blog the way the generated main does: an application
// config directory with the required HTTP limits, then party.Activate with
// the plugin ID.
// config directory with the required HTTP limits and admin secret, then
// party.Activate with the plugin ID.
func activate(t *testing.T) (*backpack.App, party.Plugin) {
t.Helper()
dir := t.TempDir()
@@ -29,6 +30,10 @@ func activate(t *testing.T) (*backpack.App, party.Plugin) {
if err != nil {
t.Fatal(err)
}
// The admin controller makes the admin API mount, which needs a secret.
if err := cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes"); err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
plugins, err := party.Activate(app, []string{"acme.blog"})
if err != nil {
@@ -76,6 +81,23 @@ func TestPluginActivates(t *testing.T) {
if len(hasMigrations.Migrations()) == 0 {
t.Error("Migrations is empty")
}
hasCommands, ok := p.(pact.HasCommands)
if !ok {
t.Fatal("plugin does not implement pact.HasCommands")
}
if !slices.ContainsFunc(hasCommands.Commands(), func(c bonfire.Command) bool { return c.Name == "blog:publish" }) {
t.Error("Commands does not include blog:publish")
}
hasAdmin, ok := p.(pact.HasAdminControllers)
if !ok {
t.Fatal("plugin does not implement pact.HasAdminControllers")
}
if ctls := hasAdmin.AdminControllers(); len(ctls) != 1 || ctls[0].ID() != "acme.blog.posts" {
t.Errorf("AdminControllers = %v, want acme.blog.posts", ctls)
}
if _, ok := p.(pact.AdminAssets); !ok {
t.Error("plugin does not implement pact.AdminAssets")
}
if got := app.Config.Int("acme.blog.per_page"); got != 15 {
t.Errorf("acme.blog.per_page = %d, want the plugin default 15", got)
}

View File

@@ -0,0 +1,43 @@
// Code generated by summer make. DO NOT EDIT.
package console
import (
"context"
"fmt"
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
"git.golem15.com/golem15/summercms/modules/bonfire"
"gorm.io/gorm"
)
// PublishCommand returns the blog:publish console command. withDB runs the
// command's work with the application's database; the plugin supplies it.
func PublishCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) error) bonfire.Command {
return bonfire.Command{
Name: "blog:publish",
Description: "Publish a blog post by its slug",
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
slug, _ := in.Argument("slug")
if slug == "" {
return fmt.Errorf("blog:publish: a slug is required")
}
return withDB(ctx, func(db *gorm.DB) error {
// A bound parameter, never the slug spliced into SQL. Publishing
// twice keeps the first publication time.
res := db.WithContext(ctx).Model(&models.Post{}).
Where("slug = ?", slug).
Update("published_at", gorm.Expr("COALESCE(published_at, NOW())"))
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return fmt.Errorf("blog:publish: no post has the slug %q", slug)
}
out.Printf("published %s\n", slug)
return nil
})
},
}
}

View File

@@ -0,0 +1,44 @@
package console_test
import (
"bytes"
"context"
"errors"
"testing"
"git.golem15.com/golem15/summercms/docs/examples/blog/console"
"git.golem15.com/golem15/summercms/modules/bonfire"
"gorm.io/gorm"
)
func TestPublishCommandShape(t *testing.T) {
called := false
withDB := func(ctx context.Context, fn func(*gorm.DB) error) error {
called = true
return errors.New("no database in this test")
}
cmd := console.PublishCommand(withDB)
if cmd.Name != "blog:publish" {
t.Errorf("Name = %q, want blog:publish", cmd.Name)
}
if len(cmd.Args) != 1 || cmd.Args[0].Name != "slug" || !cmd.Args[0].Required {
t.Errorf("Args = %+v, want one required slug argument", cmd.Args)
}
if cmd.Description == "" {
t.Error("Description is empty")
}
cmds := []bonfire.Command{cmd}
var out bytes.Buffer
if err := bonfire.Call(context.Background(), cmds, "blog:publish", nil, &out); err == nil {
t.Error("blog:publish without a slug returned no error")
}
if called {
t.Error("blog:publish without a slug reached the database")
}
err := bonfire.Call(context.Background(), cmds, "blog:publish", []string{"hello-world"}, &out)
if err == nil || !called {
t.Errorf("blog:publish hello-world: err = %v, called = %v; want the withDB error", err, called)
}
}

View File

@@ -0,0 +1,34 @@
// Code generated by summer make. DO NOT EDIT.
package controllers
import (
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
"git.golem15.com/golem15/summercms/modules/pact"
)
var (
_ pact.AdminController = postsAdmin{}
_ pact.AdminRecordSource = postsAdmin{}
_ pact.AdminPermissioned = postsAdmin{}
)
// postsAdmin is the Go form of the Posts backend controller with the List
// and Form behaviours.
type postsAdmin struct{}
func (postsAdmin) ID() string { return "acme.blog.posts" }
func (postsAdmin) ModelName() string { return "Post" }
func (postsAdmin) ConfigDir() string { return "controllers/posts" }
// NewRecord returns the model the generic admin handlers query and fill.
func (postsAdmin) NewRecord() any { return &models.Post{} }
// RequiredPermissions replaces $requiredPermissions: an administrator needs
// this permission before any schema or record is served.
func (postsAdmin) RequiredPermissions() []string {
return []string{"acme.blog.access_posts"}
}
// PostsController returns the acme.blog.posts admin controller.
func PostsController() pact.AdminController { return postsAdmin{} }

View File

@@ -0,0 +1,12 @@
name: acme.blog::lang.posts.post
form: ~/plugins/acme/blog/models/posts/fields.yaml
modelClass: Post
defaultRedirect: acme/blog/posts
create:
redirect: acme/blog/posts/update/:id
redirectClose: acme/blog/posts
update:
redirect: acme/blog/posts
redirectClose: acme/blog/posts

View File

@@ -0,0 +1,13 @@
list: ~/plugins/acme/blog/models/posts/columns.yaml
modelClass: Post
title: acme.blog::lang.posts.title
recordUrl: acme/blog/posts/update/:id
recordsPerPage: 20
showCheckboxes: true
defaultSort:
column: published_at
direction: desc
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt

View File

@@ -0,0 +1,99 @@
package controllers_test
import (
"io/fs"
"slices"
"testing"
"git.golem15.com/golem15/summercms/docs/examples/blog"
"git.golem15.com/golem15/summercms/docs/examples/blog/controllers"
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
func TestPostsControllerDeclaration(t *testing.T) {
ctl := controllers.PostsController()
if ctl.ID() != "acme.blog.posts" {
t.Errorf("ID = %q, want acme.blog.posts", ctl.ID())
}
if ctl.ModelName() != "Post" {
t.Errorf("ModelName = %q, want Post", ctl.ModelName())
}
if ctl.ConfigDir() != "controllers/posts" {
t.Errorf("ConfigDir = %q, want controllers/posts", ctl.ConfigDir())
}
perm, ok := ctl.(pact.AdminPermissioned)
if !ok {
t.Fatal("the controller does not declare a permission (pact.AdminPermissioned)")
}
if got := perm.RequiredPermissions(); !slices.Equal(got, []string{"acme.blog.access_posts"}) {
t.Errorf("RequiredPermissions = %v, want [acme.blog.access_posts]", got)
}
src, ok := ctl.(pact.AdminRecordSource)
if !ok {
t.Fatal("the controller does not implement pact.AdminRecordSource")
}
if _, ok := src.NewRecord().(*models.Post); !ok {
t.Errorf("NewRecord is %T, want *models.Post", src.NewRecord())
}
plugin := &blog.Plugin{}
fsys := plugin.AdminFS()
form, err := cabana.CompileForm("acme.blog", ctl, fsys)
if err != nil {
t.Fatalf("CompileForm: %v", err)
}
var fields []string
for _, f := range form.Fields {
fields = append(fields, f.Name)
}
if want := []string{"title", "slug", "body"}; !slices.Equal(fields, want) {
t.Errorf("form fields = %v, want %v", fields, want)
}
list, err := cabana.CompileList("acme.blog", ctl, fsys)
if err != nil {
t.Fatalf("CompileList: %v", err)
}
if list.ModelClass != "Post" || list.DefaultSort == nil {
t.Errorf("list = %+v, want modelClass Post with a default sort", list)
}
for _, name := range []string{"controllers/posts/config_form.yaml", "controllers/posts/config_list.yaml", "models/posts/fields.yaml", "models/posts/columns.yaml"} {
if _, err := fs.Stat(fsys, name); err != nil {
t.Errorf("AdminFS is missing %s: %v", name, err)
}
}
// cabana compiles every admin controller at start-up without a database.
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: "testing", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
if err := cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes"); err != nil {
t.Fatal(err)
}
routes, err := cabana.Activate(backpack.New(cfg), []party.Plugin{plugin})
if err != nil {
t.Fatalf("cabana.Activate: %v", err)
}
if routes == nil {
t.Fatal("cabana.Activate mounted no admin routes")
}
editor := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{"acme.blog.access_posts": true}}
visitor := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{}}
if !cabana.Allows(editor, perm.RequiredPermissions()) {
t.Error("an administrator with acme.blog.access_posts is refused")
}
if cabana.Allows(visitor, perm.RequiredPermissions()) {
t.Error("an administrator without acme.blog.access_posts is allowed")
}
declared := slices.ContainsFunc(plugin.Permissions(), func(p pact.Permission) bool { return p.Code == "acme.blog.access_posts" })
if !declared {
t.Error("the plugin does not declare acme.blog.access_posts in Permissions")
}
}

View File

@@ -1 +1,12 @@
{}
plugin:
name: Blog
description: A simple blog.
permissions:
access_posts: Manage blog posts
posts:
title: Posts
post: Post
title_field: Title
slug: Slug
body: Body
published_at: Published

View File

@@ -11,12 +11,13 @@ import (
// Post is a blog post, the Go form of the WinterCMS Acme\Blog\Models\Post
// model.
type Post struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
Slug string `gorm:"column:slug"`
Body string `gorm:"column:body"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
Slug string `gorm:"column:slug"`
Body string `gorm:"column:body"`
PublishedAt *time.Time `gorm:"column:published_at"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
// TableName keeps the WinterCMS table name.
@@ -26,6 +27,15 @@ func (Post) TableName() string { return "acme_blog_posts" }
// set from a request.
func (Post) Fillable() []string { return []string{"title", "slug", "body"} }
// Rules is the Go form of $rules. The admin API checks them with
// lagoon.Validate on every save; unique ignores the post being updated.
func (Post) Rules() map[string]string {
return map[string]string{
"title": "required|max:255",
"slug": "required|max:255|unique:acme_blog_posts",
}
}
// NewPost is the Go form of Post::make($input): it copies only the fillable
// keys of input onto a new post and drops the rest, such as id.
func NewPost(input map[string]any) (*Post, error) {

View File

@@ -0,0 +1,10 @@
columns:
title:
label: acme.blog::lang.posts.title_field
searchable: true
slug:
label: acme.blog::lang.posts.slug
searchable: true
published_at:
label: acme.blog::lang.posts.published_at
type: datetime

View File

@@ -0,0 +1,15 @@
fields:
title:
label: acme.blog::lang.posts.title_field
type: text
span: left
required: true
slug:
label: acme.blog::lang.posts.slug
type: text
span: right
required: true
body:
label: acme.blog::lang.posts.body
type: textarea
size: large

View File

@@ -1,14 +1,18 @@
package blog
import (
"context"
"embed"
"io/fs"
"git.golem15.com/golem15/summercms/docs/examples/blog/console"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
var (
@@ -20,6 +24,9 @@ var (
_ pact.HasCommands = (*Plugin)(nil)
_ pact.HasJobs = (*Plugin)(nil)
_ pact.HasAdminControllers = (*Plugin)(nil)
_ pact.AdminAssets = (*Plugin)(nil)
_ pact.HasPermissions = (*Plugin)(nil)
_ pact.HasNavigation = (*Plugin)(nil)
)
//go:embed config
@@ -31,6 +38,9 @@ var langFS embed.FS
//go:embed views/mail
var mailFS embed.FS
//go:embed controllers/*/*.yaml models/*/*.yaml
var adminFS embed.FS
// Plugin is the acme.blog plugin, the Go form of Plugin.php.
type Plugin struct {
app *backpack.App
@@ -57,12 +67,56 @@ func (p *Plugin) MailLayouts() map[string]string { return nil }
func (p *Plugin) Models() []any { return generatedModels() }
func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() }
func (p *Plugin) Commands() []bonfire.Command { return generatedCommands() }
func (p *Plugin) Jobs() []pact.Job { return generatedJobs() }
func (p *Plugin) AdminControllers() []pact.AdminController {
return generatedAdminControllers()
}
// Commands returns the generated commands plus blog:publish, which needs the
// database and so is built here with the plugin's withDB.
func (p *Plugin) Commands() []bonfire.Command {
return append(generatedCommands(), console.PublishCommand(p.withDB))
}
// AdminFS is the admin YAML the controllers read: controllers/posts and
// models/posts.
func (p *Plugin) AdminFS() fs.FS { return adminFS }
// Permissions replaces registerPermissions().
func (p *Plugin) Permissions() []pact.Permission {
return []pact.Permission{{
Code: "acme.blog.access_posts",
Tab: "acme.blog::lang.plugin.name",
Label: "acme.blog::lang.permissions.access_posts",
}}
}
// Navigation replaces registerNavigation().
func (p *Plugin) Navigation() []pact.NavigationItem {
return []pact.NavigationItem{{
Code: "blog",
Label: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Permissions: []string{"acme.blog.access_posts"},
Controller: "acme.blog.posts",
}}
}
// withDB runs fn with the application's database: the one the serve command
// published, or, when a console command runs, one opened from the config for
// the duration of fn.
func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error {
if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil {
return fn(gdb)
}
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app)
if err != nil {
return err
}
defer sqlDB.Close()
return fn(gdb)
}
func init() {
party.Register(&Plugin{})
}

View File

@@ -0,0 +1,344 @@
package blog_test
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"gorm.io/gorm"
)
// The Docker tests run against a throwaway testcontainers Postgres, never a
// developer or shared database. Under -short the container is not started
// and the tests skip; in a full run a missing Docker daemon fails the
// package.
var (
pgContainer *postgres.PostgresContainer
pgAdmin *sql.DB
pgDSN string
)
func TestMain(m *testing.M) {
if !isShort() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
err := startPostgres(ctx)
cancel()
if err != nil {
fmt.Fprintf(os.Stderr, "blog: testcontainers postgres: %v\n", err)
stopPostgres()
os.Exit(1)
}
}
code := m.Run()
stopPostgres()
os.Exit(code)
}
func isShort() bool {
for _, a := range os.Args {
if a == "-test.short" || a == "-test.short=true" {
return true
}
}
return false
}
func startPostgres(ctx context.Context) error {
ctr, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("blog"),
postgres.WithUsername("blog"),
postgres.WithPassword("blog"),
postgres.BasicWaitStrategies(),
)
if err != nil {
return err
}
pgContainer = ctr
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
if err != nil {
return err
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return err
}
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return err
}
pgAdmin, pgDSN = db, dsn
return nil
}
func stopPostgres() {
if pgAdmin != nil {
_ = pgAdmin.Close()
}
if pgContainer != nil {
_ = testcontainers.TerminateContainer(pgContainer)
}
}
// icuDatabase creates a database for one test with the ICU pl-PL locale
// lagoon requires, and drops it when the test ends. It returns the pool
// opened on it and its DSN.
func icuDatabase(t *testing.T) (*sql.DB, string) {
t.Helper()
if testing.Short() {
t.Skip("requires testcontainers postgres")
}
if pgAdmin == nil {
t.Fatal("postgres unavailable: the container was not started")
}
name := "blog_" + strings.ToLower(strings.NewReplacer("/", "_", "-", "_").Replace(t.Name()))
quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
if _, err := pgAdmin.ExecContext(t.Context(), `CREATE DATABASE `+quoted+` TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'`); err != nil {
t.Fatalf("create %s: %v", name, err)
}
u, err := url.Parse(pgDSN)
if err != nil {
t.Fatal(err)
}
u.Path = "/" + name
dsn := u.String()
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = db.Close()
_, _ = pgAdmin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS `+quoted+` WITH (FORCE)`)
})
return db, dsn
}
// migrated activates acme.blog, migrates a fresh ICU database and publishes
// it on the application, as the serve command does at start-up.
func migrated(t *testing.T) (*backpack.App, party.Plugin, *gorm.DB) {
t.Helper()
sqlDB, _ := icuDatabase(t)
gdb, err := lagoon.Use(t.Context(), sqlDB)
if err != nil {
t.Fatalf("lagoon.Use: %v", err)
}
app, p := activate(t)
if err := lagoon.Migrate(gdb, []party.Plugin{p}); err != nil {
t.Fatalf("lagoon.Migrate: %v", err)
}
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
t.Fatalf("lagoon.Publish: %v", err)
}
return app, p, gdb
}
// createPost writes a post through the fill allow-list, as a real write path
// would, and sets published_at when publishedAt is not nil.
func createPost(t *testing.T, gdb *gorm.DB, input map[string]any, publishedAt *time.Time) *models.Post {
t.Helper()
post, err := models.NewPost(input)
if err != nil {
t.Fatalf("NewPost: %v", err)
}
post.PublishedAt = publishedAt
if err := gdb.WithContext(t.Context()).Create(post).Error; err != nil {
t.Fatalf("create %v: %v", input, err)
}
return post
}
func TestMigrateUpAndRollback(t *testing.T) {
_, p, gdb := migrated(t)
m := gdb.Migrator()
for _, col := range []string{"id", "title", "slug", "body", "published_at", "created_at", "updated_at"} {
if !m.HasColumn(&models.Post{}, col) {
t.Errorf("acme_blog_posts has no %s column after migrate", col)
}
}
plugins := []party.Plugin{p}
if err := lagoon.RollbackLast(gdb, plugins, "acme.blog"); err != nil {
t.Fatalf("RollbackLast: %v", err)
}
if m.HasColumn(&models.Post{}, "published_at") {
t.Error("published_at is still there after rolling back the last migration")
}
if !m.HasTable(&models.Post{}) {
t.Error("rolling back the last migration dropped the table too")
}
if err := lagoon.Migrate(gdb, plugins); err != nil {
t.Fatalf("migrate again: %v", err)
}
if !m.HasColumn(&models.Post{}, "published_at") {
t.Error("published_at is missing after migrating again")
}
}
func TestPostsRouteAgainstDatabase(t *testing.T) {
app, p, gdb := migrated(t)
older := time.Date(2026, 1, 2, 10, 0, 0, 0, time.UTC)
newer := time.Date(2026, 1, 3, 10, 0, 0, 0, time.UTC)
createPost(t, gdb, map[string]any{"title": "First", "slug": "first", "body": "One."}, &older)
createPost(t, gdb, map[string]any{"title": "Second", "slug": "second", "body": "Two."}, &newer)
createPost(t, gdb, map[string]any{"title": "Draft", "slug": "draft", "body": "Not yet."}, nil)
// A forged id is dropped by the allow-list, so the database assigns one.
forged := createPost(t, gdb, map[string]any{"id": 9999, "title": "Third", "slug": "third"}, &older)
if forged.ID == 9999 {
t.Fatal("the fill allow-list let the request choose the id")
}
h, err := surf.Assemble(app, []party.Plugin{p})
if err != nil {
t.Fatalf("Assemble: %v", err)
}
get := func(target string) (int, map[string]any) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("GET %s: %v\n%s", target, err, rec.Body.String())
}
return rec.Code, body
}
code, body := get("/api/blog/posts")
if code != http.StatusOK {
t.Fatalf("GET /api/blog/posts = %d, want 200: %v", code, body)
}
data, _ := body["data"].([]any)
var slugs []string
for _, row := range data {
slugs = append(slugs, row.(map[string]any)["slug"].(string))
}
if got, want := strings.Join(slugs, ","), "second,third,first"; got != want {
t.Errorf("slugs = %s, want %s (published only, newest first)", got, want)
}
if first, ok := data[0].(map[string]any); ok {
if first["published_at"] != "2026-01-03T10:00:00+00:00" {
t.Errorf("published_at = %v, want the Carbon form 2026-01-03T10:00:00+00:00", first["published_at"])
}
if _, leaked := first["created_at"]; leaked {
t.Error("the response leaks created_at, which postJSON does not declare")
}
}
meta, _ := body["meta"].(map[string]any)
if meta["total"] != float64(3) || meta["per_page"] != float64(15) || meta["current_page"] != float64(1) {
t.Errorf("meta = %v, want total 3, per_page 15 (the plugin default), current_page 1", meta)
}
code, body = get("/api/blog/posts?page=2&per_page=2")
data, _ = body["data"].([]any)
if code != http.StatusOK || len(data) != 1 || data[0].(map[string]any)["slug"] != "first" {
t.Errorf("page 2 of 2 = %d %v, want only first", code, body)
}
meta, _ = body["meta"].(map[string]any)
if meta["last_page"] != float64(2) {
t.Errorf("meta = %v, want last_page 2", meta)
}
_, body = get("/api/blog/posts?per_page=100000")
meta, _ = body["meta"].(map[string]any)
if meta["per_page"] != float64(100) {
t.Errorf("per_page=100000 gave meta %v, want per_page clamped to 100", meta)
}
}
func TestPublishCommandAgainstDatabase(t *testing.T) {
_, p, gdb := migrated(t)
createPost(t, gdb, map[string]any{"title": "Hello", "slug": "hello-world", "body": "Hi."}, nil)
cmds := p.(pact.HasCommands).Commands()
var out bytes.Buffer
if err := bonfire.Call(t.Context(), cmds, "blog:publish", []string{"hello-world"}, &out); err != nil {
t.Fatalf("blog:publish hello-world: %v\n%s", err, out.String())
}
if got := strings.TrimSpace(out.String()); got != "published hello-world" {
t.Errorf("output = %q, want %q", got, "published hello-world")
}
var post models.Post
if err := gdb.Where("slug = ?", "hello-world").First(&post).Error; err != nil {
t.Fatal(err)
}
if post.PublishedAt == nil {
t.Fatal("published_at is still NULL after blog:publish")
}
first := *post.PublishedAt
// Publishing again keeps the first publication time.
out.Reset()
if err := bonfire.Call(t.Context(), cmds, "blog:publish", []string{"hello-world"}, &out); err != nil {
t.Fatalf("second blog:publish: %v", err)
}
if err := gdb.Where("slug = ?", "hello-world").First(&post).Error; err != nil {
t.Fatal(err)
}
if !post.PublishedAt.Equal(first) {
t.Errorf("published_at changed from %v to %v on a second publish", first, *post.PublishedAt)
}
// A slug that is SQL is only ever a bound value.
for _, slug := range []string{"missing", "x' OR '1'='1"} {
err := bonfire.Call(t.Context(), cmds, "blog:publish", []string{slug}, &out)
if err == nil || !strings.Contains(err.Error(), "no post has the slug") {
t.Errorf("blog:publish %q: err = %v, want no post has the slug", slug, err)
}
}
}
// TestPublishCommandOpensDatabase runs blog:publish the way the application
// binary does: nothing is published on the app, so the command opens the
// database from database.dsn for the duration of its work.
func TestPublishCommandOpensDatabase(t *testing.T) {
_, _, gdb := migrated(t)
createPost(t, gdb, map[string]any{"title": "Hello", "slug": "hello-world"}, nil)
var name string
if err := gdb.Raw("SELECT current_database()").Scan(&name).Error; err != nil {
t.Fatal(err)
}
u, err := url.Parse(pgDSN)
if err != nil {
t.Fatal(err)
}
u.Path = "/" + name
app, p := activate(t)
if err := app.Config.Set("database.dsn", u.String()); err != nil {
t.Fatal(err)
}
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))
if err := app.Config.Set("app.key", key); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if err := bonfire.Call(t.Context(), p.(pact.HasCommands).Commands(), "blog:publish", []string{"hello-world"}, &out); err != nil {
t.Fatalf("blog:publish: %v\n%s", err, out.String())
}
var post models.Post
if err := gdb.Where("slug = ?", "hello-world").First(&post).Error; err != nil {
t.Fatal(err)
}
if post.PublishedAt == nil {
t.Error("published_at is still NULL after blog:publish opened its own database")
}
}

View File

@@ -3,6 +3,7 @@
package blog
import (
"git.golem15.com/golem15/summercms/docs/examples/blog/controllers"
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
"git.golem15.com/golem15/summercms/docs/examples/blog/updates"
"git.golem15.com/golem15/summercms/modules/bonfire"
@@ -19,6 +20,7 @@ func generatedModels() []any {
func generatedMigrations() []*gormigrate.Migration {
return []*gormigrate.Migration{
updates.CreatePosts(),
updates.AddPublishedAt(),
}
}
@@ -31,5 +33,7 @@ func generatedJobs() []pact.Job {
}
func generatedAdminControllers() []pact.AdminController {
return nil
return []pact.AdminController{
controllers.PostsController(),
}
}

View File

@@ -23,15 +23,16 @@ func (p *Plugin) Routes(r pact.Router) error {
// postJSON is the response shape of one post. It is built field by field,
// so a column added to the model never leaks into the API.
type postJSON struct {
ID uint `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Body string `json:"body"`
CreatedAt wire.Time `json:"created_at"`
ID uint `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Body string `json:"body"`
PublishedAt wire.Time `json:"published_at"`
}
// listPosts answers GET /api/blog/posts?page=N&per_page=M with one page of
// posts, newest first, in the {data, meta} shape of Laravel's paginator.
// published posts, newest first, in the {data, meta} shape of Laravel's
// paginator. Drafts, whose published_at is NULL, are never listed.
func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) {
db, ok := p.app.Lookup[*gorm.DB]()
if !ok {
@@ -41,25 +42,25 @@ func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) {
page := queryInt(r, "page", 1, 1, 10000)
perPage := queryInt(r, "per_page", p.app.Config.Int("acme.blog.per_page"), 1, 100)
q := db.WithContext(r.Context()).Model(&models.Post{})
q := db.WithContext(r.Context()).Model(&models.Post{}).Where("published_at IS NOT NULL")
var total int64
if err := q.Count(&total).Error; err != nil {
wire.WriteOpaque500(w)
return
}
var posts []models.Post
if err := q.Order("created_at DESC, id DESC").Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
if err := q.Order("published_at DESC, id DESC").Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
wire.WriteOpaque500(w)
return
}
rows := make([]postJSON, 0, len(posts))
for _, post := range posts {
rows = append(rows, postJSON{
ID: post.ID,
Title: post.Title,
Slug: post.Slug,
Body: post.Body,
CreatedAt: wire.Time{Time: post.CreatedAt.UTC().Truncate(time.Second)},
ID: post.ID,
Title: post.Title,
Slug: post.Slug,
Body: post.Body,
PublishedAt: wire.Time{Time: post.PublishedAt.UTC().Truncate(time.Second)},
})
}
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))

View File

@@ -0,0 +1,21 @@
// Code generated by summer make. DO NOT EDIT.
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddPublishedAt returns the 20260101000100_add_published_at gormigrate entry.
func AddPublishedAt() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20260101000100_add_published_at",
Migrate: func(tx *gorm.DB) error {
return tx.Exec("ALTER TABLE acme_blog_posts ADD COLUMN published_at TIMESTAMPTZ NULL").Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec("ALTER TABLE acme_blog_posts DROP COLUMN IF EXISTS published_at").Error
},
}
}