- menus controller gains the app accessor and FormAfterCreate/Update/Delete purging kind menu with the menu code - menu entries purge kind menu_entry with the entry id after the placement checks pass, on delete, and through pact.ReorderAfterSave with partition:<menu id> - purgeSiteCache helper on cabana.TxFromContext and cooler.SiteCacheFromApp - TestMenuAdminPurgesSiteCache on Postgres
280 lines
10 KiB
Go
280 lines
10 KiB
Go
package menu_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/sm-menu-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
|
)
|
|
|
|
// purgeTestToken is a 40-character purge token for the receiver.
|
|
const purgeTestToken = "menu-purge-test-token-0123456789abcdefgh"
|
|
|
|
const purgeTestPath = "/api/__site__/cache/purge"
|
|
|
|
// purgeRequest is one request the purge receiver got.
|
|
type purgeRequest struct {
|
|
Method string
|
|
Path string
|
|
Authorization string
|
|
Body map[string]string
|
|
// Seen is what the receiver's probe read from the database while the
|
|
// request was in flight.
|
|
Seen string
|
|
}
|
|
|
|
// purgeReceiver stands in for the host site's purge route on 127.0.0.1.
|
|
type purgeReceiver struct {
|
|
srv *httptest.Server
|
|
mu sync.Mutex
|
|
got []purgeRequest
|
|
status int
|
|
probe func() string
|
|
}
|
|
|
|
func newPurgeReceiver(t *testing.T) *purgeReceiver {
|
|
t.Helper()
|
|
r := &purgeReceiver{status: http.StatusOK}
|
|
r.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
raw, _ := io.ReadAll(req.Body)
|
|
var body map[string]string
|
|
_ = json.Unmarshal(raw, &body)
|
|
r.mu.Lock()
|
|
probe, status := r.probe, r.status
|
|
r.mu.Unlock()
|
|
seen := ""
|
|
if probe != nil {
|
|
seen = probe()
|
|
}
|
|
r.mu.Lock()
|
|
r.got = append(r.got, purgeRequest{req.Method, req.URL.Path, req.Header.Get("Authorization"), body, seen})
|
|
r.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if status == http.StatusOK {
|
|
_, _ = w.Write([]byte(`{"purged":4}`))
|
|
}
|
|
}))
|
|
t.Cleanup(r.srv.Close)
|
|
return r
|
|
}
|
|
|
|
func (r *purgeReceiver) take() []purgeRequest {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
got := r.got
|
|
r.got = nil
|
|
return got
|
|
}
|
|
|
|
func (r *purgeReceiver) setStatus(status int) {
|
|
r.mu.Lock()
|
|
r.status = status
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
func (r *purgeReceiver) setProbe(probe func() string) {
|
|
r.mu.Lock()
|
|
r.probe = probe
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
// expectOnePurge asserts exactly one authorized purge of kind and slug
|
|
// arrived since the last take, and returns it.
|
|
func (r *purgeReceiver) expectOnePurge(t *testing.T, stage, kind, slug string) purgeRequest {
|
|
t.Helper()
|
|
got := r.take()
|
|
if len(got) != 1 {
|
|
t.Fatalf("%s: %d purge requests, want 1: %+v", stage, len(got), got)
|
|
}
|
|
req := got[0]
|
|
if req.Method != http.MethodPost || req.Path != purgeTestPath {
|
|
t.Fatalf("%s: purge request %s %s, want POST %s", stage, req.Method, req.Path, purgeTestPath)
|
|
}
|
|
if req.Authorization != "Bearer "+purgeTestToken {
|
|
t.Fatalf("%s: Authorization = %q, want the bearer token", stage, req.Authorization)
|
|
}
|
|
if req.Body["kind"] != kind || req.Body["slug"] != slug || len(req.Body) != 2 {
|
|
t.Fatalf("%s: purge body = %#v, want kind %q and slug %q", stage, req.Body, kind, slug)
|
|
}
|
|
return req
|
|
}
|
|
|
|
func (r *purgeReceiver) expectNone(t *testing.T, stage string) {
|
|
t.Helper()
|
|
if got := r.take(); len(got) != 0 {
|
|
t.Fatalf("%s: %d purge requests, want none: %+v", stage, len(got), got)
|
|
}
|
|
}
|
|
|
|
// TestMenuAdminPurgesSiteCache proves a menu create, update and delete and a
|
|
// menu-entry create, update, reorder and delete in the admin each send one
|
|
// authorized purge through the framework's site_cache keys after the save
|
|
// commits, that an entry refused by the placement check sends none, and
|
|
// that a failing site never fails a save or a reorder.
|
|
func TestMenuAdminPurgesSiteCache(t *testing.T) {
|
|
recv := newPurgeReceiver(t)
|
|
e := bootMenu(t)
|
|
// SiteCacheFromApp reads the keys on the first save, so they can be set
|
|
// after boot.
|
|
if err := e.app.Config.Set(cooler.SiteCachePurgeURLKey, recv.srv.URL+purgeTestPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := e.app.Config.Set(cooler.SiteCachePurgeTokenKey, purgeTestToken); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token := e.admin(t, permManage)
|
|
|
|
// The probe reads committed state through the pool while the purge is
|
|
// in flight: the menu's name and entry count, the Home entry's title and
|
|
// sort order, or "missing".
|
|
var probeEntry atomic.Uint64
|
|
recv.setProbe(func() string {
|
|
var menu models.Menu
|
|
if err := e.gdb.Where("code = ?", "purge-main").Limit(1).Find(&menu).Error; err != nil {
|
|
return "error: " + err.Error()
|
|
}
|
|
out := "menu=missing"
|
|
if menu.ID != 0 {
|
|
var entries int64
|
|
if err := e.gdb.Model(&models.MenuEntry{}).Where("menu_id = ?", menu.ID).Count(&entries).Error; err != nil {
|
|
return "error: " + err.Error()
|
|
}
|
|
out = fmt.Sprintf("menu=%s entries=%d", menu.Name, entries)
|
|
}
|
|
if id := probeEntry.Load(); id != 0 {
|
|
var entry models.MenuEntry
|
|
if err := e.gdb.Where("id = ?", id).Limit(1).Find(&entry).Error; err != nil {
|
|
return "error: " + err.Error()
|
|
}
|
|
if entry.ID == 0 {
|
|
out += " entry=missing"
|
|
} else {
|
|
out += fmt.Sprintf(" entry=%s/%d", models.Text(entry.Title), intOf(entry.SortOrder))
|
|
}
|
|
}
|
|
return out
|
|
})
|
|
|
|
mainID := e.create(t, token, menusPath, map[string]any{"name": "Main", "code": "purge-main", "is_active": true})
|
|
if req := recv.expectOnePurge(t, "menu create", "menu", "purge-main"); req.Seen != "menu=Main entries=0" {
|
|
t.Fatalf("menu create: the purge saw %q, want the committed menu", req.Seen)
|
|
}
|
|
menuPath := fmt.Sprintf("%s/%d", menusPath, mainID)
|
|
if rec := e.call(t, http.MethodPut, menuPath, token, map[string]any{"name": "Main edited"}); rec.Code != http.StatusOK {
|
|
t.Fatalf("menu update: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if req := recv.expectOnePurge(t, "menu update", "menu", "purge-main"); req.Seen != "menu=Main edited entries=0" {
|
|
t.Fatalf("menu update: the purge saw %q, want the committed name (purge before commit)", req.Seen)
|
|
}
|
|
|
|
home := e.create(t, token, entriesPath, map[string]any{"menu": mainID, "type": "url", "title": "Home", "url": "/", "target": "_self"})
|
|
homeSlug := strconv.FormatUint(uint64(home), 10)
|
|
if req := recv.expectOnePurge(t, "entry create", "menu_entry", homeSlug); req.Seen != "menu=Main edited entries=1" {
|
|
t.Fatalf("entry create: the purge saw %q, want the committed entry", req.Seen)
|
|
}
|
|
about := e.create(t, token, entriesPath, map[string]any{"menu": mainID, "type": "url", "title": "About", "url": "/about"})
|
|
recv.expectOnePurge(t, "second entry create", "menu_entry", strconv.FormatUint(uint64(about), 10))
|
|
probeEntry.Store(uint64(home))
|
|
homePath := fmt.Sprintf("%s/%d", entriesPath, home)
|
|
|
|
t.Run("entry update", func(t *testing.T) {
|
|
if rec := e.call(t, http.MethodPut, homePath, token, map[string]any{"title": "Start"}); rec.Code != http.StatusOK {
|
|
t.Fatalf("entry update: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if req := recv.expectOnePurge(t, "entry update", "menu_entry", homeSlug); req.Seen != fmt.Sprintf("menu=Main edited entries=2 entry=Start/%d", intOf(e.entry(t, home).SortOrder)) {
|
|
t.Fatalf("entry update: the purge saw %q, want the committed title", req.Seen)
|
|
}
|
|
})
|
|
|
|
t.Run("refused entries send none", func(t *testing.T) {
|
|
e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"type": "url", "title": "Nowhere", "url": "/"}, "menu", "golem15.menu::lang.errors.menu_required")
|
|
recv.expectNone(t, "create without a menu")
|
|
e.expect422(t, token, http.MethodPut, fmt.Sprintf("%s/%d", entriesPath, about), map[string]any{"parent": about}, "parent", "golem15.menu::lang.errors.parent_cycle")
|
|
recv.expectNone(t, "update with a parent cycle")
|
|
})
|
|
|
|
t.Run("reorder", func(t *testing.T) {
|
|
nodes := []map[string]any{
|
|
{"id": about, "parent_id": nil, "sort_order": 1},
|
|
{"id": home, "parent_id": nil, "sort_order": 7},
|
|
}
|
|
rec := e.call(t, http.MethodPost, entriesPath+"/reorder", token, map[string]any{"partition": mainID, "nodes": nodes})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("reorder: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if req := recv.expectOnePurge(t, "reorder", "menu_entry", fmt.Sprintf("partition:%d", mainID)); req.Seen != "menu=Main edited entries=2 entry=Start/7" {
|
|
t.Fatalf("reorder: the purge saw %q, want the committed order (purge before commit)", req.Seen)
|
|
}
|
|
// A stale reorder writes nothing and sends nothing.
|
|
rec = e.call(t, http.MethodPost, entriesPath+"/reorder", token, map[string]any{"partition": mainID, "nodes": nodes[:1]})
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("stale reorder: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
recv.expectNone(t, "stale reorder")
|
|
})
|
|
|
|
t.Run("a failing site never fails a save or a reorder", func(t *testing.T) {
|
|
recv.setStatus(http.StatusInternalServerError)
|
|
defer recv.setStatus(http.StatusOK)
|
|
if rec := e.call(t, http.MethodPut, homePath, token, map[string]any{"title": "Failing"}); rec.Code != http.StatusOK {
|
|
t.Fatalf("entry update with a failing purge: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
recv.expectOnePurge(t, "failing entry update", "menu_entry", homeSlug)
|
|
if got := models.Text(e.entry(t, home).Title); got != "Failing" {
|
|
t.Fatalf("title = %q, want the update saved", got)
|
|
}
|
|
nodes := []map[string]any{
|
|
{"id": home, "parent_id": nil, "sort_order": 1},
|
|
{"id": about, "parent_id": nil, "sort_order": 2},
|
|
}
|
|
if rec := e.call(t, http.MethodPost, entriesPath+"/reorder", token, map[string]any{"partition": mainID, "nodes": nodes}); rec.Code != http.StatusOK {
|
|
t.Fatalf("reorder with a failing purge: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
recv.expectOnePurge(t, "failing reorder", "menu_entry", fmt.Sprintf("partition:%d", mainID))
|
|
if got := intOf(e.entry(t, home).SortOrder); got != 1 {
|
|
t.Fatalf("Home sort_order = %d, want the reorder saved", got)
|
|
}
|
|
})
|
|
|
|
t.Run("entry delete", func(t *testing.T) {
|
|
if rec := e.call(t, http.MethodDelete, homePath, token, nil); rec.Code != http.StatusOK {
|
|
t.Fatalf("entry delete: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if req := recv.expectOnePurge(t, "entry delete", "menu_entry", homeSlug); req.Seen != "menu=Main edited entries=1 entry=missing" {
|
|
t.Fatalf("entry delete: the purge saw %q, want the entry already deleted", req.Seen)
|
|
}
|
|
})
|
|
|
|
t.Run("menu delete", func(t *testing.T) {
|
|
if rec := e.call(t, http.MethodDelete, menuPath, token, nil); rec.Code != http.StatusOK {
|
|
t.Fatalf("menu delete: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
got := recv.take()
|
|
var menuPurges int
|
|
for _, req := range got {
|
|
if req.Body["kind"] == "menu" {
|
|
menuPurges++
|
|
if req.Body["slug"] != "purge-main" || req.Authorization != "Bearer "+purgeTestToken {
|
|
t.Fatalf("menu delete purge = %+v", req)
|
|
}
|
|
if !strings.HasPrefix(req.Seen, "menu=missing") {
|
|
t.Fatalf("menu delete: the purge saw %q, want the menu already deleted", req.Seen)
|
|
}
|
|
}
|
|
}
|
|
if menuPurges != 1 {
|
|
t.Fatalf("menu delete sent %d menu purges, want 1: %+v", menuPurges, got)
|
|
}
|
|
})
|
|
}
|