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