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
+1245
-92
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**.
|
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)
|
||||||
@@ -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
@@ -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
@@ -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` |
|
||||||
|
|||||||
@@ -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
|
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
|
|
||||||
)
|
|
||||||
+113
-22
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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) {
|
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) {
|
||||||
|
|||||||
@@ -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
|
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 {
|
||||||
|
|||||||
+19
-18
@@ -7,35 +7,36 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type recordedMail struct {
|
||||||
|
Address string
|
||||||
|
Template string
|
||||||
|
Data any
|
||||||
|
}
|
||||||
|
|
||||||
type mockMailRepo struct {
|
type mockMailRepo struct {
|
||||||
SentEmails []struct {
|
SentEmails []recordedMail
|
||||||
Address string
|
fail failSwitch
|
||||||
Template string
|
}
|
||||||
Data any
|
|
||||||
}
|
func newMockMailRepo() *mockMailRepo {
|
||||||
ShouldFail bool
|
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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
-28
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user