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": "

Because.

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