Refac/domain driven #1
No files matched your search
@@ -78,7 +78,7 @@ func getUseCases(repos *Repositories) *UseCases {
|
|||||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo),
|
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo),
|
||||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo),
|
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo),
|
||||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo),
|
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo),
|
||||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo),
|
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, repos.ImageRepo),
|
||||||
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
|
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
|
||||||
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher),
|
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher),
|
||||||
UpdateUserUseCase: useCases.NewUpdateUserUseCase(repos.UserRepo, hasher),
|
UpdateUserUseCase: useCases.NewUpdateUserUseCase(repos.UserRepo, hasher),
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
Feature: Deleting an article also deletes its images
|
||||||
|
|
||||||
|
As an authenticated RenewCMS user managing the CMS
|
||||||
|
I want deleting an article to also delete every image attached to it
|
||||||
|
So that removing an article doesn't leave orphaned image rows and files behind,
|
||||||
|
and so that an id that doesn't exist still answers 404 the same way every other
|
||||||
|
article write does
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given internal/domain/article.Article carries Images []*image.Image, populated by
|
||||||
|
ArticleRepository.Get via Preload("Images")
|
||||||
|
And internal/domain/image.Repository already exposes Delete(id uint32) error, which
|
||||||
|
on the real ImageRepository also removes the uploaded file from UPLOAD_DIR
|
||||||
|
And before this slice, DeleteArticleUseCase.DeleteArticle only called
|
||||||
|
articleRepository.Delete(id) and never touched image.Repository, so an article's
|
||||||
|
images were never deleted along with it
|
||||||
|
And this slice changes NewDeleteArticleUseCase to also take an image.Repository, and
|
||||||
|
changes DeleteArticleUseCase.DeleteArticle(id) to look up the article, delete every
|
||||||
|
image in its Images slice through image.Repository.Delete, and only then delete the
|
||||||
|
article itself through article.Repository.Delete
|
||||||
|
And this slice rewires api/dependecyInjection.go so
|
||||||
|
useCases.NewDeleteArticleUseCase(repos.ArticleRepo, repos.ImageRepo) supplies both
|
||||||
|
repositories
|
||||||
|
And DELETE /v1/article/{id} keeps sitting behind the jwtauth Verifier and
|
||||||
|
Authenticator group in api/router.go, unchanged from
|
||||||
|
features/write_not_found_errors.feature
|
||||||
|
And DELETE /v1/article/{id} keeps answering 204 with an empty body on success and 404
|
||||||
|
with body apperr.ArticleNotFoundErrorMessage for an id absent from the article
|
||||||
|
repository, backed by errors.Is(err, article.ErrArticleNotFound), unchanged from
|
||||||
|
features/write_not_found_errors.feature
|
||||||
|
And no image HTTP endpoint is mounted in api/router.go as of this slice, so cascading
|
||||||
|
image deletion is only observable through the image repository's state, not through
|
||||||
|
a separate HTTP call
|
||||||
|
And the use case deletes images in the order they appear in the article's Images
|
||||||
|
slice, and aborts on the first image.Repository.Delete error, leaving the article
|
||||||
|
and any not-yet-deleted images untouched, and answering the same generic 500 the
|
||||||
|
DeleteArticle handler already falls back to on any non-not-found error
|
||||||
|
|
||||||
|
Scenario: Deleting an article with images removes the article and every one of its images
|
||||||
|
Given a valid jwt cookie for a user with id 1
|
||||||
|
And the article repository contains an article with id 1 whose Images slice holds
|
||||||
|
image id 10 and image id 11
|
||||||
|
And the image repository contains images with id 10 and id 11
|
||||||
|
When the client sends DELETE /v1/article/1 with that cookie
|
||||||
|
Then the response status is 204
|
||||||
|
And the article repository no longer contains an article with id 1
|
||||||
|
And the image repository no longer contains an image with id 10 or id 11
|
||||||
|
|
||||||
|
Scenario: Deleting an article with no images still succeeds
|
||||||
|
Given a valid jwt cookie for a user with id 1
|
||||||
|
And the article repository contains an article with id 2 whose Images slice is empty
|
||||||
|
When the client sends DELETE /v1/article/2 with that cookie
|
||||||
|
Then the response status is 204
|
||||||
|
And the article repository no longer contains an article with id 2
|
||||||
|
And no call reaches the image repository's Delete method
|
||||||
|
|
||||||
|
Scenario: Deleting an unknown article still answers 404 and deletes no images
|
||||||
|
Given a valid jwt cookie for a user with id 1
|
||||||
|
And the article repository has no article with id 9999
|
||||||
|
When the client sends DELETE /v1/article/9999 with that cookie
|
||||||
|
Then the response status is 404
|
||||||
|
And the response body is the text "Article not found"
|
||||||
|
And no call reaches the image repository's Delete method
|
||||||
|
|
||||||
|
Scenario: Deleting an article still requires authentication
|
||||||
|
Given no jwt cookie is sent
|
||||||
|
And the article repository contains an article with id 1 whose Images slice holds
|
||||||
|
image id 10
|
||||||
|
When the client sends DELETE /v1/article/1 without a cookie
|
||||||
|
Then the response status is 401
|
||||||
|
And the article repository still contains the article with id 1
|
||||||
|
And the image repository still contains the image with id 10
|
||||||
|
|
||||||
|
Scenario: A failure deleting one of an article's images aborts the whole delete
|
||||||
|
Given a valid jwt cookie for a user with id 1
|
||||||
|
And the article repository contains an article with id 3 whose Images slice holds
|
||||||
|
image id 20 followed by image id 21
|
||||||
|
And the image repository contains images with id 20 and id 21
|
||||||
|
And the image repository's Delete fails for image id 20
|
||||||
|
When the client sends DELETE /v1/article/3 with that cookie
|
||||||
|
Then the response status is 500
|
||||||
|
And the response body is the text "Failed to delete article"
|
||||||
|
And the article repository still contains the article with id 3
|
||||||
|
And the image repository still contains the image with id 21
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# QA procedure: delete_article_cascades_images
|
||||||
|
|
||||||
|
Package: `test` (black-box, extending `article_test.go`).
|
||||||
|
|
||||||
|
## Setup shared by every case
|
||||||
|
1. Call `validator.InitValidator()` once.
|
||||||
|
2. Reuse `mockArticleRepo` (`test/article_test.go`) and `mockImageRepo`
|
||||||
|
(`test/image_test.go`) as-is. Extend `mockArticleRepo` seeding so a seeded article's
|
||||||
|
`Images []*image.Image` can hold pointers into the same `mockImageRepo.images` map used
|
||||||
|
by the test, so deleting through the article side and asserting through the image side
|
||||||
|
observe the same state.
|
||||||
|
3. Add a call counter (or reuse the existing `failSwitch`/call-tracking pattern already in
|
||||||
|
`test/`) on `mockImageRepo.Delete` so a scenario can assert it was never called, and reuse
|
||||||
|
`mockImageRepo.fail` (the existing `failSwitch`) to make `Delete` fail for a specific
|
||||||
|
image id.
|
||||||
|
4. Wire the real use cases over the fakes: `useCases.NewCreateArticleUseCase`,
|
||||||
|
`useCases.NewGetArticleUseCase`, `useCases.NewListArticlesUseCase`,
|
||||||
|
`useCases.NewUpdateArticleUseCase`, and the new two-argument
|
||||||
|
`useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)`. Build `article.NewHandler(...)`
|
||||||
|
with the same constructor shape it already has (the use case's internal dependency count
|
||||||
|
changes, not the handler's).
|
||||||
|
5. Build a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
|
||||||
|
6. Mount the routes on a fresh `chi.NewRouter()` mirroring `api/router.go`'s current shape:
|
||||||
|
`GET /` under `jwtauth.Verifier` only; `GET /{id}`, `POST /`, `DELETE /{id}`,
|
||||||
|
`PATCH /{id}`, `PATCH /{id}/publish`, `PATCH /{id}/unpublish` under `jwtauth.Verifier` +
|
||||||
|
`jwtauth.Authenticator`.
|
||||||
|
7. Mint a valid jwt cookie for user id 1: `token.Encode(map[string]any{"user_id": uint32(1)})`,
|
||||||
|
set as the `jwt` cookie on the relevant `httptest.NewRequest`.
|
||||||
|
|
||||||
|
## HTTP checks
|
||||||
|
8. Seed `imageRepo.images[10]` and `imageRepo.images[11]`, then seed
|
||||||
|
`articleRepo.articles[1] = article.Article{ID: 1, Title: "Backyard Renovation Update",
|
||||||
|
Images: []*image.Image{imageRepo.images[10], imageRepo.images[11]}}` (pointers into the
|
||||||
|
same map). Send `DELETE /v1/article/1` with the valid cookie -> status `204`; assert
|
||||||
|
`articleRepo.articles` no longer has key `1`; assert `imageRepo.images` no longer has
|
||||||
|
keys `10` or `11`.
|
||||||
|
9. Seed `articleRepo.articles[2] = article.Article{ID: 2, Title: "Draft: Kitchen Remodel"}`
|
||||||
|
with a nil/empty `Images` slice. Send `DELETE /v1/article/2` with the valid cookie ->
|
||||||
|
status `204`; assert `articleRepo.articles` no longer has key `2`; assert the image-delete
|
||||||
|
call counter is `0`.
|
||||||
|
10. Empty `articleRepo`, empty `imageRepo`. Send `DELETE /v1/article/9999` with the valid
|
||||||
|
cookie -> status `404`, body `"Article not found\n"`; assert the image-delete call
|
||||||
|
counter is `0`.
|
||||||
|
11. Seed `imageRepo.images[10]` and `articleRepo.articles[1]` with `Images` holding image id
|
||||||
|
10, per step 8's pattern. Send `DELETE /v1/article/1` with **no** cookie -> status `401`;
|
||||||
|
assert `articleRepo.articles[1]` is still present and `imageRepo.images[10]` is still
|
||||||
|
present.
|
||||||
|
12. Seed `imageRepo.images[20]` and `imageRepo.images[21]`, then seed
|
||||||
|
`articleRepo.articles[3] = article.Article{ID: 3, Title: "Kitchen Progress Photos",
|
||||||
|
Images: []*image.Image{imageRepo.images[20], imageRepo.images[21]}}` (image 20 before
|
||||||
|
image 21 in the slice). Arm `imageRepo.fail` so `Delete` returns an error only when
|
||||||
|
called with id `20` (leave id `21` unarmed). Send `DELETE /v1/article/3` with the valid
|
||||||
|
cookie -> status `500`, body `"Failed to delete article\n"`; assert
|
||||||
|
`articleRepo.articles[3]` is still present; assert `imageRepo.images[21]` is still
|
||||||
|
present (never reached because the delete aborted on image `20` first).
|
||||||
|
|
||||||
|
## Manual verification (not exercised by the Go test)
|
||||||
|
- Real cascading delete against a real SQLite file and real `UPLOAD_DIR`: confirm that after
|
||||||
|
`DELETE /v1/article/{id}` on a real running instance, the uploaded image files under
|
||||||
|
`UPLOAD_DIR` for that article are gone from disk and the `images` table no longer holds
|
||||||
|
those rows, and that no orphaned rows are left behind in the `article_images` join table.
|
||||||
|
Not exercised by `task test` (fakes only); requires `task dev` or a built `task build`
|
||||||
|
binary with a scratch DB/upload dir.
|
||||||
|
- SPA-visible behaviour: confirm the back-office article deletion flow (if/when one exists)
|
||||||
|
reflects that any images shown for that article are gone too. No such UI exists yet as of
|
||||||
|
this slice.
|
||||||
|
- No SMTP involvement.
|
||||||
@@ -1,15 +1,30 @@
|
|||||||
package useCases
|
package useCases
|
||||||
|
|
||||||
import "RenewCMS/internal/domain/article"
|
import (
|
||||||
|
"RenewCMS/internal/domain/article"
|
||||||
|
"RenewCMS/internal/domain/image"
|
||||||
|
)
|
||||||
|
|
||||||
type DeleteArticleUseCase struct {
|
type DeleteArticleUseCase struct {
|
||||||
articleRepository article.Repository
|
articleRepository article.Repository
|
||||||
|
imageRepository image.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDeleteArticleUseCase(articleRepository article.Repository) *DeleteArticleUseCase {
|
func NewDeleteArticleUseCase(articleRepository article.Repository, imageRepository image.Repository) *DeleteArticleUseCase {
|
||||||
return &DeleteArticleUseCase{articleRepository}
|
return &DeleteArticleUseCase{articleRepository, imageRepository}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *DeleteArticleUseCase) DeleteArticle(userId uint32) error {
|
func (g *DeleteArticleUseCase) DeleteArticle(id uint32) error {
|
||||||
return g.articleRepository.Delete(userId)
|
a, err := g.articleRepository.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, img := range a.Images {
|
||||||
|
if err := g.imageRepository.Delete(img.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return g.articleRepository.Delete(id)
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
|
|||||||
useCases.NewCreateArticleUseCase(articleRepo),
|
useCases.NewCreateArticleUseCase(articleRepo),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
|
|||||||
useCases.NewCreateArticleUseCase(repo),
|
useCases.NewCreateArticleUseCase(repo),
|
||||||
useCases.NewGetArticleUseCase(repo),
|
useCases.NewGetArticleUseCase(repo),
|
||||||
useCases.NewListArticlesUseCase(repo),
|
useCases.NewListArticlesUseCase(repo),
|
||||||
useCases.NewDeleteArticleUseCase(repo),
|
useCases.NewDeleteArticleUseCase(repo, newMockImageRepo()),
|
||||||
useCases.NewUpdateArticleUseCase(repo),
|
useCases.NewUpdateArticleUseCase(repo),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
|
|||||||
useCases.NewCreateArticleUseCase(articleRepo),
|
useCases.NewCreateArticleUseCase(articleRepo),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type mockImageRepo struct {
|
|||||||
images map[uint32]imageDomain.Image
|
images map[uint32]imageDomain.Image
|
||||||
nextID uint32
|
nextID uint32
|
||||||
fail failSwitch
|
fail failSwitch
|
||||||
|
failDeleteFor map[uint32]error
|
||||||
|
deleteCalls []uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMockImageRepo() *mockImageRepo {
|
func newMockImageRepo() *mockImageRepo {
|
||||||
@@ -45,9 +47,13 @@ func (m *mockImageRepo) Create(input imageDomain.ImageInput) (imageDomain.Image,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockImageRepo) Delete(id uint32) error {
|
func (m *mockImageRepo) Delete(id uint32) error {
|
||||||
|
m.deleteCalls = append(m.deleteCalls, id)
|
||||||
if err := m.fail.trigger("Delete"); err != nil {
|
if err := m.fail.trigger("Delete"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err, ok := m.failDeleteFor[id]; ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, ok := m.images[id]; !ok {
|
if _, ok := m.images[id]; !ok {
|
||||||
return imageDomain.ErrImageNotFound
|
return imageDomain.ErrImageNotFound
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
|
|||||||
useCases.NewCreateArticleUseCase(articleRepo),
|
useCases.NewCreateArticleUseCase(articleRepo),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo),
|
||||||
)
|
)
|
||||||
hasher := hashing.NewBcryptHasher()
|
hasher := hashing.NewBcryptHasher()
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
|
|||||||
useCases.NewCreateArticleUseCase(articleRepo),
|
useCases.NewCreateArticleUseCase(articleRepo),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ func TestDeleteArticleReportsMissingArticle(t *testing.T) {
|
|||||||
t.Run("id_1", func(t *testing.T) {
|
t.Run("id_1", func(t *testing.T) {
|
||||||
repo := newMockArticleRepo()
|
repo := newMockArticleRepo()
|
||||||
repo.articles[1] = article.Article{ID: 1}
|
repo.articles[1] = article.Article{ID: 1}
|
||||||
uc := useCases.NewDeleteArticleUseCase(repo)
|
uc := useCases.NewDeleteArticleUseCase(repo, newMockImageRepo())
|
||||||
|
|
||||||
err := uc.DeleteArticle(1)
|
err := uc.DeleteArticle(1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -332,7 +332,7 @@ func TestDeleteArticleReportsMissingArticle(t *testing.T) {
|
|||||||
t.Run("id_9", func(t *testing.T) {
|
t.Run("id_9", func(t *testing.T) {
|
||||||
repo := newMockArticleRepo()
|
repo := newMockArticleRepo()
|
||||||
repo.articles[1] = article.Article{ID: 1}
|
repo.articles[1] = article.Article{ID: 1}
|
||||||
uc := useCases.NewDeleteArticleUseCase(repo)
|
uc := useCases.NewDeleteArticleUseCase(repo, newMockImageRepo())
|
||||||
|
|
||||||
err := uc.DeleteArticle(9)
|
err := uc.DeleteArticle(9)
|
||||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||||
@@ -345,7 +345,7 @@ func TestDeleteArticlePropagatesRepositoryFailure(t *testing.T) {
|
|||||||
repo := newMockArticleRepo()
|
repo := newMockArticleRepo()
|
||||||
repo.articles[1] = article.Article{ID: 1}
|
repo.articles[1] = article.Article{ID: 1}
|
||||||
repo.fail.failNext("Delete")
|
repo.fail.failNext("Delete")
|
||||||
uc := useCases.NewDeleteArticleUseCase(repo)
|
uc := useCases.NewDeleteArticleUseCase(repo, newMockImageRepo())
|
||||||
|
|
||||||
err := uc.DeleteArticle(1)
|
err := uc.DeleteArticle(1)
|
||||||
if !errors.Is(err, errRepositoryFailure) {
|
if !errors.Is(err, errRepositoryFailure) {
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
|
|||||||
useCases.NewCreateArticleUseCase(articleRepo),
|
useCases.NewCreateArticleUseCase(articleRepo),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user