Files
sm-journal-plugin/record_events_test.go
Jakub Zych 4b3a8bb049 feat: record events from the Journal API writes (D-102)
- POST/PUT/DELETE /_journal/api/v1/posts fire RecordSaved/RecordDeleted
  through cabana.EmitRecordSaved/EmitRecordDeleted, so a slug changed over
  MCP reaches subscribers like an admin save
- PUT reports the changed columns and each submitted translation that
  changed, read before the write (a translated slug per locale)
- TestAPIRecordEvents covers POST, PUT slug change and DELETE
2026-10-26 22:35:08 +01:00

252 lines
8.5 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)
}
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)
}
}