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:
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user