Files
RenewCMS/test/seo_data_media_http_test.go

480 lines
16 KiB
Go

package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"reflect"
"strconv"
"testing"
)
func (e *seoEnv) bodyUsing(imageID uint32) string {
return `<img src="` + producersBannerPath + strconv.Itoa(int(imageID)) + `.png">`
}
func (e *seoEnv) updateArticle(id uint32, change func(a *article.Article)) {
stored := e.articles.articles[id]
change(&stored)
e.articles.articles[id] = stored
}
func (e *seoEnv) usedIn(t *testing.T, imageID uint32) int {
t.Helper()
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/?size=100", "")
var listed struct {
Items []struct {
ID uint32 `json:"id"`
UsedIn int `json:"used_in"`
} `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
for _, item := range listed.Items {
if item.ID == imageID {
return item.UsedIn
}
}
t.Fatalf("image %d is not listed", imageID)
return 0
}
func (e *seoEnv) unusedListing(t *testing.T) []uint32 {
t.Helper()
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/?filter=unused&size=100", "")
var listed struct {
Items []struct {
ID uint32 `json:"id"`
} `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
ids := []uint32{}
for _, item := range listed.Items {
ids = append(ids, item.ID)
}
return ids
}
func (e *seoEnv) usage(t *testing.T, imageID uint32) []map[string]any {
t.Helper()
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/"+strconv.Itoa(int(imageID))+"/usage", "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var items []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return items
}
func TestSeoDataMediaUsage(t *testing.T) {
t.Run("A cover or social image counts as used", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) { a.CoverImageID = idPointer(5) })
items := e.usage(t, 5)
if len(items) != 1 || items[0]["id"] != float64(seoSpringID) || e.usedIn(t, 5) != 1 || len(e.unusedListing(t)) != 1 {
t.Errorf("usage %v, used_in %d, unused %v", items, e.usedIn(t, 5), e.unusedListing(t))
}
})
t.Run("Each reference kind counts", func(t *testing.T) {
cases := []struct {
name string
prep func(e *seoEnv)
listed bool
}{
{"the cover of an article", func(e *seoEnv) {
e.updateArticle(seoSpringID, func(a *article.Article) { a.CoverImageID = idPointer(5) })
}, false},
{"the social image of an article", func(e *seoEnv) {
e.updateArticle(seoSpringID, func(a *article.Article) { a.SocialImageID = idPointer(5) })
}, false},
{"the default social image of a site", func(e *seoEnv) {
shop := e.sites.sites[siteShopID]
shop.DefaultSocialImageID = idPointer(5)
e.sites.sites[siteShopID] = shop
}, false},
{"the avatar of an author", func(e *seoEnv) {
e.authors.seed(2, "Camille", "camille")
a := e.authors.authors[2]
a.AvatarID = idPointer(5)
e.authors.authors[2] = a
}, false},
{"a file name in an article body", func(e *seoEnv) { e.updateArticle(seoSpringID, func(a *article.Article) { a.Body = e.bodyUsing(5) }) }, false},
{"nothing", func(e *seoEnv) {}, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
c.prep(e)
unused := e.unusedListing(t)
listed := false
for _, id := range unused {
if id == 5 {
listed = true
}
}
if listed != c.listed {
t.Errorf("unused = %v", unused)
}
})
}
})
t.Run("An article that uses the image twice counts once", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) {
a.Body = e.bodyUsing(5)
a.CoverImageID = idPointer(5)
a.SocialImageID = idPointer(5)
})
if used := e.usedIn(t, 5); used != 1 {
t.Errorf("used_in = %d", used)
}
})
t.Run("The media list sums every kind", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(1, "Cover", siteMainID, "cover")
e.seedArticle(2, "Social", siteMainID, "social")
e.seedArticle(3, "Body", siteMainID, "body")
e.updateArticle(1, func(a *article.Article) { a.CoverImageID = idPointer(5) })
e.updateArticle(2, func(a *article.Article) { a.SocialImageID = idPointer(5) })
e.updateArticle(3, func(a *article.Article) { a.Body = e.bodyUsing(5) })
shop := e.sites.sites[siteShopID]
shop.DefaultSocialImageID = idPointer(5)
e.sites.sites[siteShopID] = shop
e.authors.seed(2, "Camille", "camille")
author := e.authors.authors[2]
author.AvatarID = idPointer(5)
e.authors.authors[2] = author
if used := e.usedIn(t, 5); used != 5 {
t.Errorf("used_in = %d", used)
}
})
t.Run("Usage lists articles, authors and sites", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(3, "Body article", siteMainID, "body-article")
e.seedArticle(8, "Cover article", siteMainID, "cover-article")
e.updateArticle(3, func(a *article.Article) { a.Body = e.bodyUsing(5); a.IsOnline = true })
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(5) })
e.authors.seed(2, "Camille", "camille")
author := e.authors.authors[2]
author.AvatarID = idPointer(5)
e.authors.authors[2] = author
main := e.sites.sites[siteMainID]
main.DefaultSocialImageID = idPointer(5)
e.sites.sites[siteMainID] = main
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/5/usage", "")
want := `[{"type": "article", "id": 3, "title": "Body article", "published": true, "fields": ["body"]},
{"type": "article", "id": 8, "title": "Cover article", "published": false, "fields": ["cover"]},
{"type": "author", "id": 2, "title": "Camille", "fields": ["avatar"]},
{"type": "site", "id": 1, "title": "Main", "fields": ["default_social_image"]}]`
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
expectJSONEqual(t, rec.Body.String(), want)
})
t.Run("The change is additive for existing consumers", func(t *testing.T) {
e := newSeoEnv(t)
e.seedArticle(3, "Body article", siteMainID, "body-article")
e.updateArticle(3, func(a *article.Article) { a.Body = e.bodyUsing(5); a.IsOnline = true })
items := e.usage(t, 5)
keys := []string{}
for key := range items[0] {
keys = append(keys, key)
}
if len(items) != 1 || items[0]["id"] != float64(3) || items[0]["title"] != "Body article" || items[0]["published"] != true || len(keys) != 5 {
t.Errorf("item = %v", items)
}
})
t.Run("An article using an image in several places is one item with all fields", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) {
a.Body = e.bodyUsing(5)
a.CoverImageID = idPointer(5)
a.SocialImageID = idPointer(5)
})
items := e.usage(t, 5)
if len(items) != 1 || !reflect.DeepEqual(items[0]["fields"], []any{"body", "cover", "social"}) {
t.Errorf("items = %v", items)
}
})
t.Run("An unused image has an empty usage array", func(t *testing.T) {
e := newSeoEnv(t)
e.seedBanner(9, "unused.png", "hash-unused")
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/9/usage", "")
expectStatusAndText(t, rec, http.StatusOK, "[]")
})
t.Run("An unknown image is not found", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/999/usage", "")
expectStatusAndText(t, rec, http.StatusNotFound, "Image not found")
})
t.Run("Offline articles are listed with published false", func(t *testing.T) {
e := newSeoEnv(t)
e.seedSpring()
e.updateArticle(seoSpringID, func(a *article.Article) { a.CoverImageID = idPointer(5) })
items := e.usage(t, 5)
if len(items) != 1 || items[0]["published"] != false {
t.Errorf("items = %v", items)
}
})
}
func TestSeoDataMediaDelete(t *testing.T) {
seedReferences := func(e *seoEnv) {
e.seedArticle(8, "Cover", siteMainID, "cover")
e.seedArticle(9, "Social", siteMainID, "social")
e.updateArticle(8, func(a *article.Article) {
a.CoverImageID = idPointer(5)
a.CoverImageAlt = "A field"
a.Body = "Body 8"
})
e.updateArticle(9, func(a *article.Article) { a.SocialImageID = idPointer(5); a.Body = "Body 9" })
shop := e.sites.sites[siteShopID]
shop.DefaultSocialImageID = idPointer(5)
e.sites.sites[siteShopID] = shop
e.authors.seed(2, "Camille", "camille")
author := e.authors.authors[2]
author.AvatarID = idPointer(5)
e.authors.authors[2] = author
}
t.Run("Deleting an image in use clears the references and is never refused", func(t *testing.T) {
e := newSeoEnv(t)
seedReferences(e)
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
if rec.Code != http.StatusNoContent {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if e.stored(8).CoverImageID != nil || e.stored(9).SocialImageID != nil || e.stored(8).CoverImageAlt != "A field" || e.stored(8).Body != "Body 8" || e.stored(9).Body != "Body 9" {
t.Errorf("articles = %+v %+v", e.stored(8), e.stored(9))
}
if e.authors.authors[2].AvatarID != nil || e.sites.sites[siteShopID].DefaultSocialImageID != nil {
t.Errorf("avatar or site reference left")
}
})
t.Run("A failed delete leaves every reference in place", func(t *testing.T) {
e := newSeoEnv(t)
seedReferences(e)
e.images.failDeleteFor = map[uint32]error{5: errors.New("storage down")}
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d", rec.Code)
}
cover, social := e.stored(8).CoverImageID, e.stored(9).SocialImageID
if cover == nil || *cover != 5 || social == nil || *social != 5 {
t.Errorf("article references = %v %v", cover, social)
}
if e.authors.authors[2].AvatarID == nil || e.sites.sites[siteShopID].DefaultSocialImageID == nil {
t.Errorf("avatar or site reference lost")
}
})
t.Run("A failure clearing the article references restores the other ones", func(t *testing.T) {
e := newSeoEnv(t)
seedReferences(e)
e.articles.fail.failNext("ClearImage")
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
if rec.Code != http.StatusInternalServerError || e.authors.authors[2].AvatarID == nil || e.sites.sites[siteShopID].DefaultSocialImageID == nil {
t.Errorf("status %d", rec.Code)
}
})
}
func TestSeoDataMediaMerge(t *testing.T) {
seedDuplicates := func(e *seoEnv) {
e.seedBanner(5, "a.png", "same-hash")
e.seedBanner(6, "b.png", "same-hash")
e.seedArticle(8, "Cover", siteMainID, "cover")
e.seedArticle(9, "Social", siteMainID, "social")
}
mergeBody := `{"keep_id": 5, "merge_ids": [6]}`
t.Run("Merging copies repoints article cover and social images", func(t *testing.T) {
e := newSeoEnv(t)
seedDuplicates(e)
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6) })
e.updateArticle(9, func(a *article.Article) { a.SocialImageID = idPointer(6) })
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", mergeBody)
decoded := decodeSeoObject(t, rec)
if rec.Code != http.StatusOK || decoded["articles_updated"] != float64(2) {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if *e.stored(8).CoverImageID != 5 || *e.stored(9).SocialImageID != 5 {
t.Errorf("references = %v %v", e.stored(8).CoverImageID, e.stored(9).SocialImageID)
}
})
t.Run("The merge result lists the updated article ids", func(t *testing.T) {
e := newSeoEnv(t)
seedDuplicates(e)
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6) })
e.updateArticle(9, func(a *article.Article) { a.SocialImageID = idPointer(6) })
merge := useCases.NewMergeMediaUseCase(e.images, e.articles, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors)
result, err := merge.Merge(testCtx, 5, []uint32{6})
if err != nil || result.ArticlesUpdated != 2 || !reflect.DeepEqual(result.UpdatedArticleIDs, []uint32{8, 9}) {
t.Errorf("result %+v, %v", result, err)
}
})
t.Run("An article rewritten in body and cover counts once", func(t *testing.T) {
e := newSeoEnv(t)
seedDuplicates(e)
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6); a.Body = e.bodyUsing(6) })
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", mergeBody)
if decodeSeoObject(t, rec)["articles_updated"] != float64(1) || *e.stored(8).CoverImageID != 5 || e.stored(8).Body != e.bodyUsing(5) {
t.Errorf("status %d body %s stored %+v", rec.Code, rec.Body.String(), e.stored(8))
}
})
t.Run("Authors and sites keep being repointed", func(t *testing.T) {
e := newSeoEnv(t)
seedDuplicates(e)
e.authors.seed(2, "Camille", "camille")
author := e.authors.authors[2]
author.AvatarID = idPointer(6)
e.authors.authors[2] = author
shop := e.sites.sites[siteShopID]
shop.DefaultSocialImageID = idPointer(6)
e.sites.sites[siteShopID] = shop
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", mergeBody)
decoded := decodeSeoObject(t, rec)
if decoded["authors_updated"] != float64(1) || decoded["sites_updated"] != float64(1) {
t.Errorf("body %s", rec.Body.String())
}
})
t.Run("A failed repoint leaves the bodies as they were", func(t *testing.T) {
e := newSeoEnv(t)
seedDuplicates(e)
e.updateArticle(8, func(a *article.Article) { a.Body = e.bodyUsing(6) })
e.articles.fail.failNext("RepointImage")
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", mergeBody)
if rec.Code != http.StatusInternalServerError || e.stored(8).Body != e.bodyUsing(6) {
t.Errorf("status %d body %q", rec.Code, e.stored(8).Body)
}
})
}
func TestSeoDataMediaPurge(t *testing.T) {
seedGroup := func(e *seoEnv) {
e.seedBanner(5, "a.png", "same-hash")
e.seedBanner(6, "b.png", "same-hash")
e.seedArticle(8, "Cover", siteMainID, "cover")
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6) })
}
t.Run("Purge repoints articles the same way", func(t *testing.T) {
e := newSeoEnv(t)
seedGroup(e)
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(5) })
e.updateArticle(8, func(a *article.Article) { a.SocialImageID = idPointer(6) })
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/purge", `{}`)
decoded := decodeSeoObject(t, rec)
if rec.Code != http.StatusOK || decoded["articles_updated"] != float64(1) || *e.stored(8).SocialImageID != 5 || *e.stored(8).CoverImageID != 5 {
t.Errorf("status %d body %s stored %+v", rec.Code, rec.Body.String(), e.stored(8))
}
})
t.Run("A failed purge group leaves covers pointing at existing images", func(t *testing.T) {
e := newSeoEnv(t)
seedGroup(e)
e.images.failDeleteFor = map[uint32]error{6: errors.New("storage down"), 5: errors.New("storage down")}
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/purge", `{}`)
decoded := decodeSeoObject(t, rec)
if rec.Code != http.StatusOK || decoded["failed_groups"] != float64(1) {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
for _, stored := range e.articles.articles {
for _, reference := range []*uint32{stored.CoverImageID, stored.SocialImageID} {
if reference == nil {
continue
}
if _, exists := e.images.images[*reference]; !exists {
t.Errorf("article %d points at the missing image %d", stored.ID, *reference)
}
}
}
})
t.Run("Cover and social usage counts in the purge preview", func(t *testing.T) {
e := newSeoEnv(t)
seedGroup(e)
rec := e.as(t, apiKeyOwnerId, http.MethodGet, "/v1/media/purge-preview", "")
var preview struct {
Groups int `json:"groups"`
Articles int `json:"articles"`
List []struct {
KeepID uint32 `json:"keep_id"`
UsedIn int `json:"used_in"`
} `json:"list"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &preview); err != nil || rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if preview.Groups != 1 || preview.Articles != 0 || len(preview.List) != 1 || preview.List[0].KeepID != 6 || preview.List[0].UsedIn != 1 {
t.Errorf("preview = %+v", preview)
}
})
}