- every read of the purge receiver first drains the app's purger through cooler.SiteCachePurger.Wait, since cooler now queues purges after commit
308 lines
11 KiB
Go
308 lines
11 KiB
Go
package menu_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-menu-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"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
|
|
// drain waits for the queued purges of the app under test; see
|
|
// drainFrom.
|
|
drain func()
|
|
}
|
|
|
|
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()
|
|
drain := r.drain
|
|
r.mu.Unlock()
|
|
if drain != nil {
|
|
drain()
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
got := r.got
|
|
r.got = nil
|
|
return got
|
|
}
|
|
|
|
// drainFrom makes every read of r first wait for the purges queued on app's
|
|
// site-cache purger: cooler queues them after commit and sends them in the
|
|
// background, so a save's purge is asserted only once it was sent.
|
|
func (r *purgeReceiver) drainFrom(t *testing.T, app *backpack.App) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.drain = func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := cooler.SiteCacheFromApp(app).Wait(ctx); err != nil {
|
|
t.Errorf("waiting for the queued site-cache purge: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
recv.drainFrom(t, e.app)
|
|
// 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)
|
|
}
|
|
})
|
|
}
|