- 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
267 lines
8.1 KiB
Go
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
|
|
}
|