package test import ( entity "RenewCMS/internal/infrastructure/persistence/models" "encoding/json" "net/http" "net/http/httptest" "reflect" "sort" "strconv" "strings" "testing" "time" validator "RenewCMS/api/pkg/validator" "gorm.io/gorm" ) const failedToRetrieveArticles = "Failed to retrieve articles\n" func expectIDs(t *testing.T, label string, got []int, want ...int) { t.Helper() if !reflect.DeepEqual(got, want) { t.Fatalf("%s: got %v, want %v", label, got, want) } } func expectCounts(t *testing.T, got map[string]int, want map[string]int) { t.Helper() if !reflect.DeepEqual(got, want) { t.Fatalf("counts: got %v, want %v", got, want) } } func bylineNames(item map[string]any, field string) []string { names := []string{} for _, entry := range item[field].([]any) { names = append(names, entry.(map[string]any)["name"].(string)) } return names } func TestArticleListLocalizationSQLite(t *testing.T) { runArticleListLocalization(t, openContractSQLite) } func TestMySQLArticleListLocalization(t *testing.T) { runArticleListLocalization(t, openContractMySQL) } func TestPostgresArticleListLocalization(t *testing.T) { runArticleListLocalization(t, openContractPostgres) } func runArticleListLocalization(t *testing.T, open func(*testing.T) *gorm.DB) { validator.InitValidator() fresh := func(t *testing.T) *localizationListEnv { return newLocalizationListEnv(t, open) } shared := fresh(t) base := func(t *testing.T) *localizationListEnv { shared.t = t shared.repo.calls = 0 shared.repo.failMethod = "" return shared } t.Run("A bare request is still the flat envelope", func(t *testing.T) { env := base(t) rec, _ := env.get(localizationOwnerID, "") var keys map[string]json.RawMessage if err := json.Unmarshal(rec.Body.Bytes(), &keys); err != nil { t.Fatal(err) } if len(keys) != 5 || keys["items"] == nil || keys["total"] == nil || keys["page"] == nil || keys["size"] == nil || keys["counts"] == nil { t.Fatalf("unexpected envelope keys: %s", rec.Body.String()) } _, body := env.get(localizationOwnerID, "?size=100") if body.Total != 17 || len(body.Items) != 17 { t.Fatalf("expected 17 versions, got total %d and %d items", body.Total, len(body.Items)) } if _, grouped := body.Items[0]["coverage"]; grouped { t.Fatal("the flat item must not carry grouped fields") } }) t.Run("lang, tab and group are not read unless asked", func(t *testing.T) { env := base(t) _, body := env.get(0, "?size=5") if body.Total != 12 || len(body.Items) != 5 { t.Fatalf("expected 12 online versions, got total %d and %d items", body.Total, len(body.Items)) } for _, item := range body.Items { if _, ok := item["lang"]; !ok { t.Fatal("items keep lang") } if _, ok := item["story_id"]; !ok { t.Fatal("items keep story_id") } } expectCounts(t, body.Counts, map[string]int{"all": 12, "online": 12, "draft": 0, "suggestions": 0}) }) t.Run("group=story needs an editorial caller", func(t *testing.T) { for name, caller := range map[string]uint32{"no cookie": 0, "malformed cookie": localizationMalformed, "unverified user": localizationUnverifiedID} { env := base(t) rec, _ := env.get(caller, "?group=story") if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), paginationBodyMismatch) { t.Fatalf("%s: expected 400, got %d %q", name, rec.Code, rec.Body.String()) } if env.repo.calls != 0 { t.Fatalf("%s: the repository was called", name) } } }) t.Run("Invalid new parameters answer 400", func(t *testing.T) { queries := []string{ "group=stories", "group=", "group=story&tab=review", "group=story&tab=published", "group=story&tab=", "tab=missing", "group=story&lang=english", "group=story&lang=xx", "group=story&lang=", "group=story&author=0", "group=story&author=abc", "group=story&author=", "author=-1", "lang=english", "lang=", "author=0", } for _, query := range queries { env := base(t) rec, _ := env.get(localizationOwnerID, "?"+query) if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), paginationBodyMismatch) { t.Fatalf("%s: expected 400, got %d %q", query, rec.Code, rec.Body.String()) } if env.repo.calls != 0 { t.Fatalf("%s: the repository was called", query) } } }) t.Run("All languages, page 1", func(t *testing.T) { env := base(t) rec, body := env.get(localizationOwnerID, "?group=story&size=5") if rec.Code != http.StatusOK || body.Total != 8 || body.Page != 1 || body.Size != 5 { t.Fatalf("unexpected page: %d total=%d page=%d size=%d", rec.Code, body.Total, body.Page, body.Size) } expectIDs(t, "stories", body.storyIDs(), 1, 2, 3, 4, 5) if body.Counts["all"] != 8 { t.Fatalf("counts.all = %d", body.Counts["all"]) } }) t.Run("All languages, page 2 is the last", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story&size=5&page=2") expectIDs(t, "stories", body.storyIDs(), 6, 7, 8) if body.Total != 8 { t.Fatalf("total = %d", body.Total) } }) t.Run("A page beyond the last is empty with the real total", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story&size=5&page=99") if len(body.Items) != 0 || body.Total != 8 || body.Counts["all"] != 8 { t.Fatalf("unexpected page: %d items, total %d, counts %v", len(body.Items), body.Total, body.Counts) } }) t.Run("A grouped row describes the representative version and the story", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story") item := body.item(1) if int(item["id"].(float64)) != 101 || item["lang"] != "en" || item["site_id"] == nil || item["slug"] != "a-101" || item["is_online"] != true { t.Fatalf("unexpected representative: %v", item) } if item["story_updated_at"] != "2026-10-30T10:00:00Z" { t.Fatalf("story_updated_at = %v", item["story_updated_at"]) } if names := bylineNames(item, "story_authors"); !reflect.DeepEqual(names, []string{"Marta Alves"}) { t.Fatalf("story_authors = %v", names) } for _, field := range []string{"coverage", "missing_langs", "capabilities"} { if item[field] == nil { t.Fatalf("%s is missing", field) } } entry := item["coverage"].([]any)[0].(map[string]any) for _, field := range []string{"id", "lang", "title", "is_online", "language_enabled", "capabilities"} { if _, ok := entry[field]; !ok { t.Fatalf("coverage entry lacks %s", field) } } }) t.Run("The representative is the default-language version, else the first readable by language code", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story") for story, want := range map[int]int{1: 101, 4: 401, 8: 801} { if got := int(body.item(story)["id"].(float64)); got != want { t.Fatalf("story %d representative = %d, want %d", story, got, want) } } if body.item(8)["lang"] != "es" { t.Fatalf("story 8 lang = %v", body.item(8)["lang"]) } }) t.Run("The representative falls back when the default version is unreadable", func(t *testing.T) { env := fresh(t) env.must(env.db.Model(&entity.Article{}).Where("id = ?", 502).UpdateColumn("is_online", true).Error) env.must(env.db.Model(&entity.Article{}).Where("id = ?", 501).UpdateColumn("is_online", false).Error) _, body := env.get(localizationWriterID, "?group=story") item := body.item(5) if int(item["id"].(float64)) != 502 || item["lang"] != "de" { t.Fatalf("expected fallback to 502, got %v", item["id"]) } if len(item["coverage"].([]any)) != 1 { t.Fatalf("the hidden version leaks: %v", item["coverage"]) } }) t.Run("story_updated_at is the latest readable version update", func(t *testing.T) { env := fresh(t) env.must(env.db.Model(&entity.Article{}).Where("id = ?", 102).UpdateColumn("updated_at", time.Date(2026, 11, 1, 8, 0, 0, 0, time.UTC)).Error) _, writer := env.get(localizationWriterID, "?group=story") if got := writer.item(1)["story_updated_at"]; got != "2026-10-30T09:00:00Z" { t.Fatalf("writer story_updated_at = %v", got) } _, owner := env.get(localizationOwnerID, "?group=story") if got := owner.item(1)["story_updated_at"]; got != "2026-11-01T08:00:00Z" { t.Fatalf("owner story_updated_at = %v", got) } expectIDs(t, "owner order", owner.storyIDs()[:2], 1, 2) }) t.Run("Coverage lists readable versions in the chip order, then the languages that can be created", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story") cases := []struct { story int coverage []string missing []string }{ {1, []string{"en", "ar", "de", "fr"}, []string{}}, {3, []string{"en", "fr"}, []string{"ar", "de"}}, {4, []string{"en"}, []string{"ar", "de", "fr"}}, {8, []string{"es"}, []string{"en", "ar", "de", "fr"}}, } for _, c := range cases { item := body.item(c.story) if got := coverageLangs(item); !reflect.DeepEqual(got, c.coverage) { t.Fatalf("story %d coverage = %v", c.story, got) } if got := stringsOf(item["missing_langs"]); !reflect.DeepEqual(got, c.missing) { t.Fatalf("story %d missing_langs = %v", c.story, got) } } if body.item(8)["coverage"].([]any)[0].(map[string]any)["language_enabled"] != false { t.Fatal("es is disabled") } }) t.Run("Coverage order is default, other enabled by tag, disabled by tag; missing is listed by the same rule", func(t *testing.T) { env := fresh(t) env.addVersion(localizationVersion{id: 802, lang: "it", online: true, title: "Journal 802", updated: localizationDay(10, 8), byline: "Luis Ferrer", noindex: true, storyKey: 8}) _, body := env.get(localizationOwnerID, "?group=story") item := body.item(8) if got := coverageLangs(item); !reflect.DeepEqual(got, []string{"es", "it"}) { t.Fatalf("coverage = %v", got) } if got := stringsOf(item["missing_langs"]); got[0] != "en" { t.Fatalf("missing_langs = %v", got) } }) t.Run("A caller who cannot create gets no missing_langs", func(t *testing.T) { _, body := base(t).get(localizationReaderID, "?group=story") for _, item := range body.Items { if len(stringsOf(item["missing_langs"])) != 0 { t.Fatalf("reader got missing_langs on %v", item["story_id"]) } } if got := coverageLangs(body.item(1)); !reflect.DeepEqual(got, []string{"en", "de"}) { t.Fatalf("reader coverage = %v", got) } }) t.Run("The row of a story whose versions are all hidden does not exist", func(t *testing.T) { _, body := base(t).get(localizationWriterID, "?group=story&size=100") if body.item(4) != nil || body.Total != 7 || body.Counts["all"] != 7 { t.Fatalf("story 4 leaks: total=%d counts=%v", body.Total, body.Counts) } }) t.Run("Hidden versions leave no trace", func(t *testing.T) { env := fresh(t) _, with := env.get(localizationWriterID, "?group=story&size=100") withRec, _ := env.get(localizationWriterID, "?group=story&size=100") without := fresh(t) without.must(without.db.Unscoped().Where("id IN ?", []uint32{102, 104, 401, 502, 602}).Delete(&entity.Article{}).Error) withoutRec, _ := without.get(localizationWriterID, "?group=story&size=100") if withRec.Body.String() != withoutRec.Body.String() { t.Fatalf("hidden versions change the response:\n%s\n%s", withRec.Body.String(), withoutRec.Body.String()) } if len(with.Items) == 0 { t.Fatal("expected rows") } }) t.Run("Unassigned articles are their own story with an empty language", func(t *testing.T) { env := fresh(t) env.addVersion(localizationVersion{id: 30, lang: "", online: true, title: "Journal 30", updated: localizationDay(1, 9), byline: "Luis Ferrer", noindex: true, storyKey: 30}) _, body := env.get(localizationOwnerID, "?group=story&size=100") item := body.item(30) if item["lang"] != "" || len(item["coverage"].([]any)) != 1 || len(stringsOf(item["missing_langs"])) != 0 { t.Fatalf("unexpected unassigned row: %v", item) } _, missing := env.get(localizationOwnerID, "?group=story&lang=fr&tab=missing&size=100") if missing.item(30) != nil { t.Fatal("unassigned article listed in the not-in view") } _, needs := env.get(localizationOwnerID, "?group=story&tab=missing&size=100") if needs.item(30) != nil { t.Fatal("unassigned article listed as needing translation") } }) t.Run("French selected", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story&lang=fr&size=5") if body.Total != 4 { t.Fatalf("total = %d", body.Total) } expectIDs(t, "stories", body.storyIDs(), 1, 2, 3, 6) expectIDs(t, "representatives", body.articleIDs(), 102, 202, 302, 602) if len(body.item(1)["coverage"].([]any)) != 4 || len(bylineNames(body.item(1), "story_authors")) != 1 { t.Fatalf("the row must carry the whole story: %v", body.item(1)) } }) t.Run("Disabled languages can be selected", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story&lang=es") expectIDs(t, "articles", body.articleIDs(), 801) if body.Total != 1 || body.item(8)["coverage"].([]any)[0].(map[string]any)["language_enabled"] != false { t.Fatalf("unexpected result: %v", body) } }) t.Run("A language nobody has gives an empty list", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story&lang=it") if len(body.Items) != 0 || body.Total != 0 { t.Fatalf("expected nothing, got %d items", len(body.Items)) } }) t.Run("lang also narrows the flat list", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?lang=fr") if body.Total != 4 || len(body.Items) != 4 { t.Fatalf("total = %d", body.Total) } for _, item := range body.Items { if item["lang"] != "fr" { t.Fatalf("item with lang %v", item["lang"]) } } if body.Counts["all"] != 4 || body.Counts["online"] != 2 || body.Counts["draft"] != 2 { t.Fatalf("counts = %v", body.Counts) } _, filtered := base(t).get(localizationOwnerID, "?lang=fr&status=draft&q=zinc") if filtered.Counts["all"] != 4 { t.Fatalf("flat counts must ignore q and status: %v", filtered.Counts) } }) t.Run("Not in French", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story&lang=fr&tab=missing&size=5") if body.Total != 4 { t.Fatalf("total = %d", body.Total) } expectIDs(t, "stories", body.storyIDs(), 4, 5, 7, 8) expectIDs(t, "representatives", body.articleIDs(), 401, 501, 701, 801) for _, item := range body.Items { if !listHasTag(stringsOf(item["missing_langs"]), "fr") { t.Fatalf("missing_langs lacks fr: %v", item["missing_langs"]) } } }) t.Run("Needs translation, no language selected", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story&tab=missing&size=5") if body.Total != 7 { t.Fatalf("total = %d", body.Total) } expectIDs(t, "stories", body.storyIDs(), 2, 3, 4, 5, 6) }) t.Run("Missing means no readable version, not no stored version", func(t *testing.T) { env := fresh(t) env.must(env.db.Model(&entity.Article{}).Where("id = ?", 401).UpdateColumn("is_online", true).Error) env.addVersion(localizationVersion{id: 402, lang: "fr", online: false, title: "Hidden French draft", updated: localizationDay(27, 8), byline: "Marta Alves", noindex: true, storyKey: 4}) rec, body := env.get(localizationWriterID, "?group=story&lang=fr&tab=missing&size=100") if body.item(4) == nil { t.Fatal("story 4 must be listed as not in French") } if strings.Contains(rec.Body.String(), "402") || strings.Contains(rec.Body.String(), "Hidden French draft") { t.Fatal("the response tells a fr version exists") } }) t.Run("Sites that do not enable the language are excluded", func(t *testing.T) { env := fresh(t) env.addVersion(localizationVersion{id: 5001, lang: "en", online: true, title: "Atelier one", updated: localizationDay(20, 9), byline: "Luis Ferrer", noindex: true, storyKey: 50, siteKey: "atelier"}) env.addVersion(localizationVersion{id: 5002, lang: "fr", online: true, title: "Atelier un", updated: localizationDay(20, 8), byline: "Luis Ferrer", noindex: true, storyKey: 50, siteKey: "atelier"}) _, german := env.get(localizationOwnerID, "?group=story&lang=de&tab=missing&size=100") if german.item(50) != nil || german.Total == 0 { t.Fatalf("atelier story listed or journal empty: %v", german.storyIDs()) } _, spanish := env.get(localizationOwnerID, "?group=story&lang=es&tab=missing") if spanish.Total != 0 || len(spanish.Items) != 0 { t.Fatalf("es is disabled, got %d", spanish.Total) } }) t.Run("Needs translation counts only languages the caller can create", func(t *testing.T) { env := base(t) _, body := env.get(localizationReaderID, "?group=story&tab=missing") if body.Total != 0 || len(body.Items) != 0 { t.Fatalf("expected nothing, got %d", body.Total) } _, french := env.get(localizationReaderID, "?group=story&lang=fr&tab=missing&size=100") expectIDs(t, "reader stories", french.storyIDs(), 1, 5, 6, 7, 8) }) t.Run("Nothing missing", func(t *testing.T) { env := fresh(t) for story := uint32(60); story < 63; story++ { env.addVersion(localizationVersion{id: story * 100, lang: "en", online: true, title: "Press en", updated: localizationDay(5, 9), byline: "Luis Ferrer", noindex: true, storyKey: story, siteKey: "press"}) env.addVersion(localizationVersion{id: story*100 + 1, lang: "fr", online: true, title: "Press fr", updated: localizationDay(5, 8), byline: "Luis Ferrer", noindex: true, storyKey: story, siteKey: "press"}) } _, body := env.get(localizationOwnerID, "?group=story&tab=missing&site=press") if len(body.Items) != 0 || body.Total != 0 || body.Counts["missing"] != 0 || body.Counts["all"] != 3 { t.Fatalf("unexpected: total=%d counts=%v", body.Total, body.Counts) } }) t.Run("Status matches a readable version of the story", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story&status=draft") expectIDs(t, "stories", body.storyIDs(), 1, 4, 5, 6) if body.Total != 4 { t.Fatalf("total = %d", body.Total) } }) t.Run("Combined filters need a single readable version to satisfy them together", func(t *testing.T) { env := fresh(t) camille := env.authors["Camille Roche"] env.must(env.db.Model(&entity.ArticleAuthor{}).Where("article_id IN ?", []uint32{102, 104}).UpdateColumn("author_id", camille).Error) marta := env.authors["Marta Alves"] _, draftMarta := env.get(localizationOwnerID, "?group=story&status=draft&author="+itoa(marta)) if draftMarta.item(1) != nil { t.Fatal("story 1 must not be listed") } _, onlineMarta := env.get(localizationOwnerID, "?group=story&status=online&author="+itoa(marta)) if onlineMarta.item(1) == nil { t.Fatal("story 1 must be listed") } _, draftCamille := env.get(localizationOwnerID, "?group=story&status=draft&author="+itoa(camille)) if draftCamille.item(1) == nil { t.Fatal("story 1 must be listed") } _, union := env.get(localizationOwnerID, "?group=story&author="+itoa(camille)) if names := bylineNames(union.item(1), "story_authors"); !reflect.DeepEqual(names, []string{"Marta Alves", "Camille Roche"}) { t.Fatalf("story_authors = %v", names) } }) t.Run("Search matches any readable title of the story", func(t *testing.T) { env := base(t) _, body := env.get(localizationOwnerID, "?group=story&q=RENOVATION") expectIDs(t, "articles", body.articleIDs(), 101) _, french := env.get(localizationOwnerID, "?group=story&lang=fr&q=chiffrons") expectIDs(t, "french", french.articleIDs(), 102) _, english := env.get(localizationOwnerID, "?group=story&lang=en&q=chiffrons") if len(english.Items) != 0 { t.Fatal("expected nothing") } _, accent := env.get(localizationOwnerID, "?group=story&q=r%C3%A9novation") expectIDs(t, "accent", accent.articleIDs(), 101) }) t.Run("Search in the not-in view matches readable source titles", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story&lang=fr&tab=missing&q=Spring") expectIDs(t, "stories", body.storyIDs(), 4) }) t.Run("Author in language mode", func(t *testing.T) { env := base(t) _, body := env.get(localizationOwnerID, "?group=story&lang=fr&author="+itoa(env.authors["Camille Roche"])) expectIDs(t, "stories", body.storyIDs(), 2, 6) }) t.Run("Site filter", func(t *testing.T) { env := fresh(t) env.addVersion(localizationVersion{id: 7001, lang: "en", online: true, title: "Press piece", updated: localizationDay(6, 9), byline: "Luis Ferrer", noindex: true, storyKey: 70, siteKey: "press"}) _, body := env.get(localizationOwnerID, "?group=story&site=press") expectIDs(t, "stories", body.storyIDs(), 70) _, unknown := env.get(localizationOwnerID, "?group=story&site=nowhere") if len(unknown.Items) != 0 || unknown.Total != 0 { t.Fatal("unknown site must be empty") } }) t.Run("SEO filter on one version", func(t *testing.T) { env := fresh(t) env.must(env.db.Model(&entity.Article{}).Where("id = ?", 502).UpdateColumn("noindex", false).Error) _, body := env.get(localizationOwnerID, "?group=story&seo=suggestions") expectIDs(t, "articles", body.articleIDs(), 501) if body.Total != 1 || body.Counts["suggestions"] != 1 { t.Fatalf("total=%d counts=%v", body.Total, body.Counts) } }) t.Run("All filters combine with paging and stay stable", func(t *testing.T) { env := fresh(t) for story := uint32(21); story <= 24; story++ { env.addVersion(localizationVersion{id: story * 10, lang: "en", online: true, title: "Journal extra", updated: localizationDay(2, 9), byline: "Luis Ferrer", noindex: true, storyKey: story}) } seen := map[int]bool{} sizes := []int{} for page := 1; page <= 3; page++ { _, body := env.get(localizationOwnerID, "?group=story&size=5&page="+itoa(uint32(page))) if body.Total != 12 { t.Fatalf("page %d total = %d", page, body.Total) } sizes = append(sizes, len(body.Items)) for _, id := range body.storyIDs() { if seen[id] { t.Fatalf("story %d repeats", id) } seen[id] = true } } if !reflect.DeepEqual(sizes, []int{5, 5, 2}) { t.Fatalf("page sizes = %v", sizes) } }) t.Run("Sort orders stories, not the page", func(t *testing.T) { env := base(t) cases := map[string][]int{ "updated_at:desc": {1, 2, 3, 4, 5, 6, 7, 8}, "updated_at:asc": {8, 7, 6, 5, 4, 3, 2, 1}, "title:asc": {1, 2, 3, 5, 6, 7, 8, 4}, "title:desc": {4, 8, 7, 6, 5, 3, 2, 1}, "status:asc": {4, 8, 7, 6, 5, 3, 2, 1}, "created_at:asc": {8, 7, 6, 5, 4, 3, 2, 1}, } for sortValue, want := range cases { _, body := env.get(localizationOwnerID, "?group=story&size=100&sort="+sortValue) expectIDs(t, sortValue, body.storyIDs(), want...) } }) t.Run("Ties end with story_id descending", func(t *testing.T) { env := fresh(t) for _, story := range []uint32{11, 12} { env.addVersion(localizationVersion{id: story * 10, lang: "en", online: true, title: "Tied", updated: localizationDay(1, 9), byline: "Luis Ferrer", noindex: true, storyKey: story}) } _, body := env.get(localizationOwnerID, "?group=story&size=100") expectIDs(t, "tail", body.storyIDs()[8:], 12, 11) }) t.Run("Counts of the Journal dataset, all languages", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story") expectCounts(t, body.Counts, map[string]int{"all": 8, "online": 7, "draft": 4, "suggestions": 0, "missing": 7}) }) t.Run("Counts of the Journal dataset, French", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story&lang=fr") expectCounts(t, body.Counts, map[string]int{"all": 4, "online": 2, "draft": 2, "suggestions": 0, "missing": 4}) }) t.Run("Counts follow the search", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story&q=zinc") expectCounts(t, body.Counts, map[string]int{"all": 0, "online": 0, "draft": 0, "suggestions": 0, "missing": 0}) if len(body.Items) != 0 || body.Total != 0 { t.Fatal("expected nothing") } }) t.Run("Counts follow the author filter", func(t *testing.T) { env := base(t) _, body := env.get(localizationOwnerID, "?group=story&author="+itoa(env.authors["Marta Alves"])) expectIDs(t, "stories", body.storyIDs(), 1, 4, 7) if body.Total != 3 || body.Counts["all"] != 3 { t.Fatalf("total=%d counts=%v", body.Total, body.Counts) } }) t.Run("Counts do not move with status or tab", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story&lang=fr&status=draft&tab=missing") if body.Counts["all"] != 4 || body.Counts["online"] != 2 || body.Counts["draft"] != 2 { t.Fatalf("counts = %v", body.Counts) } }) t.Run("A reader's counts cover readable versions only", func(t *testing.T) { _, body := base(t).get(localizationReaderID, "?group=story") if body.Counts["draft"] != 0 || body.Counts["all"] != 7 || body.Counts["missing"] != 0 { t.Fatalf("counts = %v", body.Counts) } }) t.Run("Total versus counts", func(t *testing.T) { _, body := base(t).get(localizationOwnerID, "?group=story&tab=missing") if body.Total != body.Counts["missing"] || body.Total != 7 { t.Fatalf("total=%d counts=%v", body.Total, body.Counts) } }) t.Run("Missing membership and counts agree with the missing_langs of the rows", func(t *testing.T) { env := fresh(t) env.addVersion(localizationVersion{id: 31, lang: "", online: true, title: "Journal 31", updated: localizationDay(2, 9), byline: "Luis Ferrer", noindex: true, storyKey: 31}) env.addVersion(localizationVersion{id: 32, lang: "en", online: true, title: "Journal 32", updated: localizationDay(2, 8), byline: "Luis Ferrer", noindex: true, storyKey: 31}) env.addVersion(localizationVersion{id: 33, lang: "", online: true, title: "Journal 33", updated: localizationDay(3, 9), byline: "Luis Ferrer", noindex: true, storyKey: 33}) env.addVersion(localizationVersion{id: 34, lang: "fr", online: false, title: "Journal 34", updated: localizationDay(3, 8), byline: "Luis Ferrer", noindex: true, storyKey: 33}) env.addVersion(localizationVersion{id: 35, lang: "", online: true, title: "Journal 35", updated: localizationDay(4, 9), byline: "Luis Ferrer", noindex: true, storyKey: 35}) _, all := env.get(localizationOwnerID, "?group=story&size=100") _, missing := env.get(localizationOwnerID, "?group=story&tab=missing&size=100") wanted := []int{} for _, item := range all.Items { if len(stringsOf(item["missing_langs"])) > 0 { wanted = append(wanted, int(item["story_id"].(float64))) } } got := missing.storyIDs() sort.Ints(wanted) sort.Ints(got) expectIDs(t, "needs translation", got, wanted...) if missing.Total != len(wanted) || missing.Counts["missing"] != len(wanted) || all.Counts["missing"] != len(wanted) { t.Fatalf("total=%d counts=%v, want %d", missing.Total, missing.Counts, len(wanted)) } for _, mixed := range []int{31, 33} { if missing.item(mixed) == nil { t.Fatalf("mixed story %d must be listed as needing translation", mixed) } } if missing.item(35) != nil { t.Fatal("a story with only an unassigned version must never be listed as needing translation") } for _, tag := range []string{"fr", "de", "ar", "en"} { _, notIn := env.get(localizationOwnerID, "?group=story&lang="+tag+"&tab=missing&size=100") expected := []int{} for _, item := range all.Items { if listHasTag(stringsOf(item["missing_langs"]), tag) { expected = append(expected, int(item["story_id"].(float64))) } } listed := notIn.storyIDs() sort.Ints(expected) sort.Ints(listed) expectIDs(t, "not in "+tag, listed, expected...) if notIn.Total != len(expected) || notIn.Counts["missing"] != len(expected) { t.Fatalf("not in %s: total=%d counts=%v, want %d", tag, notIn.Total, notIn.Counts, len(expected)) } } }) t.Run("The sites are read once per request", func(t *testing.T) { env := base(t) env.siteRepo.lists = 0 _, body := env.get(localizationOwnerID, "?group=story&size=100") if len(body.Items) == 0 || env.siteRepo.lists > 1 { t.Fatalf("items=%d site lists=%d, want rows and at most 1", len(body.Items), env.siteRepo.lists) } }) t.Run("An expired token is not an editorial caller", func(t *testing.T) { env := base(t) expired, err := env.token.Encode(map[string]any{"user_id": localizationOwnerID, "token_version": 0, "exp": time.Now().Add(-time.Hour).Unix()}) if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/v1/article/?group=story", nil) req.AddCookie(&http.Cookie{Name: "jwt", Value: expired}) rec := httptest.NewRecorder() env.router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest || env.repo.calls != 0 { t.Fatalf("expected 400 without repository call, got %d calls=%d", rec.Code, env.repo.calls) } }) t.Run("The grouped envelope has the documented keys and never a null list", func(t *testing.T) { env := base(t) rec, _ := env.get(localizationOwnerID, "?group=story&lang=fr&q=zzzznomatch") var raw map[string]json.RawMessage if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { t.Fatal(err) } if len(raw) != 5 || string(raw["items"]) != "[]" { t.Fatalf("unexpected empty envelope: %s", rec.Body.String()) } var counts map[string]int if err := json.Unmarshal(raw["counts"], &counts); err != nil { t.Fatal(err) } for _, key := range []string{"all", "online", "draft", "suggestions", "missing"} { if _, ok := counts[key]; !ok { t.Fatalf("counts lack %s: %v", key, counts) } } }) t.Run("Repository failure answers 500", func(t *testing.T) { for _, method := range []string{"FindStoryPage", "CountStories", "ListVersions"} { env := base(t) env.repo.failMethod = method rec, _ := env.get(localizationOwnerID, "?group=story") if rec.Code != http.StatusInternalServerError || rec.Body.String() != failedToRetrieveArticles { t.Fatalf("%s: %d %q", method, rec.Code, rec.Body.String()) } } }) } func itoa(value uint32) string { return strconv.FormatUint(uint64(value), 10) }