package sitemap_test import ( "context" "slices" "strconv" "sync" "testing" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" ) // contentPluginID is a fake content plugin with two content types whose // records live in memory (contentStore), so a test sets exactly what the // sitemap reads through signpost Records. It knows nothing about the // sitemap plugin. const contentPluginID = "acme.content" // The fake content types: pages (/pages/, group pages) and offers // (/offers/, group offers). const ( pageType = "acme-page" pageModel = "acme_pages" offerType = "acme-offer" offerModel = "acme_offers" ) // fakeRecord is one record of a fake content type. PublishedAt and Parents // are the optional descriptor facts (the News sitemap, the HTML sitemap's // tree). type fakeRecord struct { ID int Published bool LastMod time.Time Locales []pact.ContentLocale PublishedAt *time.Time Parents []pact.ContentRef } // store holds the fake records per content type key. type store struct { mu sync.Mutex records map[string][]fakeRecord } var contentStore = &store{records: map[string][]fakeRecord{}} // reset empties the store and clears it again when the test ends. func (s *store) reset(t *testing.T) { t.Helper() s.mu.Lock() s.records = map[string][]fakeRecord{} s.mu.Unlock() t.Cleanup(func() { s.mu.Lock() s.records = map[string][]fakeRecord{} s.mu.Unlock() }) } // put adds or replaces a record of typ. func (s *store) put(typ string, rec fakeRecord) { s.mu.Lock() defer s.mu.Unlock() list := s.records[typ] for i := range list { if list[i].ID == rec.ID { list[i] = rec return } } s.records[typ] = append(list, rec) } // remove deletes a record of typ. func (s *store) remove(typ string, id int) { s.mu.Lock() defer s.mu.Unlock() s.records[typ] = slices.DeleteFunc(s.records[typ], func(r fakeRecord) bool { return r.ID == id }) } // page answers one ContentList page of typ, by ascending ID. func (s *store) page(typ string, in pact.ContentList) pact.ContentPage { s.mu.Lock() defer s.mu.Unlock() list := slices.Clone(s.records[typ]) slices.SortFunc(list, func(a, b fakeRecord) int { return a.ID - b.ID }) after, _ := strconv.Atoi(in.After) limit := in.Limit if limit <= 0 { limit = 100 } out := pact.ContentPage{Records: []pact.ContentRecord{}} for _, r := range list { if r.ID <= after { continue } if len(out.Records) == limit { out.Next = strconv.Itoa(after) break } mod := r.LastMod out.Records = append(out.Records, pact.ContentRecord{ ID: strconv.Itoa(r.ID), Published: r.Published, LastMod: &mod, Locales: slices.Clone(r.Locales), PublishedAt: r.PublishedAt, Parents: slices.Clone(r.Parents), }) after = r.ID } return out } // contentPlugin is acme.content. type contentPlugin struct{} func (contentPlugin) ID() string { return contentPluginID } func (contentPlugin) Requires() []string { return nil } func (contentPlugin) Register(*backpack.App) error { return nil } func (contentPlugin) Boot(*backpack.App) error { return nil } func (contentPlugin) MenuItemTypes() []pact.MenuItemType { typ := func(key, label, model, pattern string) pact.MenuItemType { return pact.MenuItemType{ Key: key, Label: label, DefaultPattern: pattern, Placeholders: []string{"slug"}, Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) { return nil, nil }, Content: &pact.ContentDescriptor{ Model: model, Records: func(_ context.Context, in pact.ContentList) (pact.ContentPage, error) { return contentStore.page(key, in), nil }, Describe: func(context.Context, pact.ContentDescribe) (*pact.ContentRecord, error) { return nil, nil }, }, } } return []pact.MenuItemType{ typ(pageType, "Pages", pageModel, "/pages/:slug"), typ(offerType, "Offers", offerModel, "/offers/:slug"), } } var _ pact.HasMenuItemTypes = contentPlugin{} func init() { party.Register(contentPlugin{}) } // page1 is a published record in one locale at /pages/. func page1(id int, slug string, mod time.Time) fakeRecord { return fakeRecord{ID: id, Published: true, LastMod: mod, Locales: []pact.ContentLocale{{Locale: "en", URL: "/pages/" + slug, Title: "Page " + slug}}} } // deleteRecord removes a record of typ from the store and fires // pact.RecordDeleted for its model, as the admin does after a delete. func (e *sitemapEnv) deleteRecord(t *testing.T, typ, model string, id int) { t.Helper() contentStore.remove(typ, id) if err := e.app.Events.Fire(context.Background(), &pact.RecordDeleted{Model: model, ID: strconv.Itoa(id)}); err != nil { t.Fatal(err) } } // editRecord replaces a record of typ in the store and fires // pact.RecordSaved for its model with the changed attributes, as the admin // does after a save. func (e *sitemapEnv) editRecord(t *testing.T, typ, model string, rec fakeRecord, changes ...pact.RecordChange) { t.Helper() contentStore.put(typ, rec) if err := e.app.Events.Fire(context.Background(), &pact.RecordSaved{Model: model, ID: strconv.Itoa(rec.ID), Changes: changes}); err != nil { t.Fatal(err) } }