- 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
344 lines
11 KiB
Go
344 lines
11 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// testDatabase creates a database for one test and drops it when the test
|
|
// ends. It returns the pool opened on it and its DSN.
|
|
func testDatabase(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'`); 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 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, _ := testDatabase(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")
|
|
}
|
|
}
|