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"] // // [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) } }