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