refac: missing mutation tests, spa specs, checks shown to pass, touch targets, catalogue entry, production code

This commit is contained in:
Florian Sylvain committed 2026-11-04 03:04:35 +01:00
1 parent 7c15206170
commit 3e02e14da4
48 files changed
+2173 -83

No files matched your search

+1 -6
View File
@@ -5,11 +5,9 @@ import (
"RenewCMS/internal/infrastructure/persistence/database"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
"sync/atomic"
"testing"
"time"
@@ -18,12 +16,9 @@ import (
"gorm.io/gorm/logger"
)
var activityDatabaseCounter atomic.Int64
func openActivityDatabase(t *testing.T) *gorm.DB {
t.Helper()
name := fmt.Sprintf("file:activity%d?mode=memory&cache=shared", activityDatabaseCounter.Add(1))
db, err := gorm.Open(sqlite.Open(name), &gorm.Config{Logger: logger.Discard})
db, err := gorm.Open(sqlite.Open(uniqueMemoryDSN()), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("failed to open the in-memory database: %v", err)
}
+4
View File
@@ -13,6 +13,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"RenewCMS/api/pkg/authtoken"
@@ -20,6 +21,7 @@ import (
)
type fakeBodyRenderer struct {
mu sync.Mutex
fail bool
calls int
}
@@ -27,6 +29,8 @@ type fakeBodyRenderer struct {
var _ domainArticle.BodyRenderer = &fakeBodyRenderer{}
func (f *fakeBodyRenderer) Render(html string, format domainArticle.BodyFormat) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls++
if f.fail {
return "", errors.New("render failed")
+60
View File
@@ -0,0 +1,60 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"strconv"
"sync"
"testing"
)
const concurrentSaveRounds = 20
func TestArticleConcurrentSavesHTTP(t *testing.T) {
t.Run("Two saves of the same article with the same if_revision: one wins, the other conflicts", func(t *testing.T) {
for round := 0; round < concurrentSaveRounds; round++ {
w := newStoryWorld(t)
revision, _ := revisionsOf(w, 10)
bodies := []string{"<p>Mine</p>", "<p>Theirs</p>"}
responses := make([]storyResponse, len(bodies))
start := make(chan struct{})
var saves sync.WaitGroup
for i, body := range bodies {
saves.Add(1)
go func() {
defer saves.Done()
<-start
responses[i] = w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"body":"`+body+`","if_revision":`+strconv.FormatUint(uint64(revision), 10)+`}`)
}()
}
close(start)
saves.Wait()
winners, losers := 0, 0
var winningBody string
for i, response := range responses {
switch response.Code {
case http.StatusOK:
winners++
winningBody = bodies[i]
case http.StatusConflict:
losers++
if response.text() != apperr.RevisionConflictErrorMessage+"\n" {
t.Fatalf("conflict body %q", response.text())
}
if response.header(apperr.CurrentRevisionHeader) != strconv.FormatUint(uint64(revision+1), 10) {
t.Fatalf("current revision header %q", response.header(apperr.CurrentRevisionHeader))
}
default:
t.Fatalf("unexpected %d %q", response.Code, response.text())
}
}
if winners != 1 || losers != 1 {
t.Fatalf("round %d: %d saved, %d conflicted", round, winners, losers)
}
if stored, _ := revisionsOf(w, 10); stored != revision+1 || w.stored(10).Body != winningBody {
t.Fatalf("round %d: stored revision %d body %q, the winner sent %q", round, stored, w.stored(10).Body, winningBody)
}
}
})
}
+32
View File
@@ -121,6 +121,38 @@ func TestArticleCreateSiteHTTP(t *testing.T) {
}
})
t.Run("Without a site the article is created on the default site, which the activity names", func(t *testing.T) {
for _, body := range []string{`{"title":"Plain"}`, `{"title":"Plain","site":null}`} {
t.Run(body, func(t *testing.T) {
w := newCreateSiteWorld(t)
if _, err := w.sites.SetDefault(storyAtelier); err != nil {
t.Fatal(err)
}
created := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, createSitePath, body))
if created.SiteID != storyAtelier || created.Lang != "fr" {
t.Errorf("created on site %d in %q, the default Atelier in fr expected", created.SiteID, created.Lang)
}
events := w.eventsOf(audit.ActionArticleCreate)
if len(events) != 1 || events[0].Outcome != audit.OutcomeSuccess || events[0].Params[audit.ParamSite] != "atelier" {
t.Errorf("one article.create naming the default site expected: %+v", events)
}
})
}
})
t.Run("An unknown site key records the plain reason That site does not exist.", func(t *testing.T) {
w := newCreateSiteWorld(t)
w.send(storyWriter, http.MethodPost, createSitePath, `{"title":"Plain","site":"missing"}`).expect(t, http.StatusNotFound, apperr.SiteNotFoundErrorMessage)
events := w.eventsOf(audit.ActionArticleCreate)
if len(events) != 1 || events[0].FailureReason == nil || *events[0].FailureReason != "That site does not exist." {
t.Fatalf("one failed article.create with the site reason expected: %+v", events)
}
})
t.Run("The language rules are those of the chosen site", func(t *testing.T) {
w := newCreateSiteWorld(t)
+102
View File
@@ -0,0 +1,102 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"errors"
"strings"
"testing"
)
func TestArticleTitleLengthBounds(t *testing.T) {
cases := []struct {
runes int
valid bool
}{
{article.MinTitleRunes - 1, false},
{article.MinTitleRunes, true},
{article.MaxTitleRunes, true},
{article.MaxTitleRunes + 1, false},
}
for _, c := range cases {
err := article.ValidateTitle(strings.Repeat("é", c.runes))
if (err == nil) != c.valid || (err != nil && !errors.Is(err, article.ErrInvalidTitle)) {
t.Errorf("%d runes: err = %v", c.runes, err)
}
}
}
func TestArticleDetailedErrorsMatchTheirSentinel(t *testing.T) {
cases := []struct {
err error
sentinel error
}{
{&article.RevisionConflictError{Current: 4}, article.ErrRevisionConflict},
{&article.LanguageExistsError{ArticleID: 9}, article.ErrLanguageExists},
{&article.LanguageNotEnabledError{Missing: []string{"de"}}, article.ErrLanguageNotEnabled},
{&article.SlugConflictError{ArticleIDs: []uint32{3}}, article.ErrSlugConflict},
}
for _, c := range cases {
if !errors.Is(c.err, c.sentinel) || c.err.Error() != c.sentinel.Error() {
t.Errorf("%T does not match %v", c.err, c.sentinel)
}
if errors.Is(c.err, article.ErrArticleNotFound) {
t.Errorf("%T matches an unrelated sentinel", c.err)
}
}
}
func TestArticleRevisionsMoveByOne(t *testing.T) {
opened := article.Article{}.Opened()
cases := []struct {
impact article.Impact
edit, content uint32
}{
{article.Impact{}, 1, 1},
{article.Impact{Edited: true}, 2, 1},
{article.Impact{Edited: true, Content: true}, 2, 2},
}
for _, c := range cases {
revised := opened.Revised(c.impact)
if revised.EditRevision != c.edit || revised.ContentRevision != c.content {
t.Errorf("%+v: edit %d content %d", c.impact, revised.EditRevision, revised.ContentRevision)
}
}
}
func TestArticleCopiedIntoKeepsAnAuthorList(t *testing.T) {
source := article.Article{ID: 7, Body: "<p>Body</p>", ContentRevision: 3}
copied := source.CopiedInto(article.Article{Lang: "fr"})
if copied.Authors == nil || len(copied.Authors) != 0 {
t.Fatalf("authors = %#v", copied.Authors)
}
if *copied.SourceArticleID != 7 || copied.SourceRevision != 3 || copied.Body != "<p>Body</p>" {
t.Fatalf("copied = %+v", copied)
}
credited := article.Article{Authors: []author.Byline{{}}}.CopiedInto(article.Article{})
if len(credited.Authors) != 1 {
t.Fatalf("credited authors = %#v", credited.Authors)
}
}
func TestArticleStoryFiltersPerLanguage(t *testing.T) {
cases := []struct {
filters article.StoryFilters
want bool
}{
{article.StoryFilters{Lang: "fr"}, true},
{article.StoryFilters{Lang: "fr", Missing: true}, false},
{article.StoryFilters{}, false},
}
for _, c := range cases {
if got := c.filters.PerLanguage(); got != c.want {
t.Errorf("%+v: PerLanguage = %v", c.filters, got)
}
}
}
func TestArticleSEOSummaryStale(t *testing.T) {
if !(article.SEOSummary{}).Stale() || (article.SEOSummary{Status: article.SEOStatusReady}).Stale() {
t.Fatal("only a summary without status is stale")
}
}
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"reflect"
"slices"
"sort"
"strconv"
"strings"
@@ -496,6 +497,10 @@ func runArticleListLocalization(t *testing.T, open func(*testing.T) *gorm.DB) {
if len(unknown.Items) != 0 || unknown.Total != 0 {
t.Fatal("unknown site must be empty")
}
_, everySite := env.get(localizationOwnerID, "?group=story")
if !slices.Contains(everySite.storyIDs(), 70) || everySite.Total <= 1 {
t.Fatalf("without site every site is listed: %v of %d", everySite.storyIDs(), everySite.Total)
}
})
t.Run("SEO filter on one version", func(t *testing.T) {
+229
View File
@@ -0,0 +1,229 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/transaction"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"testing"
"gorm.io/gorm"
)
const sqliteBusyCode = 5
type sqliteBusyError struct{}
func (sqliteBusyError) Error() string {
return "database is busy"
}
func (sqliteBusyError) Code() int {
return sqliteBusyCode
}
func onArticleUpdate(t *testing.T, db *gorm.DB, name string, hook func(*gorm.DB)) {
t.Helper()
if err := db.Callback().Update().Before("gorm:update").Register(name, hook); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Callback().Update().Remove(name) })
}
func onArticleQuery(t *testing.T, db *gorm.DB, name string, hook func(*gorm.DB)) {
t.Helper()
if err := db.Callback().Query().After("gorm:query").Register(name, hook); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Callback().Query().Remove(name) })
}
type articleEdgesEnv struct {
db *gorm.DB
articles *persistence.ArticleRepository
siteID uint32
}
func newArticleEdgesEnv(t *testing.T) *articleEdgesEnv {
t.Helper()
db := openContractSQLite(t)
return &articleEdgesEnv{db: db, articles: persistence.NewArticleRepository(db), siteID: storageDefaultSite(t, db).ID}
}
func (e *articleEdgesEnv) create(t *testing.T, lang string, slug string, online bool) domainArticle.Article {
t.Helper()
created, err := e.articles.Create(newStorageArticle(e.siteID, lang, slug, 0))
if err != nil {
t.Fatal(err)
}
if online {
if created, err = e.articles.UpdateIsOnline(created.ID, true); err != nil {
t.Fatal(err)
}
}
return created
}
func TestArticleRepositoryLanguageScope(t *testing.T) {
e := newArticleEdgesEnv(t)
e.create(t, "fr", "bonjour", true)
e.create(t, "en", "hello", true)
t.Run("An empty language list matches nothing", func(t *testing.T) {
page, err := e.articles.FindPage(domainArticle.Filters{Langs: &[]string{}, Unpaged: true})
if err != nil || page.Total != 0 || len(page.Items) != 0 {
t.Fatalf("page = %+v, err = %v", page, err)
}
})
t.Run("A language list keeps only its languages", func(t *testing.T) {
page, err := e.articles.FindPage(domainArticle.Filters{Langs: &[]string{"fr"}, Unpaged: true})
if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].Lang != "fr" {
t.Fatalf("page = %+v, err = %v", page, err)
}
})
}
func TestArticleRepositoryCountVersions(t *testing.T) {
e := newArticleEdgesEnv(t)
e.create(t, "fr", "en-ligne", true)
draft := e.create(t, "fr", "brouillon", false)
e.create(t, "en", "other", false)
online := true
stranger := draft.CreatedBy + 1
cases := []struct {
name string
filters domainArticle.Filters
want domainArticle.Counts
}{
{"every version of the language", domainArticle.Filters{Lang: "fr"}, domainArticle.Counts{All: 2, Online: 1, Draft: 1}},
{"online only skips the drafts", domainArticle.Filters{Lang: "fr", IsOnline: &online}, domainArticle.Counts{All: 1, Online: 1}},
{"drafts are counted for their reader only", domainArticle.Filters{Lang: "fr", VisibleTo: &stranger}, domainArticle.Counts{All: 1, Online: 1}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
counts, err := e.articles.CountVersions(c.filters)
if err != nil || counts != c.want {
t.Fatalf("counts = %+v, err = %v, want %+v", counts, err, c.want)
}
})
}
}
func TestArticleRepositorySlugTaken(t *testing.T) {
e := newArticleEdgesEnv(t)
owner := e.create(t, "fr", "bonjour", false)
cases := []struct {
name string
slug string
exceptID uint32
want bool
}{
{"a slug in use is taken", "bonjour", 0, true},
{"a free slug is not taken", "salut", 0, false},
{"the slug of the article itself is not taken", "bonjour", owner.ID, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
taken, err := e.articles.SlugTaken(e.siteID, "fr", c.slug, c.exceptID)
if err != nil || taken != c.want {
t.Fatalf("taken = %v, err = %v", taken, err)
}
})
}
}
func TestArticleRepositoryUpdateRetriesLockContention(t *testing.T) {
cases := []struct {
name string
failures int
wantErr bool
attempts int
}{
{"a write that is busy twice succeeds on the third attempt", 2, false, 3},
{"a write that stays busy gives up after five attempts", 1000, true, 5},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newArticleEdgesEnv(t)
created := e.create(t, "fr", "bonjour", false)
attempts := 0
onArticleUpdate(t, e.db, "test:busy_article_writes", func(tx *gorm.DB) {
if tx.Statement.Table != "articles" {
return
}
attempts++
if attempts <= c.failures {
_ = tx.AddError(sqliteBusyError{})
}
})
updated, err := e.articles.Update(created.ID, domainArticle.Update{Excerpt: localeOf("Renamed")})
if c.wantErr != (err != nil) || attempts != c.attempts {
t.Fatalf("err = %v after %d attempts", err, attempts)
}
if !c.wantErr && updated.Excerpt != "Renamed" {
t.Fatalf("updated = %+v", updated)
}
})
}
}
func TestArticleRepositoryUpdateGivesUpOnASiteThatKeepsMoving(t *testing.T) {
e := newArticleEdgesEnv(t)
created := e.create(t, "fr", "bonjour", false)
probes := 0
onArticleQuery(t, e.db, "test:moving_article_site", func(tx *gorm.DB) {
if tx.Statement.Table != "articles" || len(tx.Statement.Selects) != 2 {
return
}
probes++
_ = tx.Session(&gorm.Session{NewDB: true, SkipHooks: true}).Model(&entity.Article{}).Where("id = ?", created.ID).UpdateColumn("site_id", uint32(probes+1000)).Error
})
_, err := e.articles.Update(created.ID, domainArticle.Update{Excerpt: localeOf("Renamed")})
if !errors.Is(err, transaction.ErrLockContention) || probes != 5 {
t.Fatalf("err = %v after %d probes", err, probes)
}
}
func TestArticleRepositoryAuthorOnlyChangeIsAnEdit(t *testing.T) {
e := newArticleEdgesEnv(t)
created := e.create(t, "fr", "bonjour", false)
writer := entity.Author{Name: "Ada", Slug: "ada", SocialLinks: "[]"}
if err := e.db.Create(&writer).Error; err != nil {
t.Fatal(err)
}
credited := []uint32{writer.ID}
updated, err := e.articles.Update(created.ID, domainArticle.Update{AuthorIDs: &credited})
if err != nil || updated.EditRevision != created.EditRevision+1 || updated.ContentRevision != created.ContentRevision {
t.Fatalf("after crediting: %+v, err = %v", updated, err)
}
again, err := e.articles.Update(created.ID, domainArticle.Update{AuthorIDs: &credited})
if err != nil || again.EditRevision != updated.EditRevision {
t.Fatalf("the same authors bumped the revision: %+v, err = %v", again, err)
}
}
func TestArticleRepositoryUnrelatedUniqueViolationIsNotALanguageClash(t *testing.T) {
e := newArticleEdgesEnv(t)
created := e.create(t, "fr", "bonjour", false)
writer := entity.Author{Name: "Ada", Slug: "ada", SocialLinks: "[]"}
if err := e.db.Create(&writer).Error; err != nil {
t.Fatal(err)
}
twice := []uint32{writer.ID, writer.ID}
_, err := e.articles.Update(created.ID, domainArticle.Update{AuthorIDs: &twice})
if err == nil || errors.Is(err, domainArticle.ErrLanguageTaken) || errors.Is(err, domainArticle.ErrSlugTaken) {
t.Fatalf("err = %v, the duplicate credit must surface as itself", err)
}
}
+48 -1
View File
@@ -5,10 +5,12 @@ import (
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/transaction"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"strings"
"testing"
@@ -25,6 +27,27 @@ type seoIndexEnv struct {
siteID uint32
}
var errSEOClearFailed = errors.New("the SEO clear failed")
type failingSEOClear struct {
domainArticle.SEOIndex
}
func (failingSEOClear) MarkSiteSEOStale(uint32) error {
return errSEOClearFailed
}
type failingSEOClearTransactor struct {
inner transaction.Transactor
}
func (f failingSEOClearTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error {
return f.inner.Run(ctx, func(repositories transaction.Repositories) error {
repositories.ArticleSEO = failingSEOClear{repositories.ArticleSEO}
return work(repositories)
})
}
func newSEOIndexEnv(t *testing.T, open func(*testing.T) *gorm.DB) *seoIndexEnv {
t.Helper()
db := open(t)
@@ -123,6 +146,30 @@ func runArticleSEOIndex(t *testing.T, open func(*testing.T) *gorm.DB) {
}
})
t.Run("A site description change whose SEO clear fails saves nothing", func(t *testing.T) {
e := newSEOIndexEnv(t, open)
created := e.create("en", 0)
e.must(e.refresh.RefreshStale())
before, err := e.sites.Get(e.siteID)
e.must(err)
description := "A description that is never saved."
update := useCases.NewUpdateSiteUseCase(e.sites, persistence.NewImageRepository(e.db), discardRecorder, failingSEOClearTransactor{persistence.NewTransactor(e.db)})
_, err = update.Update(context.Background(), e.siteID, site.Patch{DefaultDescription: &description})
if !errors.Is(err, errSEOClearFailed) {
t.Fatalf("err = %v", err)
}
after, err := e.sites.Get(e.siteID)
e.must(err)
if after.DefaultDescription != before.DefaultDescription {
t.Fatalf("the description %q was saved", after.DefaultDescription)
}
if e.status(created.ID) == "" {
t.Fatal("the stored status was cleared")
}
})
t.Run("A status computed before a concurrent write is not stored", func(t *testing.T) {
e := newSEOIndexEnv(t, open)
created := e.create("en", 0)
@@ -143,7 +190,7 @@ func runArticleSEOIndex(t *testing.T, open func(*testing.T) *gorm.DB) {
english := e.create("en", 0)
french := e.create("fr", english.StoryID)
description := strings.Repeat("Renovation notes. ", 6)
update := useCases.NewUpdateSiteUseCase(e.sites, persistence.NewImageRepository(e.db), discardRecorder, e.articles)
update := useCases.NewUpdateSiteUseCase(e.sites, persistence.NewImageRepository(e.db), discardRecorder, persistence.NewTransactor(e.db))
_, err := update.Update(context.Background(), e.siteID, site.Patch{DefaultDescription: &description})
e.must(err)
+14
View File
@@ -26,6 +26,8 @@ func (m *mockArticleRepo) seoFresh(version article.Article) bool {
}
func (m *mockArticleRepo) StaleSEO(afterID uint32, limit int) ([]article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("StaleSEO"); err != nil {
return nil, err
}
@@ -45,6 +47,8 @@ func (m *mockArticleRepo) StaleSEO(afterID uint32, limit int) ([]article.Article
}
func (m *mockArticleRepo) StoreSEO(records []article.SEORecord) error {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("StoreSEO"); err != nil {
return err
}
@@ -64,6 +68,8 @@ func (m *mockArticleRepo) StoreSEO(records []article.SEORecord) error {
}
func (m *mockArticleRepo) MarkSiteSEOStale(siteID uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
for id, version := range m.articles {
if version.SiteID == siteID {
delete(m.assessed, id)
@@ -73,6 +79,8 @@ func (m *mockArticleRepo) MarkSiteSEOStale(siteID uint32) error {
}
func (m *mockArticleRepo) CountSuggestions(filters article.Filters) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("CountSuggestions"); err != nil {
return 0, err
}
@@ -86,6 +94,8 @@ func (m *mockArticleRepo) CountSuggestions(filters article.Filters) (int, error)
}
func (m *mockArticleRepo) CountSuggestedStories(readable article.StoryFilters) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.listCalls++
if err := m.fail.trigger("CountSuggestedStories"); err != nil {
return 0, err
@@ -116,6 +126,8 @@ func mockVisible(version article.Article, onlineOnly bool, visibleTo *uint32) bo
}
func (m *mockArticleRepo) ListVersions(ids []uint32) ([]article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.listCalls++
if err := m.fail.trigger("ListVersions"); err != nil {
return nil, err
@@ -130,6 +142,8 @@ func (m *mockArticleRepo) ListVersions(ids []uint32) ([]article.Article, error)
}
func (m *mockArticleRepo) DeleteStory(storyID uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("DeleteStory"); err != nil {
return err
}
+72
View File
@@ -0,0 +1,72 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"testing"
)
type batchedSEOIndex struct {
batches [][]article.Article
stored [][]article.SEORecord
storeErr error
afterIDs []uint32
staleRead int
}
var _ article.SEOIndex = &batchedSEOIndex{}
func (b *batchedSEOIndex) StaleSEO(afterID uint32, limit int) ([]article.Article, error) {
b.afterIDs = append(b.afterIDs, afterID)
if b.staleRead >= len(b.batches) {
return nil, nil
}
batch := b.batches[b.staleRead]
b.staleRead++
return batch, nil
}
func (b *batchedSEOIndex) StoreSEO(records []article.SEORecord) error {
b.stored = append(b.stored, records)
return b.storeErr
}
func (b *batchedSEOIndex) MarkSiteSEOStale(uint32) error {
return nil
}
func staleVersion(id uint32) article.Article {
return article.Article{ID: id, SiteID: 1, Lang: "en", Title: "Renovation costs", SEOVersion: 3}
}
func TestRefreshArticleSEOBatches(t *testing.T) {
t.Run("Every stale batch is stored, each read after the last stored id", func(t *testing.T) {
index := &batchedSEOIndex{batches: [][]article.Article{{staleVersion(4), staleVersion(7)}, {staleVersion(9)}}}
err := useCases.NewRefreshArticleSEOUseCase(index, plainSites(), plainLocalization()).RefreshStale()
if err != nil {
t.Fatal(err)
}
if len(index.stored) != 2 || len(index.stored[0]) != 2 || index.stored[1][0].ArticleID != 9 || index.stored[1][0].Version != 3 {
t.Fatalf("stored %+v", index.stored)
}
if len(index.afterIDs) != 3 || index.afterIDs[0] != 0 || index.afterIDs[1] != 7 || index.afterIDs[2] != 9 {
t.Fatalf("read after %v", index.afterIDs)
}
})
t.Run("A failed store stops the refresh with its error", func(t *testing.T) {
index := &batchedSEOIndex{batches: [][]article.Article{{staleVersion(4)}, {staleVersion(9)}}, storeErr: errRepositoryFailure}
err := useCases.NewRefreshArticleSEOUseCase(index, plainSites(), plainLocalization()).RefreshStale()
if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("err = %v", err)
}
if len(index.stored) != 1 || index.staleRead != 1 {
t.Fatalf("the refresh went on after the failure: %d stores, %d reads", len(index.stored), index.staleRead)
}
})
}
+63 -5
View File
@@ -10,6 +10,7 @@ import (
"slices"
"sort"
"strings"
"sync"
"testing"
"unicode"
@@ -19,6 +20,7 @@ import (
)
type mockArticleRepo struct {
mu sync.Mutex
articles map[uint32]article.Article
failUpdateBodyFor map[uint32]error
nextID uint32
@@ -64,6 +66,8 @@ func (m *mockArticleRepo) withAuthors(a article.Article) article.Article {
}
func (m *mockArticleRepo) ListForAccount(creatorID uint32, authorID *uint32) ([]article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("ListForAccount"); err != nil {
return nil, err
}
@@ -91,6 +95,8 @@ func (m *mockArticleRepo) sortedIds() []uint32 {
}
func (m *mockArticleRepo) FindByFilters(filters article.Filters) ([]article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("FindByFilters"); err != nil {
return nil, err
}
@@ -106,6 +112,8 @@ func (m *mockArticleRepo) FindByFilters(filters article.Filters) ([]article.Arti
}
func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.listCalls++
if err := m.fail.trigger("FindPage"); err != nil {
return article.PageResult{}, err
@@ -159,6 +167,8 @@ func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult,
}
func (m *mockArticleRepo) CountByVisibility(onlineOnly bool) (article.Counts, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("CountByVisibility"); err != nil {
return article.Counts{}, err
}
@@ -175,6 +185,8 @@ func (m *mockArticleRepo) CountByVisibility(onlineOnly bool) (article.Counts, er
}
func (m *mockArticleRepo) CountVersions(filters article.Filters) (article.Counts, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.listCalls++
if err := m.fail.trigger("CountVersions"); err != nil {
return article.Counts{}, err
@@ -195,26 +207,36 @@ func (m *mockArticleRepo) CountVersions(filters article.Filters) (article.Counts
}
func (m *mockArticleRepo) FindStoryPage(filters article.StoryFilters) (article.StoryPage, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.listCalls++
return article.StoryPage{Refs: []article.StoryRef{}}, m.fail.trigger("FindStoryPage")
}
func (m *mockArticleRepo) CountStories(filters article.StoryFilters) (article.StoryCounts, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.listCalls++
return article.StoryCounts{}, m.fail.trigger("CountStories")
}
func (m *mockArticleRepo) ListByStories(storyIDs []uint32, readable article.StoryFilters) ([]article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.listCalls++
return []article.Article{}, m.fail.trigger("ListByStories")
}
func (m *mockArticleRepo) FindTitleMatches(q string, readable article.StoryFilters) ([]uint32, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.listCalls++
return []uint32{}, m.fail.trigger("FindTitleMatches")
}
func (m *mockArticleRepo) CountVisibleTo(userID uint32) (article.Counts, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("CountVisibleTo"); err != nil {
return article.Counts{}, err
}
@@ -270,11 +292,13 @@ func foldArticleSearch(value string) string {
}
func (m *mockArticleRepo) Get(id uint32) (article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("Get"); err != nil {
return article.Article{}, err
}
if m.onGet != nil {
if err := m.onGet(id); err != nil {
if err := outsideLockErr(&m.mu, func() error { return m.onGet(id) }); err != nil {
return article.Article{}, err
}
}
@@ -286,6 +310,8 @@ func (m *mockArticleRepo) Get(id uint32) (article.Article, error) {
}
func (m *mockArticleRepo) GetByName(name string) (article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("GetByName"); err != nil {
return article.Article{}, err
}
@@ -299,6 +325,8 @@ func (m *mockArticleRepo) GetByName(name string) (article.Article, error) {
}
func (m *mockArticleRepo) GetAll() ([]article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
result := make([]article.Article, 0)
for _, id := range m.sortedIds() {
result = append(result, m.articles[id])
@@ -319,6 +347,8 @@ func (m *mockArticleRepo) slugUsedOn(siteID uint32, lang string, slug string, ex
}
func (m *mockArticleRepo) SlugTaken(siteID uint32, lang string, slug string, exceptID uint32) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("SlugTaken"); err != nil {
return false, err
}
@@ -326,6 +356,8 @@ func (m *mockArticleRepo) SlugTaken(siteID uint32, lang string, slug string, exc
}
func (m *mockArticleRepo) FindByImage(imageID uint32) ([]article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("FindByImage"); err != nil {
return nil, err
}
@@ -339,6 +371,8 @@ func (m *mockArticleRepo) FindByImage(imageID uint32) ([]article.Article, error)
}
func (m *mockArticleRepo) ClearImage(imageID uint32) (article.ImageReferences, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("ClearImage"); err != nil {
return article.ImageReferences{}, err
}
@@ -359,6 +393,8 @@ func (m *mockArticleRepo) ClearImage(imageID uint32) (article.ImageReferences, e
}
func (m *mockArticleRepo) RestoreImage(references article.ImageReferences, imageID uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, id := range references.Cover {
a := m.articles[id]
a.CoverImageID = idPointer(imageID)
@@ -373,6 +409,8 @@ func (m *mockArticleRepo) RestoreImage(references article.ImageReferences, image
}
func (m *mockArticleRepo) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("RepointImage"); err != nil {
return nil, err
}
@@ -397,10 +435,12 @@ func (m *mockArticleRepo) RepointImage(fromImageIDs []uint32, toImageID uint32)
}
func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.createCalled = true
if hook := m.beforeCreate; hook != nil {
m.beforeCreate = nil
hook()
outsideLock(&m.mu, hook)
}
if err := m.fail.trigger("Create"); err != nil {
return article.Article{}, err
@@ -439,6 +479,8 @@ func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
}
func (m *mockArticleRepo) UpdateBody(id uint32, body string) (article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.updateBodyIDs = append(m.updateBodyIDs, id)
if err := m.fail.trigger("UpdateBody"); err != nil {
return article.Article{}, err
@@ -474,6 +516,8 @@ func (m *mockArticleRepo) storyHasLanguage(storyID uint32, lang string) bool {
}
func (m *mockArticleRepo) GetByLangSlug(siteID uint32, lang string, slug string) (article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("GetByLangSlug"); err != nil {
return article.Article{}, err
}
@@ -486,6 +530,8 @@ func (m *mockArticleRepo) GetByLangSlug(siteID uint32, lang string, slug string)
}
func (m *mockArticleRepo) ListByStory(storyID uint32) ([]article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("ListByStory"); err != nil {
return nil, err
}
@@ -502,12 +548,14 @@ func (m *mockArticleRepo) ListByStory(storyID uint32) ([]article.Article, error)
}
func (m *mockArticleRepo) MoveToSite(ids []uint32, siteID uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("MoveToSite"); err != nil {
return err
}
if hook := m.beforeMove; hook != nil {
m.beforeMove = nil
hook()
outsideLock(&m.mu, hook)
}
snapshot := maps.Clone(m.articles)
for position, id := range ids {
@@ -532,6 +580,8 @@ func (m *mockArticleRepo) MoveToSite(ids []uint32, siteID uint32) error {
}
func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("Update"); err != nil {
return article.Article{}, err
}
@@ -541,7 +591,7 @@ func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Arti
}
if hook := m.beforeUpdate; hook != nil {
m.beforeUpdate = nil
hook()
outsideLock(&m.mu, hook)
a = m.articles[id]
}
if update.IfRevision != nil && a.EditRevision != *update.IfRevision {
@@ -609,6 +659,8 @@ func applySeoUpdate(a *article.Article, update article.Update) {
}
func (m *mockArticleRepo) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("UpdateIsOnline"); err != nil {
return article.Article{}, err
}
@@ -622,6 +674,8 @@ func (m *mockArticleRepo) UpdateIsOnline(id uint32, isOnline bool) (article.Arti
}
func (m *mockArticleRepo) Delete(id uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("Delete"); err != nil {
return err
}
@@ -634,6 +688,8 @@ func (m *mockArticleRepo) Delete(id uint32) error {
}
func (m *mockArticleRepo) FindByBodyContaining(filename string) ([]article.Article, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("FindByBodyContaining"); err != nil {
return nil, err
}
@@ -645,12 +701,14 @@ func (m *mockArticleRepo) FindByBodyContaining(filename string) ([]article.Artic
}
}
if m.afterFindByBody != nil {
defer m.afterFindByBody()
defer outsideLock(&m.mu, m.afterFindByBody)
}
return result, nil
}
func (m *mockArticleRepo) AssignUnassignedLanguage(siteID uint32, lang string) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("AssignUnassignedLanguage"); err != nil {
return 0, err
}
+28
View File
@@ -12,6 +12,7 @@ import (
"net/http/httptest"
"sort"
"strings"
"sync"
"testing"
"time"
@@ -24,6 +25,7 @@ type authorDeleteCall struct {
}
type mockAuthorRepo struct {
mu sync.Mutex
authors map[uint32]author.Author
articles *mockArticleRepo
nextID uint32
@@ -99,6 +101,8 @@ func (m *mockAuthorRepo) sortedIDsDescending() []uint32 {
}
func (m *mockAuthorRepo) List(query author.ListQuery) (author.Page, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("List"); err != nil {
return author.Page{}, err
}
@@ -120,6 +124,8 @@ func (m *mockAuthorRepo) List(query author.ListQuery) (author.Page, error) {
}
func (m *mockAuthorRepo) Get(id uint32) (author.Author, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("Get"); err != nil {
return author.Author{}, err
}
@@ -131,6 +137,8 @@ func (m *mockAuthorRepo) Get(id uint32) (author.Author, error) {
}
func (m *mockAuthorRepo) GetBySlug(slug string) (author.Author, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("GetBySlug"); err != nil {
return author.Author{}, err
}
@@ -143,6 +151,8 @@ func (m *mockAuthorRepo) GetBySlug(slug string) (author.Author, error) {
}
func (m *mockAuthorRepo) GetByUserID(userID uint32) (author.Author, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("GetByUserID"); err != nil {
return author.Author{}, err
}
@@ -170,6 +180,8 @@ func (m *mockAuthorRepo) conflictFor(candidate author.Author) error {
}
func (m *mockAuthorRepo) Create(a author.Author) (author.Author, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.createCalls++
if err := m.fail.trigger("Create"); err != nil {
return author.Author{}, err
@@ -186,6 +198,8 @@ func (m *mockAuthorRepo) Create(a author.Author) (author.Author, error) {
}
func (m *mockAuthorRepo) Update(a author.Author) (author.Author, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("Update"); err != nil {
return author.Author{}, err
}
@@ -201,6 +215,8 @@ func (m *mockAuthorRepo) Update(a author.Author) (author.Author, error) {
}
func (m *mockAuthorRepo) DeleteMovingCredits(id uint32, reassignTo *uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
m.deleteCalls = append(m.deleteCalls, authorDeleteCall{id: id, reassignTo: reassignTo})
if err := m.fail.trigger("DeleteMovingCredits"); err != nil {
return err
@@ -221,6 +237,8 @@ func (m *mockAuthorRepo) DeleteMovingCredits(id uint32, reassignTo *uint32) erro
}
func (m *mockAuthorRepo) DeleteUncrediting(id uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
m.deleteCalls = append(m.deleteCalls, authorDeleteCall{id: id})
if err := m.fail.trigger("DeleteMovingCredits"); err != nil {
return err
@@ -273,6 +291,8 @@ func containsID(ids []uint32, wanted uint32) bool {
}
func (m *mockAuthorRepo) AvatarUsage() (map[uint32]int, error) {
m.mu.Lock()
defer m.mu.Unlock()
usage := map[uint32]int{}
for _, a := range m.authors {
if a.AvatarID != nil {
@@ -283,6 +303,8 @@ func (m *mockAuthorRepo) AvatarUsage() (map[uint32]int, error) {
}
func (m *mockAuthorRepo) FindByAvatar(imageID uint32) ([]author.Author, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("FindByAvatar"); err != nil {
return nil, err
}
@@ -302,6 +324,8 @@ func (m *mockAuthorRepo) sortedIDsAscending() []uint32 {
}
func (m *mockAuthorRepo) ClearAvatar(imageID uint32) ([]uint32, error) {
m.mu.Lock()
defer m.mu.Unlock()
cleared := []uint32{}
for _, id := range m.sortedIDsDescending() {
a := m.authors[id]
@@ -315,6 +339,8 @@ func (m *mockAuthorRepo) ClearAvatar(imageID uint32) ([]uint32, error) {
}
func (m *mockAuthorRepo) AssignAvatar(authorIDs []uint32, imageID uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, id := range authorIDs {
a := m.authors[id]
a.AvatarID = idPointer(imageID)
@@ -324,6 +350,8 @@ func (m *mockAuthorRepo) AssignAvatar(authorIDs []uint32, imageID uint32) error
}
func (m *mockAuthorRepo) RepointAvatar(fromImageIDs []uint32, toImageID uint32) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("RepointAvatar"); err != nil {
return 0, err
}
+20 -1
View File
@@ -1,9 +1,16 @@
package test
import "errors"
import (
"errors"
"sync"
)
var errRepositoryFailure = errors.New("repository failure")
var errRunawayLocking = errors.New("the use case kept locking sites without settling")
const runawayLockCalls = 1000
type failSwitch struct {
method string
err error
@@ -33,3 +40,15 @@ func (f *failSwitch) trigger(method string) error {
}
const migrationPublicURL = "https://www.example.com"
func outsideLock(mu *sync.Mutex, hook func()) {
mu.Unlock()
defer mu.Lock()
hook()
}
func outsideLockErr(mu *sync.Mutex, hook func() error) error {
mu.Unlock()
defer mu.Lock()
return hook()
}
+28
View File
@@ -10,6 +10,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
)
@@ -73,6 +74,7 @@ func (m *mockImageStorage) Delete(name string) error {
var _ imageDomain.Storage = &mockImageStorage{}
type mockImageRepo struct {
mu sync.Mutex
images map[uint32]imageDomain.Image
nextID uint32
fail failSwitch
@@ -101,6 +103,8 @@ func (m *mockImageRepo) sortedImages() []imageDomain.Image {
}
func (m *mockImageRepo) Create(input imageDomain.Image) (imageDomain.Image, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.createCalls++
if err := m.fail.trigger("Create"); err != nil {
return imageDomain.Image{}, err
@@ -117,6 +121,8 @@ func (m *mockImageRepo) Create(input imageDomain.Image) (imageDomain.Image, erro
}
func (m *mockImageRepo) ListByUploader(userID uint32) ([]imageDomain.Image, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.uploaderLists++
if m.onListByUploader != nil {
m.onListByUploader()
@@ -134,6 +140,8 @@ func (m *mockImageRepo) ListByUploader(userID uint32) ([]imageDomain.Image, erro
}
func (m *mockImageRepo) ListMissingMetadata() ([]imageDomain.Image, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("ListMissingMetadata"); err != nil {
return nil, err
}
@@ -147,6 +155,8 @@ func (m *mockImageRepo) ListMissingMetadata() ([]imageDomain.Image, error) {
}
func (m *mockImageRepo) UpdateMetadata(id uint32, metadata imageDomain.Metadata) error {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("UpdateMetadata"); err != nil {
return err
}
@@ -161,6 +171,8 @@ func (m *mockImageRepo) UpdateMetadata(id uint32, metadata imageDomain.Metadata)
}
func (m *mockImageRepo) Get(id uint32) (imageDomain.Image, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("Get"); err != nil {
return imageDomain.Image{}, err
}
@@ -172,6 +184,8 @@ func (m *mockImageRepo) Get(id uint32) (imageDomain.Image, error) {
}
func (m *mockImageRepo) Delete(id uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
m.deleteCalls = append(m.deleteCalls, id)
if m.onDelete != nil {
m.onDelete(id)
@@ -190,6 +204,8 @@ func (m *mockImageRepo) Delete(id uint32) error {
}
func (m *mockImageRepo) List(query imageDomain.SearchQuery) ([]imageDomain.Image, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.listCalls++
if err := m.fail.trigger("List"); err != nil {
return nil, err
@@ -208,6 +224,8 @@ func (m *mockImageRepo) List(query imageDomain.SearchQuery) ([]imageDomain.Image
}
func (m *mockImageRepo) FindByHash(hash string) ([]imageDomain.Image, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("FindByHash"); err != nil {
return nil, err
}
@@ -221,6 +239,8 @@ func (m *mockImageRepo) FindByHash(hash string) ([]imageDomain.Image, error) {
}
func (m *mockImageRepo) Rename(id uint32, filename string) (imageDomain.Image, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("Rename"); err != nil {
return imageDomain.Image{}, err
}
@@ -244,6 +264,8 @@ func (m *mockImageRepo) hashCounts() map[string]int {
}
func (m *mockImageRepo) ListDuplicates() ([]imageDomain.Image, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("ListDuplicates"); err != nil {
return nil, err
}
@@ -258,6 +280,8 @@ func (m *mockImageRepo) ListDuplicates() ([]imageDomain.Image, error) {
}
func (m *mockImageRepo) Counts() (imageDomain.Counts, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("Counts"); err != nil {
return imageDomain.Counts{}, err
}
@@ -271,6 +295,8 @@ func (m *mockImageRepo) Counts() (imageDomain.Counts, error) {
}
func (m *mockImageRepo) ExistsByStoredName(storedName string) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("ExistsByStoredName"); err != nil {
return false, err
}
@@ -283,6 +309,8 @@ func (m *mockImageRepo) ExistsByStoredName(storedName string) (bool, error) {
}
func (m *mockImageRepo) ListByStoredNames(storedNames []string) ([]imageDomain.Image, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("ListByStoredNames"); err != nil {
return nil, err
}
+1 -1
View File
@@ -556,7 +556,7 @@ func TestManageInviteUseCases(t *testing.T) {
}
func TestInviteRepositoryRenew(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:inviterenew?mode=memory&cache=private"), &gorm.Config{Logger: logger.Discard})
db, err := gorm.Open(sqlite.Open(uniqueMemoryDSN()), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
+125
View File
@@ -0,0 +1,125 @@
package test
import (
"RenewCMS/internal/domain/localization"
"errors"
"testing"
)
func socialLocale(value string) *string {
return &value
}
func TestLocalizationRowEquality(t *testing.T) {
base := localization.Row{Tag: "fr", IsDefault: true, Enabled: true, Confirmed: true, SocialLocale: socialLocale("fr_FR")}
differing := map[string]localization.Row{
"tag": {Tag: "de", IsDefault: true, Enabled: true, Confirmed: true, SocialLocale: socialLocale("fr_FR")},
"default": {Tag: "fr", IsDefault: false, Enabled: true, Confirmed: true, SocialLocale: socialLocale("fr_FR")},
"enabled": {Tag: "fr", IsDefault: true, Enabled: false, Confirmed: true, SocialLocale: socialLocale("fr_FR")},
"confirmed": {Tag: "fr", IsDefault: true, Enabled: true, Confirmed: false, SocialLocale: socialLocale("fr_FR")},
"social locale": {Tag: "fr", IsDefault: true, Enabled: true, Confirmed: true, SocialLocale: socialLocale("fr_CA")},
"missing social": {Tag: "fr", IsDefault: true, Enabled: true, Confirmed: true},
"same row": base,
"same social copied": {Tag: "fr", IsDefault: true, Enabled: true, Confirmed: true, SocialLocale: socialLocale("fr_FR")},
}
for name, other := range differing {
t.Run(name, func(t *testing.T) {
want := name == "same row" || name == "same social copied"
if base.Equal(other) != want || other.Equal(base) != want {
t.Fatalf("Equal = %v, want %v", base.Equal(other), want)
}
})
}
t.Run("two rows without a social locale are equal", func(t *testing.T) {
plain := localization.Row{Tag: "en", Enabled: true}
if !plain.Equal(localization.Row{Tag: "en", Enabled: true}) {
t.Fatal("rows without social locale differ")
}
})
}
func TestLocalizationRevisionTracksEveryField(t *testing.T) {
rows := []localization.Row{{Tag: "en", IsDefault: true, Enabled: true}, {Tag: "fr", Enabled: true, SocialLocale: socialLocale("fr_FR")}}
changed := [][]localization.Row{
{{Tag: "en", IsDefault: false, Enabled: true}, {Tag: "fr", IsDefault: true, Enabled: true, SocialLocale: socialLocale("fr_FR")}},
{{Tag: "en", IsDefault: true, Enabled: true}, {Tag: "fr", Enabled: false, SocialLocale: socialLocale("fr_FR")}},
{{Tag: "en", IsDefault: true, Enabled: true}, {Tag: "fr", Enabled: true}},
}
for _, other := range changed {
if localization.SameRows(rows, other) {
t.Errorf("%+v has the revision of %+v", other, rows)
}
}
reordered := []localization.Row{rows[1], rows[0]}
if !localization.SameRows(rows, reordered) {
t.Error("the order of the rows changed the revision")
}
}
func TestLocalizationSocialLocaleState(t *testing.T) {
cases := []struct {
name string
row localization.Row
siteLocale *string
want string
}{
{"an explicit social locale is set", localization.Row{Tag: "fr", SocialLocale: socialLocale("fr_FR")}, nil, localization.SocialLocaleSet},
{"a regional tag derives one", localization.Row{Tag: "fr-CA"}, nil, localization.SocialLocaleDerived},
{"the site locale lends one", localization.Row{Tag: "fr"}, socialLocale("fr-BE"), localization.SocialLocaleDerived},
{"nothing to derive from", localization.Row{Tag: "fr"}, socialLocale("de-DE"), localization.SocialLocaleNotSet},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := localization.SocialLocaleState(c.row, c.siteLocale); got != c.want {
t.Fatalf("state = %q, want %q", got, c.want)
}
})
}
}
func TestLocalizationPlanKeepsLanguagesInUse(t *testing.T) {
current := []localization.Row{{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true}, {Tag: "fr", Enabled: true}}
keepEnglishOnly := localization.Change{Default: "en", Languages: []localization.Entry{{Tag: "en", Enabled: true}}}
cases := []struct {
name string
counts map[string]localization.VersionCount
want error
}{
{"a removed language with live versions is refused", map[string]localization.VersionCount{"fr": {Total: 1}}, localization.ErrLanguageInUse},
{"a removed language with only trashed versions is refused", map[string]localization.VersionCount{"fr": {Trashed: 1}}, localization.ErrLanguageInUse},
{"a removed language without versions is dropped", map[string]localization.VersionCount{"en": {Total: 4}}, nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rows, err := localization.Plan(current, c.counts, keepEnglishOnly)
if !errors.Is(err, c.want) {
t.Fatalf("err = %v, want %v", err, c.want)
}
if c.want == nil && (len(rows) != 1 || rows[0].Tag != "en" || !rows[0].IsDefault) {
t.Fatalf("rows = %+v", rows)
}
})
}
t.Run("a kept language with versions stays", func(t *testing.T) {
keepBoth := localization.Change{Default: "en", Languages: []localization.Entry{{Tag: "en", Enabled: true}, {Tag: "fr", Enabled: false}}}
rows, err := localization.Plan(current, map[string]localization.VersionCount{"fr": {Total: 2}}, keepBoth)
if err != nil || len(rows) != 2 {
t.Fatalf("rows = %+v, err = %v", rows, err)
}
})
}
func TestLocalizationRowLookups(t *testing.T) {
rows := []localization.Row{{Tag: "en", IsDefault: true, Enabled: true}, {Tag: "fr", Enabled: false}}
t.Run("HasRow finds a disabled row and nothing else", func(t *testing.T) {
if !localization.HasRow(rows, "fr") || !localization.HasRow(rows, "en") || localization.HasRow(rows, "de") {
t.Fatal("HasRow answered wrongly")
}
})
t.Run("MissingTags lists each tag that is not enabled once, sorted, never the unassigned tag", func(t *testing.T) {
missing := localization.MissingTags(rows, []string{"fr", "", "de", "en", "fr"})
if len(missing) != 2 || missing[0] != "de" || missing[1] != "fr" {
t.Fatalf("missing = %v", missing)
}
})
}
+13 -1
View File
@@ -16,6 +16,7 @@ import (
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"github.com/go-chi/chi/v5"
@@ -32,6 +33,7 @@ const (
var errInjectedConfirm = errors.New("injected confirm failure")
type fakeLocalizationStore struct {
mu sync.Mutex
articles *mockArticleRepo
rows map[uint32][]localization.Row
trashed map[uint32]map[string]int
@@ -49,10 +51,14 @@ func newFakeLocalizationStore(articles *mockArticleRepo) *fakeLocalizationStore
}
func (f *fakeLocalizationStore) Rows(siteID uint32) ([]localization.Row, error) {
f.mu.Lock()
defer f.mu.Unlock()
return slices.Clone(f.rows[siteID]), nil
}
func (f *fakeLocalizationStore) ConfirmedSites() (map[uint32]bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
confirmed := map[uint32]bool{}
for siteID, rows := range f.rows {
if !localization.NeedsSetup(rows) {
@@ -63,6 +69,8 @@ func (f *fakeLocalizationStore) ConfirmedSites() (map[uint32]bool, error) {
}
func (f *fakeLocalizationStore) VersionCounts(siteID uint32) (map[string]localization.VersionCount, error) {
f.mu.Lock()
defer f.mu.Unlock()
counts := map[string]localization.VersionCount{}
for _, stored := range f.articles.articles {
if stored.SiteID != siteID {
@@ -84,12 +92,16 @@ func (f *fakeLocalizationStore) VersionCounts(siteID uint32) (map[string]localiz
}
func (f *fakeLocalizationStore) Replace(siteID uint32, rows []localization.Row) error {
f.mu.Lock()
defer f.mu.Unlock()
f.replaceCalls++
f.rows[siteID] = slices.Clone(rows)
return nil
}
func (f *fakeLocalizationStore) ConfirmSetup(siteID uint32, tag string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.confirmCalls++
if f.failConfirmAt == f.confirmCalls {
return errInjectedConfirm
@@ -156,7 +168,7 @@ func newLocalizationEnv(t *testing.T) *localizationEnv {
transactor := &rollbackLocalizationTransactor{store: store, sites: sites}
c := e.container
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, e.images, e.audit.recorder, e.articles)
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, e.images, e.audit.recorder, siteWriteTransactor(sites, e.articles))
c.GetSiteLanguagesUseCase = useCases.NewGetSiteLanguagesUseCase(sites, store)
c.UpdateSiteLanguagesUseCase = useCases.NewUpdateSiteLanguagesUseCase(transactor, e.audit.recorder)
c.GetLanguageSetupUseCase = useCases.NewGetLanguageSetupUseCase(sites, store)
+3
View File
@@ -197,6 +197,9 @@ func TestDriverRegistries(t *testing.T) {
if err := connection.Close(); err != nil {
t.Errorf("Close failed: %v", err)
}
if sqlDB, err := connection.DB.DB(); err != nil || sqlDB.Ping() == nil {
t.Error("the database is still open after Close")
}
})
t.Run("The storage registry returns a working local storage", func(t *testing.T) {
+12
View File
@@ -0,0 +1,12 @@
package test
import (
"fmt"
"sync/atomic"
)
var memoryDatabaseCounter atomic.Int64
func uniqueMemoryDSN() string {
return fmt.Sprintf("file:renewcms-test-%d?mode=memory&cache=shared", memoryDatabaseCounter.Add(1))
}
+6 -1
View File
@@ -26,10 +26,15 @@ import (
func openInMemoryDb(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(uniqueMemoryDSN()), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open in-memory database: %v", err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
return db
}
+21 -1
View File
@@ -3,6 +3,7 @@ package test
import (
"RenewCMS/internal/domain/role"
"slices"
"sync"
)
const (
@@ -12,6 +13,7 @@ const (
)
type mockRoleRepo struct {
mu sync.Mutex
roles map[uint32]role.Role
nextID uint32
fail failSwitch
@@ -48,6 +50,8 @@ func (m *mockRoleRepo) sortedIDs() []uint32 {
}
func (m *mockRoleRepo) List() ([]role.Role, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls["List"]++
if err := m.fail.trigger("List"); err != nil {
return nil, err
@@ -60,6 +64,8 @@ func (m *mockRoleRepo) List() ([]role.Role, error) {
}
func (m *mockRoleRepo) Get(id uint32) (role.Role, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls["Get"]++
if err := m.fail.trigger("Get"); err != nil {
return role.Role{}, err
@@ -72,6 +78,8 @@ func (m *mockRoleRepo) Get(id uint32) (role.Role, error) {
}
func (m *mockRoleRepo) GetByName(name string) (role.Role, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls["GetByName"]++
if err := m.fail.trigger("GetByName"); err != nil {
return role.Role{}, err
@@ -90,6 +98,8 @@ func (m *mockRoleRepo) GetByName(name string) (role.Role, error) {
}
func (m *mockRoleRepo) Create(created role.Role) (role.Role, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls["Create"]++
if err := m.fail.trigger("Create"); err != nil {
return role.Role{}, err
@@ -101,6 +111,8 @@ func (m *mockRoleRepo) Create(created role.Role) (role.Role, error) {
}
func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls["Save"]++
if err := m.fail.trigger("Save"); err != nil {
return role.Role{}, err
@@ -108,7 +120,7 @@ func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) {
if m.beforeSave != nil {
hook := m.beforeSave
m.beforeSave = nil
hook()
outsideLock(&m.mu, hook)
}
stored, ok := m.roles[saved.ID]
if !ok {
@@ -147,6 +159,8 @@ func (m *mockRoleRepo) memberCount(roleID uint32) int {
}
func (m *mockRoleRepo) MemberCounts() (map[uint32]int, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls["MemberCounts"]++
if err := m.fail.trigger("MemberCounts"); err != nil {
return nil, err
@@ -170,6 +184,8 @@ func (m *mockRoleRepo) MemberCounts() (map[uint32]int, error) {
}
func (m *mockRoleRepo) Delete(id uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
m.calls["Delete"]++
if err := m.fail.trigger("Delete"); err != nil {
return err
@@ -185,6 +201,8 @@ func (m *mockRoleRepo) Delete(id uint32) error {
}
func (m *mockRoleRepo) ReassignMembers(from uint32, to uint32) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls["ReassignMembers"]++
if err := m.fail.trigger("ReassignMembers"); err != nil {
return 0, err
@@ -212,6 +230,8 @@ func (m *mockRoleRepo) ReassignMembers(from uint32, to uint32) (int, error) {
}
func (m *mockRoleRepo) RemoveTypedGrants(typeKey string) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls["RemoveTypedGrants"]++
if err := m.fail.trigger("RemoveTypedGrants"); err != nil {
return 0, err
+4
View File
@@ -16,6 +16,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
)
@@ -42,12 +43,15 @@ const (
)
type contentScopeRecorder struct {
mu sync.Mutex
events []audit.Event
}
var _ audit.Recorder = &contentScopeRecorder{}
func (r *contentScopeRecorder) Record(_ context.Context, event audit.Event) {
r.mu.Lock()
defer r.mu.Unlock()
r.events = append(r.events, event)
}
+1 -1
View File
@@ -68,7 +68,7 @@ func newSeoEnv(t *testing.T) *seoEnv {
c.PreviewPurgeUseCase = useCases.NewPreviewPurgeUseCase(p.images, p.articles, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder)
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder, p.articles)
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder, siteWriteTransactor(sites, p.articles))
c.SetDefaultSiteUseCase = useCases.NewSetDefaultSiteUseCase(sites, recorder)
c.DeleteSiteUseCase = useCases.NewDeleteSiteUseCase(sites, recorder)
c.ListActivityUseCase = useCases.NewListActivityUseCase(p.audit.store, p.users, p.articles, p.images, p.keys, p.audit.clock, sites, authors)
+1 -1
View File
@@ -153,7 +153,7 @@ func TestSiteUseCaseRules(t *testing.T) {
repo := newMockArticleRepo()
sites := newMockSiteRepo(repo)
sites.seed(2, "Shop", "shop", false)
uc := useCases.NewUpdateSiteUseCase(sites, newMockImageRepo(), discardRecorder, repo)
uc := useCases.NewUpdateSiteUseCase(sites, newMockImageRepo(), discardRecorder, siteWriteTransactor(sites, repo))
flag := true
_, err := uc.Update(testCtx, 2, site.Patch{IsDefault: &flag})
+49 -5
View File
@@ -14,6 +14,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
@@ -21,6 +22,7 @@ import (
)
type mockSiteRepo struct {
mu sync.Mutex
sites map[uint32]site.Site
articles *mockArticleRepo
nextID uint32
@@ -36,7 +38,12 @@ type mockSiteRepo struct {
}
func (m *mockSiteRepo) LockSite(id uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
m.lockedSites = append(m.lockedSites, id)
if len(m.lockedSites) > runawayLockCalls {
return errRunawayLocking
}
if m.busy {
return transaction.ErrLockContention
}
@@ -44,13 +51,18 @@ func (m *mockSiteRepo) LockSite(id uint32) error {
}
func (m *mockSiteRepo) TryLockSites(ids []uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
m.tryLocked = append(m.tryLocked, slices.Clone(ids))
if len(m.tryLocked) > runawayLockCalls {
return errRunawayLocking
}
if m.busy {
return transaction.ErrLockContention
}
if hook := m.afterTryLock; hook != nil {
m.afterTryLock = nil
hook()
outsideLock(&m.mu, hook)
}
return nil
}
@@ -98,9 +110,15 @@ func (m *mockSiteRepo) defaults() []uint32 {
}
func (m *mockSiteRepo) List() ([]site.Site, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("List"); err != nil {
return nil, err
}
return m.sortedSites(), nil
}
func (m *mockSiteRepo) sortedSites() []site.Site {
result := make([]site.Site, 0, len(m.sites))
for _, s := range m.sites {
result = append(result, m.withCount(s))
@@ -115,17 +133,19 @@ func (m *mockSiteRepo) List() ([]site.Site, error) {
}
return result[i].ID < result[j].ID
})
return result, nil
return result
}
func (m *mockSiteRepo) Find(query site.ListQuery) (site.Page, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("Find"); err != nil {
return site.Page{}, err
}
all, err := m.List()
if err != nil {
if err := m.fail.trigger("List"); err != nil {
return site.Page{}, err
}
all := m.sortedSites()
needle := foldArticleSearch(strings.TrimSpace(query.Q))
matching := []site.Site{}
for _, s := range all {
@@ -142,6 +162,8 @@ func (m *mockSiteRepo) Find(query site.ListQuery) (site.Page, error) {
}
func (m *mockSiteRepo) Get(id uint32) (site.Site, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("Get"); err != nil {
return site.Site{}, err
}
@@ -153,6 +175,8 @@ func (m *mockSiteRepo) Get(id uint32) (site.Site, error) {
}
func (m *mockSiteRepo) GetByKey(key string) (site.Site, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("GetByKey"); err != nil {
return site.Site{}, err
}
@@ -165,6 +189,8 @@ func (m *mockSiteRepo) GetByKey(key string) (site.Site, error) {
}
func (m *mockSiteRepo) GetDefault() (site.Site, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("GetDefault"); err != nil {
return site.Site{}, err
}
@@ -175,6 +201,8 @@ func (m *mockSiteRepo) GetDefault() (site.Site, error) {
}
func (m *mockSiteRepo) Create(s site.Site) (site.Site, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.createCalls++
if err := m.fail.trigger("Create"); err != nil {
return site.Site{}, err
@@ -192,6 +220,8 @@ func (m *mockSiteRepo) Create(s site.Site) (site.Site, error) {
}
func (m *mockSiteRepo) Update(s site.Site) (site.Site, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("Update"); err != nil {
return site.Site{}, err
}
@@ -207,6 +237,8 @@ func (m *mockSiteRepo) Update(s site.Site) (site.Site, error) {
}
func (m *mockSiteRepo) SetDefault(id uint32) (site.Site, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.setDefaultCalls++
if _, ok := m.sites[id]; !ok {
return site.Site{}, site.ErrSiteNotFound
@@ -227,6 +259,8 @@ func (m *mockSiteRepo) SetDefault(id uint32) (site.Site, error) {
}
func (m *mockSiteRepo) DeleteMovingArticles(id uint32, reassignTo *uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
m.deleteCalls++
m.deleteReassignments = append(m.deleteReassignments, reassignTo)
if err := m.fail.trigger("DeleteMovingArticles"); err != nil {
@@ -258,6 +292,8 @@ func (m *mockSiteRepo) DeleteMovingArticles(id uint32, reassignTo *uint32) error
}
func (m *mockSiteRepo) ClearImage(imageID uint32) ([]uint32, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("ClearImage"); err != nil {
return nil, err
}
@@ -274,6 +310,8 @@ func (m *mockSiteRepo) ClearImage(imageID uint32) ([]uint32, error) {
}
func (m *mockSiteRepo) AssignImage(siteIDs []uint32, imageID uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, id := range siteIDs {
s := m.sites[id]
assigned := imageID
@@ -284,6 +322,8 @@ func (m *mockSiteRepo) AssignImage(siteIDs []uint32, imageID uint32) error {
}
func (m *mockSiteRepo) RepointImage(fromImageIDs []uint32, toImageID uint32) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("RepointImage"); err != nil {
return 0, err
}
@@ -338,7 +378,7 @@ func newSiteEnv(t *testing.T) *siteEnv {
c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, plainAuthors())
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder)
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder, p.articles)
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder, siteWriteTransactor(sites, p.articles))
c.SetDefaultSiteUseCase = useCases.NewSetDefaultSiteUseCase(sites, recorder)
c.DeleteSiteUseCase = useCases.NewDeleteSiteUseCase(sites, recorder)
router := chi.NewRouter()
@@ -427,3 +467,7 @@ func plainSites() *mockSiteRepo {
sites.seed(1, "Default", "default", true)
return sites
}
func siteWriteTransactor(sites site.Repository, seo article.SEOIndex) transaction.Transactor {
return repositoriesTransactor{transaction.Repositories{Sites: sites, ArticleSEO: seo}}
}
+4
View File
@@ -74,6 +74,10 @@ func TestStoryDeleteHTTP(t *testing.T) {
if !slices.Equal(w.storyVersionIDs(), before) {
t.Fatalf("versions changed: %v", w.storyVersionIDs())
}
failed := w.eventsOf(audit.ActionArticleDelete)
if len(failed) != 1 || failed[0].Outcome != audit.OutcomeFailed || failed[0].Target.ID != "10" {
t.Fatalf("one failed article.delete on the oldest readable version expected: %+v", failed)
}
})
t.Run("An unknown or unreadable article answers the same not found", func(t *testing.T) {
+25
View File
@@ -74,3 +74,28 @@ func TestMoveStoryRelocksUntilTheSiteSetIsStable(t *testing.T) {
}
})
}
func TestDeleteStoryRelocksUntilTheSiteSetIsStable(t *testing.T) {
t.Run("A set that never settles is refused as lock contention after the last round", func(t *testing.T) {
w := newStoryWorld(t)
listing := &shiftingStoryRepo{mockArticleRepo: w.articles, shiftFrom: 1}
listing.shift = func() {
shifted := w.articles.articles[13]
shifted.SiteID = 100 + uint32(listing.listings)
w.articles.articles[13] = shifted
}
deleter := useCases.NewDeleteStoryUseCase(repositoriesTransactor{transaction.Repositories{Articles: listing, Sites: w.sites}}, listing, discardRecorder)
err := deleter.DeleteStory(context.Background(), useCases.DeleteStoryCommand{StoryID: storyStory, Caller: ownerCaller(storyOwner)})
if !errors.Is(err, transaction.ErrLockContention) {
t.Errorf("expected lock contention, got %v", err)
}
if listing.listings != 6 {
t.Errorf("expected five lock rounds plus the refusal lookup, got %d listings", listing.listings)
}
if _, kept := w.articles.articles[13]; !kept {
t.Error("nothing may be deleted")
}
})
}
+45 -11
View File
@@ -35,6 +35,7 @@ func (s *stubHasher) Compare(hashed string, value string) error {
}
type mockUserRepo struct {
mu sync.Mutex
users map[uint32]user.User
nextID uint32
findByIDsCalls int
@@ -60,7 +61,6 @@ type mockUserRepo struct {
beforeTransfer func()
beforeClearVerification func()
knownRoles *mockRoleRepo
confirmLock sync.Mutex
}
func newMockUserRepo() *mockUserRepo {
@@ -83,8 +83,8 @@ func (m *mockUserRepo) sortedIds() []uint32 {
}
func (m *mockUserRepo) Get(id uint32) (user.User, error) {
m.confirmLock.Lock()
defer m.confirmLock.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
m.getCalls++
if err := m.fail.trigger("Get"); err != nil {
return user.User{}, err
@@ -97,6 +97,8 @@ func (m *mockUserRepo) Get(id uint32) (user.User, error) {
}
func (m *mockUserRepo) GetByUsername(username string) (user.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("GetByUsername"); err != nil {
return user.User{}, err
}
@@ -110,6 +112,8 @@ func (m *mockUserRepo) GetByUsername(username string) (user.User, error) {
}
func (m *mockUserRepo) GetByEmail(email string) (user.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("GetByEmail"); err != nil {
return user.User{}, err
}
@@ -123,8 +127,8 @@ func (m *mockUserRepo) GetByEmail(email string) (user.User, error) {
}
func (m *mockUserRepo) IsEmailInUse(email string, exceptUserId uint32) (bool, error) {
m.confirmLock.Lock()
defer m.confirmLock.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
return m.emailInUse(email, exceptUserId)
}
@@ -141,6 +145,8 @@ func (m *mockUserRepo) emailInUse(email string, exceptUserId uint32) (bool, erro
}
func (m *mockUserRepo) SetPendingEmailChange(userId uint32, pendingEmail string, code string, expiration time.Time) (user.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("SetPendingEmailChange"); err != nil {
return user.User{}, err
}
@@ -156,6 +162,8 @@ func (m *mockUserRepo) SetPendingEmailChange(userId uint32, pendingEmail string,
}
func (m *mockUserRepo) ClearPendingEmailChange(userId uint32) (user.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("ClearPendingEmailChange"); err != nil {
return user.User{}, err
}
@@ -171,8 +179,8 @@ func (m *mockUserRepo) ClearPendingEmailChange(userId uint32) (user.User, error)
}
func (m *mockUserRepo) ConfirmEmailChange(userId uint32, pendingEmail string, code string, now time.Time) (user.User, error) {
m.confirmLock.Lock()
defer m.confirmLock.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("ConfirmEmailChange"); err != nil {
return user.User{}, err
}
@@ -198,6 +206,8 @@ func (m *mockUserRepo) ConfirmEmailChange(userId uint32, pendingEmail string, co
}
func (m *mockUserRepo) GetAll() ([]user.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.getAllCalls++
if err := m.fail.trigger("GetAll"); err != nil {
return nil, err
@@ -210,6 +220,8 @@ func (m *mockUserRepo) GetAll() ([]user.User, error) {
}
func (m *mockUserRepo) FindByIDs(ids []uint32) ([]user.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("FindByIDs"); err != nil {
return nil, err
}
@@ -224,6 +236,8 @@ func (m *mockUserRepo) FindByIDs(ids []uint32) ([]user.User, error) {
}
func (m *mockUserRepo) Create(u user.User) (user.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("Create"); err != nil {
return user.User{}, err
}
@@ -234,6 +248,8 @@ func (m *mockUserRepo) Create(u user.User) (user.User, error) {
}
func (m *mockUserRepo) Delete(id uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("Delete"); err != nil {
return err
}
@@ -245,6 +261,8 @@ func (m *mockUserRepo) Delete(id uint32) error {
}
func (m *mockUserRepo) DeleteIfRole(id uint32, roleID uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
m.deleteIfRoleCalls++
if err := m.fail.trigger("DeleteIfRole"); err != nil {
return err
@@ -252,7 +270,7 @@ func (m *mockUserRepo) DeleteIfRole(id uint32, roleID uint32) error {
if m.beforeDeleteIfRole != nil {
hook := m.beforeDeleteIfRole
m.beforeDeleteIfRole = nil
hook()
outsideLock(&m.mu, hook)
}
stored, ok := m.users[id]
if !ok {
@@ -266,6 +284,8 @@ func (m *mockUserRepo) DeleteIfRole(id uint32, roleID uint32) error {
}
func (m *mockUserRepo) UpdateVerificationStatus(userId uint32, isVerified bool) (user.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("UpdateVerificationStatus"); err != nil {
return user.User{}, err
}
@@ -279,6 +299,8 @@ func (m *mockUserRepo) UpdateVerificationStatus(userId uint32, isVerified bool)
}
func (m *mockUserRepo) UpdatePassword(userId uint32, password string) (user.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("UpdatePassword"); err != nil {
return user.User{}, err
}
@@ -292,6 +314,8 @@ func (m *mockUserRepo) UpdatePassword(userId uint32, password string) (user.User
}
func (m *mockUserRepo) UpdatePasswordResetCode(userId uint32, code string, expiration time.Time) (user.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.updatePasswordResetCodeCalls++
m.lastUpdatePasswordResetCodeUserId = userId
m.lastUpdatePasswordResetCodeCode = code
@@ -310,6 +334,8 @@ func (m *mockUserRepo) UpdatePasswordResetCode(userId uint32, code string, expir
}
func (m *mockUserRepo) ClearVerificationCode(userId uint32) (user.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.clearVerificationCodeCalls++
m.clearVerificationCodeUserIds = append(m.clearVerificationCodeUserIds, userId)
if err := m.fail.trigger("ClearVerificationCode"); err != nil {
@@ -318,7 +344,7 @@ func (m *mockUserRepo) ClearVerificationCode(userId uint32) (user.User, error) {
if m.beforeClearVerification != nil {
hook := m.beforeClearVerification
m.beforeClearVerification = nil
hook()
outsideLock(&m.mu, hook)
}
u, ok := m.users[userId]
if !ok {
@@ -338,6 +364,8 @@ func (m *mockUserRepo) ClearVerificationCode(userId uint32) (user.User, error) {
}
func (m *mockUserRepo) ResetPassword(userId uint32, hashedPassword string) (user.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.resetPasswordCalls++
m.lastResetPasswordUserId = userId
m.lastResetPasswordHash = hashedPassword
@@ -357,6 +385,8 @@ func (m *mockUserRepo) ResetPassword(userId uint32, hashedPassword string) (user
}
func (m *mockUserRepo) UpdateDisplayName(userId uint32, displayName string) (user.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.updateDisplayNameCalls++
m.lastUpdateDisplayName = displayName
if err := m.fail.trigger("UpdateDisplayName"); err != nil {
@@ -380,6 +410,8 @@ func (m *mockUserRepo) roleExists(id uint32) bool {
}
func (m *mockUserRepo) UpdateRole(id uint32, from uint32, to uint32, assignedBy uint32) (user.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.updateRoleCalls++
if err := m.fail.trigger("UpdateRole"); err != nil {
return user.User{}, err
@@ -387,7 +419,7 @@ func (m *mockUserRepo) UpdateRole(id uint32, from uint32, to uint32, assignedBy
if m.beforeUpdateRole != nil {
hook := m.beforeUpdateRole
m.beforeUpdateRole = nil
hook()
outsideLock(&m.mu, hook)
}
stored, ok := m.users[id]
if !ok {
@@ -406,6 +438,8 @@ func (m *mockUserRepo) UpdateRole(id uint32, from uint32, to uint32, assignedBy
}
func (m *mockUserRepo) TransferOwnership(from uint32, to uint32, ownerRoleID uint32, formerRoleID uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
m.transferCalls++
if err := m.fail.trigger("TransferOwnership"); err != nil {
return err
@@ -413,7 +447,7 @@ func (m *mockUserRepo) TransferOwnership(from uint32, to uint32, ownerRoleID uin
if m.beforeTransfer != nil {
hook := m.beforeTransfer
m.beforeTransfer = nil
hook()
outsideLock(&m.mu, hook)
}
former, formerFound := m.users[from]
if !formerFound || former.RoleID != ownerRoleID {