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:
1 parent
09bd5afa4a
commit
7c57173497
12 files changed
+1237
-84
No files matched your search
@@ -94,3 +94,7 @@ These rules apply to every file in this repo , for humans and agents alike.
|
||||
4. **Needless complexity**.
|
||||
5. **Needless repetition**.
|
||||
6. **Opacity** — the code is hard to understand.
|
||||
|
||||
## Credits
|
||||
|
||||
Thanks to [@wojtekluwojteklu/clean_code.md](https://gist.github.com/wojteklu/73c6914cc446146b8b533c0988cf8d29)
|
||||
@@ -17,7 +17,7 @@ Data is stored in `./data/` by default. For a real server (systemd, HTTPS, backu
|
||||
|
||||
### 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
|
||||
cp .env.example .env
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ tasks:
|
||||
desc: Gremlins mutation testing of the use cases → tmp/gremlins.json
|
||||
cmds:
|
||||
- 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:
|
||||
desc: gofmt check, go vet, eslint (--fix) and vue-tsc
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
| 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 |
|
||||
| [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` |
|
||||
|
||||
@@ -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
|
||||
@@ -1,10 +1,11 @@
|
||||
module RenewCMS
|
||||
|
||||
go 1.25.4
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/MadAppGang/httplog v1.3.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/jwtauth/v5 v5.3.3
|
||||
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/jwx/v2 v2.1.6 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/segmentio/asm v1.2.1 // 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
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
modernc.org/libc v1.67.0 // indirect
|
||||
@@ -45,10 +49,3 @@ require (
|
||||
modernc.org/memory v1.11.0 // 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
|
||||
)
|
||||
+111
-20
@@ -2,58 +2,150 @@ package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
imageDomain "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type mockArticleRepo struct {
|
||||
articles map[uint32]article.Article
|
||||
images map[uint32]*imageDomain.Image
|
||||
nextID uint32
|
||||
fail failSwitch
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) FindByFilters(f article.Filters) ([]article.Article, error) {
|
||||
var results []article.Article
|
||||
for _, a := range m.articles {
|
||||
if f.IsOnline != nil && a.IsOnline == *f.IsOnline {
|
||||
results = append(results, a)
|
||||
func newMockArticleRepo() *mockArticleRepo {
|
||||
return &mockArticleRepo{
|
||||
articles: make(map[uint32]article.Article),
|
||||
images: make(map[uint32]*imageDomain.Image),
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
|
||||
func (m *mockArticleRepo) sortedIds() []uint32 {
|
||||
ids := make([]uint32, 0, len(m.articles))
|
||||
for id := range m.articles {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
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) {
|
||||
if err := m.fail.trigger("Get"); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
a, ok := m.articles[id]
|
||||
if !ok {
|
||||
return article.Article{}, article.ErrArticleNotFound
|
||||
return article.Article{}, gorm.ErrRecordNotFound
|
||||
}
|
||||
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) {
|
||||
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
|
||||
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 {
|
||||
if err := m.fail.trigger("Delete"); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(m.articles, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) GetByName(n string) (article.Article, error) { return article.Article{}, nil }
|
||||
func (m *mockArticleRepo) GetAll() []article.Article { return nil }
|
||||
func (m *mockArticleRepo) UpdateBody(id uint32, b string) (article.Article, error) {
|
||||
return article.Article{}, nil
|
||||
func (m *mockArticleRepo) AddImage(articleId uint32, imageId uint32) error {
|
||||
if err := m.fail.trigger("AddImage"); err != nil {
|
||||
return err
|
||||
}
|
||||
func (m *mockArticleRepo) UpdateIsOnline(id uint32, o bool) (article.Article, error) {
|
||||
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) AddImage(p, i uint32) error { return nil }
|
||||
|
||||
var _ article.Repository = &mockArticleRepo{}
|
||||
|
||||
func TestCreateArticle(t *testing.T) {
|
||||
repo := &mockArticleRepo{articles: make(map[uint32]article.Article)}
|
||||
repo := newMockArticleRepo()
|
||||
uc := useCases.NewCreateArticleUseCase(repo)
|
||||
|
||||
t.Run("Valid Title", func(t *testing.T) {
|
||||
@@ -77,10 +169,9 @@ func TestCreateArticle(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetOnlineArticle(t *testing.T) {
|
||||
repo := &mockArticleRepo{articles: map[uint32]article.Article{
|
||||
1: {ID: 1, IsOnline: true},
|
||||
2: {ID: 2, IsOnline: false},
|
||||
}}
|
||||
repo := newMockArticleRepo()
|
||||
repo.articles[1] = article.Article{ID: 1, IsOnline: true}
|
||||
repo.articles[2] = article.Article{ID: 2, IsOnline: false}
|
||||
uc := useCases.NewGetArticleUseCase(repo)
|
||||
|
||||
t.Run("Article Online", func(t *testing.T) {
|
||||
|
||||
@@ -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
@@ -1,40 +1,71 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
imageDomain "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type mockImageRepo struct {
|
||||
images map[uint32]image.Image
|
||||
var errUnsupportedImageContentType = errors.New("the file must be a PNG, JPEG, WEBP, or SVG 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) {
|
||||
newId := uint32(len(m.images) + 1)
|
||||
img := image.Image{
|
||||
ID: newId,
|
||||
Path: "/static/" + input.Filename,
|
||||
type mockImageRepo struct {
|
||||
images map[uint32]imageDomain.Image
|
||||
nextID uint32
|
||||
fail failSwitch
|
||||
}
|
||||
m.images[newId] = img
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ image.Repository = &mockImageRepo{}
|
||||
var _ imageDomain.Repository = &mockImageRepo{}
|
||||
|
||||
func TestImageUseCases(t *testing.T) {
|
||||
repo := &mockImageRepo{images: make(map[uint32]image.Image)}
|
||||
repo := newMockImageRepo()
|
||||
createUc := useCases.NewCreateImageUseCase(repo)
|
||||
deleteUc := useCases.NewDeleteImageUseCase(repo)
|
||||
|
||||
t.Run("Create Image", func(t *testing.T) {
|
||||
input := image.ImageInput{
|
||||
input := imageDomain.ImageInput{
|
||||
Content: strings.NewReader("fake-image-data"),
|
||||
Filename: "test.png",
|
||||
ContentType: "image/png",
|
||||
@@ -53,7 +84,7 @@ func TestImageUseCases(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)
|
||||
if err != nil {
|
||||
|
||||
+16
-15
@@ -7,35 +7,36 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
type mockMailRepo struct {
|
||||
SentEmails []struct {
|
||||
type recordedMail struct {
|
||||
Address string
|
||||
Template string
|
||||
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 {
|
||||
if m.ShouldFail {
|
||||
return errors.New("smtp error")
|
||||
if err := m.fail.trigger("Send"); err != nil {
|
||||
return err
|
||||
}
|
||||
m.SentEmails = append(m.SentEmails, struct {
|
||||
Address string
|
||||
Template string
|
||||
Data any
|
||||
}{receiverAddress, templateName, data})
|
||||
m.SentEmails = append(m.SentEmails, recordedMail{receiverAddress, templateName, data})
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ mail.Repository = &mockMailRepo{}
|
||||
|
||||
func TestSendMail(t *testing.T) {
|
||||
repo := &mockMailRepo{}
|
||||
repo := newMockMailRepo()
|
||||
uc := useCases.NewSendMailUseCase(repo)
|
||||
|
||||
t.Run("Successful Email Send", func(t *testing.T) {
|
||||
repo.ShouldFail = false
|
||||
err := uc.SendMail("user@example.com", "welcome", "User123")
|
||||
if err != nil {
|
||||
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) {
|
||||
repo.ShouldFail = true
|
||||
repo.fail.failNext("Send")
|
||||
err := uc.SendMail("fail@example.com", "reset", nil)
|
||||
if err == nil {
|
||||
t.Errorf("Expected an error but got nil")
|
||||
if !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("Expected repository failure, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+105
-25
@@ -3,56 +3,137 @@ package test
|
||||
import (
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type mockUserRepo struct {
|
||||
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) {
|
||||
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
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Get(id uint32) (user.User, error) {
|
||||
u, ok := m.users[id]
|
||||
func (m *mockUserRepo) Delete(id uint32) error {
|
||||
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 {
|
||||
return user.User{}, nil
|
||||
return user.User{}, gorm.ErrRecordNotFound
|
||||
}
|
||||
u.IsVerified = isVerified
|
||||
m.users[userId] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) GetAll() []user.User {
|
||||
var list []user.User
|
||||
for _, u := range m.users {
|
||||
list = append(list, u)
|
||||
func (m *mockUserRepo) UpdatePassword(userId uint32, password string) (user.User, error) {
|
||||
if err := m.fail.trigger("UpdatePassword"); err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
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.IsVerified = v
|
||||
m.users[id] = u
|
||||
u.Password = password
|
||||
m.users[userId] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) GetByUsername(n string) (user.User, error) { return user.User{}, nil }
|
||||
func (m *mockUserRepo) GetByEmail(e string) (user.User, error) { return user.User{}, nil }
|
||||
func (m *mockUserRepo) Delete(id uint32) error { return nil }
|
||||
func (m *mockUserRepo) UpdatePassword(id uint32, p string) (user.User, error) {
|
||||
return user.User{}, nil
|
||||
func (m *mockUserRepo) UpdatePasswordResetCode(userId uint32, code string) (user.User, error) {
|
||||
if err := m.fail.trigger("UpdatePasswordResetCode"); err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
func (m *mockUserRepo) UpdatePasswordResetCode(id uint32, c string) (user.User, error) {
|
||||
return user.User{}, nil
|
||||
u, ok := m.users[userId]
|
||||
if !ok {
|
||||
return user.User{}, gorm.ErrRecordNotFound
|
||||
}
|
||||
u.PasswordResetCode = code
|
||||
m.users[userId] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
var _ user.Repository = &mockUserRepo{}
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
repo := &mockUserRepo{users: make(map[uint32]user.User)}
|
||||
repo := newMockUserRepo()
|
||||
uc := useCases.NewCreateUserUseCase(repo)
|
||||
|
||||
cmd := useCases.CreateUserCommand{
|
||||
@@ -74,9 +155,8 @@ func TestCreateUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateUserVerification(t *testing.T) {
|
||||
repo := &mockUserRepo{users: map[uint32]user.User{
|
||||
1: {ID: 1, IsVerified: false},
|
||||
}}
|
||||
repo := newMockUserRepo()
|
||||
repo.users[1] = user.User{ID: 1, IsVerified: false}
|
||||
uc := useCases.NewUpdateUserUseCase(repo)
|
||||
|
||||
res, err := uc.UpdateVerificationStatus(1, true)
|
||||
|
||||
Reference in new issue
Block a user