Files
summercms/modules/cabana/singleton_test.go
Jakub Zych 6312929148 fix(17-06): singleton route retries a lost first-or-create race
Two first requests to GET .../singleton both missed the record; the
loser's insert hit the unique index and the route answered 500. A
unique violation from SingletonRecord now reruns the transaction once,
which finds the winner's record. TestSingleton fires six concurrent
first requests and requires 200 with the same id from each.
2026-10-12 19:31:01 +02:00

140 lines
6.2 KiB
Go

package cabana_test
import (
"fmt"
"net/http"
"strings"
"sync"
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestSingletonCompile pins the D-11 boot rules, Docker-free: a singleton
// controller needs config_form.yaml and no config_list.yaml, and may keep
// one (its create button is dropped then).
func TestSingletonCompile(t *testing.T) {
if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, nil)}); err != nil {
t.Fatalf("fixture without a list: %v", err)
}
withList := companionsOverlay(t, map[string]string{
"controllers/profiles/config_list.yaml": "list: ~/plugins/acme/companions/models/profile/columns.yaml\nmodelClass: Profile\ntoolbar:\n buttons: [create]\n",
"models/profile/columns.yaml": "columns:\n title:\n label: Title\n",
})
if err := activateCompanions(t, companionsFSPlugin{fsys: withList}); err != nil {
t.Fatalf("singleton with a list: %v", err)
}
err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, map[string]string{"controllers/profiles/config_form.yaml": ""})})
if err == nil || !strings.Contains(err.Error(), "acme.companions.profiles: a singleton controller (pact.AdminSingleton) needs config_form.yaml") {
t.Fatalf("singleton without a form: err = %v", err)
}
// A controller that is not a singleton still needs its list.
err = activateCompanions(t, companionsFSPlugin{
fsys: companionsOverlay(t, nil),
controllers: []pact.AdminController{plainProfiles{}},
})
if err == nil || !strings.Contains(err.Error(), "config_list.yaml") {
t.Fatalf("plain controller without a list: err = %v", err)
}
}
// plainProfiles is the profiles controller without pact.AdminSingleton.
type plainProfiles struct{}
func (plainProfiles) ID() string { return "acme.companions.profiles" }
func (plainProfiles) ModelName() string { return "Profile" }
func (plainProfiles) ConfigDir() string { return "controllers/profiles" }
func (plainProfiles) RequiredPermissions() []string { return []string{"acme.companions.access"} }
func (plainProfiles) NewRecord() any { return &companionsProfile{} }
// TestSingleton pins the D-11 routes on Postgres: the singleton route
// creates the record once, also when first requests race (each answers 200
// with the same id), and answers its id; the record shows and updates; list, create, delete, bulk delete
// and reorder answer 404; the form schema says singleton; a controller that
// is not a singleton answers 404 on the route; anonymous requests 401.
func TestSingleton(t *testing.T) {
e := newCompanionsEnv(t)
// Concurrent first requests: the losers of the first-or-create race
// retry and find the winner's record.
var wg sync.WaitGroup
answers := make([]*struct {
code int
body string
}, 6)
for i := range answers {
wg.Add(1)
go func(i int) {
defer wg.Done()
rec := e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true)
answers[i] = &struct {
code int
body string
}{rec.Code, rec.Body.String()}
}(i)
}
wg.Wait()
for i, answer := range answers {
if answer.code != http.StatusOK || answer.body != answers[0].body {
t.Fatalf("concurrent first request %d: status=%d body=%s (first: %s)", i, answer.code, answer.body, answers[0].body)
}
}
first := e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true)
id := dataID(t, first.Body.Bytes())
for range 2 {
if again := dataID(t, e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true).Body.Bytes()); again != id {
t.Fatalf("id = %d, want %d", again, id)
}
}
var count int64
if err := e.db.Model(&companionsProfile{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("profiles = %d (%v)", count, err)
}
var profile companionsProfile
if err := e.db.First(&profile, id).Error; err != nil || profile.Key != "default" || profile.Title != "Default" {
t.Fatalf("profile = %+v (%v)", profile, err)
}
path := fmt.Sprintf("/acme/companions/profiles/%d", id)
if rec := e.send(t, http.MethodGet, path, nil, true); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"title":"Default"`) {
t.Fatalf("show = %d %s", rec.Code, rec.Body.String())
}
if rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Renamed"}, true); rec.Code != http.StatusOK {
t.Fatalf("update = %d %s", rec.Code, rec.Body.String())
}
if err := e.db.First(&profile, id).Error; err != nil || profile.Title != "Renamed" || profile.Key != "default" {
t.Fatalf("updated profile = %+v (%v)", profile, err)
}
if again := dataID(t, e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true).Body.Bytes()); again != id {
t.Fatalf("id after update = %d", again)
}
for _, tc := range []struct {
method, path string
body any
auth bool
status int
}{
{http.MethodGet, "/acme/companions/profiles", nil, true, http.StatusNotFound},
{http.MethodPost, "/acme/companions/profiles", map[string]any{"title": "x"}, true, http.StatusNotFound},
{http.MethodDelete, path, nil, true, http.StatusNotFound},
{http.MethodPost, "/acme/companions/profiles/bulk-delete", map[string]any{"ids": []uint{id}}, true, http.StatusNotFound},
{http.MethodGet, "/acme/companions/profiles/reorder", nil, true, http.StatusNotFound},
{http.MethodGet, "/acme/companions/links/singleton", nil, true, http.StatusNotFound},
{http.MethodGet, "/acme/companions/profiles/singleton", nil, false, http.StatusUnauthorized},
} {
if rec := e.send(t, tc.method, tc.path, tc.body, tc.auth); rec.Code != tc.status {
t.Fatalf("%s %s status=%d want %d body=%s", tc.method, tc.path, rec.Code, tc.status, rec.Body.String())
}
}
if err := e.db.Model(&companionsProfile{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("profiles after refused requests = %d (%v)", count, err)
}
schema := e.send(t, http.MethodGet, "/acme/companions/profiles/schema/form", nil, true)
if !strings.Contains(schema.Body.String(), `"singleton":true`) {
t.Fatalf("form schema = %s", schema.Body.String())
}
if links := e.send(t, http.MethodGet, "/acme/companions/links/schema/form", nil, true); strings.Contains(links.Body.String(), `"singleton":true`) {
t.Fatalf("a plain form schema says singleton: %s", links.Body.String())
}
}