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.") }) }