Files
summercms/docs/examples/blog/postgres_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

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")
}
}