Files
RenewCMS/test/delete_article_cascades_images_test.go
T

231 lines
8.2 KiB
Go

package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/middleware"
"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 {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, imageRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase,
)
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.Use(middleware.RequireVerifiedUser(getUserUseCase))
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")
}
})
}