Files
sm-faq-plugin/site_cache_admin_test.go
Jakub Zych e52b28621b test(19.3-06): wait for the queued site-cache purge before asserting (WR-01)
- every read of the purge receiver first drains the app's purger through
  cooler.SiteCachePurger.Wait, since cooler now queues purges after commit
2026-10-15 22:39:18 +02:00

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