feat: site languages, setup and site URL settings
This commit is contained in:
1 parent
0806733d5c
commit
d9158025d4
106 files changed
+10184
-56
No files matched your search
@@ -3,6 +3,7 @@ package test
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"slices"
|
||||
@@ -475,6 +476,21 @@ func (m *mockArticleRepo) FindByBodyContaining(filename string) ([]article.Artic
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) AssignUnassignedLanguage(siteID uint32, lang string) (int, error) {
|
||||
if err := m.fail.trigger("AssignUnassignedLanguage"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
assigned := 0
|
||||
for id, stored := range m.articles {
|
||||
if stored.SiteID == siteID && stored.Lang == language.Unassigned {
|
||||
stored.Lang = lang
|
||||
m.articles[id] = stored
|
||||
assigned++
|
||||
}
|
||||
}
|
||||
return assigned, nil
|
||||
}
|
||||
|
||||
var _ article.Repository = &mockArticleRepo{}
|
||||
|
||||
func TestCreateArticle(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
languagehandler "RenewCMS/api/handlers/language"
|
||||
"RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/site"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newLanguageRouter(db *gorm.DB) http.Handler {
|
||||
validator.InitValidator()
|
||||
sites := persistence.NewSiteRepository(db)
|
||||
reads := persistence.NewLocalizationRepository(db)
|
||||
transactor := persistence.NewTransactor(db)
|
||||
siteLanguages := languagehandler.NewSiteLanguagesHandler(useCases.NewGetSiteLanguagesUseCase(sites, reads), useCases.NewUpdateSiteLanguagesUseCase(transactor, discardRecorder))
|
||||
setup := languagehandler.NewSetupHandler(useCases.NewGetLanguageSetupUseCase(sites, reads), useCases.NewApplyLanguageSetupUseCase(transactor, discardRecorder))
|
||||
router := chi.NewRouter()
|
||||
router.Put("/site/{key}/languages", siteLanguages.Update)
|
||||
router.Post("/language/setup", setup.Apply)
|
||||
return router
|
||||
}
|
||||
|
||||
func sendTo(router http.Handler, method string, path string, body string) int {
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(method, path, strings.NewReader(body)))
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
func runTogether(jobs ...func()) {
|
||||
var group sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
for _, job := range jobs {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
<-start
|
||||
job()
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
group.Wait()
|
||||
}
|
||||
|
||||
func TestLanguageConcurrencyOnSQLite(t *testing.T) {
|
||||
t.Run("PUT languages racing a site creation answers 200 or 409, never 500, and keeps one default", func(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
journal := seedLocalizedSite(t, db, "journal", []entity.SiteLanguage{{Lang: "en", IsDefault: true, Enabled: true, Confirmed: true}}, map[string][]entity.Article{"en": {{Title: "a", IsOnline: true}}})
|
||||
router := newLanguageRouter(db)
|
||||
read := useCases.NewGetSiteLanguagesUseCase(persistence.NewSiteRepository(db), persistence.NewLocalizationRepository(db))
|
||||
sites := persistence.NewSiteRepository(db)
|
||||
extra := []string{"fr", "de", "es", "ar", "pt-BR", "en-GB"}
|
||||
for round, tag := range extra {
|
||||
view, err := read.Get("journal")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := languagesBody(view.Revision, "en", entryJSON("en", true, "null"), entryJSON(tag, true, "null"))
|
||||
status := 0
|
||||
var createErr error
|
||||
runTogether(
|
||||
func() { status = sendTo(router, http.MethodPut, "/site/journal/languages", body) },
|
||||
func() {
|
||||
key := fmt.Sprintf("race-%d", round)
|
||||
_, createErr = sites.Create(site.Site{Name: key, Key: key, BaseURL: "https://" + key + ".example.com", DefaultRobots: "index"})
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK && status != http.StatusConflict {
|
||||
t.Errorf("round %d: PUT status %d", round, status)
|
||||
}
|
||||
if createErr != nil {
|
||||
t.Logf("round %d: create refused: %v", round, createErr)
|
||||
}
|
||||
defaults := 0
|
||||
for _, row := range storedLanguageRows(t, db, journal.ID) {
|
||||
if row.IsDefault {
|
||||
defaults++
|
||||
}
|
||||
}
|
||||
if defaults != 1 {
|
||||
t.Errorf("round %d: %d defaults", round, defaults)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Two simultaneous setups answer 200 or 409, at least one 200, and assign every article exactly once", func(t *testing.T) {
|
||||
for round := 0; round < 5; round++ {
|
||||
db := openContractSQLite(t)
|
||||
first := seedLocalizedSite(t, db, "journal", nil, map[string][]entity.Article{"": {{Title: "a"}, {Title: "deleted"}}})
|
||||
second := seedLocalizedSite(t, db, "notes", nil, map[string][]entity.Article{"": {{Title: "b"}, {Title: "c"}}})
|
||||
router := newLanguageRouter(db)
|
||||
body := `{"sites":[{"key":"default","lang":"en"},{"key":"journal","lang":"en"},{"key":"notes","lang":"fr"}]}`
|
||||
statuses := make([]int, 2)
|
||||
runTogether(
|
||||
func() { statuses[0] = sendTo(router, http.MethodPost, "/language/setup", body) },
|
||||
func() { statuses[1] = sendTo(router, http.MethodPost, "/language/setup", body) },
|
||||
)
|
||||
accepted := 0
|
||||
for _, status := range statuses {
|
||||
switch status {
|
||||
case http.StatusOK:
|
||||
accepted++
|
||||
case http.StatusConflict:
|
||||
default:
|
||||
t.Errorf("round %d: status %d", round, status)
|
||||
}
|
||||
}
|
||||
if accepted == 0 {
|
||||
t.Errorf("round %d: no setup succeeded: %v", round, statuses)
|
||||
}
|
||||
if accepted == 2 {
|
||||
t.Logf("round %d: the setups were serialized", round)
|
||||
}
|
||||
if sendTo(router, http.MethodPost, "/language/setup", body) != http.StatusOK {
|
||||
t.Errorf("round %d: a later retry did not succeed", round)
|
||||
}
|
||||
var unassigned int64
|
||||
db.Unscoped().Model(&entity.Article{}).Where("lang = ?", "").Count(&unassigned)
|
||||
if unassigned != 0 {
|
||||
t.Errorf("round %d: %d articles unassigned", round, unassigned)
|
||||
}
|
||||
for siteID, tag := range map[uint32]string{first.ID: "en", second.ID: "fr"} {
|
||||
rows := storedLanguageRows(t, db, siteID)
|
||||
if len(rows) != 1 || rows[0].Lang != tag || !rows[0].IsDefault || !rows[0].Confirmed {
|
||||
t.Errorf("round %d: rows of site %d = %+v", round, siteID, rows)
|
||||
}
|
||||
}
|
||||
var mislabeled int64
|
||||
db.Unscoped().Model(&entity.Article{}).Where("site_id = ? AND lang <> ?", second.ID, "fr").Count(&mislabeled)
|
||||
if mislabeled != 0 {
|
||||
t.Errorf("round %d: %d articles with the wrong language", round, mislabeled)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func (e *localizationEnv) putJournal(t *testing.T, revision string, omit ...string) int {
|
||||
t.Helper()
|
||||
body := languagesBody(revision, "en", journalEntries(nil, omit...)...)
|
||||
return e.owner(t, http.MethodPut, "/v1/site/journal/languages", body).Code
|
||||
}
|
||||
|
||||
func decodeInto(t *testing.T, rec *httptest.ResponseRecorder, target any) bool {
|
||||
t.Helper()
|
||||
return json.Unmarshal(rec.Body.Bytes(), target) == nil
|
||||
}
|
||||
|
||||
func TestLanguageLockContention(t *testing.T) {
|
||||
t.Run("PUT answers 409 languages_changed when the site row cannot be locked", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
revision := e.revision(t, "journal")
|
||||
rowsBefore, _ := e.store.snapshot()
|
||||
e.sites.busy = true
|
||||
|
||||
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, "en", journalEntries(nil, "en-US")...))
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusConflict, "languages_changed")
|
||||
if rowsAfter, _ := e.store.snapshot(); !reflect.DeepEqual(rowsBefore, rowsAfter) || e.store.replaceCalls != 0 {
|
||||
t.Errorf("something was written")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A contention failure is recorded as a failed activity", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
revision := e.revision(t, "journal")
|
||||
e.sites.busy = true
|
||||
|
||||
e.putJournal(t, revision, "en-US")
|
||||
|
||||
assertProducerFailure(t, e.single(t), "The languages changed since they were read.")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLanguageSetupLocksUntilStable(t *testing.T) {
|
||||
t.Run("A site created between the first read and the lock is locked too and has to be listed", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedPendingSites()
|
||||
e.sites.afterTryLock = func() { e.seedSite(8, "Fresh", "fresh", nil, nil, 2) }
|
||||
|
||||
rec := e.postSetup(t, everyPendingSite)
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusUnprocessableEntity, "setup_incomplete")
|
||||
if !reflect.DeepEqual(e.sites.tryLocked, [][]uint32{{1, 2, 3, 4}, {8}}) {
|
||||
t.Errorf("locks = %v", e.sites.tryLocked)
|
||||
}
|
||||
if len(e.store.confirmedOrder) != 0 {
|
||||
t.Errorf("confirmed = %v", e.store.confirmedOrder)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLanguageSetupSkippedChoices(t *testing.T) {
|
||||
t.Run("Choices for sites that already have a language are reported, not dropped", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
e.seedSite(notesSiteID, "Notes", "notes", nil, nil, 3)
|
||||
|
||||
rec := e.postSetup(t, `{"sites":[{"key":"notes","lang":"fr"},{"key":"journal","lang":"fr"}]}`)
|
||||
|
||||
var body struct {
|
||||
Assigned int `json:"assigned"`
|
||||
Skipped []string `json:"skipped"`
|
||||
}
|
||||
if rec.Code != http.StatusOK || !decodeInto(t, rec, &body) {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if body.Assigned != 3 || !reflect.DeepEqual(body.Skipped, []string{"journal"}) {
|
||||
t.Errorf("body = %+v", body)
|
||||
}
|
||||
if view := e.revision(t, "journal"); view == "" || e.store.rows[journalSiteID][0].Tag != "en" {
|
||||
t.Errorf("journal changed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A setup with nothing skipped lists an empty skipped array", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedPendingSites()
|
||||
|
||||
rec := e.postSetup(t, everyPendingSite)
|
||||
|
||||
if fields := decodeFields(t, rec); string(fields["skipped"]) != "[]" {
|
||||
t.Errorf("skipped = %s", fields["skipped"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLanguageInUseCountsSoftDeletedVersions(t *testing.T) {
|
||||
t.Run("A language with only soft-deleted versions cannot be removed", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
e.store.trashed[journalSiteID] = map[string]int{"en-US": 1}
|
||||
revision := e.revision(t, "journal")
|
||||
|
||||
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, "en", journalEntries(nil, "en-US")...))
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusConflict, "language_in_use")
|
||||
})
|
||||
|
||||
t.Run("Soft-deleted versions are not shown in the version count", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
e.store.trashed[journalSiteID] = map[string]int{"en-US": 1}
|
||||
|
||||
view := decodeLanguagesView(t, e.owner(t, http.MethodGet, "/v1/site/journal/languages", ""))
|
||||
|
||||
for _, entry := range view.Languages {
|
||||
if entry.Lang == "en-US" && entry.VersionCount != 0 {
|
||||
t.Errorf("version_count = %d", entry.VersionCount)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLanguageActivity(t *testing.T) {
|
||||
t.Run("A changed language set is recorded once", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
revision := e.revision(t, "journal")
|
||||
|
||||
if status := e.putJournal(t, revision, "en-US"); status != http.StatusOK {
|
||||
t.Fatalf("status %d", status)
|
||||
}
|
||||
|
||||
event := e.single(t)
|
||||
assertProducerEvent(t, event, "site.languages_edit", audit.CategorySites, audit.OutcomeSuccess)
|
||||
assertProducerTarget(t, event, "site", "1", "Journal")
|
||||
assertProducerOwnerOrigin(t, event)
|
||||
if len(event.Changes) != 1 || event.Changes[0].Field != audit.FieldLanguages {
|
||||
t.Errorf("changes = %s", changesJSON(t, event))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A default change is recorded as its own field", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
revision := e.revision(t, "journal")
|
||||
|
||||
body := languagesBody(revision, "fr", journalEntries(nil)...)
|
||||
if rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", body); rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
event := e.single(t)
|
||||
if len(event.Changes) < 1 || event.Changes[0].Field != audit.FieldDefaultLanguage || *event.Changes[0].Before != "en" || *event.Changes[0].After != "fr" {
|
||||
t.Errorf("changes = %s", changesJSON(t, event))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A PUT that changes nothing records nothing", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
|
||||
if status := e.putJournal(t, e.revision(t, "journal")); status != http.StatusOK {
|
||||
t.Fatalf("status %d", status)
|
||||
}
|
||||
|
||||
e.noEvent(t)
|
||||
})
|
||||
|
||||
t.Run("A refused PUT is recorded as failed", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
|
||||
e.putJournal(t, "stale")
|
||||
|
||||
assertProducerFailure(t, e.single(t), "The languages changed since they were read.")
|
||||
})
|
||||
|
||||
t.Run("A read records nothing", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
|
||||
e.read(t, "journal")
|
||||
|
||||
e.noEvent(t)
|
||||
})
|
||||
|
||||
t.Run("A setup is recorded once per confirmed site", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedPendingSites()
|
||||
|
||||
if rec := e.postSetup(t, everyPendingSite); rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
events := e.events(t)
|
||||
if len(events) != 4 {
|
||||
t.Fatalf("events = %d", len(events))
|
||||
}
|
||||
for _, event := range events {
|
||||
assertProducerEvent(t, event, "site.language_setup", audit.CategorySites, audit.OutcomeSuccess)
|
||||
if len(event.Changes) != 1 || event.Changes[0].Field != audit.FieldDefaultLanguage {
|
||||
t.Errorf("changes = %s", changesJSON(t, event))
|
||||
}
|
||||
}
|
||||
if events[0].Params[audit.ParamAssigned] == nil {
|
||||
t.Errorf("params = %v", events[0].Params)
|
||||
}
|
||||
wantLang := map[string]string{"Journal": "en", "Notes": "en", "Archive": "pt-BR", "Gallery": "en-GB"}
|
||||
wantAssigned := map[string]int{"Journal": 8, "Notes": 3, "Archive": 5, "Gallery": 0}
|
||||
for _, event := range events {
|
||||
lang, ok := wantLang[event.Target.Name]
|
||||
if !ok {
|
||||
t.Fatalf("target = %q", event.Target.Name)
|
||||
}
|
||||
if after := event.Changes[0].After; after == nil || *after != lang {
|
||||
t.Errorf("%s change = %s", event.Target.Name, changesJSON(t, event))
|
||||
}
|
||||
if got := fmt.Sprint(event.Params[audit.ParamAssigned]); got != fmt.Sprint(wantAssigned[event.Target.Name]) {
|
||||
t.Errorf("%s assigned = %s", event.Target.Name, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A refused setup is recorded once as failed", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedPendingSites()
|
||||
|
||||
e.postSetup(t, `{"sites":[{"key":"journal","lang":"en"}]}`)
|
||||
|
||||
assertProducerFailure(t, e.single(t), "Every site that needs a language must be listed.")
|
||||
})
|
||||
|
||||
t.Run("A setup refused by contention is recorded as failed", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedPendingSites()
|
||||
e.sites.busy = true
|
||||
|
||||
e.postSetup(t, everyPendingSite)
|
||||
|
||||
assertProducerFailure(t, e.single(t), "Another change is running on this site. Please try again.")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const localizationFailureCallback = "test:fail-language-write"
|
||||
|
||||
var errInjectedLanguageWrite = errors.New("injected language write failure")
|
||||
|
||||
func failLanguageCreate(t *testing.T, db *gorm.DB, lang string) {
|
||||
t.Helper()
|
||||
err := db.Callback().Create().Before("gorm:create").Register(localizationFailureCallback, func(tx *gorm.DB) {
|
||||
if row, ok := tx.Statement.Dest.(*entity.SiteLanguage); ok && row.Lang == lang {
|
||||
_ = tx.AddError(errInjectedLanguageWrite)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Callback().Create().Remove(localizationFailureCallback) })
|
||||
}
|
||||
|
||||
func seedLocalizedSite(t *testing.T, db *gorm.DB, key string, rows []entity.SiteLanguage, articles map[string][]entity.Article) site.Site {
|
||||
t.Helper()
|
||||
created := mustCreateSite(t, persistence.NewSiteRepository(db), key, key, false)
|
||||
for _, row := range rows {
|
||||
row.SiteID = created.ID
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for lang, group := range articles {
|
||||
for _, stored := range group {
|
||||
stored.SiteID = created.ID
|
||||
stored.Lang = lang
|
||||
stored.StoryID = fixtureStory()
|
||||
stored.Slug = uniqueTestSlug()
|
||||
if err := db.Create(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Title == "deleted" {
|
||||
if err := db.Delete(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func storedLanguageRows(t *testing.T, db *gorm.DB, siteID uint32) []entity.SiteLanguage {
|
||||
t.Helper()
|
||||
var rows []entity.SiteLanguage
|
||||
if err := db.Where("site_id = ?", siteID).Order("lang ASC").Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := range rows {
|
||||
rows[index].CreatedAt, rows[index].UpdatedAt = rows[index].CreatedAt.UTC(), rows[index].UpdatedAt.UTC()
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func TestLocalizationPersistence(t *testing.T) {
|
||||
backends := []contractBackend{
|
||||
{name: "sqlite", open: openContractSQLite},
|
||||
{name: "mysql", open: openContractMySQL},
|
||||
{name: "postgres", open: openContractPostgres},
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
run func(t *testing.T, db *gorm.DB)
|
||||
}{
|
||||
{"Persistence additions are safe to migrate", func(t *testing.T, db *gorm.DB) {
|
||||
migrator := db.Migrator()
|
||||
created := seedLocalizedSite(t, db, "migrating", []entity.SiteLanguage{{Lang: "en", IsDefault: true, Enabled: true, Confirmed: true}}, nil)
|
||||
before := storedLanguageRows(t, db, created.ID)
|
||||
for _, drop := range []struct {
|
||||
model any
|
||||
column string
|
||||
}{{&entity.SiteLanguage{}, "social_locale"}, {&entity.Site{}, "public_url_pattern"}} {
|
||||
if err := migrator.DropColumn(drop.model, drop.column); err != nil {
|
||||
t.Fatalf("drop %s: %v", drop.column, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !migrator.HasColumn(&entity.SiteLanguage{}, "social_locale") || !migrator.HasColumn(&entity.Site{}, "public_url_pattern") {
|
||||
t.Fatalf("columns missing")
|
||||
}
|
||||
if pool, err := db.DB(); err == nil {
|
||||
pool.SetMaxIdleConns(0)
|
||||
}
|
||||
after := storedLanguageRows(t, db, created.ID)
|
||||
if len(after) != 1 || after[0].SocialLocale != nil || after[0].Lang != before[0].Lang {
|
||||
t.Errorf("rows = %+v", after)
|
||||
}
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("second migrate: %v", err)
|
||||
}
|
||||
if again := storedLanguageRows(t, db, created.ID); !reflect.DeepEqual(again, after) {
|
||||
t.Errorf("second migrate changed rows")
|
||||
}
|
||||
}},
|
||||
{"The public url pattern is stored and cleared", func(t *testing.T, db *gorm.DB) {
|
||||
sites := persistence.NewSiteRepository(db)
|
||||
created := mustCreateSite(t, sites, "Pattern", "pattern", false)
|
||||
created.PublicURLPattern = localeOf("https://pattern.example.com/{lang}/{slug}")
|
||||
saved, err := sites.Update(created)
|
||||
if err != nil || !sameText(saved.PublicURLPattern, created.PublicURLPattern) {
|
||||
t.Fatalf("saved = %+v, %v", saved, err)
|
||||
}
|
||||
created.PublicURLPattern = nil
|
||||
cleared, err := sites.Update(created)
|
||||
if err != nil || cleared.PublicURLPattern != nil {
|
||||
t.Fatalf("cleared = %+v, %v", cleared, err)
|
||||
}
|
||||
}},
|
||||
{"PUT replaces the set atomically, shows live versions and protects soft-deleted ones", func(t *testing.T, db *gorm.DB) {
|
||||
fr := "fr_FR"
|
||||
created := seedLocalizedSite(t, db, "journal", []entity.SiteLanguage{
|
||||
{Lang: "en", IsDefault: true, Enabled: true, Confirmed: true},
|
||||
{Lang: "fr", Enabled: true, SocialLocale: &fr},
|
||||
{Lang: "de", Enabled: true},
|
||||
{Lang: "ar", Enabled: true},
|
||||
{Lang: "es"},
|
||||
}, map[string][]entity.Article{
|
||||
"en": {{Title: "a", IsOnline: true}},
|
||||
"ar": {{Title: "draft"}},
|
||||
"es": {{Title: "deleted"}},
|
||||
})
|
||||
update := useCases.NewUpdateSiteLanguagesUseCase(persistence.NewTransactor(db), discardRecorder)
|
||||
read := useCases.NewGetSiteLanguagesUseCase(persistence.NewSiteRepository(db), persistence.NewLocalizationRepository(db))
|
||||
view, err := read.Get("journal")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := func(omit ...string) []useCases.SiteLanguageEntry {
|
||||
all := []useCases.SiteLanguageEntry{{Lang: "en", Enabled: true}, {Lang: "fr", Enabled: true, SocialLocale: &fr}, {Lang: "de", Enabled: false}, {Lang: "ar", Enabled: true}, {Lang: "es"}}
|
||||
kept := []useCases.SiteLanguageEntry{}
|
||||
for _, entry := range all {
|
||||
if !containsTag(omit, entry.Lang) {
|
||||
kept = append(kept, entry)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
command := func(def string, list []useCases.SiteLanguageEntry) useCases.UpdateSiteLanguagesCommand {
|
||||
return useCases.UpdateSiteLanguagesCommand{Key: "journal", Revision: view.Revision, Default: def, Languages: list}
|
||||
}
|
||||
|
||||
if _, err := update.Update(context.Background(), command("en", entries("ar"))); !errors.Is(err, localization.ErrLanguageInUse) {
|
||||
t.Fatalf("draft language: %v", err)
|
||||
}
|
||||
if _, err := update.Update(context.Background(), command("en", entries("es"))); !errors.Is(err, localization.ErrLanguageInUse) {
|
||||
t.Fatalf("soft-deleted language: %v", err)
|
||||
}
|
||||
for _, entry := range view.Languages {
|
||||
if entry.Lang == "es" && entry.VersionCount != 0 {
|
||||
t.Errorf("soft-deleted version shown: %+v", entry)
|
||||
}
|
||||
}
|
||||
before := storedLanguageRows(t, db, created.ID)
|
||||
failLanguageCreate(t, db, "pt")
|
||||
withNew := append(entries(), useCases.SiteLanguageEntry{Lang: "pt", Enabled: true})
|
||||
if _, err := update.Update(context.Background(), command("fr", withNew)); !errors.Is(err, errInjectedLanguageWrite) {
|
||||
t.Fatalf("injected failure: %v", err)
|
||||
}
|
||||
if after := storedLanguageRows(t, db, created.ID); !reflect.DeepEqual(before, after) {
|
||||
t.Errorf("rows changed after a failed write")
|
||||
}
|
||||
|
||||
done, err := update.Update(context.Background(), command("fr", entries("de")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if done.Default == nil || *done.Default != "fr" || len(done.Languages) != 4 {
|
||||
t.Errorf("view = %+v", done)
|
||||
}
|
||||
if _, err := update.Update(context.Background(), command("en", entries())); !errors.Is(err, localization.ErrLanguagesChanged) {
|
||||
t.Errorf("stale: %v", err)
|
||||
}
|
||||
}},
|
||||
{"Setup assigns every article including soft-deleted ones in one transaction", func(t *testing.T, db *gorm.DB) {
|
||||
first := seedLocalizedSite(t, db, "journal", []entity.SiteLanguage{{Lang: "en", IsDefault: true, Enabled: true}}, map[string][]entity.Article{"": {{Title: "a"}, {Title: "deleted"}}})
|
||||
second := seedLocalizedSite(t, db, "notes", nil, map[string][]entity.Article{"": {{Title: "b"}}})
|
||||
apply := useCases.NewApplyLanguageSetupUseCase(persistence.NewTransactor(db), discardRecorder)
|
||||
choices := []localization.SetupChoice{{Key: "default", Lang: "en"}, {Key: "journal", Lang: "en"}, {Key: "notes", Lang: "fr"}}
|
||||
|
||||
failLanguageCreate(t, db, "fr")
|
||||
if _, err := apply.Apply(context.Background(), choices); !errors.Is(err, errInjectedLanguageWrite) {
|
||||
t.Fatalf("injected failure: %v", err)
|
||||
}
|
||||
var unassigned int64
|
||||
db.Unscoped().Model(&entity.Article{}).Where("lang = ?", "").Count(&unassigned)
|
||||
if unassigned != 3 || len(storedLanguageRows(t, db, first.ID)) != 1 || storedLanguageRows(t, db, first.ID)[0].Confirmed {
|
||||
t.Fatalf("failed setup left a trace: %d unassigned", unassigned)
|
||||
}
|
||||
_ = db.Callback().Create().Remove(localizationFailureCallback)
|
||||
|
||||
result, err := apply.Apply(context.Background(), choices)
|
||||
if err != nil || result.Assigned != 3 {
|
||||
t.Fatalf("result = %+v, %v", result, err)
|
||||
}
|
||||
rows := storedLanguageRows(t, db, second.ID)
|
||||
if len(rows) != 1 || rows[0].Lang != "fr" || !rows[0].IsDefault || !rows[0].Confirmed {
|
||||
t.Errorf("rows = %+v", rows)
|
||||
}
|
||||
db.Unscoped().Model(&entity.Article{}).Where("lang = ?", "").Count(&unassigned)
|
||||
if unassigned != 0 {
|
||||
t.Errorf("%d articles unassigned", unassigned)
|
||||
}
|
||||
again, err := apply.Apply(context.Background(), choices)
|
||||
if err != nil || again.Assigned != 0 || len(again.Sites) != 0 {
|
||||
t.Errorf("retry = %+v, %v", again, err)
|
||||
}
|
||||
}},
|
||||
}
|
||||
for _, backend := range backends {
|
||||
for _, c := range cases {
|
||||
t.Run(backend.name+"/"+c.name, func(t *testing.T) {
|
||||
c.run(t, backend.open(t))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsTag(tags []string, tag string) bool {
|
||||
for _, candidate := range tags {
|
||||
if candidate == tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
journalSiteID = 1
|
||||
notesSiteID = 2
|
||||
archiveSiteID = 3
|
||||
gallerySiteID = 4
|
||||
readerUserID = 7
|
||||
)
|
||||
|
||||
var errInjectedConfirm = errors.New("injected confirm failure")
|
||||
|
||||
type fakeLocalizationStore struct {
|
||||
articles *mockArticleRepo
|
||||
rows map[uint32][]localization.Row
|
||||
trashed map[uint32]map[string]int
|
||||
failConfirmAt int
|
||||
confirmCalls int
|
||||
replaceCalls int
|
||||
nextArticleID uint32
|
||||
confirmedOrder []uint32
|
||||
}
|
||||
|
||||
var _ localization.Repository = &fakeLocalizationStore{}
|
||||
|
||||
func newFakeLocalizationStore(articles *mockArticleRepo) *fakeLocalizationStore {
|
||||
return &fakeLocalizationStore{articles: articles, rows: map[uint32][]localization.Row{}, trashed: map[uint32]map[string]int{}, nextArticleID: 100}
|
||||
}
|
||||
|
||||
func (f *fakeLocalizationStore) Rows(siteID uint32) ([]localization.Row, error) {
|
||||
return slices.Clone(f.rows[siteID]), nil
|
||||
}
|
||||
|
||||
func (f *fakeLocalizationStore) ConfirmedSites() (map[uint32]bool, error) {
|
||||
confirmed := map[uint32]bool{}
|
||||
for siteID, rows := range f.rows {
|
||||
if !localization.NeedsSetup(rows) {
|
||||
confirmed[siteID] = true
|
||||
}
|
||||
}
|
||||
return confirmed, nil
|
||||
}
|
||||
|
||||
func (f *fakeLocalizationStore) VersionCounts(siteID uint32) (map[string]localization.VersionCount, error) {
|
||||
counts := map[string]localization.VersionCount{}
|
||||
for _, stored := range f.articles.articles {
|
||||
if stored.SiteID != siteID {
|
||||
continue
|
||||
}
|
||||
count := counts[stored.Lang]
|
||||
count.Total++
|
||||
if stored.IsOnline {
|
||||
count.Published++
|
||||
}
|
||||
counts[stored.Lang] = count
|
||||
}
|
||||
for lang, trashed := range f.trashed[siteID] {
|
||||
count := counts[lang]
|
||||
count.Trashed = trashed
|
||||
counts[lang] = count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (f *fakeLocalizationStore) Replace(siteID uint32, rows []localization.Row) error {
|
||||
f.replaceCalls++
|
||||
f.rows[siteID] = slices.Clone(rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeLocalizationStore) ConfirmSetup(siteID uint32, tag string) error {
|
||||
f.confirmCalls++
|
||||
if f.failConfirmAt == f.confirmCalls {
|
||||
return errInjectedConfirm
|
||||
}
|
||||
f.confirmedOrder = append(f.confirmedOrder, siteID)
|
||||
f.rows[siteID] = []localization.Row{{Tag: tag, IsDefault: true, Enabled: true, Confirmed: true}}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeLocalizationStore) snapshot() (map[uint32][]localization.Row, map[uint32]article.Article) {
|
||||
rows := make(map[uint32][]localization.Row, len(f.rows))
|
||||
for siteID, stored := range f.rows {
|
||||
rows[siteID] = slices.Clone(stored)
|
||||
}
|
||||
return rows, maps.Clone(f.articles.articles)
|
||||
}
|
||||
|
||||
type rollbackLocalizationTransactor struct {
|
||||
store *fakeLocalizationStore
|
||||
sites site.Repository
|
||||
runs int
|
||||
}
|
||||
|
||||
var _ transaction.Transactor = &rollbackLocalizationTransactor{}
|
||||
|
||||
func (r *rollbackLocalizationTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error {
|
||||
r.runs++
|
||||
rows, articles := r.store.snapshot()
|
||||
err := work(transaction.Repositories{Articles: r.store.articles, Sites: r.sites, Localization: r.store})
|
||||
if err != nil {
|
||||
r.store.rows = rows
|
||||
r.store.articles.articles = articles
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type patternSiteRepo struct {
|
||||
*mockSiteRepo
|
||||
}
|
||||
|
||||
func (p patternSiteRepo) Update(s site.Site) (site.Site, error) {
|
||||
saved, err := p.mockSiteRepo.Update(s)
|
||||
if err != nil {
|
||||
return saved, err
|
||||
}
|
||||
stored := p.mockSiteRepo.sites[s.ID]
|
||||
stored.PublicURLPattern = s.PublicURLPattern
|
||||
p.mockSiteRepo.sites[s.ID] = stored
|
||||
return p.mockSiteRepo.withCount(stored), nil
|
||||
}
|
||||
|
||||
type localizationEnv struct {
|
||||
*siteEnv
|
||||
store *fakeLocalizationStore
|
||||
transactor *rollbackLocalizationTransactor
|
||||
nextID uint32
|
||||
}
|
||||
|
||||
func newLocalizationEnv(t *testing.T) *localizationEnv {
|
||||
t.Helper()
|
||||
e := newSiteEnv(t)
|
||||
store := newFakeLocalizationStore(e.articles)
|
||||
sites := patternSiteRepo{e.sites}
|
||||
transactor := &rollbackLocalizationTransactor{store: store, sites: sites}
|
||||
c := e.container
|
||||
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
|
||||
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, e.images, e.audit.recorder)
|
||||
c.GetSiteLanguagesUseCase = useCases.NewGetSiteLanguagesUseCase(sites, store)
|
||||
c.UpdateSiteLanguagesUseCase = useCases.NewUpdateSiteLanguagesUseCase(transactor, e.audit.recorder)
|
||||
c.GetLanguageSetupUseCase = useCases.NewGetLanguageSetupUseCase(sites, store)
|
||||
c.ApplyLanguageSetupUseCase = useCases.NewApplyLanguageSetupUseCase(transactor, e.audit.recorder)
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/v1", api.InitBackendRoutes(c, e.settings))
|
||||
e.router = router
|
||||
e.addUser(readerUserID, writerRoleID, true)
|
||||
return &localizationEnv{siteEnv: e, store: store, transactor: transactor, nextID: 1}
|
||||
}
|
||||
|
||||
func (e *localizationEnv) addArticles(siteID uint32, lang string, total int, published int) {
|
||||
for index := 0; index < total; index++ {
|
||||
e.store.nextArticleID++
|
||||
e.articles.articles[e.store.nextArticleID] = article.Article{ID: e.store.nextArticleID, Title: "Article " + lang, SiteID: siteID, Lang: lang, IsOnline: index < published}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *localizationEnv) seedRows(siteID uint32, rows ...localization.Row) {
|
||||
e.store.rows[siteID] = rows
|
||||
}
|
||||
|
||||
func (e *localizationEnv) seedJournal() {
|
||||
e.sites.seed(journalSiteID, "Journal", "journal", true)
|
||||
english := "en"
|
||||
site := e.sites.sites[journalSiteID]
|
||||
site.Locale = &english
|
||||
e.sites.sites[journalSiteID] = site
|
||||
frenchSocial := "fr_FR"
|
||||
e.seedRows(journalSiteID,
|
||||
localization.Row{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true},
|
||||
localization.Row{Tag: "fr", Enabled: true, Confirmed: true, SocialLocale: &frenchSocial},
|
||||
localization.Row{Tag: "de", Enabled: true},
|
||||
localization.Row{Tag: "ar", Enabled: true},
|
||||
localization.Row{Tag: "es"},
|
||||
localization.Row{Tag: "en-US"},
|
||||
)
|
||||
e.addArticles(journalSiteID, "en", 7, 6)
|
||||
e.addArticles(journalSiteID, "fr", 4, 2)
|
||||
e.addArticles(journalSiteID, "de", 4, 3)
|
||||
e.addArticles(journalSiteID, "ar", 1, 0)
|
||||
e.addArticles(journalSiteID, "es", 1, 0)
|
||||
}
|
||||
|
||||
func (e *localizationEnv) seedPendingSites() {
|
||||
e.seedJournalPending()
|
||||
e.seedSite(notesSiteID, "Notes", "notes", nil, nil, 3)
|
||||
pt := "pt-BR"
|
||||
e.seedSite(archiveSiteID, "Archive", "archive", &pt, &localization.Row{Tag: "pt-BR", IsDefault: true, Enabled: true}, 5)
|
||||
gb := "en-GB"
|
||||
e.seedSite(gallerySiteID, "Gallery", "gallery", &gb, &localization.Row{Tag: "en-GB", IsDefault: true, Enabled: true}, 0)
|
||||
}
|
||||
|
||||
func (e *localizationEnv) seedJournalPending() {
|
||||
en := "en"
|
||||
e.seedSite(journalSiteID, "Journal", "journal", &en, &localization.Row{Tag: "en", IsDefault: true, Enabled: true}, 8)
|
||||
}
|
||||
|
||||
func (e *localizationEnv) seedSite(id uint32, name string, key string, locale *string, row *localization.Row, articles int) {
|
||||
e.sites.seed(id, name, key, id == journalSiteID)
|
||||
stored := e.sites.sites[id]
|
||||
stored.Locale = locale
|
||||
e.sites.sites[id] = stored
|
||||
if row != nil {
|
||||
e.seedRows(id, *row)
|
||||
}
|
||||
e.addArticles(id, language.Unassigned, articles, 0)
|
||||
}
|
||||
|
||||
func (e *localizationEnv) read(t *testing.T, key string) (int, map[string]json.RawMessage) {
|
||||
t.Helper()
|
||||
rec := e.owner(t, http.MethodGet, "/v1/site/"+key+"/languages", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
return rec.Code, nil
|
||||
}
|
||||
return rec.Code, decodeFields(t, rec)
|
||||
}
|
||||
|
||||
func decodeFields(t *testing.T, rec *httptest.ResponseRecorder) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &fields); err != nil {
|
||||
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func (e *localizationEnv) revision(t *testing.T, key string) string {
|
||||
t.Helper()
|
||||
_, fields := e.read(t, key)
|
||||
var revision string
|
||||
if err := json.Unmarshal(fields["revision"], &revision); err != nil {
|
||||
t.Fatalf("revision: %v", err)
|
||||
}
|
||||
return revision
|
||||
}
|
||||
|
||||
type languagesView struct {
|
||||
Revision string `json:"revision"`
|
||||
Default *string `json:"default"`
|
||||
NeedsSetup bool `json:"needs_setup"`
|
||||
Languages []struct {
|
||||
Lang string `json:"lang"`
|
||||
Enabled bool `json:"enabled"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
VersionCount int `json:"version_count"`
|
||||
PublishedCount int `json:"published_count"`
|
||||
SocialLocale *string `json:"social_locale"`
|
||||
State string `json:"social_locale_state"`
|
||||
} `json:"languages"`
|
||||
Available []struct {
|
||||
Lang string `json:"lang"`
|
||||
Substitute *string `json:"substitute"`
|
||||
} `json:"available"`
|
||||
}
|
||||
|
||||
func decodeLanguagesView(t *testing.T, rec *httptest.ResponseRecorder) languagesView {
|
||||
t.Helper()
|
||||
var view languagesView
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
|
||||
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func (v languagesView) tags() []string {
|
||||
tags := []string{}
|
||||
for _, entry := range v.Languages {
|
||||
tags = append(tags, entry.Lang)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func keysOf(fields map[string]json.RawMessage) []string {
|
||||
keys := slices.Sorted(maps.Keys(fields))
|
||||
return keys
|
||||
}
|
||||
|
||||
func languagesBody(revision string, defaultTag string, entries ...string) string {
|
||||
return `{"revision":"` + revision + `","default":"` + defaultTag + `","languages":[` + strings.Join(entries, ",") + `]}`
|
||||
}
|
||||
|
||||
func entryJSON(lang string, enabled bool, social string) string {
|
||||
enabledText := "false"
|
||||
if enabled {
|
||||
enabledText = "true"
|
||||
}
|
||||
return `{"lang":"` + lang + `","enabled":` + enabledText + `,"social_locale":` + social + `}`
|
||||
}
|
||||
|
||||
func journalEntries(overrides map[string]string, omit ...string) []string {
|
||||
base := []struct {
|
||||
lang string
|
||||
enabled bool
|
||||
social string
|
||||
}{{"en", true, "null"}, {"fr", true, `"fr_FR"`}, {"de", true, "null"}, {"ar", true, "null"}, {"es", false, "null"}, {"en-US", false, "null"}}
|
||||
entries := []string{}
|
||||
for _, row := range base {
|
||||
if slices.Contains(omit, row.lang) {
|
||||
continue
|
||||
}
|
||||
if replacement, ok := overrides[row.lang]; ok {
|
||||
entries = append(entries, replacement)
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entryJSON(row.lang, row.enabled, row.social))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type codedSQLiteError struct{ code int }
|
||||
|
||||
func (c codedSQLiteError) Error() string { return fmt.Sprintf("sqlite code %d", c.code) }
|
||||
func (c codedSQLiteError) Code() int { return c.code }
|
||||
|
||||
func TestDriverLockContention(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
driver string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"sqlite busy", database.DriverSQLite, codedSQLiteError{5}, true},
|
||||
{"sqlite busy snapshot", database.DriverSQLite, fmt.Errorf("wrapped: %w", codedSQLiteError{5 | 2<<8}), true},
|
||||
{"sqlite locked", database.DriverSQLite, codedSQLiteError{6}, true},
|
||||
{"sqlite constraint", database.DriverSQLite, codedSQLiteError{19}, false},
|
||||
{"sqlite plain error", database.DriverSQLite, errors.New("database is locked"), false},
|
||||
{"mysql nowait", database.DriverMySQL, &mysqldriver.MySQLError{Number: 3572}, true},
|
||||
{"mysql lock wait timeout", database.DriverMySQL, fmt.Errorf("wrapped: %w", &mysqldriver.MySQLError{Number: 1205}), true},
|
||||
{"mysql duplicate", database.DriverMySQL, &mysqldriver.MySQLError{Number: 1062}, false},
|
||||
{"postgres lock not available", database.DriverPostgres, &pgconn.PgError{Code: "55P03"}, true},
|
||||
{"postgres unique violation", database.DriverPostgres, &pgconn.PgError{Code: "23505"}, false},
|
||||
{"unknown driver", "oracle", codedSQLiteError{5}, false},
|
||||
{"nil error", database.DriverSQLite, nil, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := database.IsLockContentionFor(c.driver, c.err); got != c.want {
|
||||
t.Errorf("got %v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactorClassifiesLockContention(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
transactor := persistence.NewTransactor(db)
|
||||
run := func(failure error) error {
|
||||
return transactor.Run(context.Background(), func(transaction.Repositories) error { return failure })
|
||||
}
|
||||
t.Run("a busy error becomes the domain sentinel and keeps its cause", func(t *testing.T) {
|
||||
cause := codedSQLiteError{5}
|
||||
err := run(cause)
|
||||
if !errors.Is(err, transaction.ErrLockContention) || !errors.Is(err, cause) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("another error is returned untouched", func(t *testing.T) {
|
||||
other := errors.New("other")
|
||||
if err := run(other); err != other {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("success stays nil", func(t *testing.T) {
|
||||
if err := run(nil); err != nil {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var languagesReadKeys = []string{"available", "default", "languages", "needs_setup", "revision"}
|
||||
|
||||
func TestSiteLanguagesReading(t *testing.T) {
|
||||
t.Run("Read the languages", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
|
||||
rec := e.as(t, readerUserID, http.MethodGet, "/v1/site/journal/languages", "")
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if keys := keysOf(decodeFields(t, rec)); !reflect.DeepEqual(keys, languagesReadKeys) {
|
||||
t.Errorf("keys = %v", keys)
|
||||
}
|
||||
view := decodeLanguagesView(t, rec)
|
||||
if view.Revision == "" || view.Default == nil || *view.Default != "en" || view.NeedsSetup {
|
||||
t.Errorf("header = %+v", view)
|
||||
}
|
||||
if !reflect.DeepEqual(view.tags(), []string{"en", "ar", "de", "fr", "en-US", "es"}) {
|
||||
t.Errorf("order = %v", view.tags())
|
||||
}
|
||||
counts := map[string][2]int{"en": {7, 6}, "fr": {4, 2}, "de": {4, 3}, "ar": {1, 0}, "es": {1, 0}, "en-US": {0, 0}}
|
||||
for _, entry := range view.Languages {
|
||||
if got := [2]int{entry.VersionCount, entry.PublishedCount}; got != counts[entry.Lang] {
|
||||
t.Errorf("%s counts = %v", entry.Lang, got)
|
||||
}
|
||||
}
|
||||
if available := view.Available; len(available) == 0 || !slices.ContainsFunc(available, func(o struct {
|
||||
Lang string `json:"lang"`
|
||||
Substitute *string `json:"substitute"`
|
||||
}) bool {
|
||||
return o.Lang == "pt-PT" && o.Substitute != nil && *o.Substitute == "pt"
|
||||
}) {
|
||||
t.Errorf("available = %+v", available)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Read access", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
user uint32
|
||||
key string
|
||||
status int
|
||||
}{
|
||||
{"an anonymous client", 0, "journal", http.StatusUnauthorized},
|
||||
{"a reader", readerUserID, "journal", http.StatusOK},
|
||||
{"an owner", apiKeyOwnerId, "journal", http.StatusOK},
|
||||
{"an owner on an unknown site", apiKeyOwnerId, "nowhere", http.StatusNotFound},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
rec := e.as(t, c.user, http.MethodGet, "/v1/site/"+c.key+"/languages", "")
|
||||
if rec.Code != c.status {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if c.status == http.StatusNotFound && bodyText(rec) != "site_not_found" {
|
||||
t.Errorf("body = %q", bodyText(rec))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Social locale states", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.sites.seed(5, "Atelier", "atelier", false)
|
||||
fr := "fr_FR"
|
||||
e.seedRows(5,
|
||||
localization.Row{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true},
|
||||
localization.Row{Tag: "en-GB", Enabled: true},
|
||||
localization.Row{Tag: "fr", Enabled: true, SocialLocale: &fr},
|
||||
localization.Row{Tag: "pt-BR", Enabled: true},
|
||||
)
|
||||
cases := []struct {
|
||||
lang string
|
||||
state string
|
||||
explicit *string
|
||||
effective *string
|
||||
}{
|
||||
{"en-GB", "derived", nil, localeOf("en_GB")},
|
||||
{"pt-BR", "derived", nil, localeOf("pt_BR")},
|
||||
{"fr", "set", &fr, &fr},
|
||||
{"en", "not_set", nil, nil},
|
||||
}
|
||||
rec := e.owner(t, http.MethodGet, "/v1/site/atelier/languages", "")
|
||||
view := decodeLanguagesView(t, rec)
|
||||
for _, c := range cases {
|
||||
t.Run(c.lang, func(t *testing.T) {
|
||||
for _, entry := range view.Languages {
|
||||
if entry.Lang != c.lang {
|
||||
continue
|
||||
}
|
||||
if entry.State != c.state || !sameText(entry.SocialLocale, c.explicit) {
|
||||
t.Errorf("entry = %+v", entry)
|
||||
}
|
||||
row := localization.Row{Tag: entry.Lang, SocialLocale: entry.SocialLocale}
|
||||
if !sameText(localization.EffectiveSocialLocale(row), c.effective) {
|
||||
t.Errorf("effective = %v", localization.EffectiveSocialLocale(row))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Only the explicit value and the tag's own region resolve the social locale", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.sites.seed(5, "Atelier", "atelier", false)
|
||||
gb := "en-GB"
|
||||
stored := e.sites.sites[5]
|
||||
stored.Locale = &gb
|
||||
e.sites.sites[5] = stored
|
||||
e.seedRows(5, localization.Row{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true})
|
||||
|
||||
view := decodeLanguagesView(t, e.owner(t, http.MethodGet, "/v1/site/atelier/languages", ""))
|
||||
|
||||
if view.Languages[0].State != "not_set" {
|
||||
t.Errorf("state = %q", view.Languages[0].State)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A site that still needs setup", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.sites.seed(2, "Notes", "notes", false)
|
||||
before := e.store.replaceCalls
|
||||
|
||||
view := decodeLanguagesView(t, e.as(t, readerUserID, http.MethodGet, "/v1/site/notes/languages", ""))
|
||||
put := e.owner(t, http.MethodPut, "/v1/site/notes/languages", languagesBody("any", "en", entryJSON("en", true, "null")))
|
||||
|
||||
if !view.NeedsSetup {
|
||||
t.Errorf("needs_setup = false")
|
||||
}
|
||||
expectSiteStatusAndBody(t, put, http.StatusConflict, "setup_required")
|
||||
if e.store.replaceCalls != before {
|
||||
t.Errorf("something was stored")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Sites created after the migration have no language row", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.sites.seed(2, "Fresh", "fresh", false)
|
||||
|
||||
view := decodeLanguagesView(t, e.owner(t, http.MethodGet, "/v1/site/fresh/languages", ""))
|
||||
|
||||
if !view.NeedsSetup || len(view.Languages) != 0 || view.Default != nil {
|
||||
t.Errorf("view = %+v", view)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func sameText(left *string, right *string) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == right
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func TestSiteLanguagesWriting(t *testing.T) {
|
||||
put := func(e *localizationEnv, t *testing.T, defaultTag string, entries []string) (string, string) {
|
||||
t.Helper()
|
||||
revision := e.revision(t, "journal")
|
||||
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, defaultTag, entries...))
|
||||
return rec.Body.String(), http.StatusText(rec.Code)
|
||||
}
|
||||
_ = put
|
||||
|
||||
changes := []struct {
|
||||
name string
|
||||
def string
|
||||
entries []string
|
||||
check func(t *testing.T, view languagesView)
|
||||
}{
|
||||
{"fr added with enabled true", "en", journalEntries(map[string]string{"fr": entryJSON("fr", true, `"fr_FR"`)}), func(t *testing.T, v languagesView) {
|
||||
if countTag(v, "fr") != 1 || !findLanguage(t, v, "fr").Enabled {
|
||||
t.Errorf("fr = %+v", v.tags())
|
||||
}
|
||||
}},
|
||||
{"pt-br added (lowercase) as enabled", "en", append(journalEntries(nil), entryJSON("pt-br", true, "null")), func(t *testing.T, v languagesView) {
|
||||
if countTag(v, "pt-BR") != 1 || countTag(v, "pt-br") != 0 {
|
||||
t.Errorf("tags = %v", v.tags())
|
||||
}
|
||||
}},
|
||||
{"default fr", "fr", journalEntries(nil), func(t *testing.T, v languagesView) {
|
||||
if !findLanguage(t, v, "fr").IsDefault || findLanguage(t, v, "en").IsDefault || !findLanguage(t, v, "en").Enabled {
|
||||
t.Errorf("view = %+v", v.Languages)
|
||||
}
|
||||
}},
|
||||
{"de enabled false", "en", journalEntries(map[string]string{"de": entryJSON("de", false, "null")}), func(t *testing.T, v languagesView) {
|
||||
de := findLanguage(t, v, "de")
|
||||
if de.Enabled || de.VersionCount != 4 || de.PublishedCount != 3 {
|
||||
t.Errorf("de = %+v", de)
|
||||
}
|
||||
}},
|
||||
{"es enabled true", "en", journalEntries(map[string]string{"es": entryJSON("es", true, "null")}), func(t *testing.T, v languagesView) {
|
||||
es := findLanguage(t, v, "es")
|
||||
if !es.Enabled || es.VersionCount != 1 {
|
||||
t.Errorf("es = %+v", es)
|
||||
}
|
||||
}},
|
||||
{"en-US omitted", "en", journalEntries(nil, "en-US"), func(t *testing.T, v languagesView) {
|
||||
if countTag(v, "en-US") != 0 {
|
||||
t.Errorf("tags = %v", v.tags())
|
||||
}
|
||||
}},
|
||||
{"fr social_locale fr_CA", "en", journalEntries(map[string]string{"fr": entryJSON("fr", true, `"fr_CA"`)}), func(t *testing.T, v languagesView) {
|
||||
fr := findLanguage(t, v, "fr")
|
||||
if fr.State != "set" || fr.SocialLocale == nil || *fr.SocialLocale != "fr_CA" {
|
||||
t.Errorf("fr = %+v", fr)
|
||||
}
|
||||
}},
|
||||
{"fr social_locale null", "en", journalEntries(map[string]string{"fr": entryJSON("fr", true, "null")}), func(t *testing.T, v languagesView) {
|
||||
if findLanguage(t, v, "fr").State != "not_set" {
|
||||
t.Errorf("fr = %+v", findLanguage(t, v, "fr"))
|
||||
}
|
||||
}},
|
||||
}
|
||||
for _, c := range changes {
|
||||
t.Run("Changing the set/"+c.name, func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
articlesBefore := len(e.articles.articles)
|
||||
revision := e.revision(t, "journal")
|
||||
|
||||
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, c.def, c.entries...))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
c.check(t, decodeLanguagesView(t, rec))
|
||||
if len(e.articles.articles) != articlesBefore {
|
||||
t.Errorf("articles changed")
|
||||
}
|
||||
for _, stored := range e.articles.articles {
|
||||
if stored.Lang == "" {
|
||||
t.Errorf("an article lost its language")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
rejections := []struct {
|
||||
name string
|
||||
def string
|
||||
entries func() []string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"default en with en enabled false", "en", func() []string {
|
||||
return journalEntries(map[string]string{"en": entryJSON("en", false, "null")})
|
||||
}, 422, "default_not_enabled"},
|
||||
{"default ja", "ja", func() []string { return journalEntries(nil) }, 422, "default_not_enabled"},
|
||||
{"every language enabled false", "en", func() []string {
|
||||
entries := []string{}
|
||||
for _, tag := range []string{"en", "fr", "de", "ar", "es", "en-US"} {
|
||||
entries = append(entries, entryJSON(tag, false, "null"))
|
||||
}
|
||||
return entries
|
||||
}, 422, "no_enabled_language"},
|
||||
{"languages []", "en", func() []string { return nil }, 422, "no_enabled_language"},
|
||||
{"es omitted (1 version)", "en", func() []string { return journalEntries(nil, "es") }, 409, "language_in_use"},
|
||||
{"ar omitted (1 draft or offline version)", "en", func() []string { return journalEntries(nil, "ar") }, 409, "language_in_use"},
|
||||
{"en omitted", "en", func() []string { return journalEntries(nil, "en") }, 422, "default_not_enabled"},
|
||||
{"a language english", "en", func() []string { return append(journalEntries(nil), entryJSON("english", true, "null")) }, 422, "invalid_language"},
|
||||
{"a language xx", "en", func() []string { return append(journalEntries(nil), entryJSON("xx", true, "null")) }, 422, "unsupported_language"},
|
||||
{"a language pt-PT", "en", func() []string { return append(journalEntries(nil), entryJSON("pt-PT", true, "null")) }, 422, "unsupported_language"},
|
||||
{"the same tag twice", "en", func() []string {
|
||||
return append(journalEntries(nil), entryJSON("fr", true, "null"), entryJSON("FR", true, "null"))
|
||||
}, 422, "duplicate_language"},
|
||||
{"fr social_locale fr-FR", "en", func() []string { return socialOnFrench(`"fr-FR"`) }, 422, "invalid_social_locale"},
|
||||
{"fr social_locale fr", "en", func() []string { return socialOnFrench(`"fr"`) }, 422, "invalid_social_locale"},
|
||||
{"fr social_locale empty", "en", func() []string { return socialOnFrench(`""`) }, 422, "invalid_social_locale"},
|
||||
{"fr social_locale pt_BR", "en", func() []string { return socialOnFrench(`"pt_BR"`) }, 422, "invalid_social_locale"},
|
||||
{"fr default, de disabled and es omitted", "fr", func() []string {
|
||||
return journalEntries(map[string]string{"de": entryJSON("de", false, "null")}, "es")
|
||||
}, 409, "language_in_use"},
|
||||
}
|
||||
for _, c := range rejections {
|
||||
t.Run("Rules that reject a change/"+c.name, func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
rowsBefore, articlesBefore := e.store.snapshot()
|
||||
revision := e.revision(t, "journal")
|
||||
|
||||
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, c.def, c.entries()...))
|
||||
|
||||
expectSiteStatusAndBody(t, rec, c.status, c.code)
|
||||
rowsAfter, articlesAfter := e.store.snapshot()
|
||||
if !reflect.DeepEqual(rowsBefore, rowsAfter) || !reflect.DeepEqual(articlesBefore, articlesAfter) || e.store.replaceCalls != 0 {
|
||||
t.Errorf("something was stored")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Rules that reject a change/no revision key", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", `{"default":"en","languages":[`+entryJSON("en", true, "null")+`]}`)
|
||||
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, "The request cannot be processed due to a mismatch in the format of the body.")
|
||||
})
|
||||
|
||||
t.Run("Rules that reject a change/malformed JSON", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", `{not json`)
|
||||
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, "The request cannot be processed due to a mismatch in the format of the body.")
|
||||
})
|
||||
|
||||
t.Run("Social locale format and language part", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
tag string
|
||||
social string
|
||||
valid bool
|
||||
}{
|
||||
{"fr", "fr_FR", true},
|
||||
{"fr", "fr_CA", true},
|
||||
{"fr", "pt_BR", false},
|
||||
{"fr-CA", "fr_CA", true},
|
||||
{"ar", "ara_EG", false},
|
||||
{"fr", "fr_F", false},
|
||||
{"fr", "fr__FR", false},
|
||||
{"fr", "fr_F1", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
social := c.social
|
||||
_, err := localization.NormalizeSocialLocale(c.tag, &social)
|
||||
if (err == nil) != c.valid {
|
||||
t.Errorf("%s on %s: err = %v", c.social, c.tag, err)
|
||||
}
|
||||
}
|
||||
if normalized, err := localization.NormalizeSocialLocale("fr", localeOf("fr_fr")); err != nil || *normalized != "fr_FR" {
|
||||
t.Errorf("normalized = %v, %v", normalized, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A stale revision is refused", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
stale := e.revision(t, "journal")
|
||||
other := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(stale, "en", journalEntries(map[string]string{"fr": entryJSON("fr", true, `"fr_CA"`)})...))
|
||||
if other.Code != http.StatusOK {
|
||||
t.Fatalf("other owner: %d %s", other.Code, other.Body)
|
||||
}
|
||||
rowsBefore, _ := e.store.snapshot()
|
||||
|
||||
refused := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(stale, "fr", journalEntries(nil)...))
|
||||
|
||||
expectSiteStatusAndBody(t, refused, http.StatusConflict, "languages_changed")
|
||||
if rowsAfter, _ := e.store.snapshot(); !reflect.DeepEqual(rowsBefore, rowsAfter) {
|
||||
t.Errorf("rows changed")
|
||||
}
|
||||
retry := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(e.revision(t, "journal"), "fr", journalEntries(nil)...))
|
||||
if retry.Code != http.StatusOK {
|
||||
t.Errorf("retry: %d %s", retry.Code, retry.Body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Sending the same state again is a no-op", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
first := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(e.revision(t, "journal"), "fr", journalEntries(nil)...))
|
||||
r2 := decodeLanguagesView(t, first).Revision
|
||||
calls := e.store.replaceCalls
|
||||
rowsBefore, _ := e.store.snapshot()
|
||||
|
||||
again := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(r2, "fr", journalEntries(nil)...))
|
||||
|
||||
if again.Code != http.StatusOK || decodeLanguagesView(t, again).Revision != r2 {
|
||||
t.Fatalf("again: %d %s", again.Code, again.Body)
|
||||
}
|
||||
if rowsAfter, _ := e.store.snapshot(); e.store.replaceCalls != calls || !reflect.DeepEqual(rowsBefore, rowsAfter) {
|
||||
t.Errorf("rows were written again")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Only an owner can write", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
user uint32
|
||||
status int
|
||||
}{
|
||||
{"an anonymous client", 0, http.StatusUnauthorized},
|
||||
{"a reader", readerUserID, http.StatusForbidden},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
revision := e.revision(t, "journal")
|
||||
rec := e.as(t, c.user, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, "fr", journalEntries(nil)...))
|
||||
if rec.Code != c.status || e.store.replaceCalls != 0 {
|
||||
t.Errorf("status %d, writes %d", rec.Code, e.store.replaceCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Removing a language is decided under the site lock", func(t *testing.T) {
|
||||
e := newLocalizationEnv(t)
|
||||
e.seedJournal()
|
||||
e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(e.revision(t, "journal"), "en", journalEntries(nil, "en-US")...))
|
||||
if !slices.Equal(e.sites.lockedSites, []uint32{journalSiteID}) || e.transactor.runs != 1 {
|
||||
t.Errorf("locks %v, transactions %d", e.sites.lockedSites, e.transactor.runs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func socialOnFrench(social string) []string {
|
||||
return journalEntries(map[string]string{"fr": entryJSON("fr", true, social)})
|
||||
}
|
||||
|
||||
func countTag(view languagesView, tag string) int {
|
||||
total := 0
|
||||
for _, entry := range view.Languages {
|
||||
if entry.Lang == tag {
|
||||
total++
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
type languageEntryView = struct {
|
||||
Lang string `json:"lang"`
|
||||
Enabled bool `json:"enabled"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
VersionCount int `json:"version_count"`
|
||||
PublishedCount int `json:"published_count"`
|
||||
SocialLocale *string `json:"social_locale"`
|
||||
State string `json:"social_locale_state"`
|
||||
}
|
||||
|
||||
func findLanguage(t *testing.T, view languagesView, tag string) languageEntryView {
|
||||
t.Helper()
|
||||
for _, entry := range view.Languages {
|
||||
if entry.Lang == tag {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
t.Fatalf("language %s missing from %v", tag, view.tags())
|
||||
return languageEntryView{}
|
||||
}
|
||||
|
||||
func TestSiteLanguagesRevision(t *testing.T) {
|
||||
base := []localization.Row{{Tag: "en", IsDefault: true, Enabled: true}, {Tag: "fr", Enabled: true}}
|
||||
other := func(change func(rows []localization.Row)) string {
|
||||
rows := slices.Clone(base)
|
||||
change(rows)
|
||||
return localization.Revision(rows)
|
||||
}
|
||||
reference := localization.Revision(base)
|
||||
|
||||
t.Run("the same rows give the same revision whatever the order", func(t *testing.T) {
|
||||
if localization.Revision([]localization.Row{base[1], base[0]}) != reference {
|
||||
t.Errorf("order changed the revision")
|
||||
}
|
||||
})
|
||||
t.Run("any change of lang, default, enabled or social_locale gives another one", func(t *testing.T) {
|
||||
changes := map[string]func(rows []localization.Row){
|
||||
"lang": func(rows []localization.Row) { rows[1].Tag = "de" },
|
||||
"default": func(rows []localization.Row) { rows[1].IsDefault = true },
|
||||
"enabled": func(rows []localization.Row) { rows[1].Enabled = false },
|
||||
"social": func(rows []localization.Row) { rows[1].SocialLocale = localeOf("fr_FR") },
|
||||
}
|
||||
for name, change := range changes {
|
||||
if other(change) == reference {
|
||||
t.Errorf("%s did not change the revision", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("confirmed flags do not change the revision", func(t *testing.T) {
|
||||
if other(func(rows []localization.Row) { rows[0].Confirmed = true }) != reference {
|
||||
t.Errorf("confirmed changed the revision")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/site"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const journalPattern = "https://journal.example.com/{lang}/{slug}"
|
||||
|
||||
func patternBody(value string) string {
|
||||
encoded, _ := json.Marshal(value)
|
||||
return `{"public_url_pattern":` + string(encoded) + `}`
|
||||
}
|
||||
|
||||
func newPatternEnv(t *testing.T) *localizationEnv {
|
||||
t.Helper()
|
||||
e := newLocalizationEnv(t)
|
||||
e.sites.seed(siteMainID, "Main", "main", true)
|
||||
return e
|
||||
}
|
||||
|
||||
func TestSitePublicURLPattern(t *testing.T) {
|
||||
t.Run("Reading the pattern", func(t *testing.T) {
|
||||
e := newPatternEnv(t)
|
||||
stored := e.sites.sites[siteMainID]
|
||||
pattern := journalPattern
|
||||
stored.PublicURLPattern = &pattern
|
||||
e.sites.sites[siteMainID] = stored
|
||||
|
||||
rec := e.as(t, readerUserID, http.MethodGet, "/v1/site/", "")
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var items []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(items[0]["public_url_pattern"]) != `"`+journalPattern+`"` {
|
||||
t.Errorf("pattern = %s", items[0]["public_url_pattern"])
|
||||
}
|
||||
for _, key := range []string{"id", "name", "key", "base_url", "default_social_image_id", "default_robots", "locale", "is_default", "article_count", "site_name", "title_template", "default_description"} {
|
||||
if _, present := items[0][key]; !present {
|
||||
t.Errorf("key %q missing", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
writes := []struct {
|
||||
name string
|
||||
raw string
|
||||
want *string
|
||||
start string
|
||||
}{
|
||||
{"a pattern with lang and slug", patternBody(journalPattern), localeOf(journalPattern), ""},
|
||||
{"a pattern with a path", patternBody("https://journal.example.com/{lang}/articles/{slug}"), localeOf("https://journal.example.com/{lang}/articles/{slug}"), ""},
|
||||
{"a local http pattern", patternBody("http://localhost:3000/{slug}"), localeOf("http://localhost:3000/{slug}"), ""},
|
||||
{"a repeated placeholder", patternBody("https://journal.example.com/{slug}/{slug}"), localeOf("https://journal.example.com/{slug}/{slug}"), ""},
|
||||
{"null", `{"public_url_pattern":null}`, nil, journalPattern},
|
||||
{"an empty string", patternBody(""), nil, journalPattern},
|
||||
{"a padded pattern is trimmed", patternBody(" https://journal.example.com/{slug} "), localeOf("https://journal.example.com/{slug}"), ""},
|
||||
}
|
||||
for _, c := range writes {
|
||||
t.Run("Writing the pattern/"+c.name, func(t *testing.T) {
|
||||
e := newPatternEnv(t)
|
||||
if c.start != "" {
|
||||
stored := e.sites.sites[siteMainID]
|
||||
stored.PublicURLPattern = localeOf(c.start)
|
||||
e.sites.sites[siteMainID] = stored
|
||||
}
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/1", c.raw)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if got := decodeSiteBody(t, rec).PublicURLPattern; !sameText(got, c.want) {
|
||||
t.Errorf("returned = %v", got)
|
||||
}
|
||||
if !sameText(e.sites.sites[siteMainID].PublicURLPattern, c.want) {
|
||||
t.Errorf("stored = %v", e.sites.sites[siteMainID].PublicURLPattern)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
refusals := []struct {
|
||||
value string
|
||||
code string
|
||||
}{
|
||||
{"ftp://journal.example.com/{lang}/{slug}", "invalid_scheme"},
|
||||
{"journal.example.com/{slug}", "invalid_scheme"},
|
||||
{"https:///{slug}", "invalid_host"},
|
||||
{"https://journal.example.com/{language}/{slug}", "unknown_placeholder"},
|
||||
{"https://journal.example.com/{lang", "unknown_placeholder"},
|
||||
{"https://journal.example.com/my pages/{lang}/{slug}", "not_encoded"},
|
||||
{"https://journal.example.com/é/{slug}", "not_encoded"},
|
||||
{"https://journal.example.com/{lang}", "missing_slug"},
|
||||
{"https://example.com/" + strings.Repeat("a", 494) + "{slug}", "invalid_pattern"},
|
||||
{"ftp://x.example.com/{language} here", "invalid_scheme"},
|
||||
{"https://x.example.com/{language} here", "unknown_placeholder"},
|
||||
{"https://x.example.com/here there", "not_encoded"},
|
||||
}
|
||||
for _, c := range refusals {
|
||||
t.Run("Patterns that are refused/"+c.code+" "+truncate(c.value), func(t *testing.T) {
|
||||
e := newPatternEnv(t)
|
||||
stored := e.sites.sites[siteMainID]
|
||||
stored.PublicURLPattern = localeOf(journalPattern)
|
||||
e.sites.sites[siteMainID] = stored
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/1", patternBody(c.value))
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusUnprocessableEntity, c.code)
|
||||
if !sameText(e.sites.sites[siteMainID].PublicURLPattern, localeOf(journalPattern)) {
|
||||
t.Errorf("the stored pattern changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("A pattern of 513 characters", func(t *testing.T) {
|
||||
long := "https://example.com/" + strings.Repeat("a", 513-len("https://example.com/")-len("{slug}")) + "{slug}"
|
||||
if len(long) != 513 {
|
||||
t.Fatalf("fixture length %d", len(long))
|
||||
}
|
||||
e := newPatternEnv(t)
|
||||
expectSiteStatusAndBody(t, e.owner(t, http.MethodPatch, "/v1/site/1", patternBody(long)), http.StatusUnprocessableEntity, "invalid_pattern")
|
||||
})
|
||||
|
||||
t.Run("A pattern of 512 characters is accepted", func(t *testing.T) {
|
||||
exact := "https://example.com/" + strings.Repeat("a", 512-len("https://example.com/")-len("{slug}")) + "{slug}"
|
||||
e := newPatternEnv(t)
|
||||
if rec := e.owner(t, http.MethodPatch, "/v1/site/1", patternBody(exact)); rec.Code != http.StatusOK {
|
||||
t.Errorf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Only an owner can write", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
user uint32
|
||||
status int
|
||||
}{
|
||||
{"an anonymous client", 0, http.StatusUnauthorized},
|
||||
{"a reader", readerUserID, http.StatusForbidden},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newPatternEnv(t)
|
||||
rec := e.as(t, c.user, http.MethodPatch, "/v1/site/1", patternBody(journalPattern))
|
||||
if rec.Code != c.status || e.sites.sites[siteMainID].PublicURLPattern != nil {
|
||||
t.Errorf("status %d, stored %v", rec.Code, e.sites.sites[siteMainID].PublicURLPattern)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Saving the pattern changes nothing else", func(t *testing.T) {
|
||||
e := newPatternEnv(t)
|
||||
before := e.sites.sites[siteMainID]
|
||||
|
||||
e.owner(t, http.MethodPatch, "/v1/site/1", patternBody(journalPattern))
|
||||
|
||||
after := e.sites.sites[siteMainID]
|
||||
after.PublicURLPattern = nil
|
||||
if after != before {
|
||||
t.Errorf("site changed: %+v vs %+v", after, before)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Existing site behaviour is unchanged", func(t *testing.T) {
|
||||
e := newPatternEnv(t)
|
||||
stored := e.sites.sites[siteMainID]
|
||||
stored.PublicURLPattern = localeOf(journalPattern)
|
||||
e.sites.sites[siteMainID] = stored
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/1", `{"name":"Renamed"}`)
|
||||
|
||||
if rec.Code != http.StatusOK || !sameText(e.sites.sites[siteMainID].PublicURLPattern, localeOf(journalPattern)) {
|
||||
t.Errorf("status %d, pattern %v", rec.Code, e.sites.sites[siteMainID].PublicURLPattern)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func truncate(value string) string {
|
||||
if len(value) > 40 {
|
||||
return value[:40]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func TestPublicURLPatternNormalization(t *testing.T) {
|
||||
t.Run("a nil pattern stays unset", func(t *testing.T) {
|
||||
if normalized, err := site.NormalizePublicURLPattern(nil); normalized != nil || err != nil {
|
||||
t.Errorf("got %v, %v", normalized, err)
|
||||
}
|
||||
})
|
||||
t.Run("a pattern without a host is refused as a host error", func(t *testing.T) {
|
||||
if _, err := site.NormalizePublicURLPattern(localeOf("https:///{slug}")); err != site.ErrPublicURLHost {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("percent encoded sequences are accepted and a stray percent is not", func(t *testing.T) {
|
||||
if _, err := site.NormalizePublicURLPattern(localeOf("https://a.example.com/%C3%A9/{slug}")); err != nil {
|
||||
t.Errorf("encoded: %v", err)
|
||||
}
|
||||
if _, err := site.NormalizePublicURLPattern(localeOf("https://a.example.com/100%/{slug}")); err != site.ErrPublicURLNotEncoded {
|
||||
t.Errorf("stray: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("a pattern ending in a percent sequence is judged without panicking", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pattern string
|
||||
want error
|
||||
}{
|
||||
{"a lone percent at the end", "https://a.example.com/{slug}%", site.ErrPublicURLNotEncoded},
|
||||
{"a percent and one digit at the end", "https://a.example.com/{slug}%4", site.ErrPublicURLNotEncoded},
|
||||
{"a percent and a non hex digit at the end", "https://a.example.com/{slug}%4G", site.ErrPublicURLNotEncoded},
|
||||
{"a valid sequence at the end", "https://a.example.com/{slug}%41", nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if _, err := site.NormalizePublicURLPattern(localeOf(c.pattern)); err != c.want {
|
||||
t.Errorf("err = %v, want %v", err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -27,6 +29,30 @@ type mockSiteRepo struct {
|
||||
deleteCalls int
|
||||
deleteReassignments []*uint32
|
||||
setDefaultCalls int
|
||||
lockedSites []uint32
|
||||
tryLocked [][]uint32
|
||||
busy bool
|
||||
afterTryLock func()
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) LockSite(id uint32) error {
|
||||
m.lockedSites = append(m.lockedSites, id)
|
||||
if m.busy {
|
||||
return transaction.ErrLockContention
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) TryLockSites(ids []uint32) error {
|
||||
m.tryLocked = append(m.tryLocked, slices.Clone(ids))
|
||||
if m.busy {
|
||||
return transaction.ErrLockContention
|
||||
}
|
||||
if hook := m.afterTryLock; hook != nil {
|
||||
m.afterTryLock = nil
|
||||
hook()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newMockSiteRepo(articles *mockArticleRepo) *mockSiteRepo {
|
||||
|
||||
Reference in new issue
Block a user