refac: deleting an article also deletes its images, and deleting a missing article returns 404

This commit is contained in:
fsylvain committed 2026-09-23 23:42:18 +02:00
1 parent c16fdbd26d
commit 8a8a24434a
13 files changed
+414 -18

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
useCases.NewUpdateArticleUseCase(articleRepo),
)
+1 -1
View File
@@ -25,7 +25,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
useCases.NewCreateArticleUseCase(repo),
useCases.NewGetArticleUseCase(repo),
useCases.NewListArticlesUseCase(repo),
useCases.NewDeleteArticleUseCase(repo),
useCases.NewDeleteArticleUseCase(repo, newMockImageRepo()),
useCases.NewUpdateArticleUseCase(repo),
)
+224
View File
@@ -0,0 +1,224 @@
package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func seedArticleImages(imageRepo *mockImageRepo, ids ...uint32) []*domainImage.Image {
images := make([]*domainImage.Image, len(ids))
for i, id := range ids {
imageRepo.images[id] = domainImage.Image{ID: id}
seeded := imageRepo.images[id]
images[i] = &seeded
}
return images
}
func TestDeleteArticleUseCaseCascadesImageDeletion(t *testing.T) {
t.Run("deletes every image before deleting the article, in Images slice order", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Images: seedArticleImages(imageRepo, 10, 11)}
uc := useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)
if err := uc.DeleteArticle(1); err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got := imageRepo.deleteCalls; len(got) != 2 || got[0] != 10 || got[1] != 11 {
t.Errorf("expected image Delete to be called with [10 11] in order, got %v", got)
}
if _, exists := articleRepo.articles[1]; exists {
t.Errorf("expected article 1 to have been deleted")
}
})
t.Run("stops at the first image Delete failure and leaves the article untouched", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[3] = domainArticle.Article{ID: 3, Images: seedArticleImages(imageRepo, 20, 21)}
imageRepo.failDeleteFor = map[uint32]error{20: errRepositoryFailure}
uc := useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)
err := uc.DeleteArticle(3)
if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected errRepositoryFailure, got %v", err)
}
if got := imageRepo.deleteCalls; len(got) != 1 || got[0] != 20 {
t.Errorf("expected image Delete to be called only once with 20, got %v", got)
}
if _, exists := articleRepo.articles[3]; !exists {
t.Errorf("expected article 3 to still be present")
}
if _, exists := imageRepo.images[21]; !exists {
t.Errorf("expected image 21 to still be present")
}
})
t.Run("propagates ErrArticleNotFound without touching the image repository", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
uc := useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)
err := uc.DeleteArticle(9999)
if !errors.Is(err, domainArticle.ErrArticleNotFound) {
t.Fatalf("expected article.ErrArticleNotFound, got %v", err)
}
if len(imageRepo.deleteCalls) != 0 {
t.Errorf("expected no call to the image repository's Delete method, got %v", imageRepo.deleteCalls)
}
})
}
func newDeleteArticleCascadesImagesRouter(articleRepo *mockArticleRepo, imageRepo *mockImageRepo, token *jwtauth.JWTAuth) *chi.Mux {
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, imageRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Route("/article", func(r chi.Router) {
r.Delete("/{id}", articleHandler.DeleteArticle)
})
})
})
return r
}
func TestDeleteArticleCascadesImages(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("Deleting an article with images removes the article and every one of its images", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Images: seedArticleImages(imageRepo, 10, 11)}
router := newDeleteArticleCascadesImagesRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", rec.Code)
}
if _, exists := articleRepo.articles[1]; exists {
t.Errorf("expected article 1 to have been deleted")
}
if _, exists := imageRepo.images[10]; exists {
t.Errorf("expected image 10 to have been deleted")
}
if _, exists := imageRepo.images[11]; exists {
t.Errorf("expected image 11 to have been deleted")
}
})
t.Run("Deleting an article with no images still succeeds", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[2] = domainArticle.Article{ID: 2}
router := newDeleteArticleCascadesImagesRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/2", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", rec.Code)
}
if _, exists := articleRepo.articles[2]; exists {
t.Errorf("expected article 2 to have been deleted")
}
if len(imageRepo.deleteCalls) != 0 {
t.Errorf("expected no call to the image repository's Delete method, got %v", imageRepo.deleteCalls)
}
})
t.Run("Deleting an unknown article still answers 404 and deletes no images", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
router := newDeleteArticleCascadesImagesRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/9999", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if len(imageRepo.deleteCalls) != 0 {
t.Errorf("expected no call to the image repository's Delete method, got %v", imageRepo.deleteCalls)
}
})
t.Run("Deleting an article still requires authentication", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Images: seedArticleImages(imageRepo, 10)}
router := newDeleteArticleCascadesImagesRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", rec.Code)
}
if _, exists := articleRepo.articles[1]; !exists {
t.Errorf("expected article 1 to still be present")
}
if _, exists := imageRepo.images[10]; !exists {
t.Errorf("expected image 10 to still be present")
}
})
t.Run("A failure deleting one of an article's images aborts the whole delete", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[3] = domainArticle.Article{ID: 3, Images: seedArticleImages(imageRepo, 20, 21)}
imageRepo.failDeleteFor = map[uint32]error{20: errRepositoryFailure}
router := newDeleteArticleCascadesImagesRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/3", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", rec.Code)
}
if rec.Body.String() != "Failed to delete article\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if _, exists := articleRepo.articles[3]; !exists {
t.Errorf("expected article 3 to still be present")
}
if _, exists := imageRepo.images[21]; !exists {
t.Errorf("expected image 21 to still be present")
}
})
}
+1 -1
View File
@@ -23,7 +23,7 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
useCases.NewUpdateArticleUseCase(articleRepo),
)
+9 -3
View File
@@ -19,9 +19,11 @@ var supportedImageContentTypes = map[string]bool{
}
type mockImageRepo struct {
images map[uint32]imageDomain.Image
nextID uint32
fail failSwitch
images map[uint32]imageDomain.Image
nextID uint32
fail failSwitch
failDeleteFor map[uint32]error
deleteCalls []uint32
}
func newMockImageRepo() *mockImageRepo {
@@ -45,9 +47,13 @@ func (m *mockImageRepo) Create(input imageDomain.ImageInput) (imageDomain.Image,
}
func (m *mockImageRepo) Delete(id uint32) error {
m.deleteCalls = append(m.deleteCalls, id)
if err := m.fail.trigger("Delete"); err != nil {
return err
}
if err, ok := m.failDeleteFor[id]; ok {
return err
}
if _, ok := m.images[id]; !ok {
return imageDomain.ErrImageNotFound
}
+1 -1
View File
@@ -128,7 +128,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
useCases.NewUpdateArticleUseCase(articleRepo),
)
hasher := hashing.NewBcryptHasher()
+1 -1
View File
@@ -41,7 +41,7 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
useCases.NewUpdateArticleUseCase(articleRepo),
)
+3 -3
View File
@@ -321,7 +321,7 @@ func TestDeleteArticleReportsMissingArticle(t *testing.T) {
t.Run("id_1", func(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1}
uc := useCases.NewDeleteArticleUseCase(repo)
uc := useCases.NewDeleteArticleUseCase(repo, newMockImageRepo())
err := uc.DeleteArticle(1)
if err != nil {
@@ -332,7 +332,7 @@ func TestDeleteArticleReportsMissingArticle(t *testing.T) {
t.Run("id_9", func(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1}
uc := useCases.NewDeleteArticleUseCase(repo)
uc := useCases.NewDeleteArticleUseCase(repo, newMockImageRepo())
err := uc.DeleteArticle(9)
if !errors.Is(err, article.ErrArticleNotFound) {
@@ -345,7 +345,7 @@ func TestDeleteArticlePropagatesRepositoryFailure(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1}
repo.fail.failNext("Delete")
uc := useCases.NewDeleteArticleUseCase(repo)
uc := useCases.NewDeleteArticleUseCase(repo, newMockImageRepo())
err := uc.DeleteArticle(1)
if !errors.Is(err, errRepositoryFailure) {
+1 -1
View File
@@ -192,7 +192,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
useCases.NewUpdateArticleUseCase(articleRepo),
)