refac: repository updates, image attachment and deletes report the domain not-found error for an unknown id, the same for articles, users and images, and the test fakes stop returning GORM errors

This commit is contained in:
fsylvain committed 2026-09-23 22:09:06 +02:00
1 parent 4278d96882
commit 6a2ca07b66
14 files changed
+553 -66

No files matched your search

+7 -6
View File
@@ -7,8 +7,6 @@ import (
"errors"
"sort"
"testing"
"gorm.io/gorm"
)
type mockArticleRepo struct {
@@ -99,7 +97,7 @@ func (m *mockArticleRepo) UpdateBody(id uint32, body string) (article.Article, e
}
a, ok := m.articles[id]
if !ok {
return article.Article{}, gorm.ErrRecordNotFound
return article.Article{}, article.ErrArticleNotFound
}
a.Body = body
m.articles[id] = a
@@ -112,7 +110,7 @@ func (m *mockArticleRepo) UpdateIsOnline(id uint32, isOnline bool) (article.Arti
}
a, ok := m.articles[id]
if !ok {
return article.Article{}, gorm.ErrRecordNotFound
return article.Article{}, article.ErrArticleNotFound
}
a.IsOnline = isOnline
m.articles[id] = a
@@ -123,6 +121,9 @@ func (m *mockArticleRepo) Delete(id uint32) error {
if err := m.fail.trigger("Delete"); err != nil {
return err
}
if _, ok := m.articles[id]; !ok {
return article.ErrArticleNotFound
}
delete(m.articles, id)
return nil
}
@@ -133,11 +134,11 @@ func (m *mockArticleRepo) AddImage(articleId uint32, imageId uint32) error {
}
a, ok := m.articles[articleId]
if !ok {
return gorm.ErrRecordNotFound
return article.ErrArticleNotFound
}
img, ok := m.images[imageId]
if !ok {
return gorm.ErrRecordNotFound
return imageDomain.ErrImageNotFound
}
a.Images = append(a.Images, img)
m.articles[articleId] = a
+1 -3
View File
@@ -7,8 +7,6 @@ import (
"io"
"strings"
"testing"
"gorm.io/gorm"
)
var errUnsupportedImageContentType = errors.New("the file must be a PNG, JPEG, WEBP, or SVG image")
@@ -51,7 +49,7 @@ func (m *mockImageRepo) Delete(id uint32) error {
return err
}
if _, ok := m.images[id]; !ok {
return gorm.ErrRecordNotFound
return imageDomain.ErrImageNotFound
}
delete(m.images, id)
return nil
+58 -42
View File
@@ -11,8 +11,6 @@ import (
"strings"
"testing"
"time"
"gorm.io/gorm"
)
func TestCreateArticleTitleLengthLimit(t *testing.T) {
@@ -258,8 +256,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
_, err := uc.UpdateBody(9, "New body")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
})
@@ -279,8 +277,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
_, err := uc.UpdateIsOnline(9, true)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
})
@@ -304,8 +302,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
err := uc.AddImage(9, 1)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
})
@@ -313,25 +311,34 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
err := uc.AddImage(1, 9)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
if !errors.Is(err, imageDomain.ErrImageNotFound) {
t.Errorf("expected image.ErrImageNotFound, got %v", err)
}
})
}
func TestDeleteArticleSucceedsSilently(t *testing.T) {
for _, id := range []uint32{1, 9} {
t.Run(fmt.Sprintf("id_%d", id), func(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1}
uc := useCases.NewDeleteArticleUseCase(repo)
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)
err := uc.DeleteArticle(id)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
})
}
err := uc.DeleteArticle(1)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
})
t.Run("id_9", func(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1}
uc := useCases.NewDeleteArticleUseCase(repo)
err := uc.DeleteArticle(9)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
})
}
func TestDeleteArticlePropagatesRepositoryFailure(t *testing.T) {
@@ -425,8 +432,8 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
uc := useCases.NewDeleteImageUseCase(repo)
err := uc.DeleteImage(9)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
if !errors.Is(err, imageDomain.ErrImageNotFound) {
t.Errorf("expected image.ErrImageNotFound, got %v", err)
}
})
}
@@ -604,8 +611,8 @@ func TestUpdateUserDelegatesEachOperation(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
_, err := uc.UpdateVerificationStatus(9, true)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
if !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
})
@@ -625,8 +632,8 @@ func TestUpdateUserDelegatesEachOperation(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
_, err := uc.UpdatePassword(9, "newpassword123")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
if !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
})
@@ -646,25 +653,34 @@ func TestUpdateUserDelegatesEachOperation(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
_, err := uc.UpdatePasswordResetCode(9, "reset-code-123")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
if !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
})
}
func TestDeleteUserSucceedsSilently(t *testing.T) {
for _, id := range []uint32{1, 9} {
t.Run(fmt.Sprintf("id_%d", id), func(t *testing.T) {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1}
uc := useCases.NewDeleteUserUseCase(repo)
func TestDeleteUserReportsMissingUser(t *testing.T) {
t.Run("id_1", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1}
uc := useCases.NewDeleteUserUseCase(repo)
err := uc.DeleteUser(id)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
})
}
err := uc.DeleteUser(1)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
})
t.Run("id_9", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1}
uc := useCases.NewDeleteUserUseCase(repo)
err := uc.DeleteUser(9)
if !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
})
}
func TestSendMailForwardsArgumentsVerbatim(t *testing.T) {
+6 -5
View File
@@ -6,8 +6,6 @@ import (
"errors"
"sort"
"testing"
"gorm.io/gorm"
)
type stubHasher struct {
@@ -106,6 +104,9 @@ func (m *mockUserRepo) Delete(id uint32) error {
if err := m.fail.trigger("Delete"); err != nil {
return err
}
if _, ok := m.users[id]; !ok {
return user.ErrUserNotFound
}
delete(m.users, id)
return nil
}
@@ -116,7 +117,7 @@ func (m *mockUserRepo) UpdateVerificationStatus(userId uint32, isVerified bool)
}
u, ok := m.users[userId]
if !ok {
return user.User{}, gorm.ErrRecordNotFound
return user.User{}, user.ErrUserNotFound
}
u.IsVerified = isVerified
m.users[userId] = u
@@ -129,7 +130,7 @@ func (m *mockUserRepo) UpdatePassword(userId uint32, password string) (user.User
}
u, ok := m.users[userId]
if !ok {
return user.User{}, gorm.ErrRecordNotFound
return user.User{}, user.ErrUserNotFound
}
u.Password = password
m.users[userId] = u
@@ -142,7 +143,7 @@ func (m *mockUserRepo) UpdatePasswordResetCode(userId uint32, code string) (user
}
u, ok := m.users[userId]
if !ok {
return user.User{}, gorm.ErrRecordNotFound
return user.User{}, user.ErrUserNotFound
}
u.PasswordResetCode = code
m.users[userId] = u
+30
View File
@@ -0,0 +1,30 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
domainUser "RenewCMS/internal/domain/user"
"errors"
"testing"
)
func TestDeletingTheSeededArticleActuallyRemovesTheRow(t *testing.T) {
repo := newSeededArticleRepository(t)
if err := repo.Delete(1); err != nil {
t.Fatalf("expected Delete(1) to succeed, got %v", err)
}
if _, err := repo.Get(1); !errors.Is(err, domainArticle.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound after delete, got %v", err)
}
}
func TestDeletingTheSeededUserActuallyRemovesTheRow(t *testing.T) {
repo := newSeededUserRepository(t)
if err := repo.Delete(1); err != nil {
t.Fatalf("expected Delete(1) to succeed, got %v", err)
}
if _, err := repo.Get(1); !errors.Is(err, domainUser.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound after delete, got %v", err)
}
}
+259
View File
@@ -0,0 +1,259 @@
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"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm"
)
func newSeededArticleRepository(t *testing.T) *persistence.ArticleRepository {
t.Helper()
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.Article{}, &models.Image{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
repo := persistence.NewArticleRepository(db)
if _, err := repo.Create(domainArticle.Article{Title: "Backyard Renovation Update", Body: "Body"}); err != nil {
t.Fatalf("failed to seed article: %v", err)
}
return repo
}
func assertWriteResultIsArticleNotFound(t *testing.T, err error) {
t.Helper()
if !errors.Is(err, domainArticle.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
if errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected the domain error, not gorm.ErrRecordNotFound")
}
}
func assertWriteResultIsImageNotFound(t *testing.T, err error) {
t.Helper()
if !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected image.ErrImageNotFound, got %v", err)
}
if errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected the domain error, not gorm.ErrRecordNotFound")
}
}
func assertWriteResultIsUserNotFound(t *testing.T, err error) {
t.Helper()
if !errors.Is(err, domainUser.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
if errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected the domain error, not gorm.ErrRecordNotFound")
}
}
func TestTheArticleRepositoryReportsAMissingArticleAsADomainErrorOnAWrite(t *testing.T) {
t.Run("UpdateBody and body New body", func(t *testing.T) {
repo := newSeededArticleRepository(t)
_, err := repo.UpdateBody(9, "New body")
assertWriteResultIsArticleNotFound(t, err)
})
t.Run("UpdateIsOnline and is_online true", func(t *testing.T) {
repo := newSeededArticleRepository(t)
_, err := repo.UpdateIsOnline(9, true)
assertWriteResultIsArticleNotFound(t, err)
})
t.Run("Delete", func(t *testing.T) {
repo := newSeededArticleRepository(t)
err := repo.Delete(9)
assertWriteResultIsArticleNotFound(t, err)
})
}
func TestAddImageReportsAMissingArticleAsADomainErrorNotAMissingImage(t *testing.T) {
repo := newSeededArticleRepository(t)
err := repo.AddImage(9, 1)
assertWriteResultIsArticleNotFound(t, err)
}
func TestAddImageReportsAMissingImageAsADomainErrorOnceTheArticleExists(t *testing.T) {
repo := newSeededArticleRepository(t)
err := repo.AddImage(1, 9)
assertWriteResultIsImageNotFound(t, err)
}
func newSeededUserRepository(t *testing.T) *persistence.UserRepository {
t.Helper()
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.User{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
repo := persistence.NewUserRepository(db)
if _, err := repo.Create(domainUser.User{Username: "alice", Password: "hunter2example", Email: "alice@example.com", VerificationCode: "code"}); err != nil {
t.Fatalf("failed to seed user: %v", err)
}
return repo
}
func TestTheUserRepositoryReportsAMissingUserAsADomainErrorOnAWrite(t *testing.T) {
t.Run("UpdateVerificationStatus and is_verified true", func(t *testing.T) {
repo := newSeededUserRepository(t)
_, err := repo.UpdateVerificationStatus(9, true)
assertWriteResultIsUserNotFound(t, err)
})
t.Run("UpdatePassword and password newHashedPassword", func(t *testing.T) {
repo := newSeededUserRepository(t)
_, err := repo.UpdatePassword(9, "newHashedPassword")
assertWriteResultIsUserNotFound(t, err)
})
t.Run("UpdatePasswordResetCode and code newHashedResetCode", func(t *testing.T) {
repo := newSeededUserRepository(t)
_, err := repo.UpdatePasswordResetCode(9, "newHashedResetCode")
assertWriteResultIsUserNotFound(t, err)
})
t.Run("Delete", func(t *testing.T) {
repo := newSeededUserRepository(t)
err := repo.Delete(9)
assertWriteResultIsUserNotFound(t, err)
})
}
func TestTheImageRepositoryReportsAMissingImageAsADomainErrorOnDelete(t *testing.T) {
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.Image{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
if err := db.Create(&models.Image{Path: "/static/uploadedImages/one.png"}).Error; err != nil {
t.Fatalf("failed to seed image: %v", err)
}
repo := persistence.NewImageRepository(db)
err := repo.Delete(9)
assertWriteResultIsImageNotFound(t, err)
}
func TestTheFakesMirrorTheRealRepositoriesOnEveryWriteNotFoundPath(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
if _, err := articleRepo.UpdateBody(9, "New body"); !errors.Is(err, domainArticle.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
if _, err := articleRepo.UpdateIsOnline(9, true); !errors.Is(err, domainArticle.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
if err := articleRepo.Delete(9); !errors.Is(err, domainArticle.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
if err := articleRepo.AddImage(9, 1); !errors.Is(err, domainArticle.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
articleRepo.images[1] = &domainImage.Image{ID: 1}
if err := articleRepo.AddImage(1, 9); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected image.ErrImageNotFound, got %v", err)
}
userRepo := newMockUserRepo()
userRepo.users[1] = domainUser.User{ID: 1, Username: "alice"}
if _, err := userRepo.UpdateVerificationStatus(9, true); !errors.Is(err, domainUser.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
if _, err := userRepo.UpdatePassword(9, "newHashedPassword"); !errors.Is(err, domainUser.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
if _, err := userRepo.UpdatePasswordResetCode(9, "newHashedResetCode"); !errors.Is(err, domainUser.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
if err := userRepo.Delete(9); !errors.Is(err, domainUser.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
}
func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(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 TestWriteNotFoundErrorsHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("Deleting an unknown article answers 404, not a silent success or a 500", func(t *testing.T) {
articleRepo := newMockArticleRepo()
router := newDeleteArticleRouter(articleRepo, 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())
}
})
t.Run("Deleting an existing article still answers 204", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
router := newDeleteArticleRouter(articleRepo, 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")
}
})
t.Run("The delete-article route still requires authentication before existence is even checked", func(t *testing.T) {
articleRepo := newMockArticleRepo()
router := newDeleteArticleRouter(articleRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/9999", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", rec.Code)
}
})
}