- classes.SitemapFilter implements the sitemap plugin's contract.Filter on the production values: redirected, noindex and canonicalised addresses leave the sitemaps with their reason; staging lists the same set - the plugin requires golem15.sitemap and publishes the filter in Register - go.mod requires sm-sitemap-plugin, go.work mounts the sibling checkout
563 lines
19 KiB
Go
563 lines
19 KiB
Go
package seo_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
seo "git.golem15.com/golem15/sm-seo-plugin"
|
|
_ "git.golem15.com/golem15/sm-sitemap-plugin"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"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/lagoon/attach"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
"github.com/go-gormigrate/gormigrate/v2"
|
|
"gocloud.dev/blob/memblob"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// The fake content plugin acme.pages: pages (a tabbed admin form) behind
|
|
// the menu item type acme-page (/pages/:slug), with a content descriptor
|
|
// naming the admin controller. It knows nothing about the SEO plugin.
|
|
|
|
const (
|
|
pagesControllerID = "acme.pages.pages"
|
|
pagesPermission = "acme.pages.access"
|
|
pagesTypeKey = "acme-page"
|
|
pagesModel = "acme_pages_pages"
|
|
tagsTypeKey = "acme-tag"
|
|
notesControllerID = "acme.pages.notes"
|
|
notesTypeKey = "acme-note"
|
|
notesModel = "acme_pages_notes"
|
|
adminJWTSecret = "seo-admin-test-only-hs256-secret-key"
|
|
siteURL = "https://www.example.test"
|
|
)
|
|
|
|
// page is the fake content model.
|
|
type page struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Title string `gorm:"column:title"`
|
|
Slug string `gorm:"column:slug"`
|
|
Excerpt string `gorm:"column:excerpt"`
|
|
Published bool `gorm:"column:published"`
|
|
}
|
|
|
|
func (page) TableName() string { return pagesModel }
|
|
func (*page) Fillable() []string { return []string{"title", "slug", "excerpt", "published"} }
|
|
func (*page) Rules() map[string]string { return map[string]string{} }
|
|
func (pagesController) ID() string { return pagesControllerID }
|
|
func (pagesController) ModelName() string { return "Page" }
|
|
func (pagesController) ConfigDir() string { return "controllers/pages" }
|
|
func (pagesController) NewRecord() any { return &page{} }
|
|
func (pagesController) RequiredPermissions() []string {
|
|
return []string{pagesPermission}
|
|
}
|
|
|
|
type pagesController struct{}
|
|
|
|
// note is the model of the tab-less acme.pages.notes form.
|
|
type note struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Title string `gorm:"column:title"`
|
|
Slug string `gorm:"column:slug"`
|
|
}
|
|
|
|
func (note) TableName() string { return notesModel }
|
|
func (*note) Fillable() []string { return []string{"title", "slug"} }
|
|
func (*note) Rules() map[string]string { return map[string]string{} }
|
|
func (notesController) ID() string { return notesControllerID }
|
|
func (notesController) ModelName() string { return "Note" }
|
|
func (notesController) ConfigDir() string { return "controllers/notes" }
|
|
func (notesController) NewRecord() any { return ¬e{} }
|
|
func (notesController) RequiredPermissions() []string {
|
|
return []string{pagesPermission}
|
|
}
|
|
|
|
type notesController struct{}
|
|
|
|
// pagesPlugin is acme.pages.
|
|
type pagesPlugin struct {
|
|
app *backpack.App
|
|
}
|
|
|
|
var pagesFields = `fields:
|
|
title:
|
|
label: Title
|
|
type: text
|
|
slug:
|
|
label: Slug
|
|
type: text
|
|
excerpt:
|
|
label: Excerpt
|
|
type: textarea
|
|
tab: Content
|
|
published:
|
|
label: Published
|
|
type: switch
|
|
tab: Manage
|
|
`
|
|
|
|
func (*pagesPlugin) ID() string { return "acme.pages" }
|
|
func (*pagesPlugin) Requires() []string { return nil }
|
|
func (p *pagesPlugin) Register(app *backpack.App) error {
|
|
p.app = app
|
|
// The fixture stands in for the translate plugin: en is the default
|
|
// locale and pl is enabled, so translatable admin values are locale
|
|
// maps as on a real site.
|
|
return app.Publish[cabana.TranslationWriter](&fakeTranslations{values: map[string]string{}})
|
|
}
|
|
func (*pagesPlugin) Boot(*backpack.App) error { return nil }
|
|
func (*pagesPlugin) AdminFS() fs.FS {
|
|
return fstest.MapFS{
|
|
"controllers/pages/config_form.yaml": &fstest.MapFile{Data: []byte("name: Page\nform: ~/plugins/acme/pages/models/page/fields.yaml\nmodelClass: Page\n")},
|
|
"controllers/pages/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/pages/models/page/columns.yaml\nmodelClass: Page\nrecordsPerPage: 20\ntoolbar:\n buttons: [create]\n")},
|
|
"models/page/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n title:\n label: Title\n")},
|
|
"models/page/fields.yaml": &fstest.MapFile{Data: []byte(pagesFields)},
|
|
"controllers/notes/config_form.yaml": &fstest.MapFile{Data: []byte("name: Note\nform: ~/plugins/acme/pages/models/note/fields.yaml\nmodelClass: Note\n")},
|
|
"controllers/notes/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/pages/models/note/columns.yaml\nmodelClass: Note\nrecordsPerPage: 20\ntoolbar:\n buttons: [create]\n")},
|
|
"models/note/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n title:\n label: Title\n")},
|
|
"models/note/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n title:\n label: Title\n type: text\n slug:\n label: Slug\n type: text\n")},
|
|
}
|
|
}
|
|
func (*pagesPlugin) AdminControllers() []pact.AdminController {
|
|
return []pact.AdminController{pagesController{}, notesController{}}
|
|
}
|
|
func (*pagesPlugin) Permissions() []pact.Permission {
|
|
return []pact.Permission{{Code: pagesPermission, Tab: "Pages", Label: "Manage pages"}}
|
|
}
|
|
func (*pagesPlugin) Migrations() []*gormigrate.Migration {
|
|
return []*gormigrate.Migration{{
|
|
ID: "acme_pages_create",
|
|
Migrate: func(tx *gorm.DB) error {
|
|
return tx.Exec(`CREATE TABLE acme_pages_pages (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
slug TEXT NOT NULL DEFAULT '',
|
|
excerpt TEXT NOT NULL DEFAULT '',
|
|
published BOOLEAN NOT NULL DEFAULT FALSE
|
|
)`).Error
|
|
},
|
|
}, {
|
|
ID: "acme_notes_create",
|
|
Migrate: func(tx *gorm.DB) error {
|
|
return tx.Exec(`CREATE TABLE acme_pages_notes (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL DEFAULT '', slug TEXT NOT NULL DEFAULT '')`).Error
|
|
},
|
|
}}
|
|
}
|
|
func (*pagesPlugin) Models() []any { return []any{page{}, note{}} }
|
|
|
|
// db is the write transaction in ctx, else the application database.
|
|
func (p *pagesPlugin) db(ctx context.Context) *gorm.DB {
|
|
if tx, ok := cabana.TxFromContext(ctx); ok && tx != nil {
|
|
return tx
|
|
}
|
|
db, _ := p.app.Lookup[*gorm.DB]()
|
|
return db.WithContext(ctx)
|
|
}
|
|
|
|
// describe answers a page in en (/pages/<slug>) and pl (/pl/pages/<slug>).
|
|
func (p *pagesPlugin) describe(row page) pact.ContentRecord {
|
|
return pact.ContentRecord{
|
|
ID: strconv.FormatUint(uint64(row.ID), 10),
|
|
Published: row.Published,
|
|
Locales: []pact.ContentLocale{
|
|
{Locale: "en", URL: "/pages/" + row.Slug, Title: row.Title, Description: row.Excerpt},
|
|
{Locale: "pl", URL: "/pl/pages/" + row.Slug, Title: row.Title, Description: row.Excerpt},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (p *pagesPlugin) MenuItemTypes() []pact.MenuItemType {
|
|
return []pact.MenuItemType{p.pageType(), p.tagType(), p.noteType()}
|
|
}
|
|
|
|
// noteType is acme-note (/notes/:slug), edited in a form without tabs.
|
|
func (p *pagesPlugin) noteType() pact.MenuItemType {
|
|
return pact.MenuItemType{
|
|
Key: notesTypeKey,
|
|
Label: "Notes",
|
|
DefaultPattern: "/notes/:slug",
|
|
Placeholders: []string{"slug"},
|
|
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
|
return nil, nil
|
|
},
|
|
Content: &pact.ContentDescriptor{
|
|
Controller: notesControllerID,
|
|
Model: notesModel,
|
|
Describe: func(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
|
var rows []note
|
|
if err := p.db(ctx).Where("id = ?", in.ID).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
|
|
return nil, err
|
|
}
|
|
return &pact.ContentRecord{ID: in.ID, Published: true, Locales: []pact.ContentLocale{{Locale: "en", URL: "/notes/" + rows[0].Slug, Title: rows[0].Title}}}, nil
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// tagType is acme-tag (/pages/tag/:slug): a content type without an admin
|
|
// controller, so it gets per-type settings but no SEO tab.
|
|
func (p *pagesPlugin) tagType() pact.MenuItemType {
|
|
return pact.MenuItemType{
|
|
Key: tagsTypeKey,
|
|
Label: "Tags",
|
|
DefaultPattern: "/pages/tag/:slug",
|
|
Placeholders: []string{"slug"},
|
|
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
|
return nil, nil
|
|
},
|
|
Content: &pact.ContentDescriptor{
|
|
Model: "acme_pages_tags",
|
|
Describe: func(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
|
return &pact.ContentRecord{ID: in.ID, Published: true, Locales: []pact.ContentLocale{{Locale: "en", URL: "/pages/tag/" + in.ID, Title: "Tag " + in.ID}}}, nil
|
|
},
|
|
Match: func(_ context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
|
values, ok := signpost.MatchPattern(in.Pattern, in.Path)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return &pact.ContentMatchResult{ID: values["slug"], Locale: "en", Exact: true}, nil
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// pageType is acme-page (/pages/:slug, /pl/pages/:slug).
|
|
func (p *pagesPlugin) pageType() pact.MenuItemType {
|
|
return pact.MenuItemType{
|
|
Key: pagesTypeKey,
|
|
Label: "Page",
|
|
DefaultPattern: "/pages/:slug",
|
|
Placeholders: []string{"slug"},
|
|
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
|
return nil, nil
|
|
},
|
|
Content: &pact.ContentDescriptor{
|
|
Controller: pagesControllerID,
|
|
Model: pagesModel,
|
|
Describe: func(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
|
var rows []page
|
|
if err := p.db(ctx).Where("id = ?", in.ID).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
|
|
return nil, err
|
|
}
|
|
rec := p.describe(rows[0])
|
|
return &rec, nil
|
|
},
|
|
Records: func(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
|
after, _ := strconv.ParseUint(in.After, 10, 64)
|
|
var rows []page
|
|
if err := p.db(ctx).Where("id > ?", after).Order("id ASC").Limit(in.Limit).Find(&rows).Error; err != nil {
|
|
return pact.ContentPage{}, err
|
|
}
|
|
out := pact.ContentPage{}
|
|
for _, row := range rows {
|
|
out.Records = append(out.Records, p.describe(row))
|
|
}
|
|
if len(rows) == in.Limit && len(rows) > 0 {
|
|
out.Next = out.Records[len(out.Records)-1].ID
|
|
}
|
|
return out, nil
|
|
},
|
|
Match: func(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
|
path, locale := in.Path, "en"
|
|
if rest, ok := strings.CutPrefix(path, "/pl/"); ok {
|
|
path, locale = "/"+rest, "pl"
|
|
}
|
|
values, ok := signpost.MatchPattern(in.Pattern, path)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
var rows []page
|
|
if err := p.db(ctx).Where("slug = ? AND published", values["slug"]).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
|
|
return nil, err
|
|
}
|
|
return &pact.ContentMatchResult{ID: strconv.FormatUint(uint64(rows[0].ID), 10), Locale: locale, Exact: true}, nil
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
var (
|
|
_ party.Plugin = (*pagesPlugin)(nil)
|
|
_ pact.HasAdminControllers = (*pagesPlugin)(nil)
|
|
_ pact.HasMenuItemTypes = (*pagesPlugin)(nil)
|
|
_ pact.HasMigrations = (*pagesPlugin)(nil)
|
|
_ pact.HasPermissions = (*pagesPlugin)(nil)
|
|
)
|
|
|
|
func init() {
|
|
party.Register(&pagesPlugin{})
|
|
}
|
|
|
|
// testEnv is a booted application with acme.pages and golem15.seo.
|
|
type testEnv struct {
|
|
h http.Handler
|
|
app *backpack.App
|
|
db *gorm.DB
|
|
sql *sql.DB
|
|
}
|
|
|
|
var seq atomic.Uint64
|
|
|
|
// adminPrefix is the admin JSON API prefix.
|
|
func adminAPI(rel string) string { return cabana.DefaultAdminPrefix + "/api/v1" + rel }
|
|
|
|
// bootEnv boots acme.pages and golem15.seo on a fresh database.
|
|
func bootEnv(t *testing.T, extraEnv ...string) *testEnv {
|
|
t.Helper()
|
|
return bootPlugins(t, []string{"acme.pages", "golem15.seo"}, extraEnv...)
|
|
}
|
|
|
|
// bootPlugins boots the plugins ids on a fresh database. golem15.seo
|
|
// requires golem15.sitemap, so the sitemap plugin is booted with it.
|
|
func bootPlugins(t *testing.T, ids []string, extraEnv ...string) *testEnv {
|
|
t.Helper()
|
|
if slices.Contains(ids, "golem15.seo") && !slices.Contains(ids, seo.SitemapPluginID) {
|
|
ids = append([]string{seo.SitemapPluginID}, ids...)
|
|
}
|
|
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: seo-test\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{
|
|
Dir: dir,
|
|
Environ: append([]string{
|
|
"SUMMER_ENV=production",
|
|
"SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret,
|
|
"SUMMER_GOLEM15__SEO__SITE_URL=" + siteURL,
|
|
}, extraEnv...),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for key, value := range map[string]any{
|
|
"http.body_limits.default_bytes": 1048576,
|
|
"http.body_limits.upload_bytes": 8388608,
|
|
"mail.driver": "log",
|
|
"mail.from": "noreply@example.test",
|
|
} {
|
|
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)
|
|
}
|
|
bucket := memblob.OpenBucket(nil)
|
|
t.Cleanup(func() { _ = bucket.Close() })
|
|
if err := attach.Publish(app, bucket); 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 &testEnv{h: h, app: app, db: gdb, sql: sqlDB}
|
|
}
|
|
|
|
// admin creates a backend user with the given grants (superuser when
|
|
// super is true) and returns its access token.
|
|
func (e *testEnv) admin(t *testing.T, super bool, permissions ...string) string {
|
|
t.Helper()
|
|
stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, seq.Add(1))
|
|
login := "admin-" + stamp
|
|
password := "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, err := json.Marshal(grants)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var roleID uint
|
|
if err := e.db.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.db.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, ?, ?, NOW(), NOW())`,
|
|
login, login+"@example.test", hash, super, roleID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := e.call(t, http.MethodPost, adminAPI("/auth/login"), "", map[string]string{"login": login, "password": password})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("admin login %s: %d %s", login, rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Data struct {
|
|
AccessToken string `json:"access_token"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.AccessToken == "" {
|
|
t.Fatalf("admin login body: %s err=%v", rec.Body.String(), err)
|
|
}
|
|
return body.Data.AccessToken
|
|
}
|
|
|
|
// call runs one request against the application.
|
|
func (e *testEnv) 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, path, reader)
|
|
req.Host = "attacker.example"
|
|
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", seq.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
|
|
}
|
|
|
|
// expect runs a request and fails unless it answers status.
|
|
func (e *testEnv) expect(t *testing.T, status int, method, path, token string, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
rec := e.call(t, method, path, token, body)
|
|
if rec.Code != status {
|
|
t.Fatalf("%s %s = %d, want %d: %s", method, path, rec.Code, status, rec.Body.String())
|
|
}
|
|
return rec
|
|
}
|
|
|
|
// createPage creates a page through the admin and returns its id.
|
|
func (e *testEnv) createPage(t *testing.T, token string, body map[string]any) string {
|
|
t.Helper()
|
|
rec := e.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/acme/pages/pages"), token, body)
|
|
var out struct {
|
|
Data struct {
|
|
ID json.Number `json:"id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || out.Data.ID == "" {
|
|
t.Fatalf("create body %s: %v", rec.Body.String(), err)
|
|
}
|
|
return out.Data.ID.String()
|
|
}
|
|
|
|
// resolve calls the public resolve endpoint.
|
|
func (e *testEnv) resolve(t *testing.T, target string) map[string]any {
|
|
t.Helper()
|
|
rec := e.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/resolve?url="+urlQueryEscape(target), "", nil)
|
|
var out map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// schemaField is one field of a form schema.
|
|
type schemaField struct {
|
|
Name string `json:"name"`
|
|
Tab string `json:"tab"`
|
|
}
|
|
|
|
// formSchema returns the form schema fields of controller (vendor/plugin/ctl).
|
|
func (e *testEnv) formSchema(t *testing.T, token, controller string) []schemaField {
|
|
t.Helper()
|
|
rec := e.expect(t, http.StatusOK, http.MethodGet, adminAPI("/"+controller+"/schema/form"), token, nil)
|
|
var out struct {
|
|
Data struct {
|
|
Fields []schemaField `json:"fields"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out.Data.Fields
|
|
}
|
|
|
|
// seoPlugin returns the activated SEO plugin.
|
|
func (e *testEnv) seoPlugin(t *testing.T) *seo.Plugin {
|
|
t.Helper()
|
|
plugins, ok := e.app.Lookup[party.Activated]()
|
|
if !ok {
|
|
t.Fatal("no activated plugins")
|
|
}
|
|
for _, p := range plugins {
|
|
if sp, ok := p.(*seo.Plugin); ok {
|
|
return sp
|
|
}
|
|
}
|
|
t.Fatal("golem15.seo is not activated")
|
|
return nil
|
|
}
|
|
|
|
// jsonContains reports whether body contains the compact JSON fragment.
|
|
func jsonContains(body, fragment string) bool {
|
|
return bytes.Contains([]byte(body), []byte(fragment))
|
|
}
|
|
|
|
// fakeTranslations is an in-memory cabana.TranslationWriter: en default, pl
|
|
// enabled.
|
|
type fakeTranslations struct {
|
|
mu sync.Mutex
|
|
values map[string]string
|
|
}
|
|
|
|
func (*fakeTranslations) DefaultLocale(context.Context, *gorm.DB) (string, error) { return "en", nil }
|
|
func (*fakeTranslations) EnabledLocales(context.Context, *gorm.DB) ([]string, error) {
|
|
return []string{"en", "pl"}, nil
|
|
}
|
|
func (f *fakeTranslations) WriteTranslated(_ context.Context, _ *gorm.DB, model any, field, locale, value string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.values[fmt.Sprintf("%T|%s|%s", model, field, locale)] = value
|
|
return nil
|
|
}
|
|
func (f *fakeTranslations) TranslatedExact(_ context.Context, _ *gorm.DB, model any, field, locale string) (string, bool, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
v, ok := f.values[fmt.Sprintf("%T|%s|%s", model, field, locale)]
|
|
return v, ok, nil
|
|
}
|