- FormAfterCreate/FormAfterUpdate run the locale check, then register a purge of kind faq through cooler.PurgeSiteCacheAfterCommit - new FormAfterDelete purges after the delete commits - purgeSiteCache helper takes the save transaction from cabana.TxFromContext and the purger from cooler.SiteCacheFromApp (site_cache.* keys) - TestFaqAdminPurgesSiteCache on Postgres
209 lines
6.5 KiB
Go
209 lines
6.5 KiB
Go
package faq_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/sm-faq-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
|
)
|
|
|
|
// purgeTestToken is a 40-character purge token for the receiver.
|
|
const purgeTestToken = "faq-purge-test-token-0123456789abcdefghi"
|
|
|
|
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":2}`))
|
|
}
|
|
}))
|
|
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 faq and slug
|
|
// arrived since the last take, and returns it.
|
|
func (r *purgeReceiver) expectOnePurge(t *testing.T, stage, 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"] != "faq" || req.Body["slug"] != slug || len(req.Body) != 2 {
|
|
t.Fatalf("%s: purge body = %#v, want kind faq and slug %q", stage, req.Body, slug)
|
|
}
|
|
return req
|
|
}
|
|
|
|
// TestFaqAdminPurgesSiteCache proves a FAQ create, update and delete in the
|
|
// admin each send one authorized purge of kind faq through the framework's
|
|
// site_cache keys after the save commits, that a create refused by the
|
|
// locale check sends none, and that a failing site never fails the save.
|
|
func TestFaqAdminPurgesSiteCache(t *testing.T) {
|
|
recv := newPurgeReceiver(t)
|
|
e := bootFAQ(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, permFAQs)
|
|
en := e.localeID(t, "en")
|
|
const base = "/golem15/faq/faqs"
|
|
|
|
// The probe reads the committed row through the pool while the purge
|
|
// request is in flight: its name, or "missing".
|
|
recv.setProbe(func() string {
|
|
var faq models.FAQ
|
|
if err := e.gdb.Where("slug = ?", "purge-faq").Limit(1).Find(&faq).Error; err != nil {
|
|
return "error: " + err.Error()
|
|
}
|
|
if faq.ID == 0 {
|
|
return "missing"
|
|
}
|
|
return faq.Name
|
|
})
|
|
body := func(name string) map[string]any {
|
|
return map[string]any{
|
|
"name": name,
|
|
"slug": "purge-faq",
|
|
"locale": en,
|
|
"is_active": true,
|
|
"content": []any{map[string]any{"question": "Why?", "answer": "<p>Because.</p>"}},
|
|
}
|
|
}
|
|
|
|
rec := e.call(t, http.MethodPost, base, token, body("Before"))
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var created adminRecord
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id, _ := created.Data["id"].(float64)
|
|
if id == 0 {
|
|
t.Fatalf("create answered no id: %s", rec.Body.String())
|
|
}
|
|
itemPath := fmt.Sprintf("%s/%d", base, int64(id))
|
|
if req := recv.expectOnePurge(t, "create", "purge-faq"); req.Seen != "Before" {
|
|
t.Fatalf("create: the purge saw %q, want the committed row", req.Seen)
|
|
}
|
|
|
|
t.Run("update", func(t *testing.T) {
|
|
if rec := e.call(t, http.MethodPut, itemPath, token, body("After")); rec.Code != http.StatusOK {
|
|
t.Fatalf("update: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if req := recv.expectOnePurge(t, "update", "purge-faq"); req.Seen != "After" {
|
|
t.Fatalf("update: the purge saw %q, want the committed %q (purge before commit)", req.Seen, "After")
|
|
}
|
|
})
|
|
|
|
t.Run("a create refused by the locale check sends none", func(t *testing.T) {
|
|
rec := e.call(t, http.MethodPost, base, token, map[string]any{"name": "No locale", "slug": "no-locale"})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "locale_required") {
|
|
t.Fatalf("create without locale: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := recv.take(); len(got) != 0 {
|
|
t.Fatalf("a refused create sent %d purge requests: %+v", len(got), got)
|
|
}
|
|
})
|
|
|
|
t.Run("a failing site never fails the save", func(t *testing.T) {
|
|
recv.setStatus(http.StatusInternalServerError)
|
|
defer recv.setStatus(http.StatusOK)
|
|
if rec := e.call(t, http.MethodPut, itemPath, token, body("While failing")); rec.Code != http.StatusOK {
|
|
t.Fatalf("update with a failing purge: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
recv.expectOnePurge(t, "failing purge", "purge-faq")
|
|
var stored models.FAQ
|
|
if err := e.gdb.First(&stored, int64(id)).Error; err != nil || stored.Name != "While failing" {
|
|
t.Fatalf("stored = %+v (%v), want the name saved", stored, err)
|
|
}
|
|
})
|
|
|
|
t.Run("delete", func(t *testing.T) {
|
|
if rec := e.call(t, http.MethodDelete, itemPath, token, nil); rec.Code != http.StatusOK {
|
|
t.Fatalf("delete: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if req := recv.expectOnePurge(t, "delete", "purge-faq"); req.Seen != "missing" {
|
|
t.Fatalf("delete: the purge saw %q, want the row already deleted", req.Seen)
|
|
}
|
|
})
|
|
}
|