package test import ( "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/article" "net/http" "reflect" "strconv" "testing" "time" ) func (e *seoOutEnv) cleanArticles() { delete(e.articles.articles, seoOutArticleID) delete(e.articles.articles, seoOutDraftID) } func (e *seoOutEnv) seedListed(id uint32, title string, online bool, mutate func(*article.Article)) { e.credit(seoOutAuthorMarta, "Marta Alves", "") e.articles.credits[seoOutArticleID] = nil e.articles.credit(id, seoOutAuthorMarta) e.articles.articles[id] = article.Article{ ID: id, Title: title, Body: textBody(400, "

h

"), IsOnline: online, SiteID: siteMainID, Slug: "slug-" + strconv.Itoa(int(id)), SEOTitle: repeated("t", 45), SEODescription: repeated("d", 100), CoverImageID: idPointer(5), CoverImageAlt: "alt", SEODismissed: []string{}, CreatedAt: seoOutCreated, UpdatedAt: seoOutCreated.Add(time.Duration(id) * time.Hour), } if mutate != nil { found := e.articles.articles[id] mutate(&found) e.articles.articles[id] = found } } func (e *seoOutEnv) seedFourListed() { e.cleanArticles() e.seedListed(1, "Ready", true, nil) e.seedListed(2, "Two", true, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = nil, ""; a.SEOTitle = repeated("t", 70) }) e.seedListed(3, "Hidden", true, func(a *article.Article) { a.Noindex = true a.CoverImageID, a.CoverImageAlt = nil, "" a.SEOTitle = repeated("t", 70) }) e.seedListed(4, "Four", true, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = nil, "" a.SEOTitle = repeated("t", 70) a.Slug = repeated("s", 80) a.SEODismissed = []string{"slug_long"} }) } func (e *seoOutEnv) list(t *testing.T, who string, query string) map[string]any { t.Helper() rec := e.call(t, who, http.MethodGet, "/v1/article/"+query, "") if rec.Code != http.StatusOK { t.Fatalf("list %s: %d %s", query, rec.Code, rec.Body.String()) } return decodeSeoObject(t, rec) } func itemByID(list map[string]any, id int) map[string]any { for _, item := range list["items"].([]any) { if found := item.(map[string]any); int(found["id"].(float64)) == id { return found } } return nil } func TestSeoOutputList(t *testing.T) { t.Run("Items carry seo_status and seo_suggestions", func(t *testing.T) { e := newSeoOutEnv(t) e.seedFourListed() list := e.list(t, "owner", "") want := map[int][2]any{1: {"ready", 0.0}, 2: {"suggestions", 2.0}, 3: {"hidden", 2.0}, 4: {"suggestions", 2.0}} for id, expected := range want { item := itemByID(list, id) if item["seo_status"] != expected[0] || item["seo_suggestions"] != expected[1] { t.Errorf("item %d = %v %v", id, item["seo_status"], item["seo_suggestions"]) } if _, present := item["seo_dismissed"]; present { t.Errorf("item %d carries seo_dismissed", id) } if _, present := item["excerpt"]; !present { t.Errorf("item %d has no excerpt", id) } } }) t.Run("The list and the check agree", func(t *testing.T) { e := newSeoOutEnv(t) e.seedFourListed() list := e.list(t, "owner", "") for id := 1; id <= 4; id++ { rec := e.call(t, "owner", http.MethodPost, "/v1/article/"+strconv.Itoa(id)+"/seo/check", `{}`) checked := decodeSeoObject(t, rec) item := itemByID(list, id) if checked["status"] != item["seo_status"] || float64(len(checked["suggestions"].([]any))) != item["seo_suggestions"] { t.Errorf("article %d: list %v/%v, check %v", id, item["seo_status"], item["seo_suggestions"], checked) } } }) t.Run("Anonymous callers see online items with their status", func(t *testing.T) { e := newSeoOutEnv(t) e.seedFourListed() e.seedListed(5, "Draft", false, nil) list := e.list(t, "", "") if !reflect.DeepEqual(listIDs(list), []int{4, 3, 2, 1}) || itemByID(list, 2)["seo_status"] != "suggestions" { t.Errorf("ids = %v", listIDs(list)) } }) t.Run("The seo filter", func(t *testing.T) { cases := []struct { value string ids []int total float64 }{{"ready", []int{1}, 1}, {"suggestions", []int{4, 2}, 2}, {"hidden", []int{3}, 1}} for _, c := range cases { e := newSeoOutEnv(t) e.seedFourListed() list := e.list(t, "owner", "?seo="+c.value) if !reflect.DeepEqual(listIDs(list), c.ids) || list["total"] != c.total { t.Errorf("%s: ids %v total %v", c.value, listIDs(list), list["total"]) } } }) t.Run("The filter composes with search, status, site, sort and paging", func(t *testing.T) { e := newSeoOutEnv(t) e.cleanArticles() for id := uint32(1); id <= 7; id++ { e.seedListed(id, "Tile "+strconv.Itoa(int(id)), false, func(a *article.Article) { a.SiteID = seoOutJournalID; a.CoverImageID, a.CoverImageAlt = nil, "" }) } for id := uint32(8); id <= 10; id++ { e.seedListed(id, "Tile "+strconv.Itoa(int(id)), true, func(a *article.Article) { a.SiteID = seoOutJournalID; a.CoverImageID, a.CoverImageAlt = nil, "" }) } for id := uint32(11); id <= 14; id++ { e.seedListed(id, "Tile "+strconv.Itoa(int(id)), false, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = nil, "" }) } base := "?seo=suggestions&q=tile&status=draft&site=journal&sort=title:asc&size=5&page=" first, second := e.list(t, "owner", base+"1"), e.list(t, "owner", base+"2") if first["total"] != 7.0 || !reflect.DeepEqual(listIDs(second), []int{6, 7}) { t.Errorf("first %v second %v total %v", listIDs(first), listIDs(second), first["total"]) } for _, id := range listIDs(first) { for _, other := range listIDs(second) { if id == other { t.Errorf("id %d on both pages", id) } } } }) t.Run("Counts stay unfiltered", func(t *testing.T) { e := newSeoOutEnv(t) e.cleanArticles() for id := uint32(1); id <= 6; id++ { online := id <= 4 e.seedListed(id, "A"+strconv.Itoa(int(id)), online, func(a *article.Article) { if id%2 == 0 { a.CoverImageID, a.CoverImageAlt = nil, "" } }) } list := e.list(t, "owner", "?seo=suggestions") if !reflect.DeepEqual(list["counts"], map[string]any{"all": 6.0, "online": 4.0, "draft": 2.0}) || list["total"] != 3.0 { t.Errorf("list = %v %v", list["counts"], list["total"]) } }) t.Run("The filter is applied before paging", func(t *testing.T) { e := newSeoOutEnv(t) e.cleanArticles() for id := uint32(1); id <= 30; id++ { e.seedListed(id, "A"+strconv.Itoa(int(id)), true, func(a *article.Article) { if id <= 12 { a.CoverImageID, a.CoverImageAlt = nil, "" } }) } list := e.list(t, "owner", "?seo=suggestions&size=5&page=3") if len(listIDs(list)) != 2 || list["total"] != 12.0 { t.Errorf("ids %v total %v", listIDs(list), list["total"]) } }) t.Run("The filter respects visibility", func(t *testing.T) { e := newSeoOutEnv(t) e.cleanArticles() e.seedListed(1, "Offline", false, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = nil, "" }) list := e.list(t, "", "?seo=suggestions") if len(listIDs(list)) != 0 || list["total"] != 0.0 { t.Errorf("list = %v", list) } }) t.Run("Dismissed codes are never counted in the filter", func(t *testing.T) { e := newSeoOutEnv(t) e.cleanArticles() e.seedListed(1, "Dismissed", true, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = nil, "" a.SEODismissed = []string{"cover_none"} }) if ids := listIDs(e.list(t, "owner", "?seo=suggestions")); len(ids) != 0 { t.Errorf("ids = %v", ids) } }) t.Run("An invalid seo value is refused", func(t *testing.T) { for _, query := range []string{"seo=maybe", "seo=", "seo=READY", "seo=ready,hidden", "seo=1"} { e := newSeoOutEnv(t) e.seedFourListed() before := e.counted.readCalls() expectStatusAndText(t, e.call(t, "owner", http.MethodGet, "/v1/article/?"+query, ""), 400, apperr.BodyErrorMessage) if e.counted.readCalls() != before { t.Errorf("%s: the repository was called", query) } } }) t.Run("Invalid format is still reported first", func(t *testing.T) { e := newSeoOutEnv(t) expectStatusAndText(t, e.call(t, "owner", http.MethodGet, "/v1/article/?seo=maybe&format=pdf", ""), 400, apperr.ArticleBodyFormatErrorMessage) }) t.Run("The existing list behaviour is unchanged without the filter", func(t *testing.T) { e := newSeoOutEnv(t) list := e.list(t, "owner", "") if list["total"] != 2.0 || list["page"] != 1.0 || list["size"] != 25.0 || !reflect.DeepEqual(list["counts"], map[string]any{"all": 2.0, "online": 1.0, "draft": 1.0}) { t.Errorf("list = %v", list) } }) }