feature/fanout #3
			
				
			
		
		
		
	
							
								
								
									
										6
									
								
								Makefile
								
								
								
								
							
							
						
						
									
										6
									
								
								Makefile
								
								
								
								
							|  | @ -51,9 +51,9 @@ gen-mongo-model: # 建立 rpc 資料庫 | |||
| 	# 只產生 Model 剩下的要自己撰寫,連欄位名稱也是 | ||||
| 	goctl model mongo -t post --dir ./internal/model/mongo --style $(GO_ZERO_STYLE) | ||||
| 	goctl model mongo -t comment --dir ./internal/model/mongo --style $(GO_ZERO_STYLE) | ||||
| #	goctl model mongo -t tags --dir ./internal/model/mongo --style $(GO_ZERO_STYLE)
 | ||||
| #	goctl model mongo -t post_likes --dir ./internal/model/mongo --style $(GO_ZERO_STYLE)
 | ||||
| #	goctl model mongo -t comment_likes --dir ./internal/model/mongo --style $(GO_ZERO_STYLE)
 | ||||
| 	goctl model mongo -t tags --dir ./internal/model/mongo --style $(GO_ZERO_STYLE) | ||||
| 	goctl model mongo -t post_likes --dir ./internal/model/mongo --style $(GO_ZERO_STYLE) | ||||
| 	goctl model mongo -t comment_likes --dir ./internal/model/mongo --style $(GO_ZERO_STYLE) | ||||
| 	@echo "Generate mongo model files successfully" | ||||
| 
 | ||||
| .PHONY: mock-gen | ||||
|  |  | |||
|  | @ -12,3 +12,7 @@ Mongo: | |||
|   Password: "" | ||||
|   Port: "27017" | ||||
|   Database: digimon_tweeting | ||||
| 
 | ||||
| TimelineSetting: | ||||
|   Expire: 86400 | ||||
|   MaxLength: 1000 | ||||
|  | @ -201,8 +201,17 @@ message GetTimelineResp | |||
| message AddPostToTimelineReq | ||||
| { | ||||
|   string uid = 1; // key | ||||
|   repeated PostDetailItem posts = 2; | ||||
|   repeated PostTimelineItem posts = 3; | ||||
| } | ||||
| 
 | ||||
| // 貼文更新 | ||||
| message PostTimelineItem | ||||
| { | ||||
|   string post_id = 1;       // 貼文ID | ||||
|   int64 created_at = 7;     // 發佈時間 -> 排序使用 | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| // TimelineService 業務邏輯在外面組合 | ||||
| service TimelineService | ||||
| { | ||||
|  |  | |||
							
								
								
									
										3
									
								
								go.mod
								
								
								
								
							
							
						
						
									
										3
									
								
								go.mod
								
								
								
								
							|  | @ -5,6 +5,7 @@ go 1.22.3 | |||
| require ( | ||||
| 	code.30cm.net/digimon/library-go/errs v1.2.4 | ||||
| 	code.30cm.net/digimon/library-go/validator v1.0.0 | ||||
| 	github.com/alicebob/miniredis/v2 v2.33.0 | ||||
| 	github.com/stretchr/testify v1.9.0 | ||||
| 	github.com/zeromicro/go-zero v1.7.0 | ||||
| 	go.mongodb.org/mongo-driver v1.16.0 | ||||
|  | @ -14,6 +15,7 @@ require ( | |||
| ) | ||||
| 
 | ||||
| require ( | ||||
| 	github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect | ||||
| 	github.com/beorn7/perks v1.0.1 // indirect | ||||
| 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect | ||||
| 	github.com/cespare/xxhash/v2 v2.3.0 // indirect | ||||
|  | @ -65,6 +67,7 @@ require ( | |||
| 	github.com/xdg-go/scram v1.1.2 // indirect | ||||
| 	github.com/xdg-go/stringprep v1.0.4 // indirect | ||||
| 	github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a // indirect | ||||
| 	github.com/yuin/gopher-lua v1.1.1 // indirect | ||||
| 	go.etcd.io/etcd/api/v3 v3.5.15 // indirect | ||||
| 	go.etcd.io/etcd/client/pkg/v3 v3.5.15 // indirect | ||||
| 	go.etcd.io/etcd/client/v3 v3.5.15 // indirect | ||||
|  |  | |||
|  | @ -4,6 +4,7 @@ import "github.com/zeromicro/go-zero/zrpc" | |||
| 
 | ||||
| type Config struct { | ||||
| 	zrpc.RpcServerConf | ||||
| 
 | ||||
| 	Mongo struct { | ||||
| 		Schema   string | ||||
| 		User     string | ||||
|  | @ -12,4 +13,9 @@ type Config struct { | |||
| 		Port     string | ||||
| 		Database string | ||||
| 	} | ||||
| 
 | ||||
| 	TimelineSetting struct { | ||||
| 		Expire    int64 // Second
 | ||||
| 		MaxLength int64 // 暫存筆數
 | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -11,3 +11,7 @@ const ( | |||
| 	AdTypeOnlyAd | ||||
| 	AdTypeOnlyNotAd | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	LastOfTimelineFlag = "NoMoreData" | ||||
| ) | ||||
|  |  | |||
|  | @ -33,6 +33,10 @@ const ( | |||
| 	CommentListErrorCode | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	TimeLineErrorCode ErrorCode = iota + 20 | ||||
| ) | ||||
| 
 | ||||
| func CommentError(ec ErrorCode, s ...string) *ers.LibError { | ||||
| 	return ers.NewError(code.CloudEPTweeting, code.DBError, | ||||
| 		ec.ToUint32(), | ||||
|  |  | |||
|  | @ -0,0 +1,19 @@ | |||
| package domain | ||||
| 
 | ||||
| import "strings" | ||||
| 
 | ||||
| type RedisKey string | ||||
| 
 | ||||
| func (key RedisKey) ToString() string { | ||||
| 	return string(key) | ||||
| } | ||||
| 
 | ||||
| func (key RedisKey) With(s ...string) RedisKey { | ||||
| 	parts := append([]string{string(key)}, s...) | ||||
| 
 | ||||
| 	return RedisKey(strings.Join(parts, ":")) | ||||
| } | ||||
| 
 | ||||
| const ( | ||||
| 	TimelineRedisKey RedisKey = "timeline" | ||||
| ) | ||||
|  | @ -0,0 +1,45 @@ | |||
| package domain | ||||
| 
 | ||||
| import ( | ||||
| 	"testing" | ||||
| 
 | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
| 
 | ||||
| // TestRedisKeyToString 測試 ToString 方法
 | ||||
| func TestRedisKeyToString(t *testing.T) { | ||||
| 	key := RedisKey("user:timeline") | ||||
| 	expected := "user:timeline" | ||||
| 	result := key.ToString() | ||||
| 
 | ||||
| 	assert.Equal(t, expected, result, "ToString should return the correct string representation of RedisKey") | ||||
| } | ||||
| 
 | ||||
| // TestRedisKeyWith 測試 With 方法
 | ||||
| func TestRedisKeyWith(t *testing.T) { | ||||
| 	key := RedisKey("user:timeline") | ||||
| 	subKey := "12345" | ||||
| 	expected := "user:timeline:12345" | ||||
| 	result := key.With(subKey) | ||||
| 
 | ||||
| 	assert.Equal(t, RedisKey(expected), result, "With should correctly concatenate the RedisKey with the provided subKey") | ||||
| } | ||||
| 
 | ||||
| // TestRedisKeyWithMultiple 測試 With 方法與多個參數
 | ||||
| func TestRedisKeyWithMultiple(t *testing.T) { | ||||
| 	key := RedisKey("user:timeline") | ||||
| 	subKeys := []string{"12345", "posts"} | ||||
| 	expected := "user:timeline:12345:posts" | ||||
| 	result := key.With(subKeys...) | ||||
| 
 | ||||
| 	assert.Equal(t, RedisKey(expected), result, "With should correctly concatenate the RedisKey with multiple provided subKeys") | ||||
| } | ||||
| 
 | ||||
| // TestRedisKeyWithEmpty 測試 With 方法與空參數
 | ||||
| func TestRedisKeyWithEmpty(t *testing.T) { | ||||
| 	key := RedisKey("user:timeline") | ||||
| 	expected := "user:timeline" | ||||
| 	result := key.With() | ||||
| 
 | ||||
| 	assert.Equal(t, RedisKey(expected), result, "With should return the original key when no subKeys are provided") | ||||
| } | ||||
|  | @ -0,0 +1,57 @@ | |||
| package repository | ||||
| 
 | ||||
| import ( | ||||
| 	"app-cloudep-tweeting-service/gen_result/pb/tweeting" | ||||
| 	"context" | ||||
| ) | ||||
| 
 | ||||
| /* | ||||
| 	---------------------------------------- | ||||
| 	|       |        |        |         | | ||||
| 	| data A| data B |NO Data |  ....   | | ||||
| 	|       |        |        |         | | ||||
| 	---------------------------------------- | ||||
| 
 | ||||
| 	動態時報在發現這個 Queue 有 No Data 的 Flag  時 | ||||
| 	就不再去 Query 資料庫,防止被狂刷。 | ||||
| 	只是要注意在業務上何時要 加入/刪除 這個 Flag | ||||
| */ | ||||
| 
 | ||||
| // TimelineRepository 定義時間線的存儲接口,可以根據不同的排序策略實現。
 | ||||
| type TimelineRepository interface { | ||||
| 	// AddPost 將貼文添加到動態時報,並根據排序策略進行排序。
 | ||||
| 	AddPost(ctx context.Context, req AddPostRequest) error | ||||
| 	// FetchTimeline 獲取指定用戶的動態時報。
 | ||||
| 	FetchTimeline(ctx context.Context, req FetchTimelineRequest) (FetchTimelineResponse, error) | ||||
| 	// SetNoMoreDataFlag 標記時間線已完整,避免繼續查詢資料庫。
 | ||||
| 	SetNoMoreDataFlag(ctx context.Context, uid string) error | ||||
| 	// HasNoMoreData 檢查時間線是否已完整,決定是否需要查詢資料庫。
 | ||||
| 	HasNoMoreData(ctx context.Context, uid string) (bool, error) | ||||
| 	// ClearNoMoreDataFlag 清除時間線的 "NoMoreData" 標誌。
 | ||||
| 	ClearNoMoreDataFlag(ctx context.Context, uid string) error | ||||
| } | ||||
| 
 | ||||
| // AddPostRequest 用於將貼文添加到時間線的請求結構體。
 | ||||
| type AddPostRequest struct { | ||||
| 	UID       string | ||||
| 	PostItems []TimelineItem | ||||
| } | ||||
| 
 | ||||
| // TimelineItem 表示時間線中的一個元素,排序依據取決於 Score。
 | ||||
| type TimelineItem struct { | ||||
| 	PostID string // 貼文ID
 | ||||
| 	Score  int64  // 排序使用的分數,根據具體實現可能代表時間、優先級等
 | ||||
| } | ||||
| 
 | ||||
| // FetchTimelineRequest 用於獲取時間線的請求結構體。
 | ||||
| type FetchTimelineRequest struct { | ||||
| 	UID       string | ||||
| 	PageSize  int64 | ||||
| 	PageIndex int64 | ||||
| } | ||||
| 
 | ||||
| // FetchTimelineResponse 表示獲取時間線的回應結構體。
 | ||||
| type FetchTimelineResponse struct { | ||||
| 	Items []TimelineItem | ||||
| 	Page  tweeting.Pager | ||||
| } | ||||
|  | @ -0,0 +1,143 @@ | |||
| package repository | ||||
| 
 | ||||
| import ( | ||||
| 	"app-cloudep-tweeting-service/gen_result/pb/tweeting" | ||||
| 	"app-cloudep-tweeting-service/internal/config" | ||||
| 	"app-cloudep-tweeting-service/internal/domain" | ||||
| 	"app-cloudep-tweeting-service/internal/domain/repository" | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/zeromicro/go-zero/core/stores/redis" | ||||
| ) | ||||
| 
 | ||||
| // TODO 第一版本先使用 Redis 來做,後續如果有效能考量,在考慮使用其他方案
 | ||||
| 
 | ||||
| type TimelineRepositoryParam struct { | ||||
| 	Config config.Config | ||||
| 	Redis  redis.Redis | ||||
| } | ||||
| 
 | ||||
| type TimelineRepository struct { | ||||
| 	cfg   config.Config | ||||
| 	redis redis.Redis | ||||
| } | ||||
| 
 | ||||
| func MustGenerateUseCase(param TimelineRepositoryParam) repository.TimelineRepository { | ||||
| 	return &TimelineRepository{ | ||||
| 		cfg:   param.Config, | ||||
| 		redis: param.Redis, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // AddPost 將貼文添加到時間線,並根據 Score 排序
 | ||||
| func (t *TimelineRepository) AddPost(ctx context.Context, req repository.AddPostRequest) error { | ||||
| 	key := domain.TimelineRedisKey.With(req.UID).ToString() | ||||
| 
 | ||||
| 	// 準備要插入的元素
 | ||||
| 	zItems := make([]redis.Pair, len(req.PostItems)) | ||||
| 	for i, item := range req.PostItems { | ||||
| 		zItems[i] = redis.Pair{ | ||||
| 			Score: item.Score, | ||||
| 			Key:   item.PostID, | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// 將 ZSet 元素添加到 Redis
 | ||||
| 	_, err := t.redis.ZaddsCtx(ctx, key, zItems...) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// 檢查 ZSet 長度,並在超過 maxLength 時刪除多餘的元素
 | ||||
| 	if t.cfg.TimelineSetting.MaxLength > 0 { | ||||
| 		// 這裡從 0 到 - (maxLength+1) 代表超過限制的元素範圍
 | ||||
| 		_, err := t.redis.ZremrangebyrankCtx(ctx, key, 0, -(t.cfg.TimelineSetting.MaxLength + 1)) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// 設置過期時間
 | ||||
| 	return t.redis.ExpireCtx(ctx, key, int(t.cfg.TimelineSetting.Expire)) | ||||
| } | ||||
| 
 | ||||
| // FetchTimeline 獲取指定用戶的動態時報
 | ||||
| func (t *TimelineRepository) FetchTimeline(ctx context.Context, req repository.FetchTimelineRequest) (repository.FetchTimelineResponse, error) { | ||||
| 	key := domain.TimelineRedisKey.With(req.UID).ToString() | ||||
| 
 | ||||
| 	start := (req.PageIndex - 1) * req.PageSize | ||||
| 	end := start + req.PageSize - 1 | ||||
| 
 | ||||
| 	// 從 Redis 中按分數由高到低獲取時間線元素
 | ||||
| 	pair, err := t.redis.ZrevrangeWithScoresCtx(ctx, key, start, end) | ||||
| 	if err != nil { | ||||
| 		return repository.FetchTimelineResponse{}, err | ||||
| 	} | ||||
| 
 | ||||
| 	// 構建返回結果
 | ||||
| 	items := make([]repository.TimelineItem, len(pair)) | ||||
| 	for i, z := range pair { | ||||
| 		items[i] = repository.TimelineItem{ | ||||
| 			PostID: z.Key, | ||||
| 			Score:  z.Score, | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// 計算總數量
 | ||||
| 	total, err := t.redis.ZcardCtx(ctx, key) | ||||
| 	if err != nil { | ||||
| 		return repository.FetchTimelineResponse{}, err | ||||
| 	} | ||||
| 
 | ||||
| 	return repository.FetchTimelineResponse{ | ||||
| 		Items: items, | ||||
| 		Page: tweeting.Pager{ | ||||
| 			Total: int64(total), | ||||
| 			Index: req.PageIndex, | ||||
| 			Size:  req.PageSize, | ||||
| 		}, | ||||
| 	}, nil | ||||
| } | ||||
| 
 | ||||
| // SetNoMoreDataFlag 標記時間線已完整,避免繼續查詢資料庫
 | ||||
| func (t *TimelineRepository) SetNoMoreDataFlag(ctx context.Context, uid string) error { | ||||
| 	key := domain.TimelineRedisKey.With(uid).ToString() | ||||
| 
 | ||||
| 	// 添加一個標誌到時間線的 ZSet
 | ||||
| 	_, err := t.redis.ZaddsCtx(ctx, key, redis.Pair{ | ||||
| 		Score: time.Now().UTC().Unix(), | ||||
| 		Key:   domain.LastOfTimelineFlag, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// 設置過期時間
 | ||||
| 	return t.redis.ExpireCtx(ctx, key, int(t.cfg.TimelineSetting.Expire)) | ||||
| } | ||||
| 
 | ||||
| // HasNoMoreData 檢查時間線是否已完整,決定是否需要查詢資料庫
 | ||||
| func (t *TimelineRepository) HasNoMoreData(ctx context.Context, uid string) (bool, error) { | ||||
| 	key := domain.TimelineRedisKey.With(uid).ToString() | ||||
| 
 | ||||
| 	// 檢查 "NoMoreData" 標誌是否存在
 | ||||
| 	score, err := t.redis.ZscoreCtx(ctx, key, domain.LastOfTimelineFlag) | ||||
| 	if errors.Is(err, redis.Nil) { | ||||
| 		return false, nil // 標誌不存在
 | ||||
| 	} | ||||
| 	if err != nil { | ||||
| 		return false, err // 其他錯誤
 | ||||
| 	} | ||||
| 
 | ||||
| 	return score != 0, nil | ||||
| } | ||||
| 
 | ||||
| // ClearNoMoreDataFlag 清除時間線的 "NoMoreData" 標誌
 | ||||
| func (t *TimelineRepository) ClearNoMoreDataFlag(ctx context.Context, uid string) error { | ||||
| 	key := domain.TimelineRedisKey.With(uid).ToString() | ||||
| 	// 移除 "NoMoreData" 標誌
 | ||||
| 	_, err := t.redis.ZremCtx(ctx, key, domain.LastOfTimelineFlag) | ||||
| 	return err | ||||
| } | ||||
|  | @ -0,0 +1,439 @@ | |||
| package repository | ||||
| 
 | ||||
| import ( | ||||
| 	"app-cloudep-tweeting-service/internal/config" | ||||
| 	"app-cloudep-tweeting-service/internal/domain" | ||||
| 	"app-cloudep-tweeting-service/internal/domain/repository" | ||||
| 	"context" | ||||
| 	"github.com/alicebob/miniredis/v2" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/zeromicro/go-zero/core/stores/redis" | ||||
| 	"testing" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| func NewRepo() (*miniredis.Miniredis, repository.TimelineRepository, error) { | ||||
| 	r1, err := miniredis.Run() | ||||
| 	if err != nil { | ||||
| 		return nil, nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	newRedis, err := redis.NewRedis(redis.RedisConf{ | ||||
| 		Host: r1.Addr(), | ||||
| 		Type: redis.ClusterType, | ||||
| 		Pass: "", | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		r1.Close() | ||||
| 		return nil, nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	c := config.Config{ | ||||
| 		TimelineSetting: struct { | ||||
| 			Expire    int64 | ||||
| 			MaxLength int64 | ||||
| 		}{Expire: 86400, MaxLength: 1000}, | ||||
| 	} | ||||
| 
 | ||||
| 	timelineRepo := MustGenerateUseCase(TimelineRepositoryParam{ | ||||
| 		Config: c, | ||||
| 		Redis:  *newRedis, | ||||
| 	}) | ||||
| 
 | ||||
| 	return r1, timelineRepo, nil | ||||
| } | ||||
| 
 | ||||
| func TestAddPost(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name      string | ||||
| 		action    func(t *testing.T, repo repository.TimelineRepository) error | ||||
| 		expectErr bool | ||||
| 		validate  func(t *testing.T, r1 *miniredis.Miniredis) | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "success", | ||||
| 			action: func(t *testing.T, repo repository.TimelineRepository) error { | ||||
| 				ctx := context.Background() | ||||
| 				uid := "OOOOOOKJ" | ||||
| 
 | ||||
| 				return repo.AddPost(ctx, repository.AddPostRequest{ | ||||
| 					UID: uid, | ||||
| 					PostItems: []repository.TimelineItem{ | ||||
| 						{PostID: "post1", Score: 100}, | ||||
| 					}, | ||||
| 				}) | ||||
| 			}, | ||||
| 			expectErr: false, | ||||
| 			validate: func(t *testing.T, r1 *miniredis.Miniredis) { | ||||
| 				uid := "OOOOOOKJ" | ||||
| 				key := domain.TimelineRedisKey.With(uid).ToString() | ||||
| 				score, err := r1.ZScore(key, "post1") | ||||
| 				assert.NoError(t, err) | ||||
| 				assert.Equal(t, float64(100), score) | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "timeout", | ||||
| 			action: func(t *testing.T, repo repository.TimelineRepository) error { | ||||
| 				ctx := context.Background() | ||||
| 				timeoutCtx, cancel := context.WithTimeout(ctx, 1*time.Millisecond) | ||||
| 				defer cancel() | ||||
| 				time.Sleep(2 * time.Millisecond) | ||||
| 
 | ||||
| 				uid := "OOOOOLK" | ||||
| 				return repo.AddPost(timeoutCtx, repository.AddPostRequest{ | ||||
| 					UID: uid, | ||||
| 					PostItems: []repository.TimelineItem{ | ||||
| 						{PostID: "post2", Score: 200}, | ||||
| 					}, | ||||
| 				}) | ||||
| 			}, | ||||
| 			expectErr: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Redis error on Zadd", | ||||
| 			action: func(t *testing.T, repo repository.TimelineRepository) error { | ||||
| 				r1, repo, err := NewRepo() | ||||
| 				assert.NoError(t, err) | ||||
| 				r1.Close() // 模拟 Redis 错误
 | ||||
| 
 | ||||
| 				ctx := context.Background() | ||||
| 				uid := "OOOOOWE" | ||||
| 				return repo.AddPost(ctx, repository.AddPostRequest{ | ||||
| 					UID: uid, | ||||
| 					PostItems: []repository.TimelineItem{ | ||||
| 						{PostID: "post3", Score: 300}, | ||||
| 					}, | ||||
| 				}) | ||||
| 			}, | ||||
| 			expectErr: true, | ||||
| 		}, | ||||
| 	} | ||||
| 
 | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			r1, repo, err := NewRepo() | ||||
| 			assert.NoError(t, err) | ||||
| 			defer r1.Close() | ||||
| 
 | ||||
| 			err = tt.action(t, repo) | ||||
| 
 | ||||
| 			if tt.expectErr { | ||||
| 				assert.Error(t, err) | ||||
| 			} else { | ||||
| 				assert.NoError(t, err) | ||||
| 				if tt.validate != nil { | ||||
| 					tt.validate(t, r1) | ||||
| 				} | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func TestFetchTimeline(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name      string | ||||
| 		action    func(t *testing.T, repo repository.TimelineRepository) (repository.FetchTimelineResponse, error) | ||||
| 		expectErr bool | ||||
| 		validate  func(t *testing.T, r1 *miniredis.Miniredis, resp *repository.FetchTimelineResponse) | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "FetchTimeline - success", | ||||
| 			action: func(t *testing.T, repo repository.TimelineRepository) (repository.FetchTimelineResponse, error) { | ||||
| 				ctx := context.Background() | ||||
| 				uid := "user123" | ||||
| 
 | ||||
| 				_ = repo.AddPost(ctx, repository.AddPostRequest{ | ||||
| 					UID: uid, | ||||
| 					PostItems: []repository.TimelineItem{ | ||||
| 						{PostID: "post1", Score: 200}, | ||||
| 						{PostID: "post2", Score: 100}, | ||||
| 					}, | ||||
| 				}) | ||||
| 
 | ||||
| 				return repo.FetchTimeline(ctx, repository.FetchTimelineRequest{ | ||||
| 					UID:       uid, | ||||
| 					PageSize:  10, | ||||
| 					PageIndex: 1, | ||||
| 				}) | ||||
| 			}, | ||||
| 			expectErr: false, | ||||
| 			validate: func(t *testing.T, r1 *miniredis.Miniredis, resp *repository.FetchTimelineResponse) { | ||||
| 				assert.Equal(t, 2, len(resp.Items)) | ||||
| 				assert.Equal(t, "post1", resp.Items[0].PostID) | ||||
| 				assert.Equal(t, "post2", resp.Items[1].PostID) | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "FetchTimeline - timeout", | ||||
| 			action: func(t *testing.T, repo repository.TimelineRepository) (repository.FetchTimelineResponse, error) { | ||||
| 				ctx := context.Background() | ||||
| 				timeoutCtx, cancel := context.WithTimeout(ctx, 1*time.Millisecond) | ||||
| 				defer cancel() | ||||
| 				time.Sleep(2 * time.Millisecond) | ||||
| 
 | ||||
| 				uid := "user123" | ||||
| 				return repo.FetchTimeline(timeoutCtx, repository.FetchTimelineRequest{ | ||||
| 					UID:       uid, | ||||
| 					PageSize:  10, | ||||
| 					PageIndex: 1, | ||||
| 				}) | ||||
| 			}, | ||||
| 			expectErr: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "FetchTimeline - Redis error on ZrangebyscoreWithScoresCtx", | ||||
| 			action: func(t *testing.T, repo repository.TimelineRepository) (repository.FetchTimelineResponse, error) { | ||||
| 				r1, repo, err := NewRepo() | ||||
| 				assert.NoError(t, err) | ||||
| 				defer r1.Close() | ||||
| 
 | ||||
| 				uid := "user123" | ||||
| 
 | ||||
| 				_ = repo.AddPost(context.Background(), repository.AddPostRequest{ | ||||
| 					UID: uid, | ||||
| 					PostItems: []repository.TimelineItem{ | ||||
| 						{PostID: "post3", Score: 300}, | ||||
| 					}, | ||||
| 				}) | ||||
| 
 | ||||
| 				r1.Close() | ||||
| 
 | ||||
| 				return repo.FetchTimeline(context.Background(), repository.FetchTimelineRequest{ | ||||
| 					UID:       uid, | ||||
| 					PageSize:  10, | ||||
| 					PageIndex: 1, | ||||
| 				}) | ||||
| 			}, | ||||
| 			expectErr: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "FetchTimeline - Redis error on ZcardCtx", | ||||
| 			action: func(t *testing.T, repo repository.TimelineRepository) (repository.FetchTimelineResponse, error) { | ||||
| 				r1, repo, err := NewRepo() | ||||
| 				assert.NoError(t, err) | ||||
| 				defer r1.Close() | ||||
| 
 | ||||
| 				uid := "user123" | ||||
| 
 | ||||
| 				_ = repo.AddPost(context.Background(), repository.AddPostRequest{ | ||||
| 					UID: uid, | ||||
| 					PostItems: []repository.TimelineItem{ | ||||
| 						{PostID: "post4", Score: 400}, | ||||
| 					}, | ||||
| 				}) | ||||
| 
 | ||||
| 				r1.Close() | ||||
| 
 | ||||
| 				return repo.FetchTimeline(context.Background(), repository.FetchTimelineRequest{ | ||||
| 					UID:       uid, | ||||
| 					PageSize:  10, | ||||
| 					PageIndex: 1, | ||||
| 				}) | ||||
| 			}, | ||||
| 			expectErr: true, | ||||
| 		}, | ||||
| 	} | ||||
| 
 | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			r1, repo, err := NewRepo() | ||||
| 			assert.NoError(t, err) | ||||
| 			defer r1.Close() | ||||
| 
 | ||||
| 			resp, err := tt.action(t, repo) | ||||
| 
 | ||||
| 			if tt.expectErr { | ||||
| 				assert.Error(t, err) | ||||
| 			} else { | ||||
| 				assert.NoError(t, err) | ||||
| 				if tt.validate != nil { | ||||
| 					tt.validate(t, r1, &resp) | ||||
| 				} | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func TestSetNoMoreDataFlag(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name      string | ||||
| 		action    func(t *testing.T, repo repository.TimelineRepository) error | ||||
| 		expectErr bool | ||||
| 		validate  func(t *testing.T, r1 *miniredis.Miniredis) | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "SetNoMoreDataFlag - success", | ||||
| 			action: func(t *testing.T, repo repository.TimelineRepository) error { | ||||
| 				ctx := context.Background() | ||||
| 				uid := "user123" | ||||
| 				return repo.SetNoMoreDataFlag(ctx, uid) | ||||
| 			}, | ||||
| 			expectErr: false, | ||||
| 			validate: func(t *testing.T, r1 *miniredis.Miniredis) { | ||||
| 				uid := "user123" | ||||
| 				key := domain.TimelineRedisKey.With(uid).ToString() | ||||
| 				score, _ := r1.ZScore(key, domain.LastOfTimelineFlag) | ||||
| 				assert.NotZero(t, score) | ||||
| 
 | ||||
| 				// 驗證是否設定過期時間
 | ||||
| 				ttl := r1.TTL(key) | ||||
| 				assert.Equal(t, time.Duration(86400)*time.Second, ttl) | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "SetNoMoreDataFlag - Redis error on ZaddsCtx", | ||||
| 			action: func(t *testing.T, repo repository.TimelineRepository) error { | ||||
| 				r1, repo, err := NewRepo() | ||||
| 				assert.NoError(t, err) | ||||
| 				r1.Close() // 手動關閉,復現錯誤
 | ||||
| 
 | ||||
| 				ctx := context.Background() | ||||
| 				uid := "user123" | ||||
| 				return repo.SetNoMoreDataFlag(ctx, uid) | ||||
| 			}, | ||||
| 			expectErr: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "SetNoMoreDataFlag - Redis error on ExpireCtx", | ||||
| 			action: func(t *testing.T, repo repository.TimelineRepository) error { | ||||
| 				r1, repo, err := NewRepo() | ||||
| 				assert.NoError(t, err) | ||||
| 
 | ||||
| 				ctx := context.Background() | ||||
| 				uid := "user123" | ||||
| 
 | ||||
| 				r1.Close() | ||||
| 
 | ||||
| 				return repo.SetNoMoreDataFlag(ctx, uid) | ||||
| 			}, | ||||
| 			expectErr: true, | ||||
| 		}, | ||||
| 	} | ||||
| 
 | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			r1, repo, err := NewRepo() | ||||
| 			assert.NoError(t, err) | ||||
| 			defer r1.Close() | ||||
| 
 | ||||
| 			err = tt.action(t, repo) | ||||
| 
 | ||||
| 			if tt.expectErr { | ||||
| 				assert.Error(t, err) | ||||
| 			} else { | ||||
| 				assert.NoError(t, err) | ||||
| 				if tt.validate != nil { | ||||
| 					tt.validate(t, r1) | ||||
| 				} | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func TestHasNoMoreData(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name      string | ||||
| 		action    func(t *testing.T, repo repository.TimelineRepository) (bool, error) | ||||
| 		expectErr bool | ||||
| 		expected  bool | ||||
| 		setup     func(r1 *miniredis.Miniredis) | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "HasNoMoreData - 標誌存在", | ||||
| 			action: func(t *testing.T, repo repository.TimelineRepository) (bool, error) { | ||||
| 				ctx := context.Background() | ||||
| 				uid := "user123" | ||||
| 				return repo.HasNoMoreData(ctx, uid) | ||||
| 			}, | ||||
| 			expectErr: false, | ||||
| 			expected:  true, | ||||
| 			setup: func(r1 *miniredis.Miniredis) { | ||||
| 				uid := "user123" | ||||
| 				key := domain.TimelineRedisKey.With(uid).ToString() | ||||
| 				_, _ = r1.ZAdd(key, float64(time.Now().UTC().Unix()), domain.LastOfTimelineFlag) | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "HasNoMoreData - 標誌不存在", | ||||
| 			action: func(t *testing.T, repo repository.TimelineRepository) (bool, error) { | ||||
| 				ctx := context.Background() | ||||
| 				uid := "user123" | ||||
| 				return repo.HasNoMoreData(ctx, uid) | ||||
| 			}, | ||||
| 			expectErr: false, | ||||
| 			expected:  false, | ||||
| 			setup:     func(r1 *miniredis.Miniredis) {}, // 不設置標誌
 | ||||
| 		}, | ||||
| 	} | ||||
| 
 | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			r1, repo, err := NewRepo() | ||||
| 			assert.NoError(t, err) | ||||
| 			defer r1.Close() | ||||
| 
 | ||||
| 			tt.setup(r1) | ||||
| 
 | ||||
| 			result, err := tt.action(t, repo) | ||||
| 
 | ||||
| 			if tt.expectErr { | ||||
| 				assert.Error(t, err) | ||||
| 			} else { | ||||
| 				assert.NoError(t, err) | ||||
| 				assert.Equal(t, tt.expected, result) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func TestClearNoMoreDataFlag(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name      string | ||||
| 		action    func(t *testing.T, repo repository.TimelineRepository) error | ||||
| 		expectErr bool | ||||
| 		setup     func(r1 *miniredis.Miniredis) | ||||
| 		validate  func(t *testing.T, r1 *miniredis.Miniredis) | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "ClearNoMoreDataFlag - 成功清除標誌", | ||||
| 			action: func(t *testing.T, repo repository.TimelineRepository) error { | ||||
| 				ctx := context.Background() | ||||
| 				uid := "user123" | ||||
| 				return repo.ClearNoMoreDataFlag(ctx, uid) | ||||
| 			}, | ||||
| 			expectErr: false, | ||||
| 			setup: func(r1 *miniredis.Miniredis) { | ||||
| 				uid := "user123" | ||||
| 				key := domain.TimelineRedisKey.With(uid).ToString() | ||||
| 				_, err := r1.ZAdd(key, 100, domain.LastOfTimelineFlag) // 設置標誌
 | ||||
| 				assert.NoError(t, err) | ||||
| 			}, | ||||
| 			validate: func(t *testing.T, r1 *miniredis.Miniredis) { | ||||
| 				uid := "user123" | ||||
| 				key := domain.TimelineRedisKey.With(uid).ToString() | ||||
| 				_, err := r1.ZScore(key, domain.LastOfTimelineFlag) | ||||
| 				assert.Error(t, err) // 標誌應該已被移除
 | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
| 
 | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			r1, repo, err := NewRepo() | ||||
| 			assert.NoError(t, err) | ||||
| 			defer r1.Close() | ||||
| 
 | ||||
| 			tt.setup(r1) | ||||
| 
 | ||||
| 			err = tt.action(t, repo) | ||||
| 
 | ||||
| 			if tt.expectErr { | ||||
| 				assert.Error(t, err) | ||||
| 			} else { | ||||
| 				assert.NoError(t, err) | ||||
| 				tt.validate(t, r1) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | @ -7,7 +7,7 @@ import ( | |||
| 	"context" | ||||
| 
 | ||||
| 	"app-cloudep-tweeting-service/gen_result/pb/tweeting" | ||||
| 	"app-cloudep-tweeting-service/internal/logic/commentservice" | ||||
| 	commentservicelogic "app-cloudep-tweeting-service/internal/logic/commentservice" | ||||
| 	"app-cloudep-tweeting-service/internal/svc" | ||||
| ) | ||||
| 
 | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ import ( | |||
| 	"context" | ||||
| 
 | ||||
| 	"app-cloudep-tweeting-service/gen_result/pb/tweeting" | ||||
| 	"app-cloudep-tweeting-service/internal/logic/postservice" | ||||
| 	postservicelogic "app-cloudep-tweeting-service/internal/logic/postservice" | ||||
| 	"app-cloudep-tweeting-service/internal/svc" | ||||
| ) | ||||
| 
 | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ import ( | |||
| 	"context" | ||||
| 
 | ||||
| 	"app-cloudep-tweeting-service/gen_result/pb/tweeting" | ||||
| 	"app-cloudep-tweeting-service/internal/logic/timelineservice" | ||||
| 	timelineservicelogic "app-cloudep-tweeting-service/internal/logic/timelineservice" | ||||
| 	"app-cloudep-tweeting-service/internal/svc" | ||||
| ) | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue