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

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

No files matched your search

+113 -22
View File
@@ -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
}
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{}
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) {
+21
View File
@@ -0,0 +1,21 @@
package test
import "errors"
var errRepositoryFailure = errors.New("repository failure")
type failSwitch struct {
method string
}
func (f *failSwitch) failNext(method string) {
f.method = method
}
func (f *failSwitch) trigger(method string) error {
if f.method == method {
f.method = ""
return errRepositoryFailure
}
return nil
}
+44 -13
View File
@@ -1,40 +1,71 @@
package test
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
}
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
}
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 {
+19 -18
View File
@@ -7,35 +7,36 @@ import (
"testing"
)
type recordedMail struct {
Address string
Template string
Data any
}
type mockMailRepo struct {
SentEmails []struct {
Address string
Template string
Data any
}
ShouldFail bool
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)
}
})
}
+720
View File
@@ -0,0 +1,720 @@
package test
import (
"RenewCMS/internal/domain/article"
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"time"
"gorm.io/gorm"
)
func TestCreateArticleTitleLengthLimit(t *testing.T) {
cases := []struct {
length int
expectValid bool
}{
{0, false},
{2, false},
{3, true},
{200, true},
{201, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo)
title := strings.Repeat("a", tc.length)
result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
if tc.expectValid {
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID == 0 {
t.Errorf("expected repository-assigned id, got 0")
}
if result.IsOnline {
t.Errorf("expected is_online false, got true")
}
if len(repo.articles) != 1 {
t.Errorf("expected 1 article stored, got %d", len(repo.articles))
}
} else {
if !errors.Is(err, article.ErrInvalidTitle) {
t.Errorf("expected article.ErrInvalidTitle, got %v", err)
}
if len(repo.articles) != 0 {
t.Errorf("expected no article stored, got %d", len(repo.articles))
}
}
})
}
}
func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) {
repo := newMockArticleRepo()
repo.fail.failNext("Create")
uc := useCases.NewCreateArticleUseCase(repo)
_, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"})
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
if len(repo.articles) != 0 {
t.Errorf("expected no article stored, got %d", len(repo.articles))
}
}
func TestGetArticleAndGetOnlineArticle(t *testing.T) {
newRepo := func() *mockArticleRepo {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, Title: "Online Article", IsOnline: true}
repo.articles[2] = article.Article{ID: 2, Title: "Offline Article", IsOnline: false}
return repo
}
t.Run("GetArticle_1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
result, err := uc.GetArticle(1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected article id 1, got %d", result.ID)
}
})
t.Run("GetArticle_2", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
result, err := uc.GetArticle(2)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 2 {
t.Errorf("expected article id 2, got %d", result.ID)
}
})
t.Run("GetArticle_9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
_, err := uc.GetArticle(9)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
t.Run("GetOnlineArticle_1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
result, err := uc.GetOnlineArticle(1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected article id 1, got %d", result.ID)
}
})
t.Run("GetOnlineArticle_2", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
_, err := uc.GetOnlineArticle(2)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
})
t.Run("GetOnlineArticle_9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
_, err := uc.GetOnlineArticle(9)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
if errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected the raw not-found error, not article.ErrArticleNotFound")
}
})
}
func TestGetArticleByNameDelegatesVerbatim(t *testing.T) {
newRepo := func() *mockArticleRepo {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, Title: "Backyard Renovation Update"}
return repo
}
t.Run("Backyard_Renovation_Update", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
result, err := uc.GetArticleByName("Backyard Renovation Update")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected article id 1, got %d", result.ID)
}
})
t.Run("Unknown_Title", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
_, err := uc.GetArticleByName("Unknown Title")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
}
func assertArticleIds(t *testing.T, articles []article.Article, expected []uint32) {
t.Helper()
if len(articles) != len(expected) {
t.Fatalf("expected %d articles, got %d", len(expected), len(articles))
}
for i, id := range expected {
if articles[i].ID != id {
t.Errorf("expected article id %d at index %d, got %d", id, i, articles[i].ID)
}
}
}
func TestListArticlesFiltersByIsOnline(t *testing.T) {
newRepo := func() *mockArticleRepo {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, Title: "A", IsOnline: true}
repo.articles[2] = article.Article{ID: 2, Title: "B", IsOnline: false}
repo.articles[3] = article.Article{ID: 3, Title: "C", IsOnline: true}
return repo
}
t.Run("ListOnlineArticles", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo)
result, err := uc.ListOnlineArticles()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertArticleIds(t, result, []uint32{1, 3})
})
t.Run("ListAllArticles", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo)
result, err := uc.ListAllArticles()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertArticleIds(t, result, []uint32{1, 2, 3})
})
}
func TestListOnlineArticlesEmptyRepositoryReturnsEmptySlice(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewListArticlesUseCase(repo)
result, err := uc.ListOnlineArticles()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result == nil {
t.Errorf("expected an empty slice, got nil")
}
if len(result) != 0 {
t.Errorf("expected an empty slice, got %d items", len(result))
}
}
func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
newRepo := func() *mockArticleRepo {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, Title: "Existing"}
repo.images[1] = &imageDomain.Image{ID: 1, Path: "/static/one.png"}
return repo
}
t.Run("UpdateBody_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
result, err := uc.UpdateBody(1, "New body")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 || result.Body != "New body" {
t.Errorf("unexpected result: %+v", result)
}
})
t.Run("UpdateBody_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
_, err := uc.UpdateBody(9, "New body")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
t.Run("UpdateIsOnline_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
result, err := uc.UpdateIsOnline(1, true)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 || !result.IsOnline {
t.Errorf("unexpected result: %+v", result)
}
})
t.Run("UpdateIsOnline_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
_, err := uc.UpdateIsOnline(9, true)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
t.Run("AddImage_article1_image1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
err := uc.AddImage(1, 1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
updated, err := repo.Get(1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(updated.Images) != 1 || updated.Images[0].ID != 1 {
t.Errorf("expected image 1 attached, got %+v", updated.Images)
}
})
t.Run("AddImage_article9_image1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
err := uc.AddImage(9, 1)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
t.Run("AddImage_article1_image9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
err := uc.AddImage(1, 9)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
}
func TestDeleteArticleSucceedsSilently(t *testing.T) {
for _, id := range []uint32{1, 9} {
t.Run(fmt.Sprintf("id_%d", id), func(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1}
uc := useCases.NewDeleteArticleUseCase(repo)
err := uc.DeleteArticle(id)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
})
}
}
func TestDeleteArticlePropagatesRepositoryFailure(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1}
repo.fail.failNext("Delete")
uc := useCases.NewDeleteArticleUseCase(repo)
err := uc.DeleteArticle(1)
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
}
func TestCreateImageDelegatesContentTypeCheck(t *testing.T) {
cases := []struct {
contentType string
valid bool
}{
{"image/png", true},
{"image/jpeg", true},
{"image/webp", true},
{"image/svg+xml", true},
{"image/gif", false},
}
for _, tc := range cases {
t.Run(strings.ReplaceAll(tc.contentType, "/", "_"), func(t *testing.T) {
repo := newMockImageRepo()
uc := useCases.NewCreateImageUseCase(repo)
result, err := uc.CreateImage(imageDomain.ImageInput{
Content: strings.NewReader("fake-image-data"),
Filename: "test.img",
ContentType: tc.contentType,
})
if tc.valid {
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID == 0 {
t.Errorf("expected repository-assigned id, got 0")
}
} else {
if err == nil {
t.Errorf("expected an unsupported-content-type error, got nil")
}
if len(repo.images) != 0 {
t.Errorf("expected no image stored, got %d", len(repo.images))
}
}
})
}
}
func TestCreateImagePropagatesRepositoryFailure(t *testing.T) {
repo := newMockImageRepo()
repo.fail.failNext("Create")
uc := useCases.NewCreateImageUseCase(repo)
_, err := uc.CreateImage(imageDomain.ImageInput{
Content: strings.NewReader("data"),
Filename: "test.png",
ContentType: "image/png",
})
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
}
func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_1", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteImageUseCase(repo)
err := uc.DeleteImage(1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if _, exists := repo.images[1]; exists {
t.Errorf("expected the image to be removed from the fake")
}
})
t.Run("id_9", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteImageUseCase(repo)
err := uc.DeleteImage(9)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
}
func TestCreateUserAssignsVerificationCode(t *testing.T) {
repo := newMockUserRepo()
uc := useCases.NewCreateUserUseCase(repo)
before := time.Now()
result, err := uc.CreateUser(useCases.CreateUserCommand{
Username: "alice",
Password: "hunter2example",
Email: "alice@example.com",
})
after := time.Now()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID == 0 {
t.Errorf("expected repository-assigned id, got 0")
}
if result.IsVerified {
t.Errorf("expected is_verified false")
}
if result.VerificationCode == "" {
t.Errorf("expected a generated verification code")
}
if result.PasswordResetCode != "" {
t.Errorf("expected empty password_reset_code, got %q", result.PasswordResetCode)
}
minExpiration := before.Add(2 * time.Hour).Add(-time.Minute)
maxExpiration := after.Add(2 * time.Hour).Add(time.Minute)
if result.VerificationExpiration.Before(minExpiration) || result.VerificationExpiration.After(maxExpiration) {
t.Errorf("expected verification_expiration about 2 hours from now, got %v", result.VerificationExpiration)
}
second, err := uc.CreateUser(useCases.CreateUserCommand{
Username: "bob",
Password: "hunter2example",
Email: "bob@example.com",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if second.VerificationCode == result.VerificationCode {
t.Errorf("expected a distinct verification code from the previous call")
}
}
func TestCreateUserPropagatesRepositoryFailure(t *testing.T) {
repo := newMockUserRepo()
repo.fail.failNext("Create")
uc := useCases.NewCreateUserUseCase(repo)
_, err := uc.CreateUser(useCases.CreateUserCommand{
Username: "alice",
Password: "hunter2example",
Email: "alice@example.com",
})
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
}
func TestGetUserReadsByIdUsernameOrEmail(t *testing.T) {
newRepo := func() *mockUserRepo {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com"}
return repo
}
t.Run("GetUser_1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetUserUseCase(repo)
result, err := uc.GetUser(1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected user id 1, got %d", result.ID)
}
})
t.Run("GetUser_9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetUserUseCase(repo)
_, err := uc.GetUser(9)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
t.Run("GetUserByUsername_alice", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetUserUseCase(repo)
result, err := uc.GetUserByUsername("alice")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected user id 1, got %d", result.ID)
}
})
t.Run("GetUserByUsername_bob", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetUserUseCase(repo)
_, err := uc.GetUserByUsername("bob")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
t.Run("GetUserByEmail_alice", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetUserUseCase(repo)
result, err := uc.GetUserByEmail("alice@example.com")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected user id 1, got %d", result.ID)
}
})
t.Run("GetUserByEmail_bob", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetUserUseCase(repo)
_, err := uc.GetUserByEmail("bob@example.com")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
}
func TestListUsersReturnsEveryUser(t *testing.T) {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1}
repo.users[2] = user.User{ID: 2}
uc := useCases.NewListUsersUseCase(repo)
result := uc.ListUsers()
if len(result) != 2 {
t.Fatalf("expected 2 users, got %d", len(result))
}
ids := map[uint32]bool{result[0].ID: true, result[1].ID: true}
if !ids[1] || !ids[2] {
t.Errorf("expected users with ids 1 and 2, got %+v", result)
}
}
func TestUpdateUserDelegatesEachOperation(t *testing.T) {
newRepo := func() *mockUserRepo {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1}
return repo
}
t.Run("UpdateVerificationStatus_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo)
result, err := uc.UpdateVerificationStatus(1, true)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 || !result.IsVerified {
t.Errorf("unexpected result: %+v", result)
}
})
t.Run("UpdateVerificationStatus_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo)
_, err := uc.UpdateVerificationStatus(9, true)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
t.Run("UpdatePassword_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo)
result, err := uc.UpdatePassword(1, "newpassword123")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected user id 1, got %d", result.ID)
}
})
t.Run("UpdatePassword_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo)
_, err := uc.UpdatePassword(9, "newpassword123")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
t.Run("UpdatePasswordResetCode_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo)
result, err := uc.UpdatePasswordResetCode(1, "reset-code-123")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected user id 1, got %d", result.ID)
}
})
t.Run("UpdatePasswordResetCode_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo)
_, err := uc.UpdatePasswordResetCode(9, "reset-code-123")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
}
})
}
func TestDeleteUserSucceedsSilently(t *testing.T) {
for _, id := range []uint32{1, 9} {
t.Run(fmt.Sprintf("id_%d", id), func(t *testing.T) {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1}
uc := useCases.NewDeleteUserUseCase(repo)
err := uc.DeleteUser(id)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
})
}
}
func TestSendMailForwardsArgumentsVerbatim(t *testing.T) {
cases := []struct {
receiver string
template string
}{
{"alice@example.com", "verify-email"},
{"bob@example.com", "password-reset"},
}
for _, tc := range cases {
t.Run(tc.receiver+"_"+tc.template, func(t *testing.T) {
repo := newMockMailRepo()
uc := useCases.NewSendMailUseCase(repo)
data := map[string]string{"key": "value"}
err := uc.SendMail(tc.receiver, tc.template, data)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(repo.SentEmails) != 1 {
t.Fatalf("expected exactly one recorded send, got %d", len(repo.SentEmails))
}
sent := repo.SentEmails[0]
if sent.Address != tc.receiver || sent.Template != tc.template {
t.Errorf("unexpected recorded send: %+v", sent)
}
if !reflect.DeepEqual(sent.Data, data) {
t.Errorf("expected recorded data %+v, got %+v", data, sent.Data)
}
})
}
}
func TestSendMailPropagatesRepositoryFailureWithoutRecording(t *testing.T) {
repo := newMockMailRepo()
repo.fail.failNext("Send")
uc := useCases.NewSendMailUseCase(repo)
err := uc.SendMail("carol@example.com", "welcome", nil)
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
if len(repo.SentEmails) != 0 {
t.Errorf("expected no send recorded, got %d", len(repo.SentEmails))
}
}
+108 -28
View File
@@ -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
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
}
func (m *mockUserRepo) UpdateVerificationStatus(id uint32, v bool) (user.User, error) {
u := m.users[id]
u.IsVerified = v
m.users[id] = u
u, ok := m.users[userId]
if !ok {
return user.User{}, gorm.ErrRecordNotFound
}
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(id uint32, c 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
}
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)