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