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
+537 -50

No files matched your search

+4
View File
@@ -127,6 +127,10 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
}
if err := h.DeleteUseCase.DeleteArticle(uint32(id)); err != nil {
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
}
http.Error(w, "Failed to delete article", http.StatusInternalServerError)
return
}
+97
View File
@@ -0,0 +1,97 @@
Feature: Domain not-found errors on repository writes, image attachment and deletes
As the maintainer of RenewCMS
I want an update, an image attachment or a delete against an unknown article, user or image id
to report a domain not-found error instead of a raw GORM error or a silent no-op
So that a caller can rely on errors.Is(err, article.ErrArticleNotFound), errors.Is(err, user.ErrUserNotFound)
or errors.Is(err, image.ErrImageNotFound) on every repository method, not just the read side,
and so that DELETE /v1/article/{id} reports 404 instead of leaking a 500 for an id that no longer exists
Background:
Given internal/domain/article defines the sentinel article.ErrArticleNotFound
And internal/domain/user defines the sentinel user.ErrUserNotFound
And internal/domain/image defines the sentinel image.ErrImageNotFound
And ArticleRepository.UpdateBody, UpdateIsOnline, Delete and AddImage return article.ErrArticleNotFound,
not gorm.ErrRecordNotFound and not a silent no-op, when the article id does not match any row
And ArticleRepository.AddImage returns image.ErrImageNotFound, not gorm.ErrRecordNotFound,
when the article exists but the image id does not match any row
And UserRepository.UpdateVerificationStatus, UpdatePassword, UpdatePasswordResetCode and Delete return
user.ErrUserNotFound, not gorm.ErrRecordNotFound and not a silent no-op, when the user id does not match any row
And ImageRepository.Delete returns image.ErrImageNotFound, not gorm.ErrRecordNotFound, when the image id
does not match any row
And DELETE /v1/article/{id} sits behind the jwtauth Verifier and Authenticator group, and requires a valid jwt cookie
And UpdateArticle, AddImage, UpdateUser, DeleteUser, CreateImage and DeleteImage have no handler mounted in
api/router.go today, so this slice has no other HTTP-observable surface beyond DELETE /v1/article/{id}
Scenario Outline: The article repository reports a missing article as a domain error on a write
Given a repository backed by an in-memory SQLite database containing an article titled "Backyard Renovation Update" with id 1
When <method> is called with id 9 <extra>
Then the result is the error article.ErrArticleNotFound
And errors.Is(err, gorm.ErrRecordNotFound) is false
Examples:
| method | extra |
| UpdateBody | and body "New body" |
| UpdateIsOnline | and is_online true |
| Delete | |
Scenario: AddImage reports a missing article as a domain error, not a missing image
Given a repository backed by an in-memory SQLite database containing an article with id 1
And the same database containing an image with id 1
When AddImage is called with article id 9 and image id 1
Then the result is the error article.ErrArticleNotFound
And errors.Is(err, gorm.ErrRecordNotFound) is false
Scenario: AddImage reports a missing image as a domain error once the article exists
Given a repository backed by an in-memory SQLite database containing an article with id 1
And the same database containing no image with id 9
When AddImage is called with article id 1 and image id 9
Then the result is the error image.ErrImageNotFound
And errors.Is(err, gorm.ErrRecordNotFound) is false
Scenario Outline: The user repository reports a missing user as a domain error on a write
Given a repository backed by an in-memory SQLite database containing a user with id 1, username "alice" and email "alice@example.com"
When <method> is called with id 9 <extra>
Then the result is the error user.ErrUserNotFound
And errors.Is(err, gorm.ErrRecordNotFound) is false
Examples:
| method | extra |
| UpdateVerificationStatus | and is_verified true |
| UpdatePassword | and password "newHashedPassword" |
| UpdatePasswordResetCode | and code "newHashedResetCode" |
| Delete | |
Scenario: The image repository reports a missing image as a domain error on delete
Given a repository backed by an in-memory SQLite database containing an image with id 1
When Delete is called with id 9
Then the result is the error image.ErrImageNotFound
And errors.Is(err, gorm.ErrRecordNotFound) is false
Scenario: The test fakes mirror the real repositories on every write not-found path
Given the in-memory article, user and image fakes used in test/
When UpdateBody, UpdateIsOnline, Delete or AddImage is called against an id absent from the fake's map
Then the fake returns article.ErrArticleNotFound or image.ErrImageNotFound, matching the real repository
And when UpdateVerificationStatus, UpdatePassword, UpdatePasswordResetCode or Delete is called against an id
absent from the fake's map, the fake returns user.ErrUserNotFound, matching the real repository
And none of the fakes construct or return gorm.ErrRecordNotFound anymore
Scenario: Deleting an unknown article answers 404, not a silent success or a 500
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"
Scenario: Deleting an existing article still answers 204
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1
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
Scenario: The delete-article route still requires authentication before existence is even checked
Given no jwt cookie is sent
And the article repository has no article with id 9999
When the client sends DELETE /v1/article/9999 without a cookie
Then the response status is 401
+65
View File
@@ -0,0 +1,65 @@
# QA procedure: write_not_found_errors
Package: `test` (black-box, extending `article_test.go` / `user_test.go` / `image_test.go` / `not_found_errors_test.go`).
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Update the in-memory fakes (`mockArticleRepo`, `mockUserRepo`, `mockImageRepo` in `test/`) so that:
- `mockArticleRepo.UpdateBody`, `UpdateIsOnline` and `AddImage` return `article.ErrArticleNotFound` /
`image.ErrImageNotFound` (per the missing id) instead of `gorm.ErrRecordNotFound`;
- `mockArticleRepo.Delete` returns `article.ErrArticleNotFound` when the id isn't in the map, instead of
silently succeeding;
- `mockUserRepo.UpdateVerificationStatus`, `UpdatePassword`, `UpdatePasswordResetCode` return
`user.ErrUserNotFound` instead of `gorm.ErrRecordNotFound`;
- `mockUserRepo.Delete` returns `user.ErrUserNotFound` when the id isn't in the map, instead of silently
succeeding;
- `mockImageRepo.Delete` returns `image.ErrImageNotFound` instead of `gorm.ErrRecordNotFound`.
No fake in `test/` should reference `gorm.ErrRecordNotFound` (or import `gorm.io/gorm`) after this slice,
except where still needed to assert a real repository's behavior in a repository-level test.
3. Wire the real use cases (`useCases.NewUpdateArticleUseCase`, `useCases.NewDeleteArticleUseCase`,
`useCases.NewUpdateUserUseCase`, `useCases.NewDeleteUserUseCase`, `useCases.NewDeleteImageUseCase`) over
the fakes for the use-case-level checks.
4. Build `article.NewHandler(...)` from the article use cases plus a test `jwtauth.New("HS256", []byte("test-secret"), nil)`,
and mount it under a `chi.NewRouter()` group wrapped by `jwtauth.Verifier(token)` + `jwtauth.Authenticator(token)`,
mirroring `api/router.go`. No live server.
## Repository-level checks (real persistence repositories, in-memory SQLite, no HTTP)
Use the existing `openInMemoryDb(t)` helper from `test/not_found_errors_test.go`.
5. `db.AutoMigrate(&models.Article{}, &models.Image{})`. Build a real `persistence.NewArticleRepository(db)` and
`persistence.NewImageRepository(db)`. Seed one article (id 1) and one image (id 1) via `Create`.
- `UpdateBody(9, "New body")` → assert `errors.Is(err, article.ErrArticleNotFound)` true,
`errors.Is(err, gorm.ErrRecordNotFound)` false.
- `UpdateIsOnline(9, true)` → same assertions.
- `Delete(9)` → same assertions. Then `Delete(1)` on the seeded article → assert `err == nil` and a
subsequent `Get(1)` returns `article.ErrArticleNotFound` (the row is actually gone).
- `AddImage(9, 1)` (unknown article, known image) → assert `errors.Is(err, article.ErrArticleNotFound)` true.
- `AddImage(1, 9)` (known article, unknown image) → assert `errors.Is(err, image.ErrImageNotFound)` true,
`errors.Is(err, gorm.ErrRecordNotFound)` false.
6. On the same or a fresh in-memory DB, `db.AutoMigrate(&models.User{})`. Build a real
`persistence.NewUserRepository(db)`, seed a user id 1.
- `UpdateVerificationStatus(9, true)`, `UpdatePassword(9, "x")`, `UpdatePasswordResetCode(9, "y")` → each
asserts `errors.Is(err, user.ErrUserNotFound)` true, `errors.Is(err, gorm.ErrRecordNotFound)` false.
- `Delete(9)` → same assertions. Then `Delete(1)` on the seeded user → assert `err == nil` and a
subsequent `Get(1)` returns `user.ErrUserNotFound`.
7. `db.AutoMigrate(&models.Image{})`. Build a real `persistence.NewImageRepository(db)`, seed one image via
direct DB insert (or `Create` with a fake `io.Reader` and `UPLOAD_DIR` pointed at `t.TempDir()`).
- `Delete(9)` → assert `errors.Is(err, image.ErrImageNotFound)` true, `errors.Is(err, gorm.ErrRecordNotFound)` false.
## Fake-level checks (mirrors the real repositories, package-internal, no HTTP)
8. For each of `mockArticleRepo`, `mockUserRepo`, `mockImageRepo`, call the write methods listed in step 2
against an id absent from the fake's map and assert they return the matching domain sentinel, never
`gorm.ErrRecordNotFound`.
## HTTP checks (fakes, per the shared setup above) — DELETE /v1/article/{id} only
9. Mint a valid jwt cookie for user id 1 (as in `not_found_errors_test.go`, step 8 there).
10. `DELETE /v1/article/9999` with that cookie, empty repo id 9999 → assert status `404`, body
`"Article not found\n"`.
11. `DELETE /v1/article/1` with that cookie, repo seeded with an article id 1 → assert status `204`, and that
the fake no longer holds id 1 afterward.
12. `DELETE /v1/article/9999` with **no** cookie → assert status `401` (regression: jwtauth still gates the route).
## Manual verification (not exercised by the Go test)
- None. `UpdateArticle`, `AddImage`, `UpdateUser`, `DeleteUser`, `CreateImage` and `DeleteImage` have no HTTP
route today (not mounted in `api/router.go`), so there is no SPA-visible behavior or manual HTTP check for
them in this slice — only the repository/fake-level checks above apply. No SMTP involvement.
+3
View File
@@ -1,10 +1,13 @@
package image
import (
"errors"
"io"
"time"
)
var ErrImageNotFound = errors.New("image not found")
type ImageInput struct {
Content io.Reader
Filename string
@@ -2,6 +2,7 @@ package persistence
import (
domain "RenewCMS/internal/domain/article"
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
@@ -89,7 +90,7 @@ func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article,
var localArticle entity.Article
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
if err != nil {
return domain.Article{}, err
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
localArticle.Body = body
@@ -107,7 +108,7 @@ func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Art
var localArticle entity.Article
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
if err != nil {
return domain.Article{}, err
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
localArticle.IsOnline = isOnline
@@ -120,20 +121,21 @@ func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Art
}
func (a *ArticleRepository) Delete(id uint32) error {
return a.db.Delete(&entity.Article{}, id).Error
result := a.db.Delete(&entity.Article{}, id)
return deleteOrNotFound(result, domain.ErrArticleNotFound)
}
func (a *ArticleRepository) AddImage(articleId uint32, imageId uint32) error {
var localArticle entity.Article
err := a.db.Model(&entity.Article{}).First(&localArticle, articleId).Error
if err != nil {
return err
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
var localImage entity.Image
err = a.db.Model(&entity.Image{}).First(&localImage, imageId).Error
if err != nil {
return err
return mapNotFoundErr(err, imageDomain.ErrImageNotFound)
}
err = a.db.Model(&localArticle).Association("Images").Append(&localImage)
@@ -65,7 +65,7 @@ func (i ImageRepository) Delete(id uint32) error {
var entityImage entity.Image
err := i.db.Model(&entity.Image{}).First(&entityImage, id).Error
if err != nil {
return err
return mapNotFoundErr(err, domain.ErrImageNotFound)
}
fileName := filepath.Base(entityImage.Path)
@@ -12,3 +12,13 @@ func mapNotFoundErr(err error, sentinel error) error {
}
return err
}
func deleteOrNotFound(result *gorm.DB, sentinel error) error {
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return sentinel
}
return nil
}
@@ -46,7 +46,8 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
}
func (u *UserRepository) Delete(id uint32) error {
return u.db.Delete(&domainUser.User{}, id).Error
result := u.db.Delete(&domainUser.User{}, id)
return deleteOrNotFound(result, domainUser.ErrUserNotFound)
}
func (u *UserRepository) GetAll() []domainUser.User {
@@ -85,7 +86,7 @@ func (u *UserRepository) UpdateVerificationStatus(userId uint32, isVerified bool
var localUser entity.User
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
if err != nil {
return domainUser.User{}, err
return domainUser.User{}, mapNotFoundErr(err, domainUser.ErrUserNotFound)
}
localUser.IsVerified = isVerified
@@ -101,7 +102,7 @@ func (u *UserRepository) UpdatePassword(userId uint32, password string) (domainU
var localUser entity.User
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
if err != nil {
return domainUser.User{}, err
return domainUser.User{}, mapNotFoundErr(err, domainUser.ErrUserNotFound)
}
localUser.Password = password
@@ -117,7 +118,7 @@ func (u *UserRepository) UpdatePasswordResetCode(userId uint32, code string) (do
var localUser entity.User
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
if err != nil {
return domainUser.User{}, err
return domainUser.User{}, mapNotFoundErr(err, domainUser.ErrUserNotFound)
}
localUser.PasswordResetCode = code
+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
+42 -26
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) {
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)
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) {
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)
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)
}
})
}