- every read of the purge receiver first drains the app's purger through cooler.SiteCachePurger.Wait, since cooler now queues purges after commit
237 lines
7.4 KiB
Go
237 lines
7.4 KiB
Go
package faq_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-faq-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 = "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
|
|
// 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":2}`))
|
|
}
|
|
}))
|
|
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 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)
|
|
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, 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)
|
|
}
|
|
})
|
|
}
|