Repository: Ptt-official-app/Ptt-backend
Pull request #24 (2021-01-31 10:15:09)

Feat/search articles
<!-- 原則上不接受沒有 Issue ID 的 PR。 -->
<!-- We don't accept PRs which has no Issue ID. -->

## 👏 解決掉的 issue / Resolved Issues
- #13 
- #14 
- #26 

## 📝 相關的 issue / Related Issues
NA

## ⛏ 變更內容 / Details of Changes
<!-- 簡要陳列您的修改 -->
<!-- List down your changes concisely -->
- /v1/boards/{{board_id}}/articles 取得看版內文章列表的部份加上參數 author, title_contain, recommend_count_ge

在測試的部份有稍微做了些調整，把 mock 的部份獨立檔案來放。
另外原本 repository 的地方也寫了 struct MockRepository，個人認為這應該在 usecase 即可。 
repository 要放的 mock 應該是針對 db ，目前只有刪除原本的 MockRepository，尚未新增 db mock。

ggary9424: 有關 case insensitive，author 和 title 的 query parameters 應該也要做 lowercase 再做比對
PichuChen: 會有興趣補上 testing 嗎？
ars0915: > 會有興趣補上 testing 嗎？
好的，晚點補

Patch:
diff --git a/internal/delivery/http/route_borads_articles.go b/internal/delivery/http/route_boards_articles.go
similarity index 56%
rename from internal/delivery/http/route_borads_articles.go
rename to internal/delivery/http/route_boards_articles.go
index 0f7a44b..75f1fd0 100644
--- a/internal/delivery/http/route_borads_articles.go
+++ b/internal/delivery/http/route_boards_articles.go
@@ -5,6 +5,7 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"net/http"
+	"strconv"
 
 	"github.com/Ptt-official-app/Ptt-backend/internal/usecase"
 )
@@ -27,9 +28,40 @@ func (delivery *httpDelivery) getBoardArticles(w http.ResponseWriter, r *http.Re
 		return
 	}
 
+	var recommendCountGreaterEqual, recommendCountLessEqual int
+	var recommendCountGreaterEqualIsSet, recommendCountLessEqualIsSet bool
+	queryParam := r.URL.Query()
+	recommendCountGreaterEqualParam := queryParam.Get("recommend_count_ge")
+	recommendCountGreaterEqualIsSet = recommendCountGreaterEqualParam != ""
+	recommendCountGreaterEqual, err = strconv.Atoi(recommendCountGreaterEqualParam)
+
+	if err != nil && recommendCountGreaterEqualIsSet {
+		delivery.logger.Errorf("recommend_count_ge should be integer")
+		w.WriteHeader(http.StatusBadRequest)
+		return
+	}
+
+	recommendCountLessEqualParam := queryParam.Get("recommend_count_le")
+	recommendCountLessEqualIsSet = recommendCountLessEqualParam != ""
+	recommendCountLessEqual, err = strconv.Atoi(recommendCountLessEqualParam)
+	if err != nil && recommendCountLessEqualIsSet {
+		delivery.logger.Errorf("recommend_count_le should be integer")
+		w.WriteHeader(http.StatusBadRequest)
+		return
+	}
+
+	searchCond := &usecase.ArticleSearchCond{
+		Title:                           queryParam.Get("title_contain"),
+		Author:                          queryParam.Get("author"),
+		RecommendCountGreaterEqual:      recommendCountGreaterEqual,
+		RecommendCountLessEqual:         recommendCountLessEqual,
+		RecommendCountGreaterEqualIsSet: recommendCountGreaterEqualIsSet,
+		RecommendCountLessEqualIsSet:    recommendCountLessEqualIsSet,
+	}
+
 	responseMap := map[string]interface{}{
 		"data": map[string]interface{}{
-			"items": delivery.usecase.GetBoardArticles(context.Background(), boardId),
+			"items": delivery.usecase.GetBoardArticles(context.Background(), boardId, searchCond),
 		},
 	}
 
diff --git a/internal/usecase/board.go b/internal/usecase/board.go
index d2d9f55..128550c 100644
--- a/internal/usecase/board.go
+++ b/internal/usecase/board.go
@@ -3,10 +3,25 @@ package usecase
 import (
 	"context"
 	"fmt"
+	"strings"
 
 	"github.com/PichuChen/go-bbs"
 )
 
+type ArticleSearchCond struct {
+	Title                           string
+	Author                          string
+	RecommendCountValue             int
+	RecommendCountLessThan          int
+	RecommendCountLessEqual         int
+	RecommendCountEqual             int
+	RecommendCountNotEqual          int
+	RecommendCountGreaterThan       int
+	RecommendCountGreaterEqual      int
+	RecommendCountGreaterEqualIsSet bool
+	RecommendCountLessEqualIsSet    bool
+}
+
 func (usecase *usecase) GetBoardByID(ctx context.Context, boardID string) (bbs.BoardRecord, error) {
 	for _, it := range usecase.repo.GetBoards(ctx) {
 		if boardID == it.BoardId() {
@@ -50,15 +65,25 @@ func (usecase *usecase) GetClasses(ctx context.Context, userID, classID string)
 	return boards
 }
 
-func (usecase *usecase) GetBoardArticles(ctx context.Context, boardID string) []interface{} {
+func (usecase *usecase) GetBoardArticles(ctx context.Context, boardID string, cond *ArticleSearchCond) []interface{} {
+	var articles []bbs.ArticleRecord
 	articleRecords, err := usecase.repo.GetBoardArticleRecords(ctx, boardID)
 	if err != nil {
 		usecase.logger.Warningf("open directory file error: %v", err)
 		// The board may not contain any article
 	}
 
+	if len(strings.TrimSpace(cond.Title)) > 0 ||
+		len(strings.TrimSpace(cond.Author)) > 0 ||
+		cond.RecommendCountGreaterEqualIsSet ||
+		cond.RecommendCountLessEqualIsSet {
+		articles = searchArticles(articleRecords, cond)
+	} else {
+		articles = articleRecords
+	}
+
 	items := []interface{}{}
-	for _, f := range articleRecords {
+	for _, f := range articles {
 		m := map[string]interface{}{
 			"filename": f.Filename(),
 			// Bug(pichu): f.Modified time will be 0 when file is vote
@@ -118,3 +143,28 @@ func (usecase *usecase) shouldShowOnUserLevel(board bbs.BoardRecord, userID stri
 func getArticleURL(boardId string, filename string) string {
 	return fmt.Sprintf("https://ptt-app-dev-codingman.pichuchen.tw/bbs/%s/%s.html", boardId, filename)
 }
+
+func searchArticles(fileHeaders []bbs.ArticleRecord, cond *ArticleSearchCond) []bbs.ArticleRecord {
+	var targetArticles []bbs.ArticleRecord
+
+	for _, f := range fileHeaders {
+		if !strings.Contains(strings.ToLower(f.Title()), strings.ToLower(cond.Title)) {
+			continue
+		}
+
+		if !strings.Contains(strings.ToLower(f.Owner()), strings.ToLower(cond.Author)) {
+			continue
+		}
+
+		if cond.RecommendCountGreaterEqualIsSet && f.Recommend() < cond.RecommendCountGreaterEqual {
+			continue
+		}
+
+		if cond.RecommendCountLessEqualIsSet && f.Recommend() > cond.RecommendCountLessEqual {
+			continue
+		}
+
+		targetArticles = append(targetArticles, f)
+	}
+	return targetArticles
+}
diff --git a/internal/usecase/usecase.go b/internal/usecase/usecase.go
index 4b096aa..0cd6e64 100644
--- a/internal/usecase/usecase.go
+++ b/internal/usecase/usecase.go
@@ -27,7 +27,7 @@ type Usecase interface {
 	// GetClasses returns board records in a class
 	GetClasses(ctx context.Context, userID, classID string) []bbs.BoardRecord
 	// GetBoardArticles returns articles of a board
-	GetBoardArticles(ctx context.Context, boardID string) []interface{} // FIXME: use concrete type rather than []interface{}
+	GetBoardArticles(ctx context.Context, boardID string, cond *ArticleSearchCond) []interface{} // FIXME: use concrete type rather than []interface{}
 	// GetBoardArticle returns an article file given board id and file name
 	GetBoardArticle(ctx context.Context, boardID, filename string) ([]byte, error)
 	// GetBoardTreasures returns treasures of a board


Test patch:
diff --git a/internal/delivery/http/route_boards_articles_test.go b/internal/delivery/http/route_boards_articles_test.go
new file mode 100644
index 0000000..2a47923
--- /dev/null
+++ b/internal/delivery/http/route_boards_articles_test.go
@@ -0,0 +1,42 @@
+package http
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+)
+
+func TestGetBoardArticlesBadRequest(t *testing.T) {
+	userID := "id"
+	usecase := NewMockUsecase()
+	delivery := NewHTTPDelivery(usecase)
+
+	title := ""
+	author := ""
+	recommendCountGe := "qwerty"
+	v := url.Values{}
+	v.Set("title", title)
+	v.Set("author", author)
+	v.Set("recommend_count_ge", recommendCountGe)
+	uri := fmt.Sprintf("/v1/boards/test/articles?%s", v.Encode())
+	req, err := http.NewRequest("GET", uri, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	token := usecase.CreateAccessTokenWithUsername(userID)
+	t.Logf("testing token: %v", token)
+	req.Header.Add("Authorization", "bearer "+token)
+
+	rr := httptest.NewRecorder()
+	r := http.NewServeMux()
+	r.HandleFunc("/v1/boards/", delivery.routeBoards)
+	r.ServeHTTP(rr, req)
+
+	if status := rr.Code; status != http.StatusBadRequest {
+		t.Errorf("handler returned wrong status code: got %v want %v",
+			status, http.StatusBadRequest)
+	}
+}
diff --git a/internal/delivery/http/route_boards_mock_test.go b/internal/delivery/http/route_boards_mock_test.go
new file mode 100644
index 0000000..57b1f4d
--- /dev/null
+++ b/internal/delivery/http/route_boards_mock_test.go
@@ -0,0 +1,32 @@
+package http
+
+import (
+	"context"
+
+	"github.com/PichuChen/go-bbs"
+	"github.com/Ptt-official-app/Ptt-backend/internal/usecase"
+)
+
+func (usecase *MockUsecase) GetBoardByID(ctx context.Context, boardID string) (bbs.BoardRecord, error) {
+	panic("Not implemented")
+}
+
+func (usecase *MockUsecase) GetBoards(ctx context.Context, userID string) []bbs.BoardRecord {
+	panic("Not implemented")
+}
+
+func (usecase *MockUsecase) GetClasses(ctx context.Context, userID, classID string) []bbs.BoardRecord {
+	panic("Not implemented")
+}
+
+func (usecase *MockUsecase) GetBoardArticles(ctx context.Context, boardID string, cond *usecase.ArticleSearchCond) []interface{} {
+	panic("Not implemented")
+}
+
+func (usecase *MockUsecase) GetBoardArticle(ctx context.Context, boardID, filename string) ([]byte, error) {
+	panic("Not implemented")
+}
+
+func (usecase *MockUsecase) GetBoardTreasures(ctx context.Context, boardID string, treasuresID []string) []interface{} {
+	panic("Not implemented")
+}
diff --git a/internal/delivery/http/route_mock_test.go b/internal/delivery/http/route_mock_test.go
new file mode 100644
index 0000000..2d6dffe
--- /dev/null
+++ b/internal/delivery/http/route_mock_test.go
@@ -0,0 +1,13 @@
+package http
+
+import (
+	"github.com/Ptt-official-app/Ptt-backend/internal/usecase"
+)
+
+// implements usecase.Usecase
+type MockUsecase struct {
+}
+
+func NewMockUsecase() usecase.Usecase {
+	return &MockUsecase{}
+}
diff --git a/internal/delivery/http/route_token_mock_test.go b/internal/delivery/http/route_token_mock_test.go
new file mode 100644
index 0000000..dbdaa3a
--- /dev/null
+++ b/internal/delivery/http/route_token_mock_test.go
@@ -0,0 +1,17 @@
+package http
+
+import (
+	"github.com/Ptt-official-app/Ptt-backend/internal/usecase"
+)
+
+func (usecase *MockUsecase) CreateAccessTokenWithUsername(username string) string {
+	return "token"
+}
+
+func (usecase *MockUsecase) GetUserIdFromToken(token string) (string, error) {
+	panic("Not implemented")
+}
+
+func (usecase *MockUsecase) CheckPermission(token string, permissionId []usecase.Permission, userInfo map[string]string) error {
+	return nil
+}
diff --git a/internal/delivery/http/route_users_mock_test.go b/internal/delivery/http/route_users_mock_test.go
new file mode 100644
index 0000000..2cb1323
--- /dev/null
+++ b/internal/delivery/http/route_users_mock_test.go
@@ -0,0 +1,63 @@
+package http
+
+import (
+	"context"
+	"time"
+
+	"github.com/PichuChen/go-bbs"
+)
+
+func (usecase *MockUsecase) GetUserByID(ctx context.Context, userID string) (bbs.UserRecord, error) {
+	panic("Not implemented")
+}
+
+func (usecase *MockUsecase) GetUserFavorites(ctx context.Context, userID string) ([]interface{}, error) {
+	panic("Not implemented")
+}
+
+func (usecase *MockUsecase) GetUserInformation(ctx context.Context, userID string) (map[string]interface{}, error) {
+	result := map[string]interface{}{
+		"user_id": "id",
+	}
+	return result, nil
+}
+
+type MockUserRecord struct {
+	userId string
+}
+
+func NewMockUserRecord(userId string) *MockUserRecord { return &MockUserRecord{userId: userId} }
+func (u *MockUserRecord) UserId() string              { return u.userId }
+
+// HashedPassword return user hashed password, it only for debug,
+// If you want to check is user password correct, please use
+// VerifyPassword insteaded.
+func (u *MockUserRecord) HashedPassword() string { return "" }
+
+// VerifyPassword will check user's password is OK. it will return null
+// when OK and error when there are something wrong
+func (u *MockUserRecord) VerifyPassword(password string) error { return nil }
+
+// Nickname return a string for user's nickname, this string may change
+// depend on user's mood, return empty string if this bbs system do not support
+func (u *MockUserRecord) Nickname() string { return "" }
+
+// RealName return a string for user's real name, this string may not be changed
+// return empty string if this bbs system do not support
+func (u *MockUserRecord) RealName() string { return "" }
+
+// NumLoginDays return how many days this have been login since account created.
+func (u *MockUserRecord) NumLoginDays() int { return 0 }
+
+// NumPosts return how many posts this user has posted.
+func (u *MockUserRecord) NumPosts() int { return 0 }
+
+// Money return the money this user have.
+func (u *MockUserRecord) Money() int { return 0 }
+
+// LastLogin return last login time of user
+func (u *MockUserRecord) LastLogin() time.Time { return time.Now() }
+
+// LastHost return last login host of user, it is IPv4 address usually, but it
+// could be domain name or IPv6 address.
+func (u *MockUserRecord) LastHost() string { return "" }
diff --git a/internal/delivery/http/route_users_test.go b/internal/delivery/http/route_users_test.go
index 24a38e8..d35dd11 100644
--- a/internal/delivery/http/route_users_test.go
+++ b/internal/delivery/http/route_users_test.go
@@ -1,120 +1,12 @@
 package http
 
 import (
-	"github.com/PichuChen/go-bbs"
-
-	"github.com/Ptt-official-app/Ptt-backend/internal/usecase"
-
-	"context"
 	"encoding/json"
 	"net/http"
 	"net/http/httptest"
 	"testing"
-	"time"
 )
 
-type MockUserRecord struct {
-	userId string
-}
-
-func NewMockUserRecord(userId string) *MockUserRecord { return &MockUserRecord{userId: userId} }
-func (u *MockUserRecord) UserId() string              { return u.userId }
-
-// HashedPassword return user hashed password, it only for debug,
-// If you want to check is user password correct, please use
-// VerifyPassword insteaded.
-func (u *MockUserRecord) HashedPassword() string { return "" }
-
-// VerifyPassword will check user's password is OK. it will return null
-// when OK and error when there are something wrong
-func (u *MockUserRecord) VerifyPassword(password string) error { return nil }
-
-// Nickname return a string for user's nickname, this string may change
-// depend on user's mood, return empty string if this bbs system do not support
-func (u *MockUserRecord) Nickname() string { return "" }
-
-// RealName return a string for user's real name, this string may not be changed
-// return empty string if this bbs system do not support
-func (u *MockUserRecord) RealName() string { return "" }
-
-// NumLoginDays return how many days this have been login since account created.
-func (u *MockUserRecord) NumLoginDays() int { return 0 }
-
-// NumPosts return how many posts this user has posted.
-func (u *MockUserRecord) NumPosts() int { return 0 }
-
-// Money return the money this user have.
-func (u *MockUserRecord) Money() int { return 0 }
-
-// LastLogin return last login time of user
-func (u *MockUserRecord) LastLogin() time.Time { return time.Now() }
-
-// LastHost return last login host of user, it is IPv4 address usually, but it
-// could be domain name or IPv6 address.
-func (u *MockUserRecord) LastHost() string { return "" }
-
-// implements usecase.Usecase
-type MockUsecase struct {
-}
-
-func NewMockUsecase() usecase.Usecase {
-	return &MockUsecase{}
-}
-
-// usecase/user.go
-func (usecase *MockUsecase) GetUserByID(ctx context.Context, userID string) (bbs.UserRecord, error) {
-	panic("Not implemented")
-}
-
-func (usecase *MockUsecase) GetUserFavorites(ctx context.Context, userID string) ([]interface{}, error) {
-	panic("Not implemented")
-}
-
-func (usecase *MockUsecase) GetUserInformation(ctx context.Context, userID string) (map[string]interface{}, error) {
-	result := map[string]interface{}{
-		"user_id": "id",
-	}
-	return result, nil
-}
-
-// usecase/board.go
-func (usecase *MockUsecase) GetBoardByID(ctx context.Context, boardID string) (bbs.BoardRecord, error) {
-	panic("Not implemented")
-}
-
-func (usecase *MockUsecase) GetBoards(ctx context.Context, userID string) []bbs.BoardRecord {
-	panic("Not implemented")
-}
-
-func (usecase *MockUsecase) GetClasses(ctx context.Context, userID, classID string) []bbs.BoardRecord {
-	panic("Not implemented")
-}
-
-func (usecase *MockUsecase) GetBoardArticles(ctx context.Context, boardID string) []interface{} {
-	panic("Not implemented")
-}
-
-func (usecase *MockUsecase) GetBoardArticle(ctx context.Context, boardID, filename string) ([]byte, error) {
-	panic("Not implemented")
-}
-
-func (usecase *MockUsecase) GetBoardTreasures(ctx context.Context, boardID string, treasuresID []string) []interface{} {
-	panic("Not implemented")
-}
-
-// usecase/token.go
-func (usecase *MockUsecase) CreateAccessTokenWithUsername(username string) string {
-	return "token"
-}
-
-func (usecase *MockUsecase) GetUserIdFromToken(token string) (string, error) {
-	panic("Not implemented")
-}
-
-func (usecase *MockUsecase) CheckPermission(token string, permissionId []usecase.Permission, userInfo map[string]string) error {
-	return nil
-}
-
 func TestGetUserInformation(t *testing.T) {
 
 	userID := "id"
diff --git a/internal/repository/user_test.go b/internal/repository/user_test.go
index 8fef256..c66c381 100644
--- a/internal/repository/user_test.go
+++ b/internal/repository/user_test.go
@@ -7,23 +7,6 @@ import (
 	"github.com/PichuChen/go-bbs"
 )
 
-type MockRepository struct{}
-
-// repository/board.go
-func (repo *MockRepository) GetBoards(ctx context.Context) []bbs.BoardRecord {
-	return []bbs.BoardRecord{}
-}
-func (repo *MockRepository) GetBoardArticle(ctx context.Context, boardID, filename string) ([]byte, error) {
-	return []byte{}, nil
-}
-func (repo *MockRepository) GetBoardArticleRecords(ctx context.Context, boardID string) ([]bbs.ArticleRecord, error) {
-	return []bbs.ArticleRecord{}, nil
-}
-
-func (repo *MockRepository) GetBoardTreasureRecords(ctx context.Context, boardID string, treasureIDs []string) ([]bbs.ArticleRecord, error) {
-	return []bbs.ArticleRecord{}, nil
-}
-
 func TestGetUsers(t *testing.T) {
 
 	repo := repository{
diff --git a/internal/usecase/board_mock_test.go b/internal/usecase/board_mock_test.go
new file mode 100644
index 0000000..810747b
--- /dev/null
+++ b/internal/usecase/board_mock_test.go
@@ -0,0 +1,64 @@
+package usecase
+
+import (
+	"context"
+	"time"
+
+	"github.com/PichuChen/go-bbs"
+)
+
+func (repo *MockRepository) GetBoards(ctx context.Context) []bbs.BoardRecord {
+	return []bbs.BoardRecord{}
+}
+
+func (repo *MockRepository) GetBoardArticle(ctx context.Context, boardID, filename string) ([]byte, error) {
+	return []byte{}, nil
+}
+func (repo *MockRepository) GetBoardArticleRecords(ctx context.Context, boardID string) ([]bbs.ArticleRecord, error) {
+	articleRecords := []MockArticleRecord{
+		{
+			filename:       "",
+			modified:       time.Time{},
+			recommendCount: 10,
+			owner:          "SYSOP",
+			date:           "",
+			title:          "[討論] 偶爾要發個廢文",
+			money:          0,
+		},
+		{
+			filename:       "",
+			modified:       time.Time{},
+			recommendCount: -20,
+			owner:          "XDXD",
+			date:           "",
+			title:          "[問題] UNICODE",
+			money:          0,
+		},
+	}
+	result := make([]bbs.ArticleRecord, len(articleRecords))
+	for i, v := range articleRecords {
+		result[i] = v
+	}
+	return result, nil
+}
+func (repo *MockRepository) GetBoardTreasureRecords(ctx context.Context, boardID string, treasureIDs []string) ([]bbs.ArticleRecord, error) {
+	return []bbs.ArticleRecord{}, nil
+}
+
+type MockArticleRecord struct {
+	filename       string
+	modified       time.Time
+	recommendCount int
+	owner          string
+	date           string
+	title          string
+	money          int
+}
+
+func (m MockArticleRecord) Filename() string    { return m.filename }
+func (m MockArticleRecord) Modified() time.Time { return m.modified }
+func (m MockArticleRecord) Recommend() int      { return m.recommendCount }
+func (m MockArticleRecord) Owner() string       { return m.owner }
+func (m MockArticleRecord) Date() string        { return m.date }
+func (m MockArticleRecord) Title() string       { return m.title }
+func (m MockArticleRecord) Money() int          { return m.money }
diff --git a/internal/usecase/board_test.go b/internal/usecase/board_test.go
new file mode 100644
index 0000000..1d777fd
--- /dev/null
+++ b/internal/usecase/board_test.go
@@ -0,0 +1,79 @@
+package usecase
+
+import (
+	"context"
+	"testing"
+
+	"github.com/PichuChen/go-bbs"
+)
+
+func TestSearchArticles(t *testing.T) {
+	repository := &MockRepository{}
+	articleRecords, _ := repository.GetBoardArticleRecords(context.Background(), "")
+
+	type TestCase struct {
+		input         *ArticleSearchCond
+		expectedItems []bbs.ArticleRecord
+	}
+
+	mockArticle1 := MockArticleRecord{
+		recommendCount: 10,
+		owner:          "SYSOP",
+		title:          "[討論] 偶爾要發個廢文",
+	}
+
+	mockArticle2 := MockArticleRecord{
+		recommendCount: -20,
+		owner:          "XDXD",
+		title:          "[問題] UNICODE",
+	}
+
+	cases := []TestCase{
+		{
+			input: &ArticleSearchCond{
+				Title:                           "廢文",
+				Author:                          "s",
+				RecommendCountGreaterEqual:      0,
+				RecommendCountLessEqual:         50,
+				RecommendCountGreaterEqualIsSet: true,
+				RecommendCountLessEqualIsSet:    true,
+			},
+			expectedItems: []bbs.ArticleRecord{&mockArticle1},
+		},
+		{
+			input: &ArticleSearchCond{
+				Title:                        "",
+				Author:                       "X",
+				RecommendCountLessEqual:      10,
+				RecommendCountLessEqualIsSet: true,
+			},
+			expectedItems: []bbs.ArticleRecord{&mockArticle2},
+		},
+		{
+			input: &ArticleSearchCond{
+				Title: "Unicode",
+			},
+			expectedItems: []bbs.ArticleRecord{&mockArticle2},
+		},
+		{
+			input: &ArticleSearchCond{
+				Author: "sysop",
+			},
+			expectedItems: []bbs.ArticleRecord{&mockArticle1},
+		},
+	}
+
+	for index, c := range cases {
+		cond := c.input
+		expectedItems := c.expectedItems
+
+		actualItems := searchArticles(articleRecords, cond)
+		for i, v := range actualItems {
+			if v.Title() != expectedItems[i].Title() ||
+				v.Owner() != expectedItems[i].Owner() ||
+				v.Recommend() != expectedItems[i].Recommend() {
+				t.Errorf("article not match on index %d, expected: %v, got: %v", index, expectedItems[i], v)
+			}
+		}
+	}
+}
diff --git a/internal/usecase/mock_test.go b/internal/usecase/mock_test.go
new file mode 100644
index 0000000..dc13252
--- /dev/null
+++ b/internal/usecase/mock_test.go
@@ -0,0 +1,11 @@
+package usecase
+
+import (
+	"github.com/Ptt-official-app/Ptt-backend/internal/repository"
+)
+
+// implements repository.Repository
+type MockRepository struct{}
+
+// To make sure MockRepository implement repository.Repository
+var _ repository.Repository = &MockRepository{}
diff --git a/internal/usecase/user_mock_test.go b/internal/usecase/user_mock_test.go
new file mode 100644
index 0000000..b7027aa
--- /dev/null
+++ b/internal/usecase/user_mock_test.go
@@ -0,0 +1,33 @@
+package usecase
+
+import (
+	"context"
+	"github.com/PichuChen/go-bbs"
+	"time"
+)
+
+func (repo *MockRepository) GetUsers(ctx context.Context) []bbs.UserRecord {
+	ret := []bbs.UserRecord{}
+	ret = append(ret, &MockUser{
+		userId: "pichu",
+	})
+	return ret
+}
+func (repo *MockRepository) GetUserFavoriteRecords(ctx context.Context, userID string) ([]bbs.FavoriteRecord, error) {
+	return []bbs.FavoriteRecord{}, nil
+}
+
+type MockUser struct {
+	userId string
+}
+
+func (u *MockUser) UserId() string                       { return u.userId }
+func (u *MockUser) HashedPassword() string               { return "" }
+func (u *MockUser) VerifyPassword(password string) error { return nil }
+func (u *MockUser) Nickname() string                     { return "" }
+func (u *MockUser) RealName() string                     { return "" }
+func (u *MockUser) NumLoginDays() int                    { return 0 }
+func (u *MockUser) NumPosts() int                        { return 0 }
+func (u *MockUser) Money() int                           { return 0 }
+func (u *MockUser) LastLogin() time.Time                 { return time.Unix(0, 0) }
+func (u *MockUser) LastHost() string                     { return "" }
diff --git a/internal/usecase/user_test.go b/internal/usecase/user_test.go
index 6066811..0842db8 100644
--- a/internal/usecase/user_test.go
+++ b/internal/usecase/user_test.go
@@ -2,60 +2,11 @@ package usecase
 
 import (
 	"github.com/Ptt-official-app/Ptt-backend/internal/config"
-	"github.com/Ptt-official-app/Ptt-backend/internal/repository"
-
-	"github.com/PichuChen/go-bbs"
 
 	"context"
 	"testing"
-	"time"
 )
 
-// implements repository.Repository
-type MockRepository struct{}
-
-// repository/board.go
-func (repo *MockRepository) GetBoards(ctx context.Context) []bbs.BoardRecord {
-	return []bbs.BoardRecord{}
-}
-func (repo *MockRepository) GetBoardArticle(ctx context.Context, boardID, filename string) ([]byte, error) {
-	return []byte{}, nil
-}
-func (repo *MockRepository) GetBoardArticleRecords(ctx context.Context, boardID string) ([]bbs.ArticleRecord, error) {
-	return []bbs.ArticleRecord{}, nil
-}
-
-func (repo *MockRepository) GetBoardTreasureRecords(ctx context.Context, boardID string, treasureIDs []string) ([]bbs.ArticleRecord, error) {
-	return []bbs.ArticleRecord{}, nil
-}
-
-// repository/user.go
-func (repo *MockRepository) GetUsers(ctx context.Context) []bbs.UserRecord {
-	ret := []bbs.UserRecord{}
-	ret = append(ret, &MockUser{
-		userId: "pichu",
-	})
-	return ret
-}
-func (repo *MockRepository) GetUserFavoriteRecords(ctx context.Context, userID string) ([]bbs.FavoriteRecord, error) {
-	return []bbs.FavoriteRecord{}, nil
-}
-
-type MockUser struct {
-	userId string
-}
-
-func (u *MockUser) UserId() string                       { return u.userId }
-func (u *MockUser) HashedPassword() string               { return "" }
-func (u *MockUser) VerifyPassword(password string) error { return nil }
-func (u *MockUser) Nickname() string                     { return "" }
-func (u *MockUser) RealName() string                     { return "" }
-func (u *MockUser) NumLoginDays() int                    { return 0 }
-func (u *MockUser) NumPosts() int                        { return 0 }
-func (u *MockUser) Money() int                           { return 0 }
-func (u *MockUser) LastLogin() time.Time                 { return time.Unix(0, 0) }
-func (u *MockUser) LastHost() string                     { return "" }
-
 func TestGetUserByID(t *testing.T) {
 
 	resp := &MockRepository{}
@@ -85,6 +36,3 @@ func TestGetUserByID(t *testing.T) {
 	}
 
 }
-
-// To make sure MockRepository implement repository.Repository
-var _ repository.Repository = &MockRepository{}