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) } }) }