feat: SEO backend data (1/4)
This commit is contained in:
1 parent
cb1410f0b0
commit
0eb46b0d74
90 files changed
+6094
-350
No files matched your search
@@ -0,0 +1,479 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user