Skip to content

Commit 55b6e28

Browse files
leo-aa88cursoragent
andcommitted
test: add unit tests for service and repository helpers
- BookService: cache hit/miss, nil Redis, sentinel errors, CRUD + Incr - UserService: login/register paths and sentinel errors (fakes + JWT setup) - IsBookNotFound / IsUserNotFound error chain coverage Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0dbda1a commit 55b6e28

3 files changed

Lines changed: 397 additions & 0 deletions

File tree

pkg/repository/repository_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package repository
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"gorm.io/gorm"
9+
)
10+
11+
func TestIsBookNotFound(t *testing.T) {
12+
assert.True(t, IsBookNotFound(gorm.ErrRecordNotFound))
13+
assert.False(t, IsBookNotFound(nil))
14+
assert.False(t, IsBookNotFound(errors.New("other")))
15+
assert.True(t, IsBookNotFound(errors.Join(gorm.ErrRecordNotFound, errors.New("wrap"))))
16+
}
17+
18+
func TestIsUserNotFound(t *testing.T) {
19+
assert.True(t, IsUserNotFound(gorm.ErrRecordNotFound))
20+
assert.False(t, IsUserNotFound(nil))
21+
assert.False(t, IsUserNotFound(errors.New("other")))
22+
assert.True(t, IsUserNotFound(errors.Join(gorm.ErrRecordNotFound, errors.New("wrap"))))
23+
}

pkg/service/book_service_test.go

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
package service
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"testing"
8+
"time"
9+
10+
"golang-rest-api-template/pkg/cache"
11+
"golang-rest-api-template/pkg/models"
12+
13+
"github.com/go-redis/redis/v8"
14+
"github.com/golang/mock/gomock"
15+
"github.com/stretchr/testify/assert"
16+
)
17+
18+
type fakeBookStore struct {
19+
listFn func(offset, limit int) ([]models.Book, error)
20+
createFn func(book *models.Book) error
21+
firstFn func(id uint) (*models.Book, error)
22+
updateFn func(id uint, title, author string) (*models.Book, error)
23+
deleteFn func(id uint) error
24+
}
25+
26+
func (f *fakeBookStore) List(offset, limit int) ([]models.Book, error) {
27+
if f.listFn != nil {
28+
return f.listFn(offset, limit)
29+
}
30+
return nil, nil
31+
}
32+
33+
func (f *fakeBookStore) Create(book *models.Book) error {
34+
if f.createFn != nil {
35+
return f.createFn(book)
36+
}
37+
return nil
38+
}
39+
40+
func (f *fakeBookStore) FirstByID(id uint) (*models.Book, error) {
41+
if f.firstFn != nil {
42+
return f.firstFn(id)
43+
}
44+
return nil, nil
45+
}
46+
47+
func (f *fakeBookStore) UpdateFields(id uint, title, author string) (*models.Book, error) {
48+
if f.updateFn != nil {
49+
return f.updateFn(id, title, author)
50+
}
51+
return nil, nil
52+
}
53+
54+
func (f *fakeBookStore) DeleteByID(id uint) error {
55+
if f.deleteFn != nil {
56+
return f.deleteFn(id)
57+
}
58+
return nil
59+
}
60+
61+
func TestBooksListDataCacheKey(t *testing.T) {
62+
assert.Equal(t, "books_g2_offset_5_limit_10", BooksListDataCacheKey(2, 5, 10))
63+
}
64+
65+
func TestListBooksNilRedisUsesStore(t *testing.T) {
66+
want := []models.Book{{ID: 1, Title: "a", Author: "b"}}
67+
store := &fakeBookStore{
68+
listFn: func(offset, limit int) ([]models.Book, error) {
69+
assert.Equal(t, 3, offset)
70+
assert.Equal(t, 7, limit)
71+
return want, nil
72+
},
73+
}
74+
svc := NewBookService(store, nil)
75+
got, err := svc.ListBooks(context.Background(), 3, 7)
76+
assert.NoError(t, err)
77+
assert.Equal(t, want, got)
78+
}
79+
80+
func TestListBooksCacheHit(t *testing.T) {
81+
ctrl := gomock.NewController(t)
82+
defer ctrl.Finish()
83+
mockRedis := cache.NewMockCache(ctrl)
84+
85+
want := []models.Book{{ID: 9, Title: "cached", Author: "x"}}
86+
payload, err := json.Marshal(want)
87+
assert.NoError(t, err)
88+
dataKey := BooksListDataCacheKey(0, 0, 10)
89+
90+
gomock.InOrder(
91+
mockRedis.EXPECT().Get(gomock.Any(), BooksListCacheGenKey).Return(redis.NewStringResult("", redis.Nil)),
92+
mockRedis.EXPECT().Get(gomock.Any(), dataKey).Return(redis.NewStringResult(string(payload), nil)),
93+
)
94+
95+
store := &fakeBookStore{
96+
listFn: func(offset, limit int) ([]models.Book, error) {
97+
t.Fatal("store.List should not run on cache hit")
98+
return nil, nil
99+
},
100+
}
101+
svc := NewBookService(store, mockRedis)
102+
got, err := svc.ListBooks(context.Background(), 0, 10)
103+
assert.NoError(t, err)
104+
assert.Equal(t, want, got)
105+
}
106+
107+
func TestListBooksCacheUnmarshalError(t *testing.T) {
108+
ctrl := gomock.NewController(t)
109+
defer ctrl.Finish()
110+
mockRedis := cache.NewMockCache(ctrl)
111+
dataKey := BooksListDataCacheKey(0, 1, 5)
112+
113+
gomock.InOrder(
114+
mockRedis.EXPECT().Get(gomock.Any(), BooksListCacheGenKey).Return(redis.NewStringResult("0", nil)),
115+
mockRedis.EXPECT().Get(gomock.Any(), dataKey).Return(redis.NewStringResult("not-json", nil)),
116+
)
117+
118+
svc := NewBookService(&fakeBookStore{}, mockRedis)
119+
_, err := svc.ListBooks(context.Background(), 1, 5)
120+
assert.Error(t, err)
121+
assert.ErrorIs(t, err, ErrListBooksUnmarshal)
122+
}
123+
124+
func TestListBooksStoreError(t *testing.T) {
125+
ctrl := gomock.NewController(t)
126+
defer ctrl.Finish()
127+
mockRedis := cache.NewMockCache(ctrl)
128+
dataKey := BooksListDataCacheKey(0, 0, 10)
129+
dbErr := errors.New("db down")
130+
131+
gomock.InOrder(
132+
mockRedis.EXPECT().Get(gomock.Any(), BooksListCacheGenKey).Return(redis.NewStringResult("", redis.Nil)),
133+
mockRedis.EXPECT().Get(gomock.Any(), dataKey).Return(redis.NewStringResult("", redis.Nil)),
134+
)
135+
136+
store := &fakeBookStore{
137+
listFn: func(offset, limit int) ([]models.Book, error) {
138+
return nil, dbErr
139+
},
140+
}
141+
svc := NewBookService(store, mockRedis)
142+
_, err := svc.ListBooks(context.Background(), 0, 10)
143+
assert.Error(t, err)
144+
assert.ErrorIs(t, err, ErrListBooksDB)
145+
}
146+
147+
func TestListBooksRedisSetError(t *testing.T) {
148+
ctrl := gomock.NewController(t)
149+
defer ctrl.Finish()
150+
mockRedis := cache.NewMockCache(ctrl)
151+
dataKey := BooksListDataCacheKey(0, 0, 10)
152+
want := []models.Book{{Title: "t", Author: "a"}}
153+
154+
gomock.InOrder(
155+
mockRedis.EXPECT().Get(gomock.Any(), BooksListCacheGenKey).Return(redis.NewStringResult("", redis.Nil)),
156+
mockRedis.EXPECT().Get(gomock.Any(), dataKey).Return(redis.NewStringResult("", redis.Nil)),
157+
)
158+
mockRedis.EXPECT().Set(gomock.Any(), dataKey, gomock.Any(), time.Minute).Return(redis.NewStatusResult("", errors.New("set failed")))
159+
160+
store := &fakeBookStore{
161+
listFn: func(offset, limit int) ([]models.Book, error) {
162+
return want, nil
163+
},
164+
}
165+
svc := NewBookService(store, mockRedis)
166+
_, err := svc.ListBooks(context.Background(), 0, 10)
167+
assert.Error(t, err)
168+
assert.ErrorIs(t, err, ErrListBooksRedis)
169+
}
170+
171+
func TestCreateBookBumpsGenerationWhenRedis(t *testing.T) {
172+
ctrl := gomock.NewController(t)
173+
defer ctrl.Finish()
174+
mockRedis := cache.NewMockCache(ctrl)
175+
mockRedis.EXPECT().Incr(gomock.Any(), BooksListCacheGenKey).Return(redis.NewIntResult(1, nil)).Times(1)
176+
177+
store := &fakeBookStore{
178+
createFn: func(book *models.Book) error {
179+
book.ID = 42
180+
return nil
181+
},
182+
}
183+
svc := NewBookService(store, mockRedis)
184+
book, err := svc.CreateBook(context.Background(), "t", "a")
185+
assert.NoError(t, err)
186+
assert.NotNil(t, book)
187+
assert.Equal(t, uint(42), book.ID)
188+
}
189+
190+
func TestCreateBookNoIncrWhenNilRedis(t *testing.T) {
191+
store := &fakeBookStore{
192+
createFn: func(book *models.Book) error {
193+
return nil
194+
},
195+
}
196+
svc := NewBookService(store, nil)
197+
_, err := svc.CreateBook(context.Background(), "t", "a")
198+
assert.NoError(t, err)
199+
}
200+
201+
func TestGetBookDelegates(t *testing.T) {
202+
want := &models.Book{ID: 3, Title: "x", Author: "y"}
203+
store := &fakeBookStore{
204+
firstFn: func(id uint) (*models.Book, error) {
205+
assert.Equal(t, uint(3), id)
206+
return want, nil
207+
},
208+
}
209+
svc := NewBookService(store, nil)
210+
got, err := svc.GetBook(context.Background(), 3)
211+
assert.NoError(t, err)
212+
assert.Equal(t, want, got)
213+
}
214+
215+
func TestUpdateBookBumpsGeneration(t *testing.T) {
216+
ctrl := gomock.NewController(t)
217+
defer ctrl.Finish()
218+
mockRedis := cache.NewMockCache(ctrl)
219+
mockRedis.EXPECT().Incr(gomock.Any(), BooksListCacheGenKey).Return(redis.NewIntResult(2, nil)).Times(1)
220+
221+
updated := &models.Book{ID: 1, Title: "n", Author: "m"}
222+
store := &fakeBookStore{
223+
updateFn: func(id uint, title, author string) (*models.Book, error) {
224+
assert.Equal(t, uint(1), id)
225+
assert.Equal(t, "n", title)
226+
assert.Equal(t, "m", author)
227+
return updated, nil
228+
},
229+
}
230+
svc := NewBookService(store, mockRedis)
231+
got, err := svc.UpdateBook(context.Background(), 1, "n", "m")
232+
assert.NoError(t, err)
233+
assert.Equal(t, updated, got)
234+
}
235+
236+
func TestDeleteBookBumpsGeneration(t *testing.T) {
237+
ctrl := gomock.NewController(t)
238+
defer ctrl.Finish()
239+
mockRedis := cache.NewMockCache(ctrl)
240+
mockRedis.EXPECT().Incr(gomock.Any(), BooksListCacheGenKey).Return(redis.NewIntResult(1, nil)).Times(1)
241+
242+
store := &fakeBookStore{
243+
deleteFn: func(id uint) error {
244+
assert.Equal(t, uint(9), id)
245+
return nil
246+
},
247+
}
248+
svc := NewBookService(store, mockRedis)
249+
assert.NoError(t, svc.DeleteBook(context.Background(), 9))
250+
}

pkg/service/user_service_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package service
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"errors"
7+
"testing"
8+
9+
"golang-rest-api-template/pkg/auth"
10+
"golang-rest-api-template/pkg/models"
11+
12+
"github.com/stretchr/testify/assert"
13+
"golang.org/x/crypto/bcrypt"
14+
"gorm.io/gorm"
15+
)
16+
17+
type fakeUserStore struct {
18+
findFn func(username string) (*models.User, error)
19+
createFn func(user *models.User) error
20+
}
21+
22+
func (f *fakeUserStore) FindByUsername(username string) (*models.User, error) {
23+
if f.findFn != nil {
24+
return f.findFn(username)
25+
}
26+
return nil, nil
27+
}
28+
29+
func (f *fakeUserStore) Create(user *models.User) error {
30+
if f.createFn != nil {
31+
return f.createFn(user)
32+
}
33+
return nil
34+
}
35+
36+
func TestUserServiceLoginUserNotFound(t *testing.T) {
37+
store := &fakeUserStore{
38+
findFn: func(username string) (*models.User, error) {
39+
return nil, gorm.ErrRecordNotFound
40+
},
41+
}
42+
svc := NewUserService(store)
43+
_, err := svc.Login(context.Background(), "nobody", "pw")
44+
assert.ErrorIs(t, err, ErrInvalidLogin)
45+
}
46+
47+
func TestUserServiceLoginWrongPassword(t *testing.T) {
48+
hash, err := bcrypt.GenerateFromPassword([]byte("right"), bcrypt.MinCost)
49+
assert.NoError(t, err)
50+
store := &fakeUserStore{
51+
findFn: func(username string) (*models.User, error) {
52+
return &models.User{Username: "u", Password: string(hash)}, nil
53+
},
54+
}
55+
svc := NewUserService(store)
56+
_, err = svc.Login(context.Background(), "u", "wrong")
57+
assert.ErrorIs(t, err, ErrInvalidLogin)
58+
}
59+
60+
func TestUserServiceLoginDBError(t *testing.T) {
61+
dbErr := errors.New("connection refused")
62+
store := &fakeUserStore{
63+
findFn: func(username string) (*models.User, error) {
64+
return nil, dbErr
65+
},
66+
}
67+
svc := NewUserService(store)
68+
_, err := svc.Login(context.Background(), "u", "p")
69+
assert.Error(t, err)
70+
assert.ErrorIs(t, err, ErrLoginDB)
71+
}
72+
73+
func TestUserServiceLoginSuccess(t *testing.T) {
74+
prev := auth.JWTSigningKey()
75+
t.Cleanup(func() { _ = auth.SetJWTSigningKey(prev) })
76+
assert.NoError(t, auth.SetJWTSigningKey(bytes.Repeat([]byte("s"), auth.MinJWTSecretKeyBytes)))
77+
78+
hash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.MinCost)
79+
assert.NoError(t, err)
80+
store := &fakeUserStore{
81+
findFn: func(username string) (*models.User, error) {
82+
return &models.User{Username: "alice", Password: string(hash)}, nil
83+
},
84+
}
85+
svc := NewUserService(store)
86+
tok, err := svc.Login(context.Background(), "alice", "secret")
87+
assert.NoError(t, err)
88+
assert.NotEmpty(t, tok)
89+
}
90+
91+
func TestUserServiceRegisterConflictDuplicatedKey(t *testing.T) {
92+
store := &fakeUserStore{
93+
createFn: func(user *models.User) error {
94+
return gorm.ErrDuplicatedKey
95+
},
96+
}
97+
svc := NewUserService(store)
98+
err := svc.Register(context.Background(), "dup", "password123")
99+
assert.ErrorIs(t, err, ErrRegisterConflict)
100+
}
101+
102+
func TestUserServiceRegisterConflictSQLiteMessage(t *testing.T) {
103+
store := &fakeUserStore{
104+
createFn: func(user *models.User) error {
105+
return errors.New("UNIQUE constraint failed: users.username")
106+
},
107+
}
108+
svc := NewUserService(store)
109+
err := svc.Register(context.Background(), "dup", "password123")
110+
assert.ErrorIs(t, err, ErrRegisterConflict)
111+
}
112+
113+
func TestUserServiceRegisterSaveError(t *testing.T) {
114+
saveErr := errors.New("disk full")
115+
store := &fakeUserStore{
116+
createFn: func(user *models.User) error {
117+
return saveErr
118+
},
119+
}
120+
svc := NewUserService(store)
121+
err := svc.Register(context.Background(), "u", "password123")
122+
assert.Error(t, err)
123+
assert.ErrorIs(t, err, ErrRegisterSave)
124+
}

0 commit comments

Comments
 (0)