Files
summercms/modules/lagoon/example_test.go
Jakub Zych 037dc53030 feat(lagoon): per-query collation for OrderBy, drop the database locale check
- lagoon.OrderBy takes variadic lagoon.OrderOption values; lagoon.Collate(name)
  emits a validated, double-quoted COLLATE clause (e.g. "pl-x-icu")
- remove the exported CheckLocale and the ICU pl-PL check from Open and Use
- framework test containers and per-test databases are plain PostgreSQL
- lagoon README, root README and docs pages drop the locale requirement;
  queries-and-pagination gains a "Sorting with a collation" section
  backed by ExampleCollate
2026-10-01 09:51:03 +02:00

648 lines
22 KiB
Go

package lagoon_test
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"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/driver/postgres"
"gorm.io/gorm"
)
// Post is the acme.blog post model: a plain GORM struct with lagoon column
// types.
type Post struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
Title string `gorm:"column:title" json:"title"`
Slug string `gorm:"column:slug" json:"slug"`
Views int `gorm:"column:views" json:"views"`
Tags lagoon.Jsonable[[]string] `gorm:"column:tags" json:"-"`
APIToken lagoon.Encrypted `gorm:"column:api_token" json:"-"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"-"`
Categories []Category `gorm:"many2many:acme_blog_post_categories" json:"-"`
}
// TableName keeps the WinterCMS table name.
func (Post) TableName() string { return "acme_blog_posts" }
// Fillable is the Go form of $fillable: the keys mass assignment may set.
func (Post) Fillable() []string { return []string{"title", "views"} }
// Hidden is the Go form of $hidden. The json:"-" tags are what keep these
// columns out of JSON; the list documents them for tooling.
func (Post) Hidden() []string { return []string{"tags", "api_token", "deleted_at"} }
// BeforeCreate fills the slug from the title. A hook that only touches the
// model stays on the model.
func (p *Post) BeforeCreate(tx *gorm.DB) error {
if p.Slug == "" {
p.Slug = strings.ReplaceAll(strings.ToLower(strings.TrimSpace(p.Title)), " ", "-")
}
return nil
}
// BeforeDelete soft-deletes the post's comments in the transaction of the
// post's own delete; an error aborts that delete.
func (p *Post) BeforeDelete(tx *gorm.DB) error {
return lagoon.WithSoftDeleteCascade(tx, func(tx *gorm.DB) error {
return tx.Where("post_id = ?", p.ID).Delete(&Comment{}).Error
})
}
// Comment belongs to a post and is soft-deleted with it.
type Comment struct {
ID uint `gorm:"column:id;primaryKey"`
PostID uint `gorm:"column:post_id"`
Body string `gorm:"column:body"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
}
func (Comment) TableName() string { return "acme_blog_comments" }
// Category is linked to posts through a pivot with its own sort order.
type Category struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
}
func (Category) TableName() string { return "acme_blog_categories" }
// PostCategory is the pivot model: the join table has a business column.
type PostCategory struct {
PostID uint `gorm:"column:post_id;primaryKey"`
CategoryID uint `gorm:"column:category_id;primaryKey"`
SortOrder int `gorm:"column:sort_order"`
}
func (PostCategory) TableName() string { return "acme_blog_post_categories" }
// BlogPlugin is the acme.blog plugin; only its migrations are shown here.
type BlogPlugin struct{}
var _ pact.HasMigrations = (*BlogPlugin)(nil)
func (p *BlogPlugin) ID() string { return "acme.blog" }
func (p *BlogPlugin) Requires() []string { return nil }
func (p *BlogPlugin) Register(app *backpack.App) error { return nil }
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
// Migrations returns the plugin's schema as an ordered gormigrate set. In a
// scaffolded plugin each migration is a file in updates/ and this list is
// generated in file name order.
func (p *BlogPlugin) Migrations() []*gormigrate.Migration {
return []*gormigrate.Migration{
{
ID: "20260101000100_create_posts",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`CREATE TABLE acme_blog_posts (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
tags TEXT,
api_token TEXT,
deleted_at TIMESTAMPTZ
)`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS acme_blog_posts`).Error
},
},
{
ID: "20260101000200_create_comments_and_categories",
Migrate: func(tx *gorm.DB) error {
for _, stmt := range []string{
`CREATE TABLE acme_blog_comments (id SERIAL PRIMARY KEY, post_id INTEGER NOT NULL, body TEXT NOT NULL, deleted_at TIMESTAMPTZ)`,
`CREATE TABLE acme_blog_categories (id SERIAL PRIMARY KEY, name TEXT NOT NULL)`,
`CREATE TABLE acme_blog_post_categories (post_id INTEGER NOT NULL, category_id INTEGER NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (post_id, category_id))`,
} {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS acme_blog_post_categories, acme_blog_categories, acme_blog_comments`).Error
},
},
}
}
func ExampleFill() {
input := map[string]any{"title": "Hello", "views": 3, "slug": "forged"}
var post Post
// production=false logs each dropped key once, to catch typos in development.
if err := lagoon.Fill(&post, post.Fillable(), input, true); err != nil {
fmt.Println(err)
}
fmt.Printf("%q %q %d\n", post.Title, post.Slug, post.Views)
err := lagoon.Fill(&post, post.Fillable(), map[string]any{"views": "many"}, true)
var typeErr *lagoon.FillTypeError
if errors.As(err, &typeErr) {
fmt.Println("invalid value for", typeErr.Key)
}
// Output:
// "Hello" "" 3
// invalid value for views
}
func ExampleValidate() {
rules := map[string]string{
"title": "required|max:10",
"views": "nullable|integer|max:1000",
}
input := map[string]any{"title": "", "views": 5000}
// A nil translator gives the built-in English messages; unique: rules
// need a database handle instead of nil.
errs, err := lagoon.Validate(context.Background(), nil, &Post{}, rules, input, nil)
if err != nil {
fmt.Println(err)
}
out, _ := json.Marshal(errs)
fmt.Println(string(out))
// Output:
// {"title":["The title field is required."],"views":["The views may not be greater than 1000."]}
}
func ExampleHasHidden() {
post := Post{ID: 1, Title: "Hello", APIToken: lagoon.NewEncrypted("s3cret")}
out, _ := json.Marshal(post)
fmt.Println(string(out))
var _ lagoon.HasHidden = post
// Output:
// {"id":1,"title":"Hello","slug":"","views":0}
}
func ExampleOrderBy() {
// A dry-run handle shows the SQL without a database.
db, _ := gorm.Open(postgres.New(postgres.Config{DSN: "host=127.0.0.1"}), &gorm.Config{DryRun: true, DisableAutomaticPing: true})
allowed := []string{"title", "views"}
q, err := lagoon.OrderBy(db.Model(&Post{}), "views", "desc", allowed)
if err != nil {
fmt.Println(err)
return
}
var posts []Post
fmt.Println(q.Find(&posts).Statement.SQL.String())
_, err = lagoon.OrderBy(db, "api_token", "asc", allowed)
fmt.Println(err)
_, err = lagoon.OrderBy(db, "title", "asc; DROP TABLE acme_blog_posts", allowed)
fmt.Println(err)
// Output:
// SELECT * FROM "acme_blog_posts" WHERE "acme_blog_posts"."deleted_at" IS NULL ORDER BY views DESC
// lagoon: order column "api_token" is not allow-listed
// lagoon: order direction "asc; DROP TABLE acme_blog_posts" is not allow-listed
}
func ExampleCollate() {
// A dry-run handle shows the SQL without a database.
db, _ := gorm.Open(postgres.New(postgres.Config{DSN: "host=127.0.0.1"}), &gorm.Config{DryRun: true, DisableAutomaticPing: true})
allowed := []string{"title", "views"}
// Polish alphabetical order, whatever the database's default locale.
q, err := lagoon.OrderBy(db.Model(&Post{}), "title", "asc", allowed, lagoon.Collate("pl-x-icu"))
if err != nil {
fmt.Println(err)
return
}
var posts []Post
fmt.Println(q.Find(&posts).Statement.SQL.String())
_, err = lagoon.OrderBy(db, "title", "asc", allowed, lagoon.Collate(`pl-x-icu" ASC, (SELECT 1) --`))
fmt.Println(err)
// Output:
// SELECT * FROM "acme_blog_posts" WHERE "acme_blog_posts"."deleted_at" IS NULL ORDER BY title COLLATE "pl-x-icu" ASC
// lagoon: order collation "pl-x-icu\" ASC, (SELECT 1) --" is not a valid collation name
}
func ExamplePaginate() {
rows := []map[string]any{{"id": 3, "title": "Third"}}
page := lagoon.Paginate(rows, 2, 2, 3)
out, _ := json.Marshal(page)
fmt.Println(string(out))
empty, _ := json.Marshal(lagoon.Paginate[map[string]any](nil, 1, 15, 0))
fmt.Println(string(empty))
// Output:
// {"data":[{"id":3,"title":"Third"}],"meta":{"current_page":2,"last_page":2,"per_page":2,"total":3}}
// {"data":[],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":0}}
}
func ExampleJsonable() {
tags := lagoon.Jsonable[[]string]{Data: []string{"go", "cms"}, Valid: true}
v, _ := tags.Value()
fmt.Println(v)
var none lagoon.Jsonable[[]string] // Valid false stores SQL NULL
v, _ = none.Value()
fmt.Println(v)
var read lagoon.Jsonable[[]string]
_ = read.Scan(`["winter"]`)
fmt.Println(read.Get(), read.Valid)
// Output:
// ["go","cms"]
// <nil>
// [winter] true
}
func ExampleEncrypted() {
// The application publishes the keys from app.key at boot; a test can
// install a key directly.
key := []byte("0123456789abcdef0123456789abcdef")
if err := lagoon.PublishEncryptionKeys(nil, key, nil); err != nil {
fmt.Println(err)
return
}
token := lagoon.NewEncrypted("s3cret")
stored, _ := token.Value() // what the column holds
fmt.Println(strings.Contains(fmt.Sprint(stored), "s3cret"))
var read lagoon.Encrypted
if err := read.Scan(stored); err != nil {
fmt.Println(err)
return
}
out, _ := json.Marshal(map[string]any{"api_token": read})
fmt.Println(read, string(out))
fmt.Println(read.Reveal())
// Output:
// false
// [redacted] {"api_token":"[redacted]"}
// s3cret
}
// createPost validates input, mass-assigns the fillable keys and inserts
// the post. It returns the validation errors, if any.
func createPost(ctx context.Context, db *gorm.DB, input map[string]any) (*Post, map[string][]string, error) {
// docs:start create-post
rules := map[string]string{
"title": "required|max:255|unique:acme_blog_posts",
"views": "nullable|integer|min:0",
}
var post Post
errs, err := lagoon.Validate(ctx, db, &post, rules, input, nil)
if err != nil || errs != nil {
return nil, errs, err
}
if err := lagoon.Fill(&post, post.Fillable(), input, false); err != nil {
return nil, nil, err
}
if err := db.WithContext(ctx).Create(&post).Error; err != nil {
return nil, nil, err
}
return &post, nil, nil
// docs:end create-post
}
// TestDocsModels runs the create-post region of the Models page.
func TestDocsModels(t *testing.T) {
db := lagoon.DocsDB(t, "docs_models")
plugins := []party.Plugin{&BlogPlugin{}}
if err := lagoon.Migrate(db, plugins); err != nil {
t.Fatal(err)
}
post, errs, err := createPost(t.Context(), db, map[string]any{"title": "Hello World", "views": 2, "slug": "forged"})
if err != nil || errs != nil {
t.Fatalf("createPost: %v %v", errs, err)
}
if post.ID == 0 || post.Slug != "hello-world" || post.Views != 2 {
t.Fatalf("post = %+v", post)
}
_, errs, err = createPost(t.Context(), db, map[string]any{"title": "Hello World"})
if err != nil || len(errs["title"]) != 1 {
t.Fatalf("duplicate title: %v %v", errs, err)
}
}
// migrateBlog runs the framework and plugin migrations, prints the status
// and rolls back the plugin's last migration.
func migrateBlog(db *gorm.DB) ([]string, error) {
var lines []string
// docs:start migrate
plugins := []party.Plugin{&BlogPlugin{}}
if err := lagoon.Migrate(db, plugins); err != nil {
return nil, err
}
rows, err := lagoon.Status(db, plugins)
if err != nil {
return nil, err
}
for _, row := range rows {
lines = append(lines, fmt.Sprintf("%s %s %v", row.Plugin, row.Table, row.IDs))
}
if err := lagoon.RollbackLast(db, plugins, "acme.blog"); err != nil {
return nil, err
}
// docs:end migrate
return lines, nil
}
// TestDocsMigrate runs the migrate region of the Migrations page.
func TestDocsMigrate(t *testing.T) {
db := lagoon.DocsDB(t, "docs_migrate")
lines, err := migrateBlog(db)
if err != nil {
t.Fatal(err)
}
want := "acme.blog summer_migrations_acme_blog [20260101000100_create_posts 20260101000200_create_comments_and_categories]"
if len(lines) != 1 || lines[0] != want {
t.Fatalf("status = %q, want %q", lines, want)
}
if db.Migrator().HasTable("acme_blog_comments") {
t.Fatal("rollback left acme_blog_comments")
}
if !db.Migrator().HasTable("acme_blog_posts") {
t.Fatal("rollback removed acme_blog_posts")
}
}
// listPosts returns one page of posts sorted by a column the client names.
func listPosts(ctx context.Context, db *gorm.DB, sort, dir string, page, perPage int) (lagoon.Page[Post], error) {
// docs:start list-posts
q, err := lagoon.OrderBy(db.WithContext(ctx).Model(&Post{}), sort, dir, []string{"title", "views"})
if err != nil {
return lagoon.Page[Post]{}, err // answer 422: the client asked for a column it may not sort by
}
var total int64
if err := q.Count(&total).Error; err != nil {
return lagoon.Page[Post]{}, err
}
var posts []Post
if err := q.Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
return lagoon.Page[Post]{}, err
}
return lagoon.Paginate(posts, page, perPage, total), nil
// docs:end list-posts
}
// TestDocsQueries runs the list-posts region of the Queries page.
func TestDocsQueries(t *testing.T) {
db := lagoon.DocsDB(t, "docs_queries")
if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil {
t.Fatal(err)
}
for i, title := range []string{"Alpha", "Beta", "Gamma"} {
if err := db.Create(&Post{Title: title, Views: i * 10}).Error; err != nil {
t.Fatal(err)
}
}
page, err := listPosts(t.Context(), db, "views", "desc", 2, 2)
if err != nil {
t.Fatal(err)
}
if len(page.Data) != 1 || page.Data[0].Title != "Alpha" || page.Meta.LastPage != 2 || page.Meta.Total != 3 {
t.Fatalf("page = %+v", page)
}
if _, err := listPosts(t.Context(), db, "api_token", "asc", 1, 2); err == nil {
t.Fatal("sorting by api_token succeeded")
}
}
// setCategories replaces a post's categories in the given order and reads
// them back through the pivot.
func setCategories(ctx context.Context, db *gorm.DB, post *Post, categoryIDs []uint) ([]Category, error) {
// docs:start pivot
if err := lagoon.RegisterJoinTable(db, &Post{}, "Categories", &PostCategory{}); err != nil {
return nil, err
}
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.Where("post_id = ?", post.ID).Delete(&PostCategory{}).Error; err != nil {
return err
}
rows := make([]PostCategory, len(categoryIDs))
for i, id := range categoryIDs {
rows[i] = PostCategory{PostID: post.ID, CategoryID: id, SortOrder: i}
}
return tx.Create(&rows).Error
})
if err != nil {
return nil, err
}
var categories []Category
err = db.WithContext(ctx).
Joins("JOIN acme_blog_post_categories pc ON pc.category_id = acme_blog_categories.id").
Where("pc.post_id = ?", post.ID).
Order("pc.sort_order").
Find(&categories).Error
return categories, err
// docs:end pivot
}
// TestDocsRelations runs the pivot region of the Relations page and the
// soft-delete cascade of Post.BeforeDelete.
func TestDocsRelations(t *testing.T) {
db := lagoon.DocsDB(t, "docs_relations")
if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil {
t.Fatal(err)
}
post := Post{Title: "Hello"}
news, events := Category{Name: "News"}, Category{Name: "Events"}
for _, v := range []any{&post, &news, &events} {
if err := db.Create(v).Error; err != nil {
t.Fatal(err)
}
}
got, err := setCategories(t.Context(), db, &post, []uint{events.ID, news.ID})
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Name != "Events" || got[1].Name != "News" {
t.Fatalf("categories = %+v", got)
}
var loaded Post
if err := db.Preload("Categories").First(&loaded, post.ID).Error; err != nil || len(loaded.Categories) != 2 {
t.Fatalf("Preload(Categories) = %+v, %v", loaded.Categories, err)
}
if err := db.Create(&Comment{PostID: post.ID, Body: "First"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(&post).Error; err != nil {
t.Fatal(err)
}
var left int64
if err := db.Model(&Comment{}).Where("post_id = ?", post.ID).Count(&left).Error; err != nil {
t.Fatal(err)
}
if left != 0 {
t.Fatalf("%d comments left after the post was deleted", left)
}
}
// publishPost renames a post in a transaction and notifies after commit.
// fail makes the transaction roll back.
func publishPost(ctx context.Context, db *gorm.DB, id uint, fail bool, log *[]string) error {
// docs:start publish
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.Model(&Post{}).Where("id = ?", id).Update("title", "Published").Error; err != nil {
return err
}
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, db *gorm.DB) {
*log = append(*log, fmt.Sprintf("post %d published", id)) // broadcast, index, send mail...
})
if fail {
return errors.New("rolled back") // the AfterCommit work never runs
}
return nil
})
// docs:end publish
}
// nestedPublish runs a savepoint inside a transaction; a failing savepoint
// drops its own AfterCommit work only.
func nestedPublish(ctx context.Context, db *gorm.DB, log *[]string) error {
// docs:start nested
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "outer") })
// A nested Transaction is a savepoint. Pass it the outer tx: given
// the root db handle it returns an error instead.
_ = lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error {
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "dropped") })
return errors.New("savepoint rolled back")
})
return lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error {
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "inner") })
return nil
})
})
// docs:end nested
}
// TestDocsTransactions runs the publish and nested regions of the
// Transactions page, and checks the rules the page states.
func TestDocsTransactions(t *testing.T) {
db := lagoon.DocsDB(t, "docs_transactions")
if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil {
t.Fatal(err)
}
post := Post{Title: "Draft"}
if err := db.Create(&post).Error; err != nil {
t.Fatal(err)
}
ctx := t.Context()
var log []string
if err := publishPost(ctx, db, post.ID, true, &log); err == nil || len(log) != 0 {
t.Fatalf("rolled-back publish: err %v, log %q", err, log)
}
if err := publishPost(ctx, db, post.ID, false, &log); err != nil {
t.Fatal(err)
}
if want := fmt.Sprintf("post %d published", post.ID); len(log) != 1 || log[0] != want {
t.Fatalf("log = %q, want %q", log, want)
}
log = nil
if err := nestedPublish(ctx, db, &log); err != nil {
t.Fatal(err)
}
if strings.Join(log, ",") != "outer,inner" {
t.Fatalf("nested log = %q, want outer,inner", log)
}
// A nested Transaction given the root handle fails without running.
ran := false
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
return lagoon.Transaction(ctx, db, func(context.Context, *gorm.DB) error {
ran = true
return nil
})
})
if err == nil || ran {
t.Fatalf("nested Transaction on the root handle: err %v, ran %v", err, ran)
}
// Inside a plain GORM transaction AfterCommit skips the work.
log = nil
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { log = append(log, "foreign") })
return nil
}); err != nil {
t.Fatal(err)
}
if len(log) != 0 {
t.Fatalf("AfterCommit ran inside a plain GORM transaction: %q", log)
}
}
// installCallbacks registers a GORM callback from a plugin's Boot through
// OnDatabase; its after-commit work runs once the insert commits.
func installCallbacks(app *backpack.App, log *[]string) error {
// docs:start on-database
return lagoon.OnDatabase(app, func(_ *sql.DB, gdb *gorm.DB) error {
return gdb.Callback().Create().After("gorm:create").Before("gorm:commit_or_rollback_transaction").Register("acme:post_created", func(db *gorm.DB) {
post, ok := db.Statement.Dest.(*Post)
if db.Error != nil || !ok {
return
}
lagoon.AfterCommit(db.Statement.Context, db, func(ctx context.Context, db *gorm.DB) {
*log = append(*log, "created "+post.Slug) // runs only once the insert is committed
})
})
})
// docs:end on-database
}
// TestDocsOnDatabase runs the on-database region of the Transactions page.
func TestDocsOnDatabase(t *testing.T) {
db := lagoon.DocsDB(t, "docs_on_database")
if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil {
t.Fatal(err)
}
app := backpack.New(nil)
var log []string
if err := installCallbacks(app, &log); err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
if err := lagoon.Publish(app, sqlDB, db); err != nil {
t.Fatal(err)
}
if err := db.Create(&Post{Title: "Hello"}).Error; err != nil {
t.Fatal(err)
}
if len(log) != 1 || log[0] != "created hello" {
t.Fatalf("log = %q", log)
}
}
// TestDocsModelDeclarations checks the model and plugin declarations the
// Database pages show, without a database.
func TestDocsModelDeclarations(t *testing.T) {
if got := (Post{}).TableName(); got != "acme_blog_posts" {
t.Fatalf("TableName = %q", got)
}
if got := (Post{}).Hidden(); len(got) != 3 {
t.Fatalf("Hidden = %q", got)
}
post := &Post{Title: " Hello World "}
if err := post.BeforeCreate(nil); err != nil || post.Slug != "hello-world" {
t.Fatalf("BeforeCreate: slug %q, err %v", post.Slug, err)
}
if err := post.BeforeDelete(nil); err == nil {
t.Fatal("BeforeDelete accepted a nil transaction")
}
if n := len((&BlogPlugin{}).Migrations()); n != 2 {
t.Fatalf("Migrations() = %d, want 2", n)
}
}