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.
140 lines
6.2 KiB
Go
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())
|
|
}
|
|
}
|