From 559d96b0a47d5ce312d889ba9a4f90589251449a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 15 Oct 2026 18:30:45 +0200 Subject: [PATCH] feat(19.3-04): purge the host site's page cache after menu and menu-entry saves, deletes and reorders - 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: - purgeSiteCache helper on cabana.TxFromContext and cooler.SiteCacheFromApp - TestMenuAdminPurgesSiteCache on Postgres --- README.md | 18 ++- controllers/admin_registry.go | 2 +- controllers/menuentries.go | 51 ++++++- controllers/menus.go | 42 ++++- controllers/site_cache.go | 32 ++++ site_cache_admin_test.go | 279 ++++++++++++++++++++++++++++++++++ 6 files changed, 414 insertions(+), 10 deletions(-) create mode 100644 controllers/site_cache.go create mode 100644 site_cache_admin_test.go diff --git a/README.md b/README.md index 73ee9a0..87b75e1 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ It requires no other plugin. Content types (blog posts, categories, pages) come - **Entry types.** The built-ins `url` (Link, `models.TypeURL`) and `divider` (Divider, `models.TypeDivider`), then every registry type with its owning plugin's label. The reference dropdown depends on the type through `signpost.ReferenceOptions`; built-ins take no reference. The admin save refuses an unknown type, a reference the type does not offer, a missing reference for a type that takes one, a URL outside the scheme allowlist and a target other than `_self`, `_blank` or empty. - **URL allowlist.** `classes.AllowedURL` accepts an empty URL, a path starting with `/`, a fragment starting with `#`, and `http:`, `https:`, `mailto:` and `tel:` URLs. Anything else (`javascript:`, `data:`, a URL with surrounding whitespace or control characters) is a 422 `errors.url_scheme` on save and a `null` URL in the API. - **Admin.** A top-level Menus item (icon `list`, order 510) with the side items Menus and Menu entries, both behind the permission `golem15.menu.manage_menu` (developer and publisher roles). Menus: name, code (preset from name), css_class, is_active. Menu entries: a list of every entry with a menu filter and the toolbar buttons reorder, create and delete; a form with menu, parent, type, title, url, reference, url_pattern, code, icon, css_class, target and is_published; a tree reorder page scoped to one menu (`config_reorder.yaml`, partition `menu_id`), which refuses a payload naming an entry of another menu with 409. +- **Site cache.** A menu create, update or delete and a menu-entry create, update, delete or reorder in the admin ask the host site to drop its page caches once the save commits. See [Site cache](#site-cache). - **Public API.** `GET /_menu/api/v1/menus/{code}` serves an active menu with its published entries as a nested tree. See [Usage](#usage). - **Import.** `menu:import-winter` copies a WinterCMS site's menus and entries from the JSON Lines the export SQL files produce. See [CLI commands](#cli-commands). - **Phrasebooks.** `lang/en/lang.yaml` and `lang/pl/lang.yaml` keep the PHP key names. @@ -132,6 +133,18 @@ cors: allowed_methods: ["GET"] ``` +### Site cache + +Every committed admin write of the menu screens sends one purge request to the host site, through the framework's site-cache purger in `cooler`: + +| Save | Purge body | +|------|------------| +| Menu create, update, delete | `{"kind":"menu","slug":""}` | +| Menu-entry create, update, delete | `{"kind":"menu_entry","slug":""}` | +| Menu-entry reorder | `{"kind":"menu_entry","slug":"partition:"}` (through cabana's `pact.ReorderAfterSave` hook) | + +Configure it once for the application with the framework keys `site_cache.purge_url` and `site_cache.purge_token` (`SUMMER_SITE_CACHE__PURGE_URL`, `SUMMER_SITE_CACHE__PURGE_TOKEN`); a site that extends `vue-menu-layer` 0.2.0 or later serves the matching route `POST /api/__site__/cache/purge` with `NUXT_SITE_CACHE_PURGE_TOKEN`. Without the keys nothing is sent. The entry checks (menu, parent, type, URL, target) run first: a refused save sends nothing, and a failing or slow site never fails a save or a reorder. + ### Winter import 1. On the WinterCMS database, check the table names and entry types named in the headers of `scripts/winter-export-menus.sql` and `scripts/winter-export-entries.sql`, then run both read-only exports: @@ -191,11 +204,12 @@ cors: - Type keys: PHP `link` is `url`; PHP `blog_categories` is the registry type `all-journal-categories` when a blog plugin registers it; `page` is not supported. - Saves: an entry with a parent always takes the parent's menu (PHP only when the menu was empty); the admin form refuses with 422 a parent from another menu than the one the form names (`errors.parent_menu`), and an entry moved to another menu takes its whole subtree along, so no descendant is left with a parent in another menu. An unknown type, a registry type that takes a reference saved without one (`errors.reference_required`; it would resolve to nothing and vanish from the public tree), a parent cycle, a disallowed URL or target and an entry without a menu are refused with 422; a menu code another menu uses is a 422. - API: the public JSON API replaces the PHP component and filters unpublished entries. +- Site cache: a committed save, delete or reorder purges the host site's page caches (the PHP plugin had no such hook; a site's cache held edits until its TTL ran out). - Timestamps are `TIMESTAMPTZ`. ## Dependencies -- SummerCMS modules: backpack, bonfire, cabana, lagoon, pact, party, signpost, surf, wire. +- SummerCMS modules: backpack, bonfire, cabana, cooler, lagoon, pact, party, signpost, surf, wire. - Compiled plugins: none. - Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`. Test-only: `github.com/testcontainers/testcontainers-go` with its Postgres module, `github.com/jackc/pgx/v5`. @@ -204,7 +218,7 @@ cors: ```sh go vet ./... go test -short ./... # no Docker -go test ./... # TestMenuAdminSmoke, TestMenuImportSmoke, TestMenuAPISmoke on a Postgres container +go test ./... # TestMenuAdminSmoke, TestMenuImportSmoke, TestMenuAPISmoke, TestMenuAdminPurgesSiteCache on a Postgres container ``` The smoke tests register a fake content plugin with the types `acme-page` (with references), `acme-list` (a list type) and `all-journal-categories`, so the plugin needs no content plugin to test. diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go index 215cd26..cb18e81 100644 --- a/controllers/admin_registry.go +++ b/controllers/admin_registry.go @@ -15,7 +15,7 @@ const PermissionManageMenu = "golem15.menu.manage_menu" // a database handle or registry of its own. func AdminControllers(app func() *backpack.App) []pact.AdminController { return []pact.AdminController{ - menusAdminController{}, + menusAdminController{app: app}, menuEntriesAdminController{app: app}, } } diff --git a/controllers/menuentries.go b/controllers/menuentries.go index 9865fef..9171f8f 100644 --- a/controllers/menuentries.go +++ b/controllers/menuentries.go @@ -3,6 +3,7 @@ package controllers import ( "context" "errors" + "strconv" "strings" "git.golem15.com/golem15/sm-menu-plugin/classes" @@ -186,16 +187,22 @@ func (c menuEntriesAdminController) checkEntry(model any) error { // FormAfterCreate refuses an entry that ends up in no menu (no menu chosen // and no parent to take it from). cabana writes the menu and parent from the // relation fields after the model rules ran, so the check runs once the row -// is written, inside the save's transaction. -func (menuEntriesAdminController) FormAfterCreate(ctx context.Context, model any) error { - return checkPlacement(ctx, model) +// is written, inside the save's transaction. An entry that passes purges the +// host site's page caches once the save commits. +func (c menuEntriesAdminController) FormAfterCreate(ctx context.Context, model any) error { + if err := checkPlacement(ctx, model); err != nil { + return err + } + c.purge(ctx, model) + return nil } // FormAfterUpdate also refuses a parent that is the entry itself or one of // its descendants, then moves the entry's descendants into its menu, so a // subtree follows its root to another menu instead of staying behind with -// parents in a menu it is not in. -func (menuEntriesAdminController) FormAfterUpdate(ctx context.Context, model any) error { +// parents in a menu it is not in. An update that passes purges the host +// site's page caches once the save commits. +func (c menuEntriesAdminController) FormAfterUpdate(ctx context.Context, model any) error { if err := checkPlacement(ctx, model); err != nil { return err } @@ -207,13 +214,43 @@ func (menuEntriesAdminController) FormAfterUpdate(ctx context.Context, model any if !ok { return errors.New("golem15.menu: the entry check needs the save's transaction") } - return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Exec(`WITH RECURSIVE sub(id) AS ( + err := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Exec(`WITH RECURSIVE sub(id) AS ( SELECT id FROM golem15_menu_menu_entries WHERE parent_id = ? AND id <> ? UNION SELECT e.id FROM golem15_menu_menu_entries e JOIN sub ON e.parent_id = sub.id ) UPDATE golem15_menu_menu_entries SET menu_id = ? WHERE id IN (SELECT id FROM sub) AND id <> ? AND menu_id <> ?`, entry.ID, entry.ID, entry.MenuID, entry.ID, entry.MenuID).Error + if err != nil { + return err + } + c.purge(ctx, model) + return nil +} + +// FormAfterDelete purges the host site's page caches once the delete +// commits. +func (c menuEntriesAdminController) FormAfterDelete(ctx context.Context, model any) error { + c.purge(ctx, model) + return nil +} + +// ReorderAfterSave purges the host site's page caches once the reorder of +// one menu's entries commits; the slug names the partition (the menu id). +func (c menuEntriesAdminController) ReorderAfterSave(ctx context.Context, partition *uint64) error { + slug := "reorder" + if partition != nil { + slug = "partition:" + strconv.FormatUint(*partition, 10) + } + purgeSiteCache(ctx, c.app, siteCacheKindEntry, slug) + return nil +} + +// purge registers a purge of kind menu_entry with the entry's id. +func (c menuEntriesAdminController) purge(ctx context.Context, model any) { + if entry, ok := model.(*models.MenuEntry); ok && entry != nil { + purgeSiteCache(ctx, c.app, siteCacheKindEntry, strconv.FormatUint(uint64(entry.ID), 10)) + } } // checkPlacement refuses an entry in no menu, a parent from another menu @@ -267,6 +304,8 @@ var ( _ pact.FormBeforeUpdate = menuEntriesAdminController{} _ pact.FormAfterCreate = menuEntriesAdminController{} _ pact.FormAfterUpdate = menuEntriesAdminController{} + _ pact.FormAfterDelete = menuEntriesAdminController{} + _ pact.ReorderAfterSave = menuEntriesAdminController{} _ pact.RelationExtendOptionsQuery = menuEntriesAdminController{} _ cabana.FieldRelationProvider = menuEntriesAdminController{} ) diff --git a/controllers/menus.go b/controllers/menus.go index 66714a5..d490bfb 100644 --- a/controllers/menus.go +++ b/controllers/menus.go @@ -1,7 +1,10 @@ package controllers import ( + "context" + "git.golem15.com/golem15/sm-menu-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/pact" ) @@ -10,7 +13,9 @@ const MenusControllerID = "golem15.menu.menus" // menusAdminController lists and edits menus: name, code, CSS class and the // active switch. -type menusAdminController struct{} +type menusAdminController struct { + app func() *backpack.App +} func (menusAdminController) ID() string { return MenusControllerID } @@ -24,8 +29,43 @@ func (menusAdminController) RequiredPermissions() []string { func (menusAdminController) NewRecord() any { return &models.Menu{} } +// Site-cache purge kinds of the menu plugin's saves. +const ( + siteCacheKindMenu = "menu" + siteCacheKindEntry = "menu_entry" +) + +// FormAfterCreate purges the host site's page caches once the save commits. +func (c menusAdminController) FormAfterCreate(ctx context.Context, model any) error { + c.purge(ctx, model) + return nil +} + +// FormAfterUpdate purges the host site's page caches once the save commits. +func (c menusAdminController) FormAfterUpdate(ctx context.Context, model any) error { + c.purge(ctx, model) + return nil +} + +// FormAfterDelete purges the host site's page caches once the delete +// commits. +func (c menusAdminController) FormAfterDelete(ctx context.Context, model any) error { + c.purge(ctx, model) + return nil +} + +// purge registers a purge of kind menu with the menu's code. +func (c menusAdminController) purge(ctx context.Context, model any) { + if menu, ok := model.(*models.Menu); ok && menu != nil { + purgeSiteCache(ctx, c.app, siteCacheKindMenu, menu.Code) + } +} + var ( _ pact.AdminController = menusAdminController{} _ pact.AdminPermissioned = menusAdminController{} _ pact.AdminRecordSource = menusAdminController{} + _ pact.FormAfterCreate = menusAdminController{} + _ pact.FormAfterUpdate = menusAdminController{} + _ pact.FormAfterDelete = menusAdminController{} ) diff --git a/controllers/site_cache.go b/controllers/site_cache.go new file mode 100644 index 0000000..3e13abd --- /dev/null +++ b/controllers/site_cache.go @@ -0,0 +1,32 @@ +package controllers + +import ( + "context" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/cooler" +) + +// purgeSiteCache registers one site-cache purge of kind and slug, sent by +// the framework's purger (site_cache.purge_url and purge_token) after the +// save transaction of ctx commits. Without a purge configured, or outside a +// save transaction, it does nothing. A purge never fails the save. +func purgeSiteCache(ctx context.Context, app func() *backpack.App, kind, slug string) { + if app == nil { + return + } + a := app() + if a == nil { + return + } + tx, ok := cabana.TxFromContext(ctx) + if !ok { + return + } + purger := cooler.SiteCacheFromApp(a) + if purger == nil { + return + } + cooler.PurgeSiteCacheAfterCommit(ctx, tx, purger, kind, slug) +} diff --git a/site_cache_admin_test.go b/site_cache_admin_test.go new file mode 100644 index 0000000..f2546bd --- /dev/null +++ b/site_cache_admin_test.go @@ -0,0 +1,279 @@ +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) + } + }) +}