480 lines
16 KiB
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)
|
|
}
|
|
})
|
|
|
|
}
|