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