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": "
Body
"}, "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": "Body
"}, "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) } }