feat: characterized the existing use cases as a baseline; every repo fake behaves like the real ones and can be told to fail, every use case is covered; gremlins runs clean on useCases

This commit is contained in:
fsylvain committed 2026-09-23 18:21:46 +02:00
1 parent 09bd5afa4a
commit 7c57173497
12 files changed
+1241 -88

No files matched your search

+4
View File
@@ -94,3 +94,7 @@ These rules apply to every file in this repo , for humans and agents alike.
4. **Needless complexity**. 4. **Needless complexity**.
5. **Needless repetition**. 5. **Needless repetition**.
6. **Opacity** — the code is hard to understand. 6. **Opacity** — the code is hard to understand.
## Credits
Thanks to [@wojtekluwojteklu/clean_code.md](https://gist.github.com/wojteklu/73c6914cc446146b8b533c0988cf8d29)
+1 -1
View File
@@ -17,7 +17,7 @@ Data is stored in `./data/` by default. For a real server (systemd, HTTPS, backu
### Develop ### Develop
Requires Go 1.25+, Node.js 22, [Bun](https://bun.sh) and [Task](https://taskfile.dev). Requires Go 1.27+, Node.js 22, [Bun](https://bun.sh) and [Task](https://taskfile.dev).
```sh ```sh
cp .env.example .env cp .env.example .env
+1 -1
View File
@@ -57,7 +57,7 @@ tasks:
desc: Gremlins mutation testing of the use cases → tmp/gremlins.json desc: Gremlins mutation testing of the use cases → tmp/gremlins.json
cmds: cmds:
- mkdir -p tmp - mkdir -p tmp
- gremlins unleash --integration --tags nofrontend --output tmp/gremlins.json ./internal/infrastructure/useCases - gremlins unleash --integration --tags nofrontend --coverpkg=./internal/infrastructure/useCases/... --timeout-coefficient 10 --output tmp/gremlins.json ./internal/infrastructure/useCases
lint: lint:
desc: gofmt check, go vet, eslint (--fix) and vue-tsc desc: gofmt check, go vet, eslint (--fix) and vue-tsc
+1 -1
View File
@@ -4,7 +4,7 @@
| Tool | Version | Install | | Tool | Version | Install |
|-------------------------------|----------------------------|----------------------------------------------------------| |-------------------------------|----------------------------|----------------------------------------------------------|
| [Go](https://go.dev/dl/) | 1.25+ (see `go.mod`) | | | [Go](https://go.dev/dl/) | 1.27+ (see `go.mod`) | |
| [Node.js](https://nodejs.org) | 20.19+ or 22.12+ (22 LTS recommended) | `nvm install 22` — Vite and vue-tsc run on Node | | [Node.js](https://nodejs.org) | 20.19+ or 22.12+ (22 LTS recommended) | `nvm install 22` — Vite and vue-tsc run on Node |
| [Bun](https://bun.sh) (package manager and script runner) | 1.x | `powershell -c "irm bun.sh/install.ps1 \| iex"` or `curl -fsSL https://bun.sh/install \| bash` | | [Bun](https://bun.sh) (package manager and script runner) | 1.x | `powershell -c "irm bun.sh/install.ps1 \| iex"` or `curl -fsSL https://bun.sh/install \| bash` |
| [Task](https://taskfile.dev) | 3.43+ | `go install github.com/go-task/task/v3/cmd/task@latest` | | [Task](https://taskfile.dev) | 3.43+ | `go install github.com/go-task/task/v3/cmd/task@latest` |
+208
View File
@@ -0,0 +1,208 @@
Feature: Use case baseline characterization
As the maintainer of RenewCMS
I want every use case in internal/infrastructure/useCases pinned down by tests
So that a refactor cannot silently change behaviour that callers already depend on
Background:
Given the article, image, user and mail repository fakes in test/ each implement their domain Repository interface in full, with no stubbed method returning a hardcoded zero value
And each fake mirrors the observable behaviour of the matching real repository in internal/infrastructure/persistence/repositories or internal/infrastructure/mailer, including which operations return a not-found style error for an unknown id and which succeed silently instead
And each fake can be configured to fail on its next call to a chosen method, independently of the state it already holds, returning a generic repository error
Scenario Outline: CreateArticle enforces the 3-200 character title length limit
Given an empty article repository fake
When CreateArticle is called with a title that is <length> characters long and a body "Some body text"
Then the result is <outcome>
Examples:
| length | outcome |
| 0 | the error article.ErrInvalidTitle, and no article is stored |
| 2 | the error article.ErrInvalidTitle, and no article is stored |
| 3 | a created article whose id is repository-assigned and is_online is false |
| 200 | a created article whose id is repository-assigned and is_online is false |
| 201 | the error article.ErrInvalidTitle, and no article is stored |
Scenario: CreateArticle propagates a configured repository failure
Given an article repository fake configured to fail on Create
When CreateArticle is called with a valid title "Backyard Renovation Update" and a body "Some body text"
Then the use case returns the repository's error unchanged
And no article is stored in the fake
Scenario Outline: GetArticle and GetOnlineArticle expose different behaviour for a missing article than for an offline one
Given an article repository fake containing:
| id | title | is_online |
| 1 | Online Article | true |
| 2 | Offline Article | false |
When <method> is called with id <id>
Then the result is <outcome>
Examples:
| method | id | outcome |
| GetArticle | 1 | the article with id 1 |
| GetArticle | 2 | the article with id 2 (GetArticle does not hide offline articles) |
| GetArticle | 9 | the repository's not-found error (gorm.ErrRecordNotFound) |
| GetOnlineArticle | 1 | the article with id 1 |
| GetOnlineArticle | 2 | the error article.ErrArticleNotFound |
| GetOnlineArticle | 9 | the repository's not-found error (gorm.ErrRecordNotFound), not article.ErrArticleNotFound |
Scenario Outline: GetArticleByName delegates verbatim to the repository
Given an article repository fake containing an article titled "Backyard Renovation Update" with id 1
When GetArticleByName is called with "<name>"
Then the result is <outcome>
Examples:
| name | outcome |
| Backyard Renovation Update | the article with id 1 |
| Unknown Title | the repository's not-found error (gorm.ErrRecordNotFound) |
Scenario Outline: ListArticles filters by is_online
Given an article repository fake containing:
| id | title | is_online |
| 1 | A | true |
| 2 | B | false |
| 3 | C | true |
When <method> is called
Then the result is the articles with ids <ids>, and no error
Examples:
| method | ids |
| ListOnlineArticles | 1,3 |
| ListAllArticles | 1,2,3 |
Scenario: Listing articles on an empty repository returns an empty slice, not an error
Given an empty article repository fake
When ListOnlineArticles is called
Then the result is an empty slice and no error
Scenario Outline: UpdateArticle delegates each operation to the repository and surfaces not-found for an unknown id
Given an article repository fake containing an article with id 1 and an image with id 1
When <method> is called with <input>
Then the result is <outcome>
Examples:
| method | input | outcome |
| UpdateBody | id 1, body "New body" | the article with id 1 and body "New body" |
| UpdateBody | id 9, body "New body" | the repository's not-found error (gorm.ErrRecordNotFound) |
| UpdateIsOnline | id 1, isOnline true | the article with id 1 and is_online true |
| UpdateIsOnline | id 9, isOnline true | the repository's not-found error (gorm.ErrRecordNotFound) |
| AddImage | articleId 1, imageId 1 | no error, and the image is attached to the article |
| AddImage | articleId 9, imageId 1 | the repository's not-found error (gorm.ErrRecordNotFound) |
| AddImage | articleId 1, imageId 9 | the repository's not-found error (gorm.ErrRecordNotFound) |
Scenario Outline: DeleteArticle succeeds silently whether or not the id exists
Given an article repository fake containing an article with id 1
When DeleteArticle is called with id <id>
Then no error is returned
Examples:
| id |
| 1 |
| 9 |
Scenario: DeleteArticle propagates a configured repository failure
Given an article repository fake configured to fail on Delete
When DeleteArticle is called with id 1
Then the use case returns the repository's error unchanged
Scenario Outline: CreateImage delegates verbatim to the repository, including its content-type check
Given an empty image repository fake that mirrors the real repository's accepted content types image/png, image/jpeg, image/webp and image/svg+xml
When CreateImage is called with content-type "<content_type>"
Then the result is <outcome>
Examples:
| content_type | outcome |
| image/png | a created image whose id is repository-assigned |
| image/jpeg | a created image whose id is repository-assigned |
| image/webp | a created image whose id is repository-assigned |
| image/svg+xml | a created image whose id is repository-assigned |
| image/gif | an unsupported-content-type error, and no image is stored |
Scenario: CreateImage propagates a configured repository failure
Given an image repository fake configured to fail on Create
When CreateImage is called with a valid image/png input
Then the use case returns the repository's error unchanged
Scenario Outline: DeleteImage requires the image to exist, unlike article and user deletion
Given an image repository fake containing an image with id 1
When DeleteImage is called with id <id>
Then the result is <outcome>
Examples:
| id | outcome |
| 1 | no error, and the image is removed from the fake |
| 9 | the repository's not-found error (gorm.ErrRecordNotFound) |
Scenario: CreateUser assigns a fresh verification code and starts unverified
Given an empty user repository fake
When CreateUser is called with username "alice", password "hunter2example" and email "alice@example.com"
Then the result is a created user whose id is repository-assigned, is_verified is false, verification_code is a newly generated value distinct from any previous call, verification_expiration is about 2 hours from now, and password_reset_code is empty
Scenario: CreateUser propagates a configured repository failure
Given a user repository fake configured to fail on Create
When CreateUser is called with a valid username, password and email
Then the use case returns the repository's error unchanged
Scenario Outline: GetUser reads by id, username or email and surfaces not-found
Given a user repository fake containing a user with id 1, username "alice" and email "alice@example.com"
When <method> is called with "<input>"
Then the result is <outcome>
Examples:
| method | input | outcome |
| GetUser | 1 | the user with id 1 |
| GetUser | 9 | the repository's not-found error (gorm.ErrRecordNotFound) |
| GetUserByUsername | alice | the user with id 1 |
| GetUserByUsername | bob | the repository's not-found error (gorm.ErrRecordNotFound) |
| GetUserByEmail | alice@example.com | the user with id 1 |
| GetUserByEmail | bob@example.com | the repository's not-found error (gorm.ErrRecordNotFound) |
Scenario: ListUsers returns every user in the fake
Given a user repository fake containing users with ids 1 and 2
When ListUsers is called
Then the result is the users with ids 1 and 2, and no error
Scenario Outline: UpdateUser delegates each operation to the repository and surfaces not-found for an unknown id
Given a user repository fake containing a user with id 1
When <method> is called with id <id> and <input>
Then the result is <outcome>
Examples:
| method | id | input | outcome |
| UpdateVerificationStatus | 1 | isVerified true | the user with id 1 and is_verified true |
| UpdateVerificationStatus | 9 | isVerified true | the repository's not-found error (gorm.ErrRecordNotFound) |
| UpdatePassword | 1 | password "newpassword123" | the user with id 1 |
| UpdatePassword | 9 | password "newpassword123" | the repository's not-found error (gorm.ErrRecordNotFound) |
| UpdatePasswordResetCode | 1 | code "reset-code-123" | the user with id 1 |
| UpdatePasswordResetCode | 9 | code "reset-code-123" | the repository's not-found error (gorm.ErrRecordNotFound) |
Scenario Outline: DeleteUser succeeds silently whether or not the id exists
Given a user repository fake containing a user with id 1
When DeleteUser is called with id <id>
Then no error is returned
Examples:
| id |
| 1 |
| 9 |
Scenario Outline: SendMail forwards its arguments verbatim to the mail repository
Given a mail repository fake
When SendMail is called with receiver "<receiver>", template "<template>" and some data
Then the fake recorded exactly one send with that receiver, template and data, and no error is returned
Examples:
| receiver | template |
| alice@example.com | verify-email |
| bob@example.com | password-reset |
Scenario: SendMail propagates a configured repository failure without recording a send
Given a mail repository fake configured to fail
When SendMail is called with receiver "carol@example.com", template "welcome" and nil data
Then the use case returns the repository's error unchanged
And the fake recorded no send
Scenario: Mutation testing runs clean on the use cases package
Given the test/ suite passes via `go test -tags nofrontend ./test/... -count=1`
When `gremlins unleash --integration --tags nofrontend --coverpkg=./internal/infrastructure/useCases/... --timeout-coefficient 10 --output tmp/gremlins.json ./internal/infrastructure/useCases` is run
Then the report contains zero mutants with status LIVED
And the report contains zero mutants with status NOT COVERED
+5 -8
View File
@@ -1,10 +1,11 @@
module RenewCMS module RenewCMS
go 1.25.4 go 1.27.1
require ( require (
github.com/MadAppGang/httplog v1.3.0 github.com/MadAppGang/httplog v1.3.0
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/go-chi/jwtauth/v5 v5.3.3 github.com/go-chi/jwtauth/v5 v5.3.3
github.com/go-playground/validator/v10 v10.28.0 github.com/go-playground/validator/v10 v10.28.0
@@ -34,10 +35,13 @@ require (
github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/text v0.31.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/libc v1.67.0 // indirect modernc.org/libc v1.67.0 // indirect
@@ -45,10 +49,3 @@ require (
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.40.1 // indirect modernc.org/sqlite v1.40.1 // indirect
) )
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.38.0 // indirect
)
+112 -21
View File
@@ -2,58 +2,150 @@ package test
import ( import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"errors" "errors"
"sort"
"testing" "testing"
"gorm.io/gorm"
) )
type mockArticleRepo struct { type mockArticleRepo struct {
articles map[uint32]article.Article articles map[uint32]article.Article
images map[uint32]*imageDomain.Image
nextID uint32
fail failSwitch
} }
func (m *mockArticleRepo) FindByFilters(f article.Filters) ([]article.Article, error) { func newMockArticleRepo() *mockArticleRepo {
var results []article.Article return &mockArticleRepo{
for _, a := range m.articles { articles: make(map[uint32]article.Article),
if f.IsOnline != nil && a.IsOnline == *f.IsOnline { images: make(map[uint32]*imageDomain.Image),
results = append(results, a)
} }
}
func (m *mockArticleRepo) sortedIds() []uint32 {
ids := make([]uint32, 0, len(m.articles))
for id := range m.articles {
ids = append(ids, id)
} }
return results, nil sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func (m *mockArticleRepo) FindByFilters(filters article.Filters) ([]article.Article, error) {
if err := m.fail.trigger("FindByFilters"); err != nil {
return nil, err
}
result := make([]article.Article, 0)
for _, id := range m.sortedIds() {
a := m.articles[id]
if filters.IsOnline != nil && a.IsOnline != *filters.IsOnline {
continue
}
result = append(result, a)
}
return result, nil
} }
func (m *mockArticleRepo) Get(id uint32) (article.Article, error) { func (m *mockArticleRepo) Get(id uint32) (article.Article, error) {
if err := m.fail.trigger("Get"); err != nil {
return article.Article{}, err
}
a, ok := m.articles[id] a, ok := m.articles[id]
if !ok { if !ok {
return article.Article{}, article.ErrArticleNotFound return article.Article{}, gorm.ErrRecordNotFound
} }
return a, nil return a, nil
} }
func (m *mockArticleRepo) GetByName(name string) (article.Article, error) {
if err := m.fail.trigger("GetByName"); err != nil {
return article.Article{}, err
}
for _, id := range m.sortedIds() {
a := m.articles[id]
if a.Title == name {
return a, nil
}
}
return article.Article{}, gorm.ErrRecordNotFound
}
func (m *mockArticleRepo) GetAll() []article.Article {
result := make([]article.Article, 0)
for _, id := range m.sortedIds() {
result = append(result, m.articles[id])
}
return result
}
func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) { func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
a.ID = uint32(len(m.articles) + 1) if err := m.fail.trigger("Create"); err != nil {
return article.Article{}, err
}
m.nextID++
a.ID = m.nextID
m.articles[a.ID] = a m.articles[a.ID] = a
return a, nil return a, nil
} }
func (m *mockArticleRepo) UpdateBody(id uint32, body string) (article.Article, error) {
if err := m.fail.trigger("UpdateBody"); err != nil {
return article.Article{}, err
}
a, ok := m.articles[id]
if !ok {
return article.Article{}, gorm.ErrRecordNotFound
}
a.Body = body
m.articles[id] = a
return a, nil
}
func (m *mockArticleRepo) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) {
if err := m.fail.trigger("UpdateIsOnline"); err != nil {
return article.Article{}, err
}
a, ok := m.articles[id]
if !ok {
return article.Article{}, gorm.ErrRecordNotFound
}
a.IsOnline = isOnline
m.articles[id] = a
return a, nil
}
func (m *mockArticleRepo) Delete(id uint32) error { func (m *mockArticleRepo) Delete(id uint32) error {
if err := m.fail.trigger("Delete"); err != nil {
return err
}
delete(m.articles, id) delete(m.articles, id)
return nil return nil
} }
func (m *mockArticleRepo) GetByName(n string) (article.Article, error) { return article.Article{}, nil } func (m *mockArticleRepo) AddImage(articleId uint32, imageId uint32) error {
func (m *mockArticleRepo) GetAll() []article.Article { return nil } if err := m.fail.trigger("AddImage"); err != nil {
func (m *mockArticleRepo) UpdateBody(id uint32, b string) (article.Article, error) { return err
return article.Article{}, nil }
a, ok := m.articles[articleId]
if !ok {
return gorm.ErrRecordNotFound
}
img, ok := m.images[imageId]
if !ok {
return gorm.ErrRecordNotFound
}
a.Images = append(a.Images, img)
m.articles[articleId] = a
return nil
} }
func (m *mockArticleRepo) UpdateIsOnline(id uint32, o bool) (article.Article, error) {
return article.Article{}, nil
}
func (m *mockArticleRepo) AddImage(p, i uint32) error { return nil }
var _ article.Repository = &mockArticleRepo{} var _ article.Repository = &mockArticleRepo{}
func TestCreateArticle(t *testing.T) { func TestCreateArticle(t *testing.T) {
repo := &mockArticleRepo{articles: make(map[uint32]article.Article)} repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo) uc := useCases.NewCreateArticleUseCase(repo)
t.Run("Valid Title", func(t *testing.T) { t.Run("Valid Title", func(t *testing.T) {
@@ -77,10 +169,9 @@ func TestCreateArticle(t *testing.T) {
} }
func TestGetOnlineArticle(t *testing.T) { func TestGetOnlineArticle(t *testing.T) {
repo := &mockArticleRepo{articles: map[uint32]article.Article{ repo := newMockArticleRepo()
1: {ID: 1, IsOnline: true}, repo.articles[1] = article.Article{ID: 1, IsOnline: true}
2: {ID: 2, IsOnline: false}, repo.articles[2] = article.Article{ID: 2, IsOnline: false}
}}
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo)
t.Run("Article Online", func(t *testing.T) { t.Run("Article Online", func(t *testing.T) {
+21
View File
@@ -0,0 +1,21 @@
package test
import "errors"
var errRepositoryFailure = errors.New("repository failure")
type failSwitch struct {
method string
}
func (f *failSwitch) failNext(method string) {
f.method = method
}
func (f *failSwitch) trigger(method string) error {
if f.method == method {
f.method = ""
return errRepositoryFailure
}
return nil
}
+44 -13
View File
@@ -1,40 +1,71 @@
package test package test
import ( import (
"RenewCMS/internal/domain/image" imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"errors"
"io"
"strings" "strings"
"testing" "testing"
"gorm.io/gorm"
) )
type mockImageRepo struct { var errUnsupportedImageContentType = errors.New("the file must be a PNG, JPEG, WEBP, or SVG image")
images map[uint32]image.Image
var supportedImageContentTypes = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/webp": true,
"image/svg+xml": true,
} }
func (m *mockImageRepo) Create(input image.ImageInput) (image.Image, error) { type mockImageRepo struct {
newId := uint32(len(m.images) + 1) images map[uint32]imageDomain.Image
img := image.Image{ nextID uint32
ID: newId, fail failSwitch
Path: "/static/" + input.Filename, }
func newMockImageRepo() *mockImageRepo {
return &mockImageRepo{images: make(map[uint32]imageDomain.Image)}
}
func (m *mockImageRepo) Create(input imageDomain.ImageInput) (imageDomain.Image, error) {
if err := m.fail.trigger("Create"); err != nil {
return imageDomain.Image{}, err
} }
m.images[newId] = img if !supportedImageContentTypes[input.ContentType] {
return imageDomain.Image{}, errUnsupportedImageContentType
}
if input.Content != nil {
_, _ = io.ReadAll(input.Content)
}
m.nextID++
img := imageDomain.Image{ID: m.nextID, Path: "/static/uploadedImages/" + input.Filename}
m.images[img.ID] = img
return img, nil return img, nil
} }
func (m *mockImageRepo) Delete(id uint32) error { func (m *mockImageRepo) Delete(id uint32) error {
if err := m.fail.trigger("Delete"); err != nil {
return err
}
if _, ok := m.images[id]; !ok {
return gorm.ErrRecordNotFound
}
delete(m.images, id) delete(m.images, id)
return nil return nil
} }
var _ image.Repository = &mockImageRepo{} var _ imageDomain.Repository = &mockImageRepo{}
func TestImageUseCases(t *testing.T) { func TestImageUseCases(t *testing.T) {
repo := &mockImageRepo{images: make(map[uint32]image.Image)} repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo) createUc := useCases.NewCreateImageUseCase(repo)
deleteUc := useCases.NewDeleteImageUseCase(repo) deleteUc := useCases.NewDeleteImageUseCase(repo)
t.Run("Create Image", func(t *testing.T) { t.Run("Create Image", func(t *testing.T) {
input := image.ImageInput{ input := imageDomain.ImageInput{
Content: strings.NewReader("fake-image-data"), Content: strings.NewReader("fake-image-data"),
Filename: "test.png", Filename: "test.png",
ContentType: "image/png", ContentType: "image/png",
@@ -53,7 +84,7 @@ func TestImageUseCases(t *testing.T) {
}) })
t.Run("Delete Image", func(t *testing.T) { t.Run("Delete Image", func(t *testing.T) {
repo.images[99] = image.Image{ID: 99, Path: "/static/delete-me.png"} repo.images[99] = imageDomain.Image{ID: 99, Path: "/static/delete-me.png"}
err := deleteUc.DeleteImage(99) err := deleteUc.DeleteImage(99)
if err != nil { if err != nil {
+17 -16
View File
@@ -7,35 +7,36 @@ import (
"testing" "testing"
) )
type mockMailRepo struct { type recordedMail struct {
SentEmails []struct {
Address string Address string
Template string Template string
Data any Data any
} }
ShouldFail bool
type mockMailRepo struct {
SentEmails []recordedMail
fail failSwitch
}
func newMockMailRepo() *mockMailRepo {
return &mockMailRepo{}
} }
func (m *mockMailRepo) Send(receiverAddress string, templateName string, data any) error { func (m *mockMailRepo) Send(receiverAddress string, templateName string, data any) error {
if m.ShouldFail { if err := m.fail.trigger("Send"); err != nil {
return errors.New("smtp error") return err
} }
m.SentEmails = append(m.SentEmails, struct { m.SentEmails = append(m.SentEmails, recordedMail{receiverAddress, templateName, data})
Address string
Template string
Data any
}{receiverAddress, templateName, data})
return nil return nil
} }
var _ mail.Repository = &mockMailRepo{} var _ mail.Repository = &mockMailRepo{}
func TestSendMail(t *testing.T) { func TestSendMail(t *testing.T) {
repo := &mockMailRepo{} repo := newMockMailRepo()
uc := useCases.NewSendMailUseCase(repo) uc := useCases.NewSendMailUseCase(repo)
t.Run("Successful Email Send", func(t *testing.T) { t.Run("Successful Email Send", func(t *testing.T) {
repo.ShouldFail = false
err := uc.SendMail("user@example.com", "welcome", "User123") err := uc.SendMail("user@example.com", "welcome", "User123")
if err != nil { if err != nil {
t.Fatalf("Expected no error, got %v", err) t.Fatalf("Expected no error, got %v", err)
@@ -46,10 +47,10 @@ func TestSendMail(t *testing.T) {
}) })
t.Run("Failed Email Send", func(t *testing.T) { t.Run("Failed Email Send", func(t *testing.T) {
repo.ShouldFail = true repo.fail.failNext("Send")
err := uc.SendMail("fail@example.com", "reset", nil) err := uc.SendMail("fail@example.com", "reset", nil)
if err == nil { if !errors.Is(err, errRepositoryFailure) {
t.Errorf("Expected an error but got nil") t.Errorf("Expected repository failure, got %v", err)
} }
}) })
} }
+720
View File
@@ -0,0 +1,720 @@
package test
import (
"RenewCMS/internal/domain/article"
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"time"
"gorm.io/gorm"
)
func TestCreateArticleTitleLengthLimit(t *testing.T) {
cases := []struct {
length int
expectValid bool
}{
{0, false},
{2, false},
{3, true},
{200, true},
{201, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo)
title := strings.Repeat("a", tc.length)
result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
if tc.expectValid {
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID == 0 {
t.Errorf("expected repository-assigned id, got 0")
}
if result.IsOnline {
t.Errorf("expected is_online false, got true")
}
if len(repo.articles) != 1 {
t.Errorf("expected 1 article stored, got %d", len(repo.articles))
}
} else {
if !errors.Is(err, article.ErrInvalidTitle) {
t.Errorf("expected article.ErrInvalidTitle, got %v", err)
}
if len(repo.articles) != 0 {
t.Errorf("expected no article stored, got %d", len(repo.articles))
}
}
})
}
}
func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) {
repo := newMockArticleRepo()
repo.fail.failNext("Create")
uc := useCases.NewCreateArticleUseCase(repo)
_, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"})
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
if len(repo.articles) != 0 {
t.Errorf("expected no article stored, got %d", len(repo.articles))
}
}
func TestGetArticleAndGetOnlineArticle(t *testing.T) {
newRepo := func() *mockArticleRepo {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, Title: "Online Article", IsOnline: true}
repo.articles[2] = article.Article{ID: 2, Title: "Offline Article", IsOnline: false}
return repo
}
t.Run("GetArticle_1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
result, err := uc.GetArticle(1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected article id 1, got %d", result.ID)
}
})
t.Run("GetArticle_2", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
result, err := uc.GetArticle(2)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 2 {
t.Errorf("expected article id 2, got %d", result.ID)
}
})
t.Run("GetArticle_9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
_, err := uc.GetArticle(9)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
t.Run("GetOnlineArticle_1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
result, err := uc.GetOnlineArticle(1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected article id 1, got %d", result.ID)
}
})
t.Run("GetOnlineArticle_2", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
_, err := uc.GetOnlineArticle(2)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
})
t.Run("GetOnlineArticle_9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
_, err := uc.GetOnlineArticle(9)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
if errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected the raw not-found error, not article.ErrArticleNotFound")
}
})
}
func TestGetArticleByNameDelegatesVerbatim(t *testing.T) {
newRepo := func() *mockArticleRepo {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, Title: "Backyard Renovation Update"}
return repo
}
t.Run("Backyard_Renovation_Update", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
result, err := uc.GetArticleByName("Backyard Renovation Update")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected article id 1, got %d", result.ID)
}
})
t.Run("Unknown_Title", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
_, err := uc.GetArticleByName("Unknown Title")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
}
func assertArticleIds(t *testing.T, articles []article.Article, expected []uint32) {
t.Helper()
if len(articles) != len(expected) {
t.Fatalf("expected %d articles, got %d", len(expected), len(articles))
}
for i, id := range expected {
if articles[i].ID != id {
t.Errorf("expected article id %d at index %d, got %d", id, i, articles[i].ID)
}
}
}
func TestListArticlesFiltersByIsOnline(t *testing.T) {
newRepo := func() *mockArticleRepo {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, Title: "A", IsOnline: true}
repo.articles[2] = article.Article{ID: 2, Title: "B", IsOnline: false}
repo.articles[3] = article.Article{ID: 3, Title: "C", IsOnline: true}
return repo
}
t.Run("ListOnlineArticles", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo)
result, err := uc.ListOnlineArticles()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertArticleIds(t, result, []uint32{1, 3})
})
t.Run("ListAllArticles", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo)
result, err := uc.ListAllArticles()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertArticleIds(t, result, []uint32{1, 2, 3})
})
}
func TestListOnlineArticlesEmptyRepositoryReturnsEmptySlice(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewListArticlesUseCase(repo)
result, err := uc.ListOnlineArticles()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result == nil {
t.Errorf("expected an empty slice, got nil")
}
if len(result) != 0 {
t.Errorf("expected an empty slice, got %d items", len(result))
}
}
func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
newRepo := func() *mockArticleRepo {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, Title: "Existing"}
repo.images[1] = &imageDomain.Image{ID: 1, Path: "/static/one.png"}
return repo
}
t.Run("UpdateBody_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
result, err := uc.UpdateBody(1, "New body")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 || result.Body != "New body" {
t.Errorf("unexpected result: %+v", result)
}
})
t.Run("UpdateBody_id9", func(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)
}
})
t.Run("UpdateIsOnline_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
result, err := uc.UpdateIsOnline(1, true)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 || !result.IsOnline {
t.Errorf("unexpected result: %+v", result)
}
})
t.Run("UpdateIsOnline_id9", func(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)
}
})
t.Run("AddImage_article1_image1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
err := uc.AddImage(1, 1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
updated, err := repo.Get(1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(updated.Images) != 1 || updated.Images[0].ID != 1 {
t.Errorf("expected image 1 attached, got %+v", updated.Images)
}
})
t.Run("AddImage_article9_image1", func(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)
}
})
t.Run("AddImage_article1_image9", func(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)
}
})
}
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)
err := uc.DeleteArticle(id)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
})
}
}
func TestDeleteArticlePropagatesRepositoryFailure(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1}
repo.fail.failNext("Delete")
uc := useCases.NewDeleteArticleUseCase(repo)
err := uc.DeleteArticle(1)
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
}
func TestCreateImageDelegatesContentTypeCheck(t *testing.T) {
cases := []struct {
contentType string
valid bool
}{
{"image/png", true},
{"image/jpeg", true},
{"image/webp", true},
{"image/svg+xml", true},
{"image/gif", false},
}
for _, tc := range cases {
t.Run(strings.ReplaceAll(tc.contentType, "/", "_"), func(t *testing.T) {
repo := newMockImageRepo()
uc := useCases.NewCreateImageUseCase(repo)
result, err := uc.CreateImage(imageDomain.ImageInput{
Content: strings.NewReader("fake-image-data"),
Filename: "test.img",
ContentType: tc.contentType,
})
if tc.valid {
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID == 0 {
t.Errorf("expected repository-assigned id, got 0")
}
} else {
if err == nil {
t.Errorf("expected an unsupported-content-type error, got nil")
}
if len(repo.images) != 0 {
t.Errorf("expected no image stored, got %d", len(repo.images))
}
}
})
}
}
func TestCreateImagePropagatesRepositoryFailure(t *testing.T) {
repo := newMockImageRepo()
repo.fail.failNext("Create")
uc := useCases.NewCreateImageUseCase(repo)
_, err := uc.CreateImage(imageDomain.ImageInput{
Content: strings.NewReader("data"),
Filename: "test.png",
ContentType: "image/png",
})
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
}
func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_1", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteImageUseCase(repo)
err := uc.DeleteImage(1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if _, exists := repo.images[1]; exists {
t.Errorf("expected the image to be removed from the fake")
}
})
t.Run("id_9", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteImageUseCase(repo)
err := uc.DeleteImage(9)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
}
func TestCreateUserAssignsVerificationCode(t *testing.T) {
repo := newMockUserRepo()
uc := useCases.NewCreateUserUseCase(repo)
before := time.Now()
result, err := uc.CreateUser(useCases.CreateUserCommand{
Username: "alice",
Password: "hunter2example",
Email: "alice@example.com",
})
after := time.Now()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID == 0 {
t.Errorf("expected repository-assigned id, got 0")
}
if result.IsVerified {
t.Errorf("expected is_verified false")
}
if result.VerificationCode == "" {
t.Errorf("expected a generated verification code")
}
if result.PasswordResetCode != "" {
t.Errorf("expected empty password_reset_code, got %q", result.PasswordResetCode)
}
minExpiration := before.Add(2 * time.Hour).Add(-time.Minute)
maxExpiration := after.Add(2 * time.Hour).Add(time.Minute)
if result.VerificationExpiration.Before(minExpiration) || result.VerificationExpiration.After(maxExpiration) {
t.Errorf("expected verification_expiration about 2 hours from now, got %v", result.VerificationExpiration)
}
second, err := uc.CreateUser(useCases.CreateUserCommand{
Username: "bob",
Password: "hunter2example",
Email: "bob@example.com",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if second.VerificationCode == result.VerificationCode {
t.Errorf("expected a distinct verification code from the previous call")
}
}
func TestCreateUserPropagatesRepositoryFailure(t *testing.T) {
repo := newMockUserRepo()
repo.fail.failNext("Create")
uc := useCases.NewCreateUserUseCase(repo)
_, err := uc.CreateUser(useCases.CreateUserCommand{
Username: "alice",
Password: "hunter2example",
Email: "alice@example.com",
})
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
}
func TestGetUserReadsByIdUsernameOrEmail(t *testing.T) {
newRepo := func() *mockUserRepo {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com"}
return repo
}
t.Run("GetUser_1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetUserUseCase(repo)
result, err := uc.GetUser(1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected user id 1, got %d", result.ID)
}
})
t.Run("GetUser_9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetUserUseCase(repo)
_, err := uc.GetUser(9)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
t.Run("GetUserByUsername_alice", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetUserUseCase(repo)
result, err := uc.GetUserByUsername("alice")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected user id 1, got %d", result.ID)
}
})
t.Run("GetUserByUsername_bob", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetUserUseCase(repo)
_, err := uc.GetUserByUsername("bob")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
t.Run("GetUserByEmail_alice", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetUserUseCase(repo)
result, err := uc.GetUserByEmail("alice@example.com")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected user id 1, got %d", result.ID)
}
})
t.Run("GetUserByEmail_bob", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetUserUseCase(repo)
_, err := uc.GetUserByEmail("bob@example.com")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
}
func TestListUsersReturnsEveryUser(t *testing.T) {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1}
repo.users[2] = user.User{ID: 2}
uc := useCases.NewListUsersUseCase(repo)
result := uc.ListUsers()
if len(result) != 2 {
t.Fatalf("expected 2 users, got %d", len(result))
}
ids := map[uint32]bool{result[0].ID: true, result[1].ID: true}
if !ids[1] || !ids[2] {
t.Errorf("expected users with ids 1 and 2, got %+v", result)
}
}
func TestUpdateUserDelegatesEachOperation(t *testing.T) {
newRepo := func() *mockUserRepo {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1}
return repo
}
t.Run("UpdateVerificationStatus_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo)
result, err := uc.UpdateVerificationStatus(1, true)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 || !result.IsVerified {
t.Errorf("unexpected result: %+v", result)
}
})
t.Run("UpdateVerificationStatus_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo)
_, err := uc.UpdateVerificationStatus(9, true)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
t.Run("UpdatePassword_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo)
result, err := uc.UpdatePassword(1, "newpassword123")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected user id 1, got %d", result.ID)
}
})
t.Run("UpdatePassword_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo)
_, err := uc.UpdatePassword(9, "newpassword123")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
t.Run("UpdatePasswordResetCode_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo)
result, err := uc.UpdatePasswordResetCode(1, "reset-code-123")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected user id 1, got %d", result.ID)
}
})
t.Run("UpdatePasswordResetCode_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo)
_, err := uc.UpdatePasswordResetCode(9, "reset-code-123")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, 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)
err := uc.DeleteUser(id)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
})
}
}
func TestSendMailForwardsArgumentsVerbatim(t *testing.T) {
cases := []struct {
receiver string
template string
}{
{"alice@example.com", "verify-email"},
{"bob@example.com", "password-reset"},
}
for _, tc := range cases {
t.Run(tc.receiver+"_"+tc.template, func(t *testing.T) {
repo := newMockMailRepo()
uc := useCases.NewSendMailUseCase(repo)
data := map[string]string{"key": "value"}
err := uc.SendMail(tc.receiver, tc.template, data)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(repo.SentEmails) != 1 {
t.Fatalf("expected exactly one recorded send, got %d", len(repo.SentEmails))
}
sent := repo.SentEmails[0]
if sent.Address != tc.receiver || sent.Template != tc.template {
t.Errorf("unexpected recorded send: %+v", sent)
}
if !reflect.DeepEqual(sent.Data, data) {
t.Errorf("expected recorded data %+v, got %+v", data, sent.Data)
}
})
}
}
func TestSendMailPropagatesRepositoryFailureWithoutRecording(t *testing.T) {
repo := newMockMailRepo()
repo.fail.failNext("Send")
uc := useCases.NewSendMailUseCase(repo)
err := uc.SendMail("carol@example.com", "welcome", nil)
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
if len(repo.SentEmails) != 0 {
t.Errorf("expected no send recorded, got %d", len(repo.SentEmails))
}
}
+107 -27
View File
@@ -3,56 +3,137 @@ package test
import ( import (
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"sort"
"testing" "testing"
"gorm.io/gorm"
) )
type mockUserRepo struct { type mockUserRepo struct {
users map[uint32]user.User users map[uint32]user.User
nextID uint32
fail failSwitch
}
func newMockUserRepo() *mockUserRepo {
return &mockUserRepo{users: make(map[uint32]user.User)}
}
func (m *mockUserRepo) sortedIds() []uint32 {
ids := make([]uint32, 0, len(m.users))
for id := range m.users {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func (m *mockUserRepo) Get(id uint32) (user.User, error) {
if err := m.fail.trigger("Get"); err != nil {
return user.User{}, err
}
u, ok := m.users[id]
if !ok {
return user.User{}, gorm.ErrRecordNotFound
}
return u, nil
}
func (m *mockUserRepo) GetByUsername(username string) (user.User, error) {
if err := m.fail.trigger("GetByUsername"); err != nil {
return user.User{}, err
}
for _, id := range m.sortedIds() {
u := m.users[id]
if u.Username == username {
return u, nil
}
}
return user.User{}, gorm.ErrRecordNotFound
}
func (m *mockUserRepo) GetByEmail(email string) (user.User, error) {
if err := m.fail.trigger("GetByEmail"); err != nil {
return user.User{}, err
}
for _, id := range m.sortedIds() {
u := m.users[id]
if u.Email == email {
return u, nil
}
}
return user.User{}, gorm.ErrRecordNotFound
}
func (m *mockUserRepo) GetAll() []user.User {
result := make([]user.User, 0)
for _, id := range m.sortedIds() {
result = append(result, m.users[id])
}
return result
} }
func (m *mockUserRepo) Create(u user.User) (user.User, error) { func (m *mockUserRepo) Create(u user.User) (user.User, error) {
u.ID = uint32(len(m.users) + 1) if err := m.fail.trigger("Create"); err != nil {
return user.User{}, err
}
m.nextID++
u.ID = m.nextID
m.users[u.ID] = u m.users[u.ID] = u
return u, nil return u, nil
} }
func (m *mockUserRepo) Get(id uint32) (user.User, error) { func (m *mockUserRepo) Delete(id uint32) error {
u, ok := m.users[id] if err := m.fail.trigger("Delete"); err != nil {
return err
}
delete(m.users, id)
return nil
}
func (m *mockUserRepo) UpdateVerificationStatus(userId uint32, isVerified bool) (user.User, error) {
if err := m.fail.trigger("UpdateVerificationStatus"); err != nil {
return user.User{}, err
}
u, ok := m.users[userId]
if !ok { if !ok {
return user.User{}, nil return user.User{}, gorm.ErrRecordNotFound
} }
u.IsVerified = isVerified
m.users[userId] = u
return u, nil return u, nil
} }
func (m *mockUserRepo) GetAll() []user.User { func (m *mockUserRepo) UpdatePassword(userId uint32, password string) (user.User, error) {
var list []user.User if err := m.fail.trigger("UpdatePassword"); err != nil {
for _, u := range m.users { return user.User{}, err
list = append(list, u)
} }
return list u, ok := m.users[userId]
} if !ok {
return user.User{}, gorm.ErrRecordNotFound
func (m *mockUserRepo) UpdateVerificationStatus(id uint32, v bool) (user.User, error) { }
u := m.users[id] u.Password = password
u.IsVerified = v m.users[userId] = u
m.users[id] = u
return u, nil return u, nil
} }
func (m *mockUserRepo) GetByUsername(n string) (user.User, error) { return user.User{}, nil } func (m *mockUserRepo) UpdatePasswordResetCode(userId uint32, code string) (user.User, error) {
func (m *mockUserRepo) GetByEmail(e string) (user.User, error) { return user.User{}, nil } if err := m.fail.trigger("UpdatePasswordResetCode"); err != nil {
func (m *mockUserRepo) Delete(id uint32) error { return nil } return user.User{}, err
func (m *mockUserRepo) UpdatePassword(id uint32, p string) (user.User, error) { }
return user.User{}, nil u, ok := m.users[userId]
} if !ok {
func (m *mockUserRepo) UpdatePasswordResetCode(id uint32, c string) (user.User, error) { return user.User{}, gorm.ErrRecordNotFound
return user.User{}, nil }
u.PasswordResetCode = code
m.users[userId] = u
return u, nil
} }
var _ user.Repository = &mockUserRepo{} var _ user.Repository = &mockUserRepo{}
func TestCreateUser(t *testing.T) { func TestCreateUser(t *testing.T) {
repo := &mockUserRepo{users: make(map[uint32]user.User)} repo := newMockUserRepo()
uc := useCases.NewCreateUserUseCase(repo) uc := useCases.NewCreateUserUseCase(repo)
cmd := useCases.CreateUserCommand{ cmd := useCases.CreateUserCommand{
@@ -74,9 +155,8 @@ func TestCreateUser(t *testing.T) {
} }
func TestUpdateUserVerification(t *testing.T) { func TestUpdateUserVerification(t *testing.T) {
repo := &mockUserRepo{users: map[uint32]user.User{ repo := newMockUserRepo()
1: {ID: 1, IsVerified: false}, repo.users[1] = user.User{ID: 1, IsVerified: false}
}}
uc := useCases.NewUpdateUserUseCase(repo) uc := useCases.NewUpdateUserUseCase(repo)
res, err := uc.UpdateVerificationStatus(1, true) res, err := uc.UpdateVerificationStatus(1, true)