refac: missing mutation tests, spa specs, checks shown to pass, touch targets, catalogue entry, production code
This commit is contained in:
1 parent
7c15206170
commit
3e02e14da4
48 files changed
+2173
-83
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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}}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user