Files
sm-journal-plugin/record_events_test.go
Jakub Zych 62c75b1fc2 fix: write API posts in one transaction and fire RecordSaved after its commit
POST and PUT /_journal/api/v1/posts saved the row, then the categories, tags
and translations, and fired RecordSaved only when all succeeded. A failure
part-way left a committed row (possibly a new slug) with no event, so
subscribers such as an SEO plugin never created the redirect. The write now
runs in one lagoon transaction with the event emitted inside it.
2026-10-27 15:35:07 +01:00

270 lines
9.3 KiB
Go

package journal_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"testing"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/controllers"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/signpost"
)
// savedEvent is one RecordSaved a test listener heard, with the URL the
// subscriber read back through signpost.
type savedEvent struct {
ev pact.RecordSaved
key string
url string
err error
}
// recordListener collects the record events of an application.
type recordListener struct {
mu sync.Mutex
saved []savedEvent
deleted []pact.RecordDeleted
}
func (l *recordListener) savedEvents() []savedEvent {
l.mu.Lock()
defer l.mu.Unlock()
return append([]savedEvent(nil), l.saved...)
}
func (l *recordListener) deletedEvents() []pact.RecordDeleted {
l.mu.Lock()
defer l.mu.Unlock()
return append([]pact.RecordDeleted(nil), l.deleted...)
}
func (l *recordListener) reset() {
l.mu.Lock()
defer l.mu.Unlock()
l.saved = nil
l.deleted = nil
}
// listenRecords registers a subscriber the way a shared plugin would: on
// RecordSaved it maps the event's model to its content type and describes
// the record, reading the URL of the first locale.
func listenRecords(t *testing.T, env *adminEnv) *recordListener {
t.Helper()
reg, ok := env.app.Lookup[*signpost.Registry]()
if !ok || reg == nil {
t.Fatal("signpost registry is not published")
}
l := &recordListener{}
env.app.Events.Listen("test.records", func(ctx context.Context, ev *pact.RecordSaved) error {
got := savedEvent{ev: *ev}
if typ, ok := reg.ContentTypeForModel(ev.Model); ok {
got.key = typ.Key
rec, err := reg.Describe(ctx, typ.Key, ev.ID, "")
got.err = err
if rec != nil && len(rec.Locales) > 0 {
got.url = rec.Locales[0].URL
}
}
l.mu.Lock()
l.saved = append(l.saved, got)
l.mu.Unlock()
return nil
})
env.app.Events.Listen("test.records", func(ctx context.Context, ev *pact.RecordDeleted) error {
l.mu.Lock()
l.deleted = append(l.deleted, *ev)
l.mu.Unlock()
return nil
})
return l
}
// findChange returns the change of attribute in locale.
func findChange(changes []pact.RecordChange, attribute, locale string) (pact.RecordChange, bool) {
for _, c := range changes {
if c.Attribute == attribute && c.Locale == locale {
return c, true
}
}
return pact.RecordChange{}, false
}
// TestRecordSavedDescribesNewURL is the D-92/D-93 tracer: an admin slug
// change on a Journal post reaches a subscriber after commit, and the
// subscriber reads the new URL through the post's content descriptor. A save
// that fails validation fires nothing.
func TestRecordSavedDescribesNewURL(t *testing.T) {
env := newPostsEnv(t)
token := env.admin(t,
controllers.PermissionAccessPosts,
controllers.PermissionAccessPublish,
controllers.PermissionAccessOtherPosts,
)
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
"title": map[string]any{"en": "Tracer", "pl": "Tropiciel"},
"slug": map[string]any{"en": "tracer-old", "pl": "tropiciel-stary"},
"content": map[string]any{"en": "<p>Body</p>"},
"excerpt": map[string]any{"en": "Excerpt"},
})
id := adminCreatedID(t, rec)
l := listenRecords(t, env)
path := adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id))
env.expect(t, http.StatusOK, http.MethodPut, path, token, map[string]any{
"title": map[string]any{"en": "Tracer", "pl": "Tropiciel"},
"slug": map[string]any{"en": "tracer-new", "pl": "tropiciel-nowy"},
"content": map[string]any{"en": "<p>Body</p>"},
"excerpt": map[string]any{"en": "Excerpt"},
})
got := l.savedEvents()
if len(got) != 1 {
t.Fatalf("RecordSaved events = %d, want 1: %+v", len(got), got)
}
ev := got[0]
if ev.ev.Controller != controllers.PostsControllerID {
t.Fatalf("Controller = %q, want %q", ev.ev.Controller, controllers.PostsControllerID)
}
if ev.ev.Model != (models.Post{}).MorphName() {
t.Fatalf("Model = %q, want the Post morph name", ev.ev.Model)
}
if ev.ev.ID != fmt.Sprint(id) || ev.ev.Created {
t.Fatalf("ID = %q Created = %v, want %d and false", ev.ev.ID, ev.ev.Created, id)
}
slug, ok := findChange(ev.ev.Changes, "slug", "")
if !ok || slug.Old != "tracer-old" || slug.New != "tracer-new" {
t.Fatalf("default slug change = %+v (found %v), want tracer-old -> tracer-new; changes %+v", slug, ok, ev.ev.Changes)
}
plSlug, ok := findChange(ev.ev.Changes, "slug", "pl")
if !ok || plSlug.Old != "tropiciel-stary" || plSlug.New != "tropiciel-nowy" {
t.Fatalf("pl slug change = %+v (found %v), want tropiciel-stary -> tropiciel-nowy; changes %+v", plSlug, ok, ev.ev.Changes)
}
if _, ok := findChange(ev.ev.Changes, "title", ""); ok {
t.Fatalf("unchanged title reported: %+v", ev.ev.Changes)
}
if ev.key != classes.MenuTypePost || ev.err != nil {
t.Fatalf("content type = %q err = %v, want %q", ev.key, ev.err, classes.MenuTypePost)
}
if ev.url != "/blog/tracer-new" {
t.Fatalf("described URL = %q, want /blog/tracer-new", ev.url)
}
l.reset()
bad := env.call(t, http.MethodPut, path, token, map[string]any{
"slug": map[string]any{"en": ""},
})
if bad.Code != http.StatusUnprocessableEntity {
t.Fatalf("invalid save = %d, want 422: %s", bad.Code, bad.Body.String())
}
if got := l.savedEvents(); len(got) != 0 {
t.Fatalf("failed save fired %d events: %+v", len(got), got)
}
}
// TestAPIRecordEvents (D-102): the /_journal/api/v1 write handlers fire the
// same record events as the admin, so a slug changed through MCP reaches
// subscribers: POST fires RecordSaved with Created, PUT a slug change (the
// default locale on the row, a translated locale per locale), DELETE
// RecordDeleted. Controller is "" outside the admin forms.
func TestAPIRecordEvents(t *testing.T) {
env := newPostsEnv(t)
token := env.admin(t,
controllers.PermissionAccessPosts,
controllers.PermissionAccessPublish,
controllers.PermissionAccessOtherPosts,
)
l := listenRecords(t, env)
morph := (models.Post{}).MorphName()
rec := env.expect(t, http.StatusCreated, http.MethodPost, "/_journal/api/v1/posts", token, map[string]any{
"title": "API post",
"slug": "api-old",
"content": "Body",
"translations": map[string]any{"pl": map[string]any{"slug": "api-stary"}},
})
var created struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil || created.Data.ID == 0 {
t.Fatalf("create body %s: %v", rec.Body.String(), err)
}
id := fmt.Sprint(created.Data.ID)
got := l.savedEvents()
if len(got) != 1 {
t.Fatalf("POST events = %+v, want one RecordSaved", got)
}
ev := got[0].ev
if ev.Controller != "" || ev.Model != morph || ev.ID != id || !ev.Created {
t.Fatalf("POST event = %+v", ev)
}
if c, ok := findChange(ev.Changes, "slug", ""); !ok || c.Old != nil || c.New != "api-old" {
t.Fatalf("POST slug change = %+v (found %v); changes %+v", c, ok, ev.Changes)
}
if c, ok := findChange(ev.Changes, "slug", "pl"); !ok || c.Old != nil || c.New != "api-stary" {
t.Fatalf("POST pl slug change = %+v (found %v); changes %+v", c, ok, ev.Changes)
}
if got[0].url != "/blog/api-old" {
t.Fatalf("POST described URL = %q", got[0].url)
}
l.reset()
env.expect(t, http.StatusOK, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{
"slug": "api-new",
"translations": map[string]any{"pl": map[string]any{"slug": "api-nowy"}},
})
got = l.savedEvents()
if len(got) != 1 {
t.Fatalf("PUT events = %+v, want one RecordSaved", got)
}
ev = got[0].ev
if ev.Controller != "" || ev.Model != morph || ev.ID != id || ev.Created {
t.Fatalf("PUT event = %+v", ev)
}
if c, ok := findChange(ev.Changes, "slug", ""); !ok || c.Old != "api-old" || c.New != "api-new" {
t.Fatalf("PUT slug change = %+v (found %v); changes %+v", c, ok, ev.Changes)
}
if c, ok := findChange(ev.Changes, "slug", "pl"); !ok || c.Old != "api-stary" || c.New != "api-nowy" {
t.Fatalf("PUT pl slug change = %+v (found %v); changes %+v", c, ok, ev.Changes)
}
if _, ok := findChange(ev.Changes, "title", ""); ok {
t.Fatalf("unchanged title reported: %+v", ev.Changes)
}
if got[0].url != "/blog/api-new" {
t.Fatalf("PUT described URL = %q", got[0].url)
}
// A write that fails after the row is saved rolls the row back and
// fires nothing (WR-04).
l.reset()
failed := env.call(t, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{
"slug": "api-lost",
"tags": "not a list",
})
if failed.Code != http.StatusInternalServerError {
t.Fatalf("failing PUT = %d, want 500: %s", failed.Code, failed.Body.String())
}
if saved := l.savedEvents(); len(saved) != 0 {
t.Fatalf("failing PUT fired RecordSaved: %+v", saved)
}
var slug string
if err := env.gdb.Raw("SELECT slug FROM golem15_journal_posts WHERE id = ?", id).Scan(&slug).Error; err != nil || slug != "api-new" {
t.Fatalf("slug after failing PUT = %q (err %v), want api-new", slug, err)
}
l.reset()
env.expect(t, http.StatusOK, http.MethodDelete, "/_journal/api/v1/posts/"+id, token, nil)
if saved := l.savedEvents(); len(saved) != 0 {
t.Fatalf("DELETE fired RecordSaved: %+v", saved)
}
deleted := l.deletedEvents()
if len(deleted) != 1 || deleted[0] != (pact.RecordDeleted{Model: morph, ID: id}) {
t.Fatalf("DELETE events = %+v", deleted)
}
}