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