Files
sm-journal-plugin/site_cache_admin_test.go
Jakub Zych 2a3afb5107 feat(16.2-07): purge the host site cache on every admin post and category write
- posts: FormAfterCreate/Update purge after the content_html translations,
  new FormAfterDelete; categories: FormAfterCreate and FormAfterDelete
- NewSiteCachePurger refuses half configuration, non-http(s) URLs, user
  info, fragments, http to a non-loopback host and tokens under 32 chars;
  Boot logs the endpoint host and path once
- TestPostsAdminPurgesSiteCache smoke test
- README: Configuration section (keys, contract, timing, scope), API rows
2026-10-10 18:27:12 +02:00

203 lines
6.3 KiB
Go

package journal_test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"git.golem15.com/golem15/sm-journal-plugin/controllers"
"git.golem15.com/golem15/sm-journal-plugin/models"
)
// purgeTestToken is a 40-character purge token for the receiver.
const purgeTestToken = "purge-test-token-0123456789abcdefghijklm"
const purgeTestPath = "/api/__journal__/cache/purge"
// purgeRequest is one request the purge receiver got.
type purgeRequest struct {
Method string
Path string
Authorization string
ContentType 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{
Method: req.Method,
Path: req.URL.Path,
Authorization: req.Header.Get("Authorization"),
ContentType: req.Header.Get("Content-Type"),
Body: body,
Seen: seen,
})
r.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if status == http.StatusOK {
_, _ = w.Write([]byte(`{"purged":3}`))
}
}))
t.Cleanup(r.srv.Close)
return r
}
// env returns the extra environment that points the Journal plugin at r.
func (r *purgeReceiver) env() []string {
return []string{
"SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL=" + r.srv.URL + purgeTestPath,
"SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN=" + purgeTestToken,
}
}
// take returns the requests received so far and forgets them.
func (r *purgeReceiver) take() []purgeRequest {
r.mu.Lock()
defer r.mu.Unlock()
got := r.got
r.got = nil
return got
}
func (r *purgeReceiver) setProbe(probe func() string) {
r.mu.Lock()
r.probe = probe
r.mu.Unlock()
}
// expectOnePurge asserts exactly one contract request for kind and slug was
// received 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 !strings.HasPrefix(req.ContentType, "application/json") {
t.Fatalf("%s: Content-Type = %q, want application/json", stage, req.ContentType)
}
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
}
// TestCategoriesAdminPurgesSiteCache proves an admin update of a category
// sends one authorized purge request to the host site after the save
// transaction commits (G-16.2-2, D-19).
func TestCategoriesAdminPurgesSiteCache(t *testing.T) {
recv := newPurgeReceiver(t)
env := newPostsEnv(t, recv.env()...)
token := env.admin(t, controllers.PermissionAccessCategories)
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{
"name": map[string]any{"en": "Purge news", "pl": "Wiadomości"},
"slug": map[string]any{"en": "purge-news", "pl": "wiadomosci"},
"content": "<p>Before</p>\n",
})
id := adminCreatedID(t, rec)
itemPath := adminAPI(fmt.Sprintf("%s/%d", adminCategoriesPath, id))
recv.take()
content := func() string {
var cat models.Category
if err := env.gdb.First(&cat, id).Error; err != nil {
return "error: " + err.Error()
}
if cat.Content == nil {
return ""
}
return *cat.Content
}
recv.setProbe(content)
rec = env.expect(t, http.StatusOK, http.MethodGet, itemPath, token, nil)
var served map[string]json.RawMessage
if err := json.Unmarshal(adminDecode(t, rec).Data, &served); err != nil {
t.Fatal(err)
}
const edited = "<p>After the edit</p>\n"
body := servedBody(served, "name", "slug")
body["content"] = edited
env.expect(t, http.StatusOK, http.MethodPut, itemPath, token, body)
req := recv.expectOnePurge(t, "update", "category", "purge-news")
if req.Seen != edited {
t.Fatalf("the purge saw content %q, want the committed %q (purge before commit)", req.Seen, edited)
}
}
// TestPostsAdminPurgesSiteCache proves an admin update of a post sends one
// authorized purge request with kind post after the save commits.
func TestPostsAdminPurgesSiteCache(t *testing.T) {
recv := newPurgeReceiver(t)
env := newPostsEnv(t, recv.env()...)
token := env.admin(t, controllers.PermissionAccessPosts)
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
"title": map[string]any{"en": "Purge post", "pl": "Wpis"},
"slug": map[string]any{"en": "purge-post", "pl": "wpis"},
"content": map[string]any{"en": "<p>Before</p>"},
"excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"},
})
id := adminCreatedID(t, rec)
recv.take()
recv.setProbe(func() string {
var post models.Post
if err := env.gdb.First(&post, id).Error; err != nil {
return "error: " + err.Error()
}
return post.ContentHTML
})
served := servedForm(t, env, token, id)
const edited = "<p>After the edit</p>"
body := servedBody(served, "title", "slug", "excerpt")
body["content"] = map[string]any{"en": edited}
env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, body)
req := recv.expectOnePurge(t, "update", "post", "purge-post")
if req.Seen != edited {
t.Fatalf("the purge saw content_html %q, want the committed %q (purge before commit)", req.Seen, edited)
}
}