feat: entry generation job and cleanup on delete
This commit is contained in:
250
classes/generate.go
Normal file
250
classes/generate.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/conga"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GenerateKind is the River kind of the entry generation job.
|
||||
const GenerateKind = "golem15.seo.generate_entries"
|
||||
|
||||
// generatePage is the page size of the generation job.
|
||||
const generatePage = 200
|
||||
|
||||
// GenerateEntriesArgs are the arguments of the entry generation job (D-94,
|
||||
// the mpseo GenerateSeoEntriesJob): Type limits it to one content type
|
||||
// key; empty runs every content type.
|
||||
type GenerateEntriesArgs struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// Kind is GenerateKind.
|
||||
func (GenerateEntriesArgs) Kind() string { return GenerateKind }
|
||||
|
||||
// GenerateResult counts what one generation run did.
|
||||
type GenerateResult struct {
|
||||
Created int
|
||||
Updated int
|
||||
}
|
||||
|
||||
// GenerateEntries walks the published records of every content type (or
|
||||
// of typeKey) through their descriptors and makes sure each locale with an
|
||||
// address has an entry: a missing one is created with the URL, an existing
|
||||
// one gets its URL, type and effective title and description cache
|
||||
// refreshed. Editor values are never written. Each page of records is one
|
||||
// transaction.
|
||||
func (s *Service) GenerateEntries(ctx context.Context, typeKey string) (GenerateResult, error) {
|
||||
var res GenerateResult
|
||||
reg := s.Registry()
|
||||
if reg == nil {
|
||||
return res, nil
|
||||
}
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
var types []pact.MenuItemType
|
||||
for _, typ := range reg.ContentTypes() {
|
||||
if typeKey == "" || typ.Key == typeKey {
|
||||
types = append(types, typ)
|
||||
}
|
||||
}
|
||||
if typeKey != "" && len(types) == 0 {
|
||||
return res, fmt.Errorf("golem15.seo: content type %q is not registered", typeKey)
|
||||
}
|
||||
settings, err := LoadSettings(db)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
for _, typ := range types {
|
||||
ts, err := LoadTypeSetting(db, typ.Key)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
after := ""
|
||||
for {
|
||||
page, err := reg.Records(ctx, typ.Key, after, generatePage, s.UnprefixedLocale())
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, rec := range page.Records {
|
||||
if !rec.Published {
|
||||
continue
|
||||
}
|
||||
for _, l := range rec.Locales {
|
||||
if strings.TrimSpace(l.URL) == "" {
|
||||
continue
|
||||
}
|
||||
created, err := s.refreshEntry(ctx, tx, typ, rec.ID, l, settings, ts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
res.Created++
|
||||
} else {
|
||||
res.Updated++
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return res, err
|
||||
}
|
||||
if page.Next == "" || page.Next == after || len(page.Records) == 0 {
|
||||
break
|
||||
}
|
||||
after = page.Next
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// refreshEntry creates or refreshes the entry of one record in one
|
||||
// locale; created is true for a new row.
|
||||
func (s *Service) refreshEntry(ctx context.Context, tx *gorm.DB, typ pact.MenuItemType, id string, l pact.ContentLocale, settings models.Settings, ts models.TypeSetting) (bool, error) {
|
||||
entry, found, err := LoadEntry(tx, typ.Content.Model, id, l.Locale)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
site := s.translatedSetting(ctx, tx, &settings, "site_name", settings.SiteName, l.Locale)
|
||||
fallback := s.translatedSetting(ctx, tx, &settings, "default_description", settings.DefaultDescription, l.Locale)
|
||||
title, description := EffectiveValues(entry, l, settings, ts, site, fallback)
|
||||
if !found {
|
||||
row := models.Entry{
|
||||
ModelType: typ.Content.Model, RecordID: id, Locale: l.Locale, TypeKey: typ.Key, URL: l.URL,
|
||||
EffectiveTitle: title, EffectiveDescription: description,
|
||||
}
|
||||
return true, tx.Create(&row).Error
|
||||
}
|
||||
return false, tx.Model(&models.Entry{}).Where("id = ?", entry.ID).Updates(map[string]any{
|
||||
"url": l.URL,
|
||||
"type_key": typ.Key,
|
||||
"effective_title": title,
|
||||
"effective_description": description,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// EffectiveValues are a record's effective title and description without
|
||||
// URL rules (the cache duplicate checks read): the record's values, else
|
||||
// the type or general template on the content, else the content's own,
|
||||
// else the settings.
|
||||
func EffectiveValues(entry models.Entry, l pact.ContentLocale, settings models.Settings, ts models.TypeSetting, site, fallback string) (string, string) {
|
||||
vars := TemplateVars{Title: l.Title, Description: l.Description, Site: site}
|
||||
tpl := ts.TitleTemplate
|
||||
if strings.TrimSpace(tpl) == "" {
|
||||
tpl = settings.TitleTemplate
|
||||
}
|
||||
title, _ := pick(candidate{entry.Title, SourceRecord}, candidate{RenderTemplate(tpl, vars), SourceTemplate}, candidate{l.Title, SourceContent}, candidate{site, SourceGlobal})
|
||||
description, _ := pick(candidate{entry.Description, SourceRecord}, candidate{RenderTemplate(ts.DescriptionTemplate, vars), SourceTemplate}, candidate{l.Description, SourceContent}, candidate{fallback, SourceGlobal})
|
||||
return title, description
|
||||
}
|
||||
|
||||
// EnqueueGenerate queues the generation job through conga for typeKey
|
||||
// (empty: every type) and returns the summer_jobs id.
|
||||
func (s *Service) EnqueueGenerate(ctx context.Context, db *gorm.DB, typeKey string) (uint, error) {
|
||||
if s == nil || s.app == nil {
|
||||
return 0, ErrNotBooted
|
||||
}
|
||||
m, err := conga.From(s.app)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return m.Dispatch(ctx, db, GenerateEntriesArgs{Type: typeKey}, conga.DispatchOpts{Label: s.Phrase("golem15.seo::lang.jobs.generate_entries", nil)})
|
||||
}
|
||||
|
||||
// RecordDeleted removes the entries of a deleted record in one
|
||||
// transaction and records each in history (via auto), so its values stay
|
||||
// recoverable (D-92, D-94). A model without a content type is ignored.
|
||||
func (s *Service) RecordDeleted(ctx context.Context, ev *pact.RecordDeleted) error {
|
||||
reg := s.Registry()
|
||||
if reg == nil || ev == nil {
|
||||
return nil
|
||||
}
|
||||
typ, ok := reg.ContentTypeForModel(ev.Model)
|
||||
if !ok || typ.Content == nil {
|
||||
return nil
|
||||
}
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = WithVia(ctx, models.ViaAuto)
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
entries, err := LoadEntries(tx, typ.Content.Model, ev.ID)
|
||||
if err != nil || len(entries) == 0 {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("model_type = ? AND record_id = ?", typ.Content.Model, ev.ID).Delete(&models.Entry{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for locale, e := range entries {
|
||||
if entryEmpty(e) {
|
||||
continue
|
||||
}
|
||||
title := e.EffectiveTitle
|
||||
if title == "" {
|
||||
title = e.URL
|
||||
}
|
||||
if err := s.Record(ctx, tx, Change{
|
||||
Kind: models.SubjectEntry,
|
||||
SubjectID: EntrySubject(e.ModelType, e.RecordID, locale),
|
||||
What: s.recordWhat(typ, ev.ID, pact.ContentLocale{Title: title}, locale),
|
||||
Before: entryValues(e),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RecordCreated stores the address snapshot of a newly created record:
|
||||
// each locale with an address gets an entry with its URL (existing entries,
|
||||
// written by the SEO tab in the same save, only take the URL).
|
||||
func (s *Service) RecordCreated(ctx context.Context, ev *pact.RecordSaved) error {
|
||||
reg := s.Registry()
|
||||
if reg == nil || ev == nil || !ev.Created {
|
||||
return nil
|
||||
}
|
||||
typ, ok := reg.ContentTypeForModel(ev.Model)
|
||||
if !ok || typ.Content == nil {
|
||||
return nil
|
||||
}
|
||||
rec, err := reg.Describe(ctx, typ.Key, ev.ID, s.UnprefixedLocale())
|
||||
if err != nil || rec == nil {
|
||||
return err
|
||||
}
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, l := range rec.Locales {
|
||||
if strings.TrimSpace(l.URL) == "" {
|
||||
continue
|
||||
}
|
||||
entry, found, err := LoadEntry(tx, typ.Content.Model, ev.ID, l.Locale)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
if err := tx.Model(&models.Entry{}).Where("id = ?", entry.ID).Updates(map[string]any{"url": l.URL, "type_key": typ.Key}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
row := models.Entry{ModelType: typ.Content.Model, RecordID: ev.ID, Locale: l.Locale, TypeKey: typ.Key, URL: l.URL}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
86
commands.go
Normal file
86
commands.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package seo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Commands registers seo:generate-entries.
|
||||
func (p *Plugin) Commands() []bonfire.Command {
|
||||
return []bonfire.Command{{
|
||||
Name: "seo:generate-entries",
|
||||
Description: "Queue the job that creates missing SEO entries",
|
||||
Long: `Queues the entry generation job on the background queue. The job walks the
|
||||
published records of every content type registered in signpost (or of the
|
||||
one named by --type), creates the missing SEO entry of each locale with its
|
||||
current address and refreshes the address and the effective title and
|
||||
description of existing entries. Values set in the SEO tab are never
|
||||
changed. A worker (summer serve or queue:work) runs the job.`,
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "type", Description: "Content type key (signpost menu item type) to limit the run to"},
|
||||
},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "seo:generate-entries", Description: "Queue generation for every content type"},
|
||||
{Command: "seo:generate-entries --type journal-post", Description: "Queue generation for one content type"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
typeKey, _ := in.Flag("type")
|
||||
typeKey = strings.TrimSpace(typeKey)
|
||||
svc := p.Service()
|
||||
if svc == nil {
|
||||
return classes.ErrNotBooted
|
||||
}
|
||||
if typeKey != "" {
|
||||
if reg := svc.Registry(); reg != nil {
|
||||
if _, ok := reg.ContentType(typeKey); !ok {
|
||||
return fmt.Errorf("Content type %q is not registered", typeKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
db, done, err := p.commandDB(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer done()
|
||||
id, err := svc.EnqueueGenerate(ctx, db, typeKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Success(fmt.Sprintf("Queued SEO entry generation (job #%d).", id))
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// commandDB returns the app database, opening database.dsn when the binary
|
||||
// published none for console commands. done closes a pool it opened.
|
||||
func (p *Plugin) commandDB(ctx context.Context) (*gorm.DB, func(), error) {
|
||||
app := p.app
|
||||
if app == nil {
|
||||
return nil, nil, classes.ErrNotBooted
|
||||
}
|
||||
if db, ok := app.Lookup[*gorm.DB](); ok && db != nil {
|
||||
return db, func() {}, nil
|
||||
}
|
||||
if lagoon.DSN(app.Config) == "" {
|
||||
return nil, nil, fmt.Errorf("golem15.seo: database is not configured (set SUMMER_DATABASE__DSN)")
|
||||
}
|
||||
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return gdb, func() { _ = sqlDB.Close() }, nil
|
||||
}
|
||||
|
||||
var _ pact.HasCommands = (*Plugin)(nil)
|
||||
@@ -4,13 +4,19 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// log is the controllers' logger (component=seo).
|
||||
var log = diary.For("seo")
|
||||
|
||||
// settingsAdminController edits the SEO settings singleton (D-104), linked
|
||||
// from the SEO settings tile. Its per-content-type tabs are added by the
|
||||
// plugin's own form extension on this controller.
|
||||
@@ -65,6 +71,31 @@ func (c settingsAdminController) FormBeforeCreate(ctx context.Context, model any
|
||||
return c.FormBeforeUpdate(ctx, model)
|
||||
}
|
||||
|
||||
// FormAfterUpdate queues the entry generation job once the save commits,
|
||||
// so the effective title and description cache follows new templates. A
|
||||
// queue error is logged and never fails the save.
|
||||
func (c settingsAdminController) FormAfterUpdate(ctx context.Context, _ any) error {
|
||||
app := appOf(c.app)
|
||||
svc, err := classes.NewService(app)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
tx, err := database(ctx, c.app)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
db, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil {
|
||||
return
|
||||
}
|
||||
if _, err := svc.EnqueueGenerate(ctx, db.WithContext(ctx), ""); err != nil {
|
||||
log.Warn("golem15.seo: queue entry generation", "error", err)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// stagingView is the view model of the staging partial.
|
||||
type stagingView struct {
|
||||
Staging bool
|
||||
@@ -96,4 +127,5 @@ var (
|
||||
_ pact.AdminPartialData = settingsAdminController{}
|
||||
_ pact.FormBeforeCreate = settingsAdminController{}
|
||||
_ pact.FormBeforeUpdate = settingsAdminController{}
|
||||
_ pact.FormAfterUpdate = settingsAdminController{}
|
||||
)
|
||||
|
||||
32
jobs.go
Normal file
32
jobs.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package seo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/conga"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// Jobs registers the entry generation job (D-94) on conga.
|
||||
func (p *Plugin) Jobs() []pact.Job {
|
||||
return []pact.Job{
|
||||
conga.Job(p.generateEntries, conga.MaxAttempts(3)),
|
||||
}
|
||||
}
|
||||
|
||||
// generateEntries runs one generation job.
|
||||
func (p *Plugin) generateEntries(ctx context.Context, args classes.GenerateEntriesArgs) error {
|
||||
svc := p.Service()
|
||||
if svc == nil {
|
||||
return classes.ErrNotBooted
|
||||
}
|
||||
res, err := svc.GenerateEntries(ctx, args.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pluginLog.Info("golem15.seo: entries generated", "type", args.Type, "created", res.Created, "updated", res.Updated)
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ pact.HasJobs = (*Plugin)(nil)
|
||||
@@ -147,3 +147,5 @@ history:
|
||||
diff_before: Before
|
||||
diff_after: After
|
||||
list_empty: No SEO changes recorded yet. Every change from the admin, MCP or an import appears here and can be undone.
|
||||
jobs:
|
||||
generate_entries: 'SEO: generate entries'
|
||||
|
||||
@@ -151,3 +151,5 @@ history:
|
||||
diff_before: Przed
|
||||
diff_after: Po
|
||||
list_empty: Nie zarejestrowano jeszcze zmian SEO. Każda zmiana z panelu, MCP lub importu pojawi się tutaj i będzie można ją cofnąć.
|
||||
jobs:
|
||||
generate_entries: 'SEO: generowanie wpisów'
|
||||
|
||||
32
listeners.go
Normal file
32
listeners.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package seo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// listen subscribes to the framework's record events (D-92): a deleted
|
||||
// record of a content type loses its SEO entries (kept in history), and a
|
||||
// created one gets its address snapshot. Listener errors are logged by the
|
||||
// bus and never fail the write that fired them.
|
||||
func (p *Plugin) listen(app *backpack.App) {
|
||||
if app == nil || app.Events == nil {
|
||||
return
|
||||
}
|
||||
app.Events.Listen("golem15.seo", func(ctx context.Context, ev *pact.RecordDeleted) error {
|
||||
svc := p.Service()
|
||||
if svc == nil {
|
||||
return nil
|
||||
}
|
||||
return svc.RecordDeleted(ctx, ev)
|
||||
})
|
||||
app.Events.Listen("golem15.seo", func(ctx context.Context, ev *pact.RecordSaved) error {
|
||||
svc := p.Service()
|
||||
if svc == nil || ev == nil || !ev.Created {
|
||||
return nil
|
||||
}
|
||||
return svc.RecordCreated(ctx, ev)
|
||||
})
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func (p *Plugin) Register(app *backpack.App) error {
|
||||
}
|
||||
|
||||
// Boot builds the service on the application's database, signpost registry
|
||||
// and config.
|
||||
// and config, and subscribes to the record events.
|
||||
func (p *Plugin) Boot(app *backpack.App) error {
|
||||
p.app = app
|
||||
svc, err := classes.NewService(app)
|
||||
@@ -64,6 +64,7 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
||||
return fmt.Errorf("golem15.seo: %w", err)
|
||||
}
|
||||
p.service = svc
|
||||
p.listen(app)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -110,8 +110,8 @@ func TestSEOTabPermission(t *testing.T) {
|
||||
}
|
||||
id := env.createPage(t, editor, map[string]any{"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Sneaky"}})
|
||||
var count int64
|
||||
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_entries`).Scan(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("entries after an editor create = %d err=%v, want 0", count, err)
|
||||
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_entries WHERE title <> ''`).Scan(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("entries with a title after an editor create = %d err=%v, want 0", count, err)
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), super, map[string]any{
|
||||
"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Custom"},
|
||||
@@ -221,3 +221,89 @@ func TestHistoryRecordsAdminWrite(t *testing.T) {
|
||||
t.Fatalf("ViaOf(WithVia import) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntryDeletedWithRecord checks D-92/D-94: a created record gets its
|
||||
// address snapshot per locale, and deleting it removes its entries after
|
||||
// commit while history keeps the values (via auto).
|
||||
func TestEntryDeletedWithRecord(t *testing.T) {
|
||||
env := bootEnv(t)
|
||||
token := env.admin(t, true)
|
||||
id := env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Kept in history"}})
|
||||
var urls []string
|
||||
if err := env.db.Raw(`SELECT url FROM golem15_seo_entries WHERE model_type = ? AND record_id = ? ORDER BY locale`, pagesModel, id).Scan(&urls).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(urls, " ") != "/pages/about /pl/pages/about" {
|
||||
t.Fatalf("entry URLs after create = %v, want both locales", urls)
|
||||
}
|
||||
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, adminAPI("/acme/pages/pages/"+id), token, nil)
|
||||
var count int64
|
||||
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_entries WHERE record_id = ?`, id).Scan(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("entries after delete = %d err=%v, want 0", count, err)
|
||||
}
|
||||
var deleted []revisionRow
|
||||
for _, r := range env.revisions(t, "entry") {
|
||||
if r.Via == "auto" {
|
||||
deleted = append(deleted, r)
|
||||
}
|
||||
}
|
||||
if len(deleted) != 1 || deleted[0].After != "{}" || !jsonContains(deleted[0].Before, `"title": "Kept in history"`) || deleted[0].SubjectID != pagesModel+"|"+id+"|en" {
|
||||
t.Fatalf("deletion revisions = %+v, want one with the title before", deleted)
|
||||
}
|
||||
if got := env.resolve(t, "/pages/about"); got["action"] != "not_found" {
|
||||
t.Fatalf("resolve after delete = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateEntries checks the D-94 generation job's work: every
|
||||
// published record gets an entry per locale with its URL, drafts none, and
|
||||
// a second run refreshes addresses without touching editor values.
|
||||
func TestGenerateEntries(t *testing.T) {
|
||||
env := bootEnv(t)
|
||||
if err := env.db.Exec(`INSERT INTO acme_pages_pages (title, slug, excerpt, published) VALUES ('One', 'one', 'First', TRUE), ('Two', 'two', '', TRUE), ('Draft', 'draft', '', FALSE)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := env.seoPlugin(t).Service()
|
||||
res, err := svc.GenerateEntries(t.Context(), pagesTypeKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Created != 4 || res.Updated != 0 {
|
||||
t.Fatalf("first run = %+v, want 4 created (2 records x 2 locales)", res)
|
||||
}
|
||||
type row struct {
|
||||
RecordID string
|
||||
Locale string
|
||||
URL string
|
||||
Title string
|
||||
EffectiveTitle string
|
||||
}
|
||||
var rows []row
|
||||
if err := env.db.Raw(`SELECT record_id, locale, url, title, effective_title FROM golem15_seo_entries ORDER BY record_id, locale`).Scan(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 4 || rows[0].URL != "/pages/one" || rows[1].URL != "/pl/pages/one" || rows[0].EffectiveTitle != "One" || rows[2].URL != "/pages/two" {
|
||||
t.Fatalf("entries = %+v", rows)
|
||||
}
|
||||
|
||||
if err := env.db.Exec(`UPDATE golem15_seo_entries SET title = 'Editor title' WHERE record_id = ? AND locale = 'en'`, rows[0].RecordID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.db.Exec(`UPDATE acme_pages_pages SET slug = 'one-renamed' WHERE slug = 'one'`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res, err = svc.GenerateEntries(t.Context(), ""); err != nil || res.Created != 0 || res.Updated != 4 {
|
||||
t.Fatalf("second run = %+v err=%v, want 4 updated", res, err)
|
||||
}
|
||||
var again row
|
||||
if err := env.db.Raw(`SELECT record_id, locale, url, title, effective_title FROM golem15_seo_entries WHERE record_id = ? AND locale = 'en'`, rows[0].RecordID).Scan(&again).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.Title != "Editor title" || again.URL != "/pages/one-renamed" || again.EffectiveTitle != "Editor title" {
|
||||
t.Fatalf("after the second run = %+v, want the editor title kept and the URL refreshed", again)
|
||||
}
|
||||
if _, err := svc.GenerateEntries(t.Context(), "nope"); err == nil {
|
||||
t.Fatal("unknown type: want an error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,9 @@ func TestResolverPrecedence(t *testing.T) {
|
||||
env.createPage(t, token, map[string]any{"title": "Bare", "slug": "bare", "published": true})
|
||||
env.createPage(t, token, map[string]any{"title": "Draft", "slug": "draft", "excerpt": "Secret", "published": false})
|
||||
if err := env.db.Exec(`INSERT INTO golem15_seo_entries (model_type, record_id, locale, type_key, title, description, canonical, robots, og_image, created_at, updated_at)
|
||||
VALUES (?, ?, 'en', ?, 'Custom SEO title', 'Custom SEO description', '/canonical-custom', 'noindex', '/media/custom.png', NOW(), NOW())`, pagesModel, custom, pagesTypeKey).Error; err != nil {
|
||||
VALUES (?, ?, 'en', ?, 'Custom SEO title', 'Custom SEO description', '/canonical-custom', 'noindex', '/media/custom.png', NOW(), NOW())
|
||||
ON CONFLICT (model_type, record_id, locale) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description,
|
||||
canonical = EXCLUDED.canonical, robots = EXCLUDED.robots, og_image = EXCLUDED.og_image`, pagesModel, custom, pagesTypeKey).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rule := range []map[string]any{
|
||||
|
||||
Reference in New Issue
Block a user