feat: site languages, setup and site URL settings

This commit is contained in:
Florian Sylvain committed 2026-11-01 22:13:02 +01:00
1 parent 0806733d5c
commit d9158025d4
106 files changed
+10184 -56

No files matched your search

+16
View File
@@ -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) {
+317
View File
@@ -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)
}
})
}
+145
View File
@@ -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)
}
}
})
}
+251
View File
@@ -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.")
})
}
+249
View File
@@ -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
}
+328
View File
@@ -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
}
+74
View File
@@ -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)
}
})
}
+485
View File
@@ -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")
}
})
}
+231
View File
@@ -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)
}
})
}
})
}
+26
View File
@@ -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 {