Files
RenewCMS/test/site_cleanup_test.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)
}
})
}