472 lines
15 KiB
Go
472 lines
15 KiB
Go
package test
|
|
|
|
import (
|
|
siteHandler "RenewCMS/api/handlers/site"
|
|
"RenewCMS/internal/domain/article"
|
|
"RenewCMS/internal/domain/site"
|
|
"RenewCMS/internal/infrastructure/persistence/database"
|
|
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"github.com/go-chi/chi/v5"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func imageChange(t *testing.T, e *siteEnv) (string, string) {
|
|
t.Helper()
|
|
changes := e.single(t).Changes
|
|
if len(changes) != 1 || changes[0].Field != "default_social_image" || changes[0].Before == nil || changes[0].After == nil {
|
|
t.Fatalf("changes = %+v", changes)
|
|
}
|
|
return *changes[0].Before, *changes[0].After
|
|
}
|
|
|
|
func TestSitesImageChangeLabels(t *testing.T) {
|
|
t.Run("Switching between two images records both filenames", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
e.seedBanner(5, "banner.png", "a")
|
|
e.seedBanner(6, "other.png", "b")
|
|
e.useImageOnSite(siteShopID, 5)
|
|
|
|
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 6}`)
|
|
|
|
before, after := imageChange(t, e)
|
|
if rec.Code != http.StatusOK || before != "banner.png" || after != "other.png" {
|
|
t.Errorf("status %d, before %q, after %q", rec.Code, before, after)
|
|
}
|
|
})
|
|
|
|
t.Run("Setting the same image again records nothing", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
e.seedBanner(5, "banner.png", "a")
|
|
e.useImageOnSite(siteShopID, 5)
|
|
|
|
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5}`)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d", rec.Code)
|
|
}
|
|
e.noEvent(t)
|
|
})
|
|
|
|
t.Run("An image without a filename is labelled by its id", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
e.seedBanner(5, "banner.png", "a")
|
|
e.seedBanner(6, "", "b")
|
|
e.useImageOnSite(siteShopID, 6)
|
|
|
|
e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5}`)
|
|
|
|
before, after := imageChange(t, e)
|
|
if before != "6" || after != "banner.png" {
|
|
t.Errorf("before %q, after %q", before, after)
|
|
}
|
|
})
|
|
|
|
t.Run("A previous image that no longer exists is labelled by its id", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
e.seedBanner(5, "banner.png", "a")
|
|
e.useImageOnSite(siteShopID, 99)
|
|
|
|
e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5}`)
|
|
|
|
before, after := imageChange(t, e)
|
|
if before != "99" || after != "banner.png" {
|
|
t.Errorf("before %q, after %q", before, after)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSitesImageUsageCounting(t *testing.T) {
|
|
t.Run("Renaming an image used by an article and a site counts both", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
e.seedBanner(5, "banner.png", "a")
|
|
e.useImageOnSite(siteShopID, 5)
|
|
e.articles.articles[1] = articleUsingImage(5)
|
|
|
|
rec := e.owner(t, http.MethodPatch, "/v1/media/5", `{"filename": "renamed"}`)
|
|
|
|
var body struct {
|
|
UsedIn int `json:"used_in"`
|
|
}
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if rec.Code != http.StatusOK || body.UsedIn != 2 {
|
|
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("A purge keeps the copy that only a site uses", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
e.seedDuplicates()
|
|
e.useImageOnSite(siteShopID, 6)
|
|
|
|
rec := e.owner(t, http.MethodPost, "/v1/media/purge", "")
|
|
|
|
var body map[string]int
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if _, kept := e.images.images[6]; rec.Code != http.StatusOK || body["merged"] != 1 || !kept {
|
|
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
|
|
}
|
|
if _, gone := e.images.images[5]; gone {
|
|
t.Errorf("the unused copy was kept")
|
|
}
|
|
})
|
|
}
|
|
|
|
var errInjectedDatabaseFailure = errors.New("injected database failure")
|
|
|
|
type databaseFault struct {
|
|
armed atomic.Bool
|
|
}
|
|
|
|
func injectDatabaseFault(db *gorm.DB, name string, register func(db *gorm.DB, name string, fn func(*gorm.DB))) *databaseFault {
|
|
fault := &databaseFault{}
|
|
register(db, name, func(tx *gorm.DB) {
|
|
if fault.armed.Load() {
|
|
_ = tx.AddError(errInjectedDatabaseFailure)
|
|
}
|
|
})
|
|
return fault
|
|
}
|
|
|
|
func faultOnUpdate(db *gorm.DB, name string, fn func(*gorm.DB)) {
|
|
_ = db.Callback().Update().Before("gorm:update").Register(name, fn)
|
|
}
|
|
|
|
func faultOnQuery(db *gorm.DB, name string, fn func(*gorm.DB)) {
|
|
_ = db.Callback().Query().Before("gorm:query").Register(name, fn)
|
|
}
|
|
|
|
func TestArticleRepositoryWritesOnSQLite(t *testing.T) {
|
|
setup := func(t *testing.T) (*persistence.ArticleRepository, *gorm.DB, uint32) {
|
|
t.Helper()
|
|
db := openContractSQLite(t)
|
|
repo := persistence.NewArticleRepository(db)
|
|
defaultSite, err := persistence.NewSiteRepository(db).GetDefault()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return repo, db, defaultSite.ID
|
|
}
|
|
|
|
t.Run("GetAll returns every stored article", func(t *testing.T) {
|
|
repo, _, siteID := setup(t)
|
|
for range 2 {
|
|
if _, err := repo.Create(articleOn(siteID)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if all := repo.GetAll(); len(all) != 2 {
|
|
t.Errorf("articles = %+v", all)
|
|
}
|
|
})
|
|
|
|
t.Run("GetAll returns an empty list when the query fails", func(t *testing.T) {
|
|
repo, db, _ := setup(t)
|
|
fault := injectDatabaseFault(db, "fault:getall", faultOnQuery)
|
|
fault.armed.Store(true)
|
|
|
|
all := repo.GetAll()
|
|
|
|
if all == nil || len(all) != 0 {
|
|
t.Errorf("articles = %#v", all)
|
|
}
|
|
})
|
|
|
|
t.Run("UpdateBody changes only the body", func(t *testing.T) {
|
|
repo, _, siteID := setup(t)
|
|
created, _ := repo.Create(articleOn(siteID))
|
|
|
|
updated, err := repo.UpdateBody(created.ID, "New body")
|
|
reread, _ := repo.Get(created.ID)
|
|
|
|
if err != nil || updated.Body != "New body" || updated.Title != "Article" || reread.Body != "New body" {
|
|
t.Errorf("updated %+v, reread %+v, err %v", updated, reread, err)
|
|
}
|
|
})
|
|
|
|
t.Run("Update applies each provided field", func(t *testing.T) {
|
|
repo, db, siteID := setup(t)
|
|
other, err := persistence.NewSiteRepository(db).Create(site.Site{Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "index"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
created, _ := repo.Create(articleOn(siteID))
|
|
body := "New body"
|
|
|
|
bodied, err2 := repo.Update(created.ID, article.Update{Body: &body})
|
|
moved, err3 := repo.Update(created.ID, article.Update{SiteID: &other.ID})
|
|
untouched, err4 := repo.Update(created.ID, article.Update{})
|
|
|
|
if err2 != nil || err3 != nil || err4 != nil {
|
|
t.Fatalf("errors %v %v %v", err2, err3, err4)
|
|
}
|
|
if bodied.Title != "Article" || bodied.Body != body || bodied.SiteID != siteID {
|
|
t.Errorf("bodied = %+v", bodied)
|
|
}
|
|
if moved.SiteID != other.ID || moved.Title != "Article" || moved.Body != body {
|
|
t.Errorf("moved = %+v", moved)
|
|
}
|
|
if untouched.Title != moved.Title || untouched.Body != moved.Body || untouched.SiteID != moved.SiteID {
|
|
t.Errorf("untouched = %+v, expected %+v", untouched, moved)
|
|
}
|
|
})
|
|
|
|
t.Run("Missing articles are reported as not found", func(t *testing.T) {
|
|
repo, _, _ := setup(t)
|
|
body := "Body"
|
|
|
|
_, errBody := repo.UpdateBody(99, "x")
|
|
_, errUpdate := repo.Update(99, article.Update{Body: &body})
|
|
_, errOnline := repo.UpdateIsOnline(99, true)
|
|
|
|
for name, err := range map[string]error{"UpdateBody": errBody, "Update": errUpdate, "UpdateIsOnline": errOnline} {
|
|
if !errors.Is(err, article.ErrArticleNotFound) {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("A failing save surfaces the database error", func(t *testing.T) {
|
|
repo, db, siteID := setup(t)
|
|
created, _ := repo.Create(articleOn(siteID))
|
|
fault := injectDatabaseFault(db, "fault:save", faultOnUpdate)
|
|
fault.armed.Store(true)
|
|
body := "Body"
|
|
|
|
_, errBody := repo.UpdateBody(created.ID, "x")
|
|
_, errUpdate := repo.Update(created.ID, article.Update{Body: &body})
|
|
_, errOnline := repo.UpdateIsOnline(created.ID, true)
|
|
|
|
for name, err := range map[string]error{"UpdateBody": errBody, "Update": errUpdate, "UpdateIsOnline": errOnline} {
|
|
if !errors.Is(err, errInjectedDatabaseFailure) {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSiteRepositoryLookupsAndImages(t *testing.T) {
|
|
t.Run("GetByKey finds a site and reports a missing one", func(t *testing.T) {
|
|
db := openContractSQLite(t)
|
|
sites := persistence.NewSiteRepository(db)
|
|
created := mustCreateSite(t, sites, "Shop", "shop", false)
|
|
|
|
found, err := sites.GetByKey("shop")
|
|
_, missing := sites.GetByKey("nope")
|
|
|
|
if err != nil || found.ID != created.ID || !errors.Is(missing, site.ErrSiteNotFound) {
|
|
t.Errorf("found %+v, err %v, missing %v", found, err, missing)
|
|
}
|
|
})
|
|
|
|
t.Run("AssignImage sets the image on the listed sites only", func(t *testing.T) {
|
|
db := openContractSQLite(t)
|
|
sites := persistence.NewSiteRepository(db)
|
|
first := mustCreateSite(t, sites, "First", "first", false)
|
|
second := mustCreateSite(t, sites, "Second", "second", false)
|
|
|
|
err := sites.AssignImage([]uint32{first.ID}, 7)
|
|
emptyErr := sites.AssignImage(nil, 8)
|
|
|
|
assigned, _ := sites.Get(first.ID)
|
|
other, _ := sites.Get(second.ID)
|
|
if err != nil || emptyErr != nil || assigned.DefaultSocialImageID == nil || *assigned.DefaultSocialImageID != 7 || other.DefaultSocialImageID != nil {
|
|
t.Errorf("assigned %+v, other %+v, errs %v %v", assigned, other, err, emptyErr)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSiteSeedReportsLookupFailure(t *testing.T) {
|
|
db := openContractSQLite(t)
|
|
_ = db.Callback().Query().Before("gorm:query").Register("fault:seed", func(tx *gorm.DB) {
|
|
if _, ordered := tx.Statement.Clauses["ORDER BY"]; ordered {
|
|
_ = tx.AddError(errInjectedDatabaseFailure)
|
|
}
|
|
})
|
|
|
|
err := database.SeedDefaultSite(db, migrationPublicURL)
|
|
|
|
if !errors.Is(err, errInjectedDatabaseFailure) {
|
|
t.Errorf("err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSiteHandlerLogsResponseWriteFailures(t *testing.T) {
|
|
capture := func(t *testing.T) *bytes.Buffer {
|
|
t.Helper()
|
|
var out bytes.Buffer
|
|
previous := log.Writer()
|
|
log.SetOutput(&out)
|
|
t.Cleanup(func() { log.SetOutput(previous) })
|
|
return &out
|
|
}
|
|
serve := func(e *siteEnv, w http.ResponseWriter, method string, path string) {
|
|
c := e.container
|
|
handler := siteHandler.NewHandler(c.ListSitesUseCase, c.CreateSiteUseCase, c.UpdateSiteUseCase, c.SetDefaultSiteUseCase, c.DeleteSiteUseCase)
|
|
router := chi.NewRouter()
|
|
router.Get("/", handler.List)
|
|
router.Patch("/{id}/default", handler.SetDefault)
|
|
router.ServeHTTP(w, httptest.NewRequest(method, path, nil))
|
|
}
|
|
|
|
t.Run("A failed list write is logged", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
out := capture(t)
|
|
|
|
serve(e, &erroringResponseWriter{}, http.MethodGet, "/")
|
|
|
|
if !strings.Contains(out.String(), "Failed to write response") {
|
|
t.Errorf("log = %q", out.String())
|
|
}
|
|
})
|
|
|
|
t.Run("A failed single site write is logged", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
out := capture(t)
|
|
|
|
serve(e, &erroringResponseWriter{}, http.MethodPatch, "/2/default")
|
|
|
|
if !strings.Contains(out.String(), "Failed to write response") {
|
|
t.Errorf("log = %q", out.String())
|
|
}
|
|
})
|
|
|
|
t.Run("Successful writes log nothing", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
out := capture(t)
|
|
|
|
serve(e, httptest.NewRecorder(), http.MethodGet, "/")
|
|
serve(e, httptest.NewRecorder(), http.MethodPatch, "/2/default")
|
|
|
|
if out.Len() != 0 {
|
|
t.Errorf("log = %q", out.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSiteDeletionIsCheckedInsideTheTransaction(t *testing.T) {
|
|
setup := func(t *testing.T) (*persistence.SiteRepository, *persistence.ArticleRepository, site.Site, site.Site) {
|
|
t.Helper()
|
|
db := openContractSQLite(t)
|
|
sites := persistence.NewSiteRepository(db)
|
|
articles := persistence.NewArticleRepository(db)
|
|
shop := mustCreateSite(t, sites, "Shop", "shop", false)
|
|
blog := mustCreateSite(t, sites, "Blog", "blog", false)
|
|
return sites, articles, shop, blog
|
|
}
|
|
|
|
t.Run("A site that gained articles is not deleted without a destination", func(t *testing.T) {
|
|
sites, articles, shop, _ := setup(t)
|
|
if _, err := articles.Create(articleOn(shop.ID)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := sites.DeleteMovingArticles(shop.ID, nil)
|
|
|
|
if _, getErr := sites.Get(shop.ID); !errors.Is(err, site.ErrSiteHasArticles) || getErr != nil {
|
|
t.Errorf("err = %v, get = %v", err, getErr)
|
|
}
|
|
})
|
|
|
|
t.Run("A missing destination changes nothing", func(t *testing.T) {
|
|
sites, articles, shop, _ := setup(t)
|
|
created, _ := articles.Create(articleOn(shop.ID))
|
|
missing := uint32(999)
|
|
|
|
err := sites.DeleteMovingArticles(shop.ID, &missing)
|
|
|
|
moved, _ := articles.Get(created.ID)
|
|
if _, getErr := sites.Get(shop.ID); !errors.Is(err, site.ErrSiteNotFound) || getErr != nil || moved.SiteID != shop.ID {
|
|
t.Errorf("err = %v, get = %v, article site = %d", err, getErr, moved.SiteID)
|
|
}
|
|
})
|
|
|
|
t.Run("The default site is never deleted", func(t *testing.T) {
|
|
sites, _, _, _ := setup(t)
|
|
defaultSite, _ := sites.GetDefault()
|
|
|
|
err := sites.DeleteMovingArticles(defaultSite.ID, nil)
|
|
|
|
if !errors.Is(err, site.ErrDefaultSite) {
|
|
t.Errorf("err = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("Articles move to the destination and the site goes away", func(t *testing.T) {
|
|
sites, articles, shop, blog := setup(t)
|
|
created, _ := articles.Create(articleOn(shop.ID))
|
|
|
|
err := sites.DeleteMovingArticles(shop.ID, &blog.ID)
|
|
|
|
moved, _ := articles.Get(created.ID)
|
|
_, getErr := sites.Get(shop.ID)
|
|
if err != nil || moved.SiteID != blog.ID || !errors.Is(getErr, site.ErrSiteNotFound) {
|
|
t.Errorf("err = %v, article site = %d, get = %v", err, moved.SiteID, getErr)
|
|
}
|
|
})
|
|
|
|
t.Run("An empty site is deleted without a destination", func(t *testing.T) {
|
|
sites, _, shop, _ := setup(t)
|
|
|
|
err := sites.DeleteMovingArticles(shop.ID, nil)
|
|
|
|
if _, getErr := sites.Get(shop.ID); err != nil || !errors.Is(getErr, site.ErrSiteNotFound) {
|
|
t.Errorf("err = %v, get = %v", err, getErr)
|
|
}
|
|
})
|
|
|
|
t.Run("Writing an article to a deleted site is refused", func(t *testing.T) {
|
|
sites, articles, shop, blog := setup(t)
|
|
created, _ := articles.Create(articleOn(blog.ID))
|
|
if err := sites.DeleteMovingArticles(shop.ID, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, createErr := articles.Create(articleOn(shop.ID))
|
|
_, updateErr := articles.Update(created.ID, article.Update{SiteID: &shop.ID})
|
|
|
|
unchanged, _ := articles.Get(created.ID)
|
|
if !errors.Is(createErr, site.ErrSiteNotFound) || !errors.Is(updateErr, site.ErrSiteNotFound) || unchanged.SiteID != blog.ID || len(articles.GetAll()) != 1 {
|
|
t.Errorf("create %v, update %v, site %d", createErr, updateErr, unchanged.SiteID)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSiteNameAndBaseURLRules(t *testing.T) {
|
|
t.Run("Names are 1 to 100 characters", func(t *testing.T) {
|
|
cases := map[string]bool{"": false, "a": true, strings.Repeat("a", 100): true, strings.Repeat("a", 101): false, strings.Repeat("é", 100): true}
|
|
for name, valid := range cases {
|
|
if site.IsValidName(name) != valid {
|
|
t.Errorf("%d runes: expected %v", len([]rune(name)), valid)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("The seed and the normalizer share the origin rules", func(t *testing.T) {
|
|
for _, raw := range []string{"", "ftp://example.com", "https://", "://bad", "example.com"} {
|
|
_, seedErr := site.SeedBaseURL(raw)
|
|
_, normalizeErr := site.NormalizeBaseURL(raw)
|
|
if !errors.Is(seedErr, site.ErrInvalidSiteURL) || !errors.Is(normalizeErr, site.ErrInvalidSiteURL) {
|
|
t.Errorf("%q: seed %v, normalize %v", raw, seedErr, normalizeErr)
|
|
}
|
|
}
|
|
seeded, err := site.SeedBaseURL("HTTP://Example.COM/blog?x=1")
|
|
if err != nil || seeded != "https://example.com" {
|
|
t.Errorf("seeded = %q, %v", seeded, err)
|
|
}
|
|
if _, err := site.NormalizeBaseURL("http://example.com"); !errors.Is(err, site.ErrInvalidSiteURL) {
|
|
t.Errorf("err = %v", err)
|
|
}
|
|
})
|
|
}
|