Files
sm-sitemap-plugin/sitemap_smoke_test.go
Jakub Zych 3ba70bb69f feat: sitemap settings singleton with content types and additional addresses
- one settings tile opening the golem15.sitemap.settings singleton
- Files tab (site address, file size, S1 files partial), Additional
  addresses repeater, Content types tab built from signpost at boot
- golem15_sitemap_types; a type switched off leaves the files (reason type)
- a save validates (errors.*) and dispatches a rebuild after commit
2026-11-04 11:06:29 +01:00

267 lines
8.1 KiB
Go

package sitemap_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
_ "git.golem15.com/golem15/sm-sitemap-plugin"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"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"
"gorm.io/gorm"
)
// testTypesPluginID is the fake content plugin that registers menu item
// types for the sitemap smoke tests. The real Journal and sitemap
// combination is proven by the host application's tests.
const testTypesPluginID = "acme.sitemaptypes"
// acmeTypes is a content plugin with one menu item type, acme-post, whose
// reference "7" resolves to a fixed post with two alternates.
type acmeTypes struct{}
func (acmeTypes) ID() string { return testTypesPluginID }
func (acmeTypes) Requires() []string { return nil }
func (acmeTypes) Register(*backpack.App) error { return nil }
func (acmeTypes) Boot(*backpack.App) error { return nil }
func (acmeTypes) MenuItemTypes() []pact.MenuItemType {
mtime := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
return []pact.MenuItemType{acmeList(), {
Key: "acme-post",
Label: "Acme post",
DefaultPattern: "/news/:slug",
References: func(context.Context) ([]pact.MenuItemReference, error) {
return []pact.MenuItemReference{{Value: "7", Label: "Hello"}}, nil
},
Resolve: func(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
if in.Reference != "7" {
return nil, nil
}
return &pact.ResolvedMenuItem{
Title: "Hello",
URL: "/news/hello",
MTime: &mtime,
Alternates: []pact.MenuItemAlternate{
{Locale: "pl", URL: "/news/hello"},
{Locale: "en", URL: "/en/news/hello"},
},
}, nil
},
}}
}
// acmeList is a list type of the fake plugin: no URL of its own, one post
// with two alternates as its item.
func acmeList() pact.MenuItemType {
return pact.MenuItemType{
Key: "acme-list",
Label: "Acme posts",
DefaultPattern: "/news/:slug",
PatternFrom: "acme-post",
DynamicItems: true,
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
return &pact.ResolvedMenuItem{Items: []pact.ResolvedMenuItem{{
Title: "A",
URL: "/news/a",
Alternates: []pact.MenuItemAlternate{
{Locale: "pl", URL: "/news/a"},
{Locale: "en", URL: "/en/news/a"},
},
}}}, nil
},
}
}
var _ pact.HasMenuItemTypes = acmeTypes{}
func init() {
party.Register(acmeTypes{})
}
// sitemapEnv is a booted application with the sitemap and the fake types.
type sitemapEnv struct {
h http.Handler
app *backpack.App
gdb *gorm.DB
plugins []party.Plugin
}
// bootSitemap boots the application with the sitemap and the fake types;
// overrides set further config keys.
func bootSitemap(t *testing.T, overrides ...map[string]any) *sitemapEnv {
t.Helper()
return bootPlugins(t, []string{testTypesPluginID, "golem15.sitemap"}, overrides...)
}
// bootPlugins boots the application with the plugins ids on a fresh
// database; overrides set further config keys.
func bootPlugins(t *testing.T, ids []string, overrides ...map[string]any) *sitemapEnv {
t.Helper()
sqlDB := freshDB(t)
gdb, err := lagoon.Use(t.Context(), sqlDB)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: sitemap-smoke\nlocale: en\nfallback_locale: en\nurl: https://api.example.test\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=sitemap-smoke-test-only-hs256-secret-key"},
})
if err != nil {
t.Fatal(err)
}
settings := map[string]any{
"http.body_limits.default_bytes": 1048576,
"http.body_limits.upload_bytes": 8388608,
"mail.driver": "log",
"mail.from": "noreply@example.test",
}
for _, o := range overrides {
for key, value := range o {
settings[key] = value
}
}
for key, value := range settings {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
t.Fatal(err)
}
plugins, err := party.Activate(app, ids)
if err != nil {
t.Fatal(err)
}
if err := lagoon.Migrate(gdb, plugins); err != nil {
t.Fatal(err)
}
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatal(err)
}
return &sitemapEnv{h: h, app: app, gdb: gdb, plugins: plugins}
}
func (e *sitemapEnv) get(t *testing.T, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Host = "api.example.test"
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
func (e *sitemapEnv) storeDefinition(t *testing.T, items []models.DefinitionItem) models.Definition {
t.Helper()
theme := models.DefinitionTheme
def := models.Definition{Theme: &theme}
def.SetItems(items)
if err := e.gdb.Create(&def).Error; err != nil {
t.Fatal(err)
}
return def
}
func text(s string) *string { return &s }
func writeFile(t *testing.T, dir, name, body string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func testOutput() (bonfire.Output, *bytes.Buffer) {
var buf bytes.Buffer
return bonfire.NewOutput(strings.NewReader(""), &buf, &buf), &buf
}
var adminSeq atomic.Uint64
// call sends an admin API request with an optional bearer token and JSON
// body.
func (e *sitemapEnv) call(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, cabana.DefaultAdminPrefix+"/api/v1"+path, reader)
req.Host = "api.example.test"
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", adminSeq.Add(1)%250+1)
req.Header.Set("Accept-Language", "en")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
// admin signs in a new administrator whose role grants permissions.
func (e *sitemapEnv) admin(t *testing.T, permissions ...string) string {
t.Helper()
stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1))
login, password := "admin-"+stamp, "admin-secret-"+stamp
hash, err := bouncer.HashPassword(10, password)
if err != nil {
t.Fatal(err)
}
grants := map[string]int{}
for _, code := range permissions {
grants[code] = 1
}
raw, _ := json.Marshal(grants)
var roleID uint
if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`,
"Role "+stamp, "role-"+stamp, string(raw)).Scan(&roleID).Error; err != nil || roleID == 0 {
t.Fatalf("backend role: id=%d err=%v", roleID, err)
}
if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`,
login, login+"@example.test", hash, roleID).Error; err != nil {
t.Fatal(err)
}
rec := e.call(t, http.MethodPost, "/auth/login", "", map[string]string{"login": login, "password": password})
var body struct {
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Data.AccessToken == "" {
t.Fatalf("admin login: %d %s", rec.Code, rec.Body.String())
}
return body.Data.AccessToken
}