diff --git a/classes/listing.go b/classes/listing.go new file mode 100644 index 0000000..faddacf --- /dev/null +++ b/classes/listing.go @@ -0,0 +1,154 @@ +package classes + +import ( + "context" + "strconv" + "strings" + + "git.golem15.com/golem15/sm-sitemap-plugin/contract" + "git.golem15.com/golem15/sm-sitemap-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/signpost" + "gorm.io/gorm" +) + +// Listing implements contract.Listing over the slot table and the +// settings (D-95): other plugins read the site address, the index address, +// the file groups and each group's listed addresses without importing the +// sitemap plugin. +type Listing struct { + App *backpack.App +} + +var _ contract.Listing = Listing{} + +// The page sizes of Entries. +const ( + defaultEntriesLimit = 100 + maxEntriesLimit = 1000 +) + +func (l Listing) db(ctx context.Context) (*gorm.DB, error) { + if l.App == nil { + return nil, ErrNoDatabase + } + db, ok := l.App.Lookup[*gorm.DB]() + if !ok || db == nil { + return nil, ErrNoDatabase + } + return db.WithContext(ctx), nil +} + +// Settings answers the site address, the index address, the default +// locale and the groups with listed addresses (by key), with their titles: +// a content type's saved HTML sitemap headings, else the titles of the +// published Filter's group of that key. +func (l Listing) Settings(ctx context.Context) (contract.ListingSettings, error) { + var out contract.ListingSettings + db, err := l.db(ctx) + if err != nil { + return out, err + } + settings, err := LoadSettings(db) + if err != nil { + return out, err + } + out.SiteURL = SiteURL(l.App, settings) + if out.SiteURL != "" { + out.IndexURL = out.SiteURL + "/sitemap.xml" + } + out.DefaultLocale = models.Text(settings.DefaultLocale) + var counts []struct { + Grp string `gorm:"column:grp"` + N int `gorm:"column:n"` + } + if err := db.Raw(`SELECT grp, COUNT(*) AS n FROM golem15_sitemap_urls WHERE excluded_reason = '' GROUP BY grp ORDER BY grp`).Scan(&counts).Error; err != nil { + return out, err + } + titles := map[string]map[string]string{} + if filter, ok := l.App.Lookup[contract.Filter](); ok && filter != nil { + groups, err := filter.Groups(ctx) + if err != nil { + return out, err + } + for _, g := range groups { + if len(g.Titles) > 0 { + titles[g.Key] = g.Titles + } + } + } + if reg, ok := l.App.Lookup[*signpost.Registry](); ok && reg != nil { + rows, err := LoadTypeSettings(db) + if err != nil { + return out, err + } + for _, typ := range reg.ContentTypes() { + cfg := EffectiveType(typ, rows) + if len(cfg.Titles) > 0 { + titles[cfg.Slug] = cfg.Titles + } + } + } + out.Groups = make([]contract.GroupInfo, 0, len(counts)) + for _, c := range counts { + out.Groups = append(out.Groups, contract.GroupInfo{Key: c.Grp, Slug: c.Grp, Titles: titles[c.Grp], Count: c.N}) + } + return out, nil +} + +// Entries pages a group's listed addresses in the order they were stored +// (row id); Next is the last row id of a page that has a successor. A +// Locale keeps the addresses of that locale and those without one. +func (l Listing) Entries(ctx context.Context, q contract.EntriesQuery) (contract.EntriesPage, error) { + page := contract.EntriesPage{Entries: []contract.Entry{}} + db, err := l.db(ctx) + if err != nil { + return page, err + } + settings, err := LoadSettings(db) + if err != nil { + return page, err + } + site := SiteURL(l.App, settings) + limit := q.Limit + if limit <= 0 { + limit = defaultEntriesLimit + } + limit = min(limit, maxEntriesLimit) + scope := func() *gorm.DB { + s := db.Model(&models.URL{}).Where("grp = ? AND excluded_reason = ''", q.Group) + if loc := strings.TrimSpace(q.Locale); loc != "" { + s = s.Where("locale = ? OR locale = ''", loc) + } + return s + } + var total int64 + if err := scope().Count(&total).Error; err != nil { + return page, err + } + page.Total = int(total) + q2 := scope() + if after, err := strconv.ParseUint(strings.TrimSpace(q.After), 10, 64); err == nil && after > 0 { + q2 = q2.Where("id > ?", after) + } + var rows []models.URL + if err := q2.Order("id ASC").Limit(limit + 1).Find(&rows).Error; err != nil { + return page, err + } + if len(rows) > limit { + rows = rows[:limit] + page.Next = strconv.FormatUint(rows[len(rows)-1].ID, 10) + } + for _, r := range rows { + page.Entries = append(page.Entries, contract.Entry{ + Group: r.Grp, + TypeKey: r.TypeKey, + RecordID: r.RecordID, + Locale: r.Locale, + URL: Absolute(site, r.URL), + Title: r.Title, + LastMod: r.LastMod, + }) + } + return page, nil +} diff --git a/classes/rebuild.go b/classes/rebuild.go index c161d6d..3cf5827 100644 --- a/classes/rebuild.go +++ b/classes/rebuild.go @@ -130,7 +130,8 @@ func Rebuild(ctx context.Context, app *backpack.App) (RebuildResult, error) { return res, err } reg, _ := app.Lookup[*signpost.Registry]() - found, err := collect(ctx, reg, settings, types) + filter, _ := app.Lookup[contract.Filter]() + found, err := collect(ctx, reg, filter, settings, types) if err != nil { return res, err } @@ -152,31 +153,43 @@ func Rebuild(ctx context.Context, app *backpack.App) (RebuildResult, error) { // collect walks the content types page by page and adds the additional // addresses. The records of a type switched off in the settings are kept // in the table with the reason contract.ReasonType, so the settings screen -// counts them. -func collect(ctx context.Context, reg *signpost.Registry, settings models.Settings, types map[string]models.TypeSetting) (collected, error) { +// counts them. Every other page of addresses (and the additional +// addresses) is one batch for the published contract.Filter, when there is +// one (D-95): an excluded address keeps its row with the Filter's reason, a +// Decision.Group moves it to that file group, and Decision.LastMod is the +// first lastmod of a new row. The hreflang alternates of a record are its +// listed addresses (D-55 to D-57). +func collect(ctx context.Context, reg *signpost.Registry, filter contract.Filter, settings models.Settings, types map[string]models.TypeSetting) (collected, error) { out := collected{unpublished: map[slotKey]bool{}} seen := map[slotKey]bool{} - add := func(w wanted) { - if seen[w.key] { - return + defaultLocale := models.Text(settings.DefaultLocale) + // batch decides one batch and adds its rows; rows of one record are + // adjacent, so the alternates are computed per run of a record. + batch := func(rows []wanted) error { + var fresh []wanted + for _, w := range rows { + if !seen[w.key] { + seen[w.key] = true + fresh = append(fresh, w) + } } - seen[w.key] = true - out.rows = append(out.rows, w) + if err := decide(ctx, filter, fresh); err != nil { + return err + } + setAlternates(fresh, defaultLocale) + out.rows = append(out.rows, fresh...) + return nil } - unprefixed := models.Text(settings.DefaultLocale) if reg != nil { for _, typ := range reg.ContentTypes() { cfg := EffectiveType(typ, types) - reason := "" - if !cfg.Include { - reason = contract.ReasonType - } after := "" for { - page, err := reg.Records(ctx, typ.Key, after, recordsPage, unprefixed) + page, err := reg.Records(ctx, typ.Key, after, recordsPage, defaultLocale) if err != nil { return out, fmt.Errorf("golem15.sitemap: records of %s: %w", typ.Key, err) } + var rows []wanted for _, rec := range page.Records { if !rec.Published { out.unpublished[slotKey{typ.Key, rec.ID, ""}] = true @@ -186,16 +199,22 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin if strings.TrimSpace(l.URL) == "" { continue } - add(wanted{ + w := wanted{ key: slotKey{typ.Key, rec.ID, l.Locale}, grp: cfg.Slug, url: l.URL, title: l.Title, firstMod: rec.LastMod, - reason: reason, - }) + } + if !cfg.Include { + w.reason = contract.ReasonType + } + rows = append(rows, w) } } + if err := batch(rows); err != nil { + return out, err + } if page.Next == "" || page.Next == after { break } @@ -203,13 +222,14 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin } } } + var extras []wanted for _, e := range settings.Extra() { u := strings.TrimSpace(e.URL) if !models.AllowedAddress(u) { continue } mod := models.ParseDate(e.LastMod) - add(wanted{ + extras = append(extras, wanted{ key: slotKey{models.ExtraTypeKey, ExtraRecordID(u), ""}, grp: models.ExtraGroup, url: u, @@ -218,9 +238,101 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin firstMod: mod, }) } + if err := batch(extras); err != nil { + return out, err + } return out, nil } +// decide asks filter about the rows not already left out (a type switched +// off is decided by the settings) and applies its decisions. Without a +// Filter every row stays listed. +func decide(ctx context.Context, filter contract.Filter, rows []wanted) error { + if filter == nil { + return nil + } + var asked []int + var candidates []contract.Candidate + for i, w := range rows { + if w.reason != "" { + continue + } + asked = append(asked, i) + candidates = append(candidates, contract.Candidate{TypeKey: w.key.typeKey, RecordID: w.key.recordID, Locale: w.key.locale, URL: w.url}) + } + if len(candidates) == 0 { + return nil + } + decisions, err := filter.Decide(ctx, candidates) + if err != nil { + return fmt.Errorf("golem15.sitemap: filter: %w", err) + } + if len(decisions) != len(candidates) { + return fmt.Errorf("golem15.sitemap: filter answered %d decisions for %d candidates", len(decisions), len(candidates)) + } + for j, d := range decisions { + w := &rows[asked[j]] + if d.Exclude { + w.reason = strings.TrimSpace(d.Reason) + if w.reason == "" { + w.reason = contract.ReasonNoindex + } + continue + } + if g := strings.TrimSpace(d.Group); g != "" { + if ValidSlug(g) && !ReservedSlugs[g] { + w.grp = g + } else { + pluginLog.Warn("golem15.sitemap: filter group ignored", "group", g, "type", w.key.typeKey, "record", w.key.recordID) + } + } + if d.LastMod != nil { + w.firstMod = d.LastMod + } + } + return nil +} + +// setAlternates gives the listed addresses of every record with more than +// one listed locale its hreflang alternates: each listed locale with its +// address, itself included, then x-default on the default locale's address +// (the record's first listed locale when the default has none). A locale +// without an address or left out has no alternate; a record with one +// listed locale gets none (D-55 to D-57, RESEARCH Pitfall 3). +func setAlternates(rows []wanted, defaultLocale string) { + type record struct{ typeKey, id string } + listed := map[record][]int{} + var order []record + for i, w := range rows { + if w.reason != "" || w.extra || w.key.locale == "" || w.key.locale == "x-default" { + continue + } + r := record{w.key.typeKey, w.key.recordID} + if _, ok := listed[r]; !ok { + order = append(order, r) + } + listed[r] = append(listed[r], i) + } + for _, r := range order { + idx := listed[r] + if len(idx) < 2 { + continue + } + alts := make([]models.Alternate, 0, len(idx)+1) + xDefault := rows[idx[0]].url + for _, i := range idx { + alts = append(alts, models.Alternate{Hreflang: rows[i].key.locale, Href: rows[i].url}) + if rows[i].key.locale == defaultLocale { + xDefault = rows[i].url + } + } + alts = append(alts, models.Alternate{Hreflang: "x-default", Href: xDefault}) + for _, i := range idx { + rows[i].alternates = alts + } + } +} + // apply writes the found addresses into the slot table and numbers the // files, inside the rebuild's transaction. func apply(tx *gorm.DB, found collected, settings models.Settings, site string) (RebuildResult, error) { diff --git a/filter_fixture_test.go b/filter_fixture_test.go new file mode 100644 index 0000000..bbe3b19 --- /dev/null +++ b/filter_fixture_test.go @@ -0,0 +1,59 @@ +package sitemap_test + +import ( + "context" + "strings" + "sync" + + "git.golem15.com/golem15/sm-sitemap-plugin/contract" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/party" +) + +// filterPluginID is a fake plugin that publishes a contract.Filter, as the +// SEO plugin does: it imports only the contract package. +const filterPluginID = "acme.filter" + +// fakeFilter excludes addresses containing "noindex" (reason noindex) and +// puts addresses containing "promo" into the group offers. It records the +// batches it was asked. +type fakeFilter struct { + mu sync.Mutex + batches [][]contract.Candidate +} + +func (f *fakeFilter) Decide(_ context.Context, batch []contract.Candidate) ([]contract.Decision, error) { + f.mu.Lock() + f.batches = append(f.batches, append([]contract.Candidate(nil), batch...)) + f.mu.Unlock() + out := make([]contract.Decision, len(batch)) + for i, c := range batch { + switch { + case strings.Contains(c.URL, "noindex"): + out[i] = contract.Decision{Exclude: true, Reason: contract.ReasonNoindex} + case strings.Contains(c.URL, "promo"): + out[i] = contract.Decision{Group: "offers"} + } + } + return out, nil +} + +func (f *fakeFilter) Groups(context.Context) ([]contract.Group, error) { + return []contract.Group{{Key: "offers", Titles: map[string]string{"en": "Offers", "pl": "Oferty"}}}, nil +} + +var theFilter = &fakeFilter{} + +// filterPlugin is acme.filter. +type filterPlugin struct{} + +func (filterPlugin) ID() string { return filterPluginID } +func (filterPlugin) Requires() []string { return nil } +func (filterPlugin) Register(app *backpack.App) error { + return app.Publish[contract.Filter](theFilter) +} +func (filterPlugin) Boot(*backpack.App) error { return nil } + +func init() { + party.Register(filterPlugin{}) +} diff --git a/plugin.go b/plugin.go index 1a45e08..9f713e0 100644 --- a/plugin.go +++ b/plugin.go @@ -10,7 +10,9 @@ import ( "embed" "io/fs" + "git.golem15.com/golem15/sm-sitemap-plugin/classes" "git.golem15.com/golem15/sm-sitemap-plugin/console" + "git.golem15.com/golem15/sm-sitemap-plugin/contract" "git.golem15.com/golem15/sm-sitemap-plugin/models" "git.golem15.com/golem15/sm-sitemap-plugin/updates" "git.golem15.com/golem15/summercms/modules/backpack" @@ -46,9 +48,11 @@ func (p *Plugin) ID() string { return "golem15.sitemap" } // from a named plugin, and a Filter through the service registry. func (p *Plugin) Requires() []string { return nil } +// Register publishes the sitemap's contract.Listing for other plugins +// (D-95). func (p *Plugin) Register(app *backpack.App) error { p.app = app - return nil + return app.Publish[contract.Listing](classes.Listing{App: app}) } // Boot keeps the booted application for the routes, jobs and commands and diff --git a/sitemap_index_test.go b/sitemap_index_test.go index b4bd47d..abf54d8 100644 --- a/sitemap_index_test.go +++ b/sitemap_index_test.go @@ -10,6 +10,7 @@ import ( "git.golem15.com/golem15/sm-sitemap-plugin/classes" "git.golem15.com/golem15/sm-sitemap-plugin/console" + "git.golem15.com/golem15/sm-sitemap-plugin/contract" "git.golem15.com/golem15/sm-sitemap-plugin/models" "git.golem15.com/golem15/sm-sitemap-plugin/updates" "git.golem15.com/golem15/summercms/modules/bonfire" @@ -620,3 +621,192 @@ func TestRebuildCommand(t *testing.T) { t.Fatalf("sitemap:rebuild --queue: %d\n%s", code, out) } } + +// TestHreflangAlternates pins D-55 to D-57: every address of a record with +// more than one translated locale carries xhtml:link alternates for each +// locale, itself included, plus x-default on the default locale's address; +// a locale without an address gets none, a single-locale record none. +func TestHreflangAlternates(t *testing.T) { + e := bootIndex(t) + if err := e.gdb.Exec(`UPDATE golem15_sitemap_settings SET default_locale = 'pl' WHERE id = 1`).Error; err != nil { + t.Fatal(err) + } + mod := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) + contentStore.put(pageType, fakeRecord{ID: 1, Published: true, LastMod: mod, Locales: []pact.ContentLocale{ + {Locale: "en", URL: "/en/pages/a"}, {Locale: "pl", URL: "/strony/a"}, {Locale: "de", URL: ""}, + }}) + contentStore.put(pageType, fakeRecord{ID: 2, Published: true, LastMod: mod, Locales: []pact.ContentLocale{{Locale: "pl", URL: "/strony/b"}}}) + e.rebuild(t) + body := e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String() + if !strings.Contains(body, ``) { + t.Fatalf("the xhtml namespace is not declared:\n%s", body) + } + alts := `` + + `` + + `` + for _, loc := range []string{"https://api.example.test/en/pages/a", "https://api.example.test/strony/a"} { + if !strings.Contains(body, ""+loc+"2026-10-01T08:00:00Z"+alts+"") { + t.Errorf("%s lacks the alternates:\n%s", loc, body) + } + } + if strings.Contains(body, `hreflang="de"`) { + t.Errorf("a locale without an address got an alternate:\n%s", body) + } + if !strings.Contains(body, "https://api.example.test/strony/b2026-10-01T08:00:00Z") { + t.Errorf("a single-locale record got alternates:\n%s", body) + } + + // Without a default locale setting, x-default is the record's first + // locale; a locale that leaves the record drops out of the alternates. + if err := e.gdb.Exec(`UPDATE golem15_sitemap_settings SET default_locale = NULL WHERE id = 1`).Error; err != nil { + t.Fatal(err) + } + contentStore.put(pageType, fakeRecord{ID: 1, Published: true, LastMod: mod, Locales: []pact.ContentLocale{ + {Locale: "en", URL: "/en/pages/a"}, {Locale: "pl", URL: "/strony/a"}, {Locale: "de", URL: "/de/seiten/a"}, + }}) + e.rebuild(t) + body = e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String() + if !strings.Contains(body, `hreflang="x-default" href="https://api.example.test/en/pages/a"`) || strings.Count(body, `hreflang="de"`) != 3 { + t.Fatalf("x-default without a default locale, or the new locale:\n%s", body) + } + contentStore.put(pageType, fakeRecord{ID: 1, Published: true, LastMod: mod, Locales: []pact.ContentLocale{{Locale: "en", URL: "/en/pages/a"}}}) + e.rebuild(t) + if body = e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String(); strings.Contains(body, "xhtml:link") { + t.Fatalf("a record down to one locale keeps alternates:\n%s", body) + } +} + +// TestFilterExcludes pins D-95 and D-24 with a published Filter: an +// excluded address leaves the files and is counted under its reason, an +// address the Filter moves lands in sitemap-offers-1.xml, every batch is +// one page of one type (or the additional addresses), and the Filter's +// group titles reach Listing.Settings. +func TestFilterExcludes(t *testing.T) { + theFilter.mu.Lock() + theFilter.batches = nil + theFilter.mu.Unlock() + e := bootIndex(t, filterPluginID) + mod := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) + contentStore.put(pageType, page1(1, "a", mod)) + contentStore.put(pageType, page1(2, "noindex-me", mod)) + contentStore.put(pageType, page1(3, "promo", mod)) + contentStore.put(offerType, fakeRecord{ID: 1, Published: true, LastMod: mod, Locales: []pact.ContentLocale{{Locale: "en", URL: "/offers/x"}}}) + if err := e.gdb.Exec(`UPDATE golem15_sitemap_settings SET extra_urls = '[{"url":"/kontakt"},{"url":"/noindex-extra"}]' WHERE id = 1`).Error; err != nil { + t.Fatal(err) + } + e.rebuild(t) + pages := locs(e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String()) + if strings.Join(pages, " ") != "https://api.example.test/pages/a https://api.example.test/kontakt" { + t.Fatalf("pages file = %v", pages) + } + offers := locs(e.get(t, "/_sitemap/files/sitemap-offers-1.xml").Body.String()) + if strings.Join(offers, " ") != "https://api.example.test/pages/promo https://api.example.test/offers/x" { + t.Fatalf("offers file = %v", offers) + } + var n int64 + e.gdb.Raw(`SELECT COUNT(*) FROM golem15_sitemap_urls WHERE excluded_reason = 'noindex'`).Scan(&n) + if n != 2 { + t.Fatalf("%d rows left out as noindex, want 2", n) + } + theFilter.mu.Lock() + batches := theFilter.batches + theFilter.mu.Unlock() + if len(batches) != 3 || len(batches[0]) != 3 || batches[0][0].TypeKey != pageType || batches[0][0].RecordID != "1" || batches[0][0].Locale != "en" || + batches[1][0].TypeKey != offerType || batches[2][0].TypeKey != "extra" || batches[2][0].URL != "/kontakt" { + t.Fatalf("batches = %+v", batches) + } + + listing, ok := e.app.Lookup[contract.Listing]() + if !ok { + t.Fatal("the sitemap publishes no contract.Listing") + } + settings, err := listing.Settings(context.Background()) + if err != nil { + t.Fatal(err) + } + if settings.SiteURL != "https://api.example.test" || settings.IndexURL != "https://api.example.test/sitemap.xml" || len(settings.Groups) != 2 { + t.Fatalf("listing settings = %+v", settings) + } + offersGroup := settings.Groups[0] + if offersGroup.Key != "offers" || offersGroup.Slug != "offers" || offersGroup.Count != 2 || offersGroup.Titles["pl"] != "Oferty" || settings.Groups[1].Key != "pages" || settings.Groups[1].Count != 2 { + t.Fatalf("listing groups = %+v", settings.Groups) + } + s1 := e.call(t, http.MethodGet, "/golem15/sitemap/settings/partials/sitemap_files", e.admin(t, permSitemap), nil).Body.String() + if !strings.Contains(s1, `{"text":"noindex"}`) || !strings.Contains(s1, `{"text":"2"}`) { + t.Fatalf("S1 lacks the noindex count:\n%s", s1) + } +} + +// TestNoFilter pins D-95 without a Filter: everything published is listed. +func TestNoFilter(t *testing.T) { + e := bootIndex(t) + if _, ok := e.app.Lookup[contract.Filter](); ok { + t.Fatal("a Filter is published without the filter plugin") + } + mod := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) + contentStore.put(pageType, page1(1, "noindex-me", mod)) + contentStore.put(pageType, page1(2, "promo", mod)) + e.rebuild(t) + if got := locs(e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String()); len(got) != 2 { + t.Fatalf("without a Filter: %v", got) + } + if rec := e.get(t, "/_sitemap/files/sitemap-offers-1.xml"); rec.Code != http.StatusNotFound { + t.Fatalf("an offers file without a Filter: %d", rec.Code) + } +} + +// TestListing pins contract.Listing.Entries: a group's listed addresses in +// pages of Limit with Next and Total, absolute URLs with title and +// lastmod, the locale filter keeping addresses without a locale, and an +// unknown group empty. +func TestListing(t *testing.T) { + e := bootIndex(t) + mod := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) + for i := 1; i <= 5; i++ { + contentStore.put(pageType, page1(i, string(rune('a'+i-1)), mod)) + } + contentStore.put(pageType, fakeRecord{ID: 6, Published: true, LastMod: mod, Locales: []pact.ContentLocale{{Locale: "pl", URL: "/strony/f", Title: "F"}}}) + if err := e.gdb.Exec(`UPDATE golem15_sitemap_settings SET extra_urls = '[{"url":"/kontakt"}]' WHERE id = 1`).Error; err != nil { + t.Fatal(err) + } + e.rebuild(t) + listing, ok := e.app.Lookup[contract.Listing]() + if !ok { + t.Fatal("no contract.Listing") + } + ctx := context.Background() + var all []string + after := "" + for range 10 { + page, err := listing.Entries(ctx, contract.EntriesQuery{Group: "pages", After: after, Limit: 3}) + if err != nil { + t.Fatal(err) + } + if page.Total != 7 { + t.Fatalf("Total = %d", page.Total) + } + for _, en := range page.Entries { + all = append(all, en.URL) + } + if page.Next == "" { + break + } + after = page.Next + } + if len(all) != 7 || all[0] != "https://api.example.test/pages/a" || all[6] != "https://api.example.test/kontakt" { + t.Fatalf("paged entries = %v", all) + } + first, err := listing.Entries(ctx, contract.EntriesQuery{Group: "pages", Limit: 1}) + if err != nil || len(first.Entries) != 1 || first.Entries[0].Title != "Page a" || first.Entries[0].TypeKey != pageType || first.Entries[0].RecordID != "1" || + first.Entries[0].LastMod == nil || !first.Entries[0].LastMod.Equal(mod) || first.Next == "" { + t.Fatalf("first entry = %+v %v", first, err) + } + pl, err := listing.Entries(ctx, contract.EntriesQuery{Group: "pages", Locale: "pl"}) + if err != nil || pl.Total != 2 || pl.Entries[0].URL != "https://api.example.test/strony/f" || pl.Entries[1].URL != "https://api.example.test/kontakt" { + t.Fatalf("pl entries = %+v %v", pl, err) + } + none, err := listing.Entries(ctx, contract.EntriesQuery{Group: "nope"}) + if err != nil || none.Total != 0 || len(none.Entries) != 0 || none.Next != "" { + t.Fatalf("unknown group = %+v %v", none, err) + } +}