package test import ( "RenewCMS/internal/domain/localization" "encoding/json" "net/http" "net/http/httptest" "reflect" "testing" ) const everyPendingSite = `{"sites":[{"key":"journal","lang":"en"},{"key":"notes","lang":"en"},{"key":"archive","lang":"pt-BR"},{"key":"gallery","lang":"en-GB"}]}` type setupReadBody struct { Sites []struct { Key string `json:"key"` Name string `json:"name"` Locale *string `json:"locale"` ArticleCount int `json:"article_count"` Suggestion *struct { Lang string `json:"lang"` Substituted bool `json:"substituted"` } `json:"suggestion"` } `json:"sites"` Available []struct { Lang string `json:"lang"` } `json:"available"` } type setupResultBody struct { Assigned int `json:"assigned"` Sites []struct { Key string `json:"key"` Lang string `json:"lang"` Assigned int `json:"assigned"` } `json:"sites"` } func decodeSetupResult(t *testing.T, rec *httptest.ResponseRecorder) setupResultBody { t.Helper() var body setupResultBody if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode %q: %v", rec.Body, err) } return body } func (e *localizationEnv) postSetup(t *testing.T, body string) *httptest.ResponseRecorder { t.Helper() return e.owner(t, http.MethodPost, "/v1/language/setup", body) } func (e *localizationEnv) unassigned() int { total := 0 for _, stored := range e.articles.articles { if stored.Lang == "" { total++ } } return total } func TestLanguageSetup(t *testing.T) { t.Run("Read what is pending", func(t *testing.T) { e := newLocalizationEnv(t) e.seedPendingSites() e.sites.seed(9, "Done", "done", false) e.seedRows(9, localization.Row{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true}) rec := e.owner(t, http.MethodGet, "/v1/language/setup", "") if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body) } var body setupReadBody if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } if keys := keysOf(decodeFields(t, rec)); !reflect.DeepEqual(keys, []string{"available", "sites"}) { t.Errorf("keys = %v", keys) } keys := []string{} counts := map[string]int{} suggestions := map[string]string{} for _, listed := range body.Sites { keys = append(keys, listed.Key) counts[listed.Key] = listed.ArticleCount if listed.Suggestion != nil { suggestions[listed.Key] = listed.Suggestion.Lang if listed.Suggestion.Substituted { t.Errorf("%s suggestion is substituted", listed.Key) } } } if !reflect.DeepEqual(keys, []string{"journal", "notes", "archive", "gallery"}) { t.Errorf("sites = %v", keys) } if counts["journal"] != 8 || counts["notes"] != 3 || counts["archive"] != 5 || counts["gallery"] != 0 { t.Errorf("counts = %v", counts) } if _, ok := suggestions["notes"]; ok || suggestions["archive"] != "pt-BR" { t.Errorf("suggestions = %v", suggestions) } if len(body.Available) == 0 { t.Errorf("available is empty") } }) t.Run("A substituted suggestion is flagged", func(t *testing.T) { e := newLocalizationEnv(t) e.sites.seed(2, "Belgium", "belgium", false) be := "fr-BE" stored := e.sites.sites[2] stored.Locale = &be e.sites.sites[2] = stored var body setupReadBody _ = json.Unmarshal(e.owner(t, http.MethodGet, "/v1/language/setup", "").Body.Bytes(), &body) if len(body.Sites) != 1 || body.Sites[0].Suggestion == nil || body.Sites[0].Suggestion.Lang != "fr" || !body.Sites[0].Suggestion.Substituted { t.Errorf("body = %+v", body) } }) t.Run("Read access", func(t *testing.T) { cases := []struct { name string user uint32 status int }{ {"an anonymous client", 0, http.StatusUnauthorized}, {"a reader", readerUserID, http.StatusForbidden}, {"an owner", apiKeyOwnerId, http.StatusOK}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { e := newLocalizationEnv(t) e.seedPendingSites() if rec := e.as(t, c.user, http.MethodGet, "/v1/language/setup", ""); rec.Code != c.status { t.Errorf("status %d", rec.Code) } }) } }) t.Run("Apply for every pending site", func(t *testing.T) { e := newLocalizationEnv(t) e.seedPendingSites() rec := e.postSetup(t, everyPendingSite) if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body) } result := decodeSetupResult(t, rec) if result.Assigned != 16 || len(result.Sites) != 4 { t.Errorf("result = %+v", result) } for _, id := range []uint32{journalSiteID, notesSiteID, archiveSiteID, gallerySiteID} { if localization.NeedsSetup(e.store.rows[id]) || len(e.store.rows[id]) != 1 || !e.store.rows[id][0].Enabled { t.Errorf("site %d rows = %+v", id, e.store.rows[id]) } } if e.unassigned() != 0 || len(e.articles.articles) != 16 { t.Errorf("unassigned %d of %d", e.unassigned(), len(e.articles.articles)) } _, fields := e.read(t, "journal") if string(fields["needs_setup"]) != "false" { t.Errorf("needs_setup = %s", fields["needs_setup"]) } if e.transactor.runs != 1 { t.Errorf("transactions = %d", e.transactor.runs) } }) t.Run("The owner may change a suggestion", func(t *testing.T) { e := newLocalizationEnv(t) e.seedPendingSites() rec := e.postSetup(t, `{"sites":[{"key":"journal","lang":"en"},{"key":"notes","lang":"en"},{"key":"archive","lang":"pt"},{"key":"gallery","lang":"en-GB"}]}`) if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body) } rows := e.store.rows[archiveSiteID] if len(rows) != 1 || rows[0].Tag != "pt" || !rows[0].IsDefault || !rows[0].Confirmed { t.Errorf("rows = %+v", rows) } for _, stored := range e.articles.articles { if stored.SiteID == archiveSiteID && stored.Lang != "pt" { t.Errorf("article lang = %q", stored.Lang) } } }) t.Run("A site with no articles still needs confirming", func(t *testing.T) { e := newLocalizationEnv(t) e.seedPendingSites() rec := e.postSetup(t, `{"sites":[{"key":"journal","lang":"en"},{"key":"notes","lang":"en"},{"key":"archive","lang":"pt-BR"}]}`) expectSiteStatusAndBody(t, rec, http.StatusUnprocessableEntity, "setup_incomplete") if e.unassigned() != 16 || !localization.NeedsSetup(e.store.rows[journalSiteID]) { t.Errorf("something was applied") } }) refused := []struct { name string body string status int code string }{ {"notes with lang english", `{"sites":[{"key":"notes","lang":"english"}]}`, 422, "invalid_language"}, {"notes with lang xx", `{"sites":[{"key":"notes","lang":"xx"}]}`, 422, "unsupported_language"}, {"notes with an empty lang", `{"sites":[{"key":"notes","lang":""}]}`, 422, "invalid_language"}, {"the same site key twice", `{"sites":[{"key":"notes","lang":"en"},{"key":"notes","lang":"fr"}]}`, 422, "duplicate_site"}, {"a site key nowhere", `{"sites":[{"key":"nowhere","lang":"en"}]}`, 404, "site_not_found"}, {"no sites key", `{}`, 400, "The request cannot be processed due to a mismatch in the format of the body."}, {"malformed JSON", `{"sites":`, 400, "The request cannot be processed due to a mismatch in the format of the body."}, } for _, c := range refused { t.Run("Rows that are refused/"+c.name, func(t *testing.T) { e := newLocalizationEnv(t) e.seedPendingSites() rec := e.postSetup(t, c.body) expectSiteStatusAndBody(t, rec, c.status, c.code) if e.unassigned() != 16 || len(e.store.rows) != 3 { t.Errorf("something changed") } }) } t.Run("Retrying is safe", func(t *testing.T) { e := newLocalizationEnv(t) e.seedPendingSites() e.postSetup(t, everyPendingSite) rowsBefore, articlesBefore := e.store.snapshot() again := e.postSetup(t, `{"sites":[{"key":"journal","lang":"fr"},{"key":"notes","lang":"en"},{"key":"archive","lang":"pt-BR"},{"key":"gallery","lang":"en-GB"}]}`) if again.Code != http.StatusOK || decodeSetupResult(t, again).Assigned != 0 { t.Fatalf("again: %d %s", again.Code, again.Body) } if rowsAfter, articlesAfter := e.store.snapshot(); !reflect.DeepEqual(rowsBefore, rowsAfter) || !reflect.DeepEqual(articlesBefore, articlesAfter) { t.Errorf("a retry changed something") } }) t.Run("Failure leaves nothing half done", func(t *testing.T) { e := newLocalizationEnv(t) e.seedPendingSites() e.store.failConfirmAt = 2 rec := e.postSetup(t, everyPendingSite) if rec.Code != http.StatusInternalServerError { t.Fatalf("status %d: %s", rec.Code, rec.Body) } if e.unassigned() != 16 || !localization.NeedsSetup(e.store.rows[journalSiteID]) { t.Errorf("a site was half done") } if later := e.postSetup(t, everyPendingSite); later.Code != http.StatusOK || decodeSetupResult(t, later).Assigned != 16 { t.Errorf("later: %d %s", later.Code, later.Body) } }) t.Run("Another setup is running", func(t *testing.T) { e := newLocalizationEnv(t) e.seedPendingSites() e.sites.busy = true rec := e.postSetup(t, everyPendingSite) expectSiteStatusAndBody(t, rec, http.StatusConflict, "setup_in_progress") if e.unassigned() != 16 { t.Errorf("something was applied") } e.sites.busy = false if later := e.postSetup(t, everyPendingSite); later.Code != http.StatusOK { t.Errorf("later: %d", later.Code) } }) t.Run("Locks are taken in a fixed order", func(t *testing.T) { e := newLocalizationEnv(t) e.seedPendingSites() e.postSetup(t, everyPendingSite) if !reflect.DeepEqual(e.sites.tryLocked, [][]uint32{{1, 2, 3, 4}}) { t.Errorf("locks = %v", e.sites.tryLocked) } if !reflect.DeepEqual(e.store.confirmedOrder, []uint32{1, 2, 3, 4}) { t.Errorf("confirm order = %v", e.store.confirmedOrder) } }) t.Run("Sites created after the migration stay pending", func(t *testing.T) { e := newLocalizationEnv(t) e.seedPendingSites() e.sites.seed(8, "Fresh", "fresh", false) var body setupReadBody _ = json.Unmarshal(e.owner(t, http.MethodGet, "/v1/language/setup", "").Body.Bytes(), &body) listed := false for _, pending := range body.Sites { listed = listed || pending.Key == "fresh" } if !listed { t.Errorf("fresh site not listed: %+v", body.Sites) } }) }