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