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

Before

\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 = "

After the edit

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

Before

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

After the edit

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