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:
@@ -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)
|
||||
}
|
||||
|
||||
43
docs/examples/blog/console/publish.go
Normal file
43
docs/examples/blog/console/publish.go
Normal 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
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
44
docs/examples/blog/console/publish_test.go
Normal file
44
docs/examples/blog/console/publish_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
34
docs/examples/blog/controllers/posts.go
Normal file
34
docs/examples/blog/controllers/posts.go
Normal 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{} }
|
||||
12
docs/examples/blog/controllers/posts/config_form.yaml
Normal file
12
docs/examples/blog/controllers/posts/config_form.yaml
Normal 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
|
||||
13
docs/examples/blog/controllers/posts/config_list.yaml
Normal file
13
docs/examples/blog/controllers/posts/config_list.yaml
Normal 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
|
||||
99
docs/examples/blog/controllers/posts_test.go
Normal file
99
docs/examples/blog/controllers/posts_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
10
docs/examples/blog/models/posts/columns.yaml
Normal file
10
docs/examples/blog/models/posts/columns.yaml
Normal 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
|
||||
15
docs/examples/blog/models/posts/fields.yaml
Normal file
15
docs/examples/blog/models/posts/fields.yaml
Normal 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
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
344
docs/examples/blog/postgres_test.go
Normal file
344
docs/examples/blog/postgres_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user