diff --git a/typing-server/go.sum b/typing-server/go.sum index 4b01dea5..03afeafb 100644 --- a/typing-server/go.sum +++ b/typing-server/go.sum @@ -28,14 +28,22 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70= diff --git a/typing-server/internal/domain/service/score_service_test.go b/typing-server/internal/domain/service/score_service_test.go index c1143694..56ba54be 100644 --- a/typing-server/internal/domain/service/score_service_test.go +++ b/typing-server/internal/domain/service/score_service_test.go @@ -2,7 +2,9 @@ package service import ( "context" + "errors" "reflect" + "strings" "testing" "github.com/google/uuid" @@ -10,6 +12,24 @@ import ( "github.com/su-its/typing/typing-server/internal/domain/repository" ) +type mockScoreRepository struct { + getScores func(ctx context.Context, sortBy string, start int, limit int) ([]*model.Score, int, error) + getMaxScores func(ctx context.Context, userID uuid.UUID) (*model.Score, *model.Score, error) + createScore func(ctx context.Context, userID uuid.UUID, keystrokes int, accuracy float64, isMaxKeystrokes bool, isMaxAccuracy bool) error +} + +func (m *mockScoreRepository) GetScores(ctx context.Context, sortBy string, start int, limit int) ([]*model.Score, int, error) { + return m.getScores(ctx, sortBy, start, limit) +} + +func (m *mockScoreRepository) GetMaxScores(ctx context.Context, userID uuid.UUID) (*model.Score, *model.Score, error) { + return m.getMaxScores(ctx, userID) +} + +func (m *mockScoreRepository) CreateScore(ctx context.Context, userID uuid.UUID, keystrokes int, accuracy float64, isMaxKeystrokes bool, isMaxAccuracy bool) error { + return m.createScore(ctx, userID, keystrokes, accuracy, isMaxKeystrokes, isMaxAccuracy) +} + func TestNewScoreService(t *testing.T) { type args struct { scoreRepo repository.ScoreRepository @@ -19,7 +39,15 @@ func TestNewScoreService(t *testing.T) { args args want *ScoreService }{ - // TODO: Add test cases. + { + name: "正常系: インスタンスが正しく生成される", + args: args{ + scoreRepo: &mockScoreRepository{}, + }, + want: &ScoreService{ + scoreRepo: &mockScoreRepository{}, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -37,17 +65,90 @@ func TestScoreService_ValidateScore(t *testing.T) { accuracy float64 } tests := []struct { - name string - s *ScoreService - args args - wantErr bool + name string + s *ScoreService + args args + wantErr bool + wantErrMsg string }{ - // TODO: Add test cases. + { + name: "正常系: 正しいパラメータの場合", + s: &ScoreService{ + scoreRepo: &mockScoreRepository{}, + }, + args: args{ + userID: uuid.New(), + keystrokes: 100, + accuracy: 0.5, + }, + wantErr: false, + }, + { + name: "異常系: keystrokes が負の場合", + s: &ScoreService{ + scoreRepo: &mockScoreRepository{}, + }, + args: args{ + userID: uuid.New(), + keystrokes: -10, + accuracy: 0.5, + }, + wantErr: true, + wantErrMsg: "keystrokes must be non-negative", + }, + { + name: "異常系: accuracy が 0未満の場合", + s: &ScoreService{ + scoreRepo: &mockScoreRepository{}, + }, + args: args{ + userID: uuid.New(), + keystrokes: 100, + accuracy: -0.1, + }, + wantErr: true, + }, + { + name: "異常系: accuracy が 1 を超える場合", + s: &ScoreService{ + scoreRepo: &mockScoreRepository{}, + }, + args: args{ + userID: uuid.New(), + keystrokes: 100, + accuracy: 1.1, + }, + wantErr: true, + wantErrMsg: "accuracy must be between 0 and 1", + }, + { + name: "異常系: userID が uuid.Nilの場合", + s: &ScoreService{ + scoreRepo: &mockScoreRepository{}, + }, + args: args{ + userID: uuid.Nil, + keystrokes: 100, + accuracy: 0.5, + }, + wantErr: true, + wantErrMsg: "invalid user ID\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := tt.s.ValidateScore(tt.args.userID, tt.args.keystrokes, tt.args.accuracy); (err != nil) != tt.wantErr { - t.Errorf("ScoreService.ValidateScore() error = %v, wantErr %v", err, tt.wantErr) + err := tt.s.ValidateScore(tt.args.userID, tt.args.keystrokes, tt.args.accuracy) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateScore() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErrMsg != "" { + if err == nil { + t.Fatalf("expected error %q, but got nil", tt.wantErrMsg) + } + + if !strings.Contains(err.Error(), strings.TrimSpace(tt.wantErrMsg)) { + t.Errorf("expected error message %q, but got %q", tt.wantErrMsg, err.Error()) + } } }) } @@ -65,12 +166,90 @@ func TestScoreService_ComputeRanking(t *testing.T) { args args want []*model.ScoreRanking }{ - // TODO: Add test cases. + { + name: "正常系: keystrokes で降順ソート、 start=1の場合", + s: &ScoreService{}, + args: args{ + scores: []*model.Score{ + { + ID: "s1", + Keystrokes: 200, + Accuracy: 0.90, + }, + { + ID: "s2", + Keystrokes: 300, + Accuracy: 0.85, + }, + { + ID: "s3", + Keystrokes: 100, + Accuracy: 0.95, + }, + }, + sortBy: "keystrokes", + start: 1, + }, + want: []*model.ScoreRanking{ + // s2: keystrokes=300 + {Rank: 1, Score: model.Score{ID: "s2", Keystrokes: 300, Accuracy: 0.85}}, + // s1: keystrokes=200 + {Rank: 2, Score: model.Score{ID: "s1", Keystrokes: 200, Accuracy: 0.90}}, + // s3: keystrokes=100 + {Rank: 3, Score: model.Score{ID: "s3", Keystrokes: 100, Accuracy: 0.95}}, + }, + }, + { + name: "正常系: accuracy で降順ソート、 start=1, 重複accuracyありの場合", + s: &ScoreService{}, + args: args{ + scores: []*model.Score{ + { + ID: "s1", + Keystrokes: 200, + Accuracy: 0.90, + }, + { + ID: "s2", + Keystrokes: 500, + Accuracy: 0.90, // s1 と同じ accuracy + }, + { + ID: "s3", + Keystrokes: 100, + Accuracy: 0.95, + }, + { + ID: "s4", + Keystrokes: 300, + Accuracy: 0.85, + }, + }, + sortBy: "accuracy", + start: 1, + }, + want: []*model.ScoreRanking{ + // s3: accuracy=0.95 + {Rank: 1, Score: model.Score{ID: "s3", Keystrokes: 100, Accuracy: 0.95}}, + // s1: accuracy=0.90 (上から2番目) + {Rank: 2, Score: model.Score{ID: "s1", Keystrokes: 200, Accuracy: 0.90}}, + // s2: accuracy=0.90 (上から2番目) + {Rank: 2, Score: model.Score{ID: "s2", Keystrokes: 500, Accuracy: 0.90}}, + //rank2で重複があったためrank4になる + {Rank: 4, Score: model.Score{ID: "s4", Keystrokes: 300, Accuracy: 0.85}}, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.s.ComputeRanking(tt.args.scores, tt.args.sortBy, tt.args.start); !reflect.DeepEqual(got, tt.want) { - t.Errorf("ScoreService.ComputeRanking() = %v, want %v", got, tt.want) + t.Errorf("ScoreService.ComputeRanking() = %+v, want %+v", got, tt.want) + for i := range got { + t.Logf("got[%d] = %+v", i, got[i]) + } + for i := range tt.want { + t.Logf("want[%d] = %+v", i, tt.want[i]) + } } }) } @@ -90,7 +269,118 @@ func TestScoreService_ShouldUpdateMaxScore(t *testing.T) { want1 bool wantErr bool }{ - // TODO: Add test cases. + { + name: "正常系: GetMaxScores が nil を返す場合", + s: &ScoreService{ + scoreRepo: &mockScoreRepository{ + getMaxScores: func(ctx context.Context, userID uuid.UUID) (*model.Score, *model.Score, error) { + // まだ最大スコアが登録されていない状態 + return nil, nil, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + userID: uuid.New(), + newScore: &model.Score{ + Keystrokes: 100, + Accuracy: 0.8, + }, + }, + want: true, // isMaxKeystrokes + want1: true, // isMaxAccuracy + wantErr: false, + }, + { + name: "正常系: 新しいスコアが既存より keystrokes だけ大きい場合", + s: &ScoreService{ + scoreRepo: &mockScoreRepository{ + getMaxScores: func(ctx context.Context, userID uuid.UUID) (*model.Score, *model.Score, error) { + return &model.Score{Keystrokes: 90, Accuracy: 0.8}, // maxKeystrokeScore + &model.Score{Keystrokes: 50, Accuracy: 0.9}, // maxAccuracyScore + nil + }, + }, + }, + args: args{ + ctx: context.Background(), + userID: uuid.New(), + newScore: &model.Score{ + Keystrokes: 100, + Accuracy: 0.8, // 既存 =0.9 より低い + }, + }, + want: true, + want1: false, + wantErr: false, + }, + { + name: "正常系: 新しいスコアが既存より accuracy だけ高い場合", + s: &ScoreService{ + scoreRepo: &mockScoreRepository{ + getMaxScores: func(ctx context.Context, userID uuid.UUID) (*model.Score, *model.Score, error) { + return &model.Score{Keystrokes: 200, Accuracy: 0.7}, + &model.Score{Keystrokes: 100, Accuracy: 0.8}, + nil + }, + }, + }, + args: args{ + ctx: context.Background(), + userID: uuid.New(), + newScore: &model.Score{ + Keystrokes: 150, // 既存 200 より低い + Accuracy: 0.85, + }, + }, + want: false, + want1: true, + wantErr: false, + }, + { + name: "正常系: どちらも既存より高い場合", + s: &ScoreService{ + scoreRepo: &mockScoreRepository{ + getMaxScores: func(ctx context.Context, userID uuid.UUID) (*model.Score, *model.Score, error) { + return &model.Score{Keystrokes: 200, Accuracy: 0.8}, + &model.Score{Keystrokes: 150, Accuracy: 0.85}, + nil + }, + }, + }, + args: args{ + ctx: context.Background(), + userID: uuid.New(), + newScore: &model.Score{ + Keystrokes: 300, + Accuracy: 0.9, + }, + }, + want: true, + want1: true, + wantErr: false, + }, + { + name: "異常系: リポジトリがエラーを返す場合", + s: &ScoreService{ + scoreRepo: &mockScoreRepository{ + getMaxScores: func(ctx context.Context, userID uuid.UUID) (*model.Score, *model.Score, error) { + return nil, nil, errors.New("db error") + }, + }, + }, + args: args{ + ctx: context.Background(), + userID: uuid.New(), + newScore: &model.Score{ + Keystrokes: 300, + Accuracy: 0.9, + }, + }, + want: false, + want1: false, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/typing-server/internal/domain/usecase/score_usecase.go b/typing-server/internal/domain/usecase/score_usecase.go index a54db606..25f7baf5 100644 --- a/typing-server/internal/domain/usecase/score_usecase.go +++ b/typing-server/internal/domain/usecase/score_usecase.go @@ -9,6 +9,15 @@ import ( "github.com/su-its/typing/typing-server/internal/domain/service" ) +// モック作成のためインターフェースを実装 +type IScoreUseCase interface { + GetScoresRanking(ctx context.Context, request *model.GetScoresRankingRequest) (*model.GetScoresRankingResponse, error) + RegisterScore(ctx context.Context, userID uuid.UUID, keystrokes int, accuracy float64) error +} + +// コンパイル時にインターフェースの実装を確認 +var _ IScoreUseCase = (*ScoreUseCase)(nil) + // ScoreUseCase はスコア関連のユースケース type ScoreUseCase struct { txManager repository.TxManager diff --git a/typing-server/internal/interfaces/handler/score_handler.go b/typing-server/internal/interfaces/handler/score_handler.go index a04d7ea0..9e9f220a 100644 --- a/typing-server/internal/interfaces/handler/score_handler.go +++ b/typing-server/internal/interfaces/handler/score_handler.go @@ -12,14 +12,28 @@ import ( // ScoreHandler はスコア関連の HTTP ハンドラ type ScoreHandler struct { - scoreUseCase *usecase.ScoreUseCase + scoreUseCase usecase.IScoreUseCase } // NewScoreHandler は ScoreHandler のインスタンスを生成する -func NewScoreHandler(scoreUseCase *usecase.ScoreUseCase) *ScoreHandler { +func NewScoreHandler(scoreUseCase usecase.IScoreUseCase) *ScoreHandler { return &ScoreHandler{scoreUseCase: scoreUseCase} } +const ( + ErrMsgInvalidSortbyParam = "Invalid sort_by parameter" + ErrMsgInvalidStartParam = "Invalid start parameter" + ErrMsgInvalidLimitParam = "Invalid limit parameter" + ErrMsgFetchRanking = "Failed to fetch ranking" + ErrMsgScoreEncodeResponse = "Failed to encode response" + + ErrMsgInvalidReqBody = "Invalid request body" + ErrMsgInvalidUserIdFormat = "Invalid user_id format" + ErrMsgRegisterScore = "Failed to register score" + MsgRegisteredSuccessfully = "Score registered successfully" + ErrMsgWriteResponse = "Failed to write response" +) + // GetScoresRanking はスコアランキングを取得するエンドポイント func (h *ScoreHandler) GetScoresRanking(w http.ResponseWriter, r *http.Request) { // クエリパラメータを取得 @@ -29,19 +43,19 @@ func (h *ScoreHandler) GetScoresRanking(w http.ResponseWriter, r *http.Request) // パラメータのバリデーション if sortBy != "keystrokes" && sortBy != "accuracy" { - http.Error(w, "Invalid sort_by parameter", http.StatusBadRequest) + http.Error(w, ErrMsgInvalidSortbyParam, http.StatusBadRequest) return } start, err := strconv.Atoi(startStr) if err != nil || start <= 0 { - http.Error(w, "Invalid start parameter", http.StatusBadRequest) + http.Error(w, ErrMsgInvalidStartParam, http.StatusBadRequest) return } limit, err := strconv.Atoi(limitStr) if err != nil || limit <= 0 { - http.Error(w, "Invalid limit parameter", http.StatusBadRequest) + http.Error(w, ErrMsgInvalidLimitParam, http.StatusBadRequest) return } @@ -54,14 +68,14 @@ func (h *ScoreHandler) GetScoresRanking(w http.ResponseWriter, r *http.Request) resp, err := h.scoreUseCase.GetScoresRanking(r.Context(), req) if err != nil { - http.Error(w, "Failed to fetch ranking", http.StatusInternalServerError) + http.Error(w, ErrMsgFetchRanking, http.StatusInternalServerError) return } // JSON レスポンスを返す w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(resp); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) + http.Error(w, ErrMsgScoreEncodeResponse, http.StatusInternalServerError) } } @@ -75,27 +89,27 @@ func (h *ScoreHandler) RegisterScore(w http.ResponseWriter, r *http.Request) { } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + http.Error(w, ErrMsgInvalidReqBody, http.StatusBadRequest) return } // UUID のバリデーション userID, err := uuid.Parse(req.UserID) if err != nil { - http.Error(w, "Invalid user_id format", http.StatusBadRequest) + http.Error(w, ErrMsgInvalidUserIdFormat, http.StatusBadRequest) return } // ユースケースを呼び出し err = h.scoreUseCase.RegisterScore(r.Context(), userID, req.Keystrokes, req.Accuracy) if err != nil { - http.Error(w, "Failed to register score", http.StatusInternalServerError) + http.Error(w, ErrMsgRegisterScore, http.StatusInternalServerError) return } // 成功時のレスポンス w.WriteHeader(http.StatusCreated) - if _, err := w.Write([]byte("Score registered successfully")); err != nil { - http.Error(w, "Failed to write response", http.StatusInternalServerError) + if _, err := w.Write([]byte(MsgRegisteredSuccessfully)); err != nil { + http.Error(w, ErrMsgWriteResponse, http.StatusInternalServerError) } } diff --git a/typing-server/internal/interfaces/handler/score_handler_test.go b/typing-server/internal/interfaces/handler/score_handler_test.go index 09b03d1a..e8e82ea4 100644 --- a/typing-server/internal/interfaces/handler/score_handler_test.go +++ b/typing-server/internal/interfaces/handler/score_handler_test.go @@ -1,33 +1,54 @@ package handler import ( + "bytes" + "context" + "encoding/json" + "errors" "net/http" "reflect" + "strings" "testing" + "time" "net/http/httptest" + "github.com/google/uuid" + + "github.com/su-its/typing/typing-server/internal/domain/model" "github.com/su-its/typing/typing-server/internal/domain/usecase" + "github.com/su-its/typing/typing-server/internal/testutils" ) +type mockScoreUseCase struct { + getScoresRanking func(ctx context.Context, request *model.GetScoresRankingRequest) (*model.GetScoresRankingResponse, error) + registerScore func(ctx context.Context, userID uuid.UUID, keystrokes int, accuracy float64) error +} + +func (m *mockScoreUseCase) GetScoresRanking(ctx context.Context, req *model.GetScoresRankingRequest) (*model.GetScoresRankingResponse, error) { + return m.getScoresRanking(ctx, req) +} + +func (m *mockScoreUseCase) RegisterScore(ctx context.Context, userID uuid.UUID, keystrokes int, accuracy float64) error { + return m.registerScore(ctx, userID, keystrokes, accuracy) +} + func TestNewScoreHandler(t *testing.T) { type args struct { - scoreUseCase *usecase.ScoreUseCase + scoreUseCase usecase.IScoreUseCase } - fakeUseCase := &usecase.ScoreUseCase{} tests := []struct { name string args args want *ScoreHandler }{ - // TODO: Add test cases. { name: "正常系: ScoreHandlerが正しく生成される", args: args{ - scoreUseCase: fakeUseCase, + scoreUseCase: &mockScoreUseCase{}, }, want: &ScoreHandler{ - scoreUseCase: fakeUseCase, + scoreUseCase: &mockScoreUseCase{}, }, }, } @@ -45,42 +66,264 @@ func TestScoreHandler_GetScoresRanking(t *testing.T) { w http.ResponseWriter r *http.Request } - + tests := []struct { - name string - h *ScoreHandler - args args + name string + h *ScoreHandler + args args wantStatus int wantBody string }{ - // TODO: Add test cases. { - name: "sort_by が無効な場合は 400 が返る", + name: "正常系: スコアランキングが取得できる場合(keystrokes)", + h: &ScoreHandler{ + scoreUseCase: &mockScoreUseCase{ + getScoresRanking: func(ctx context.Context, request *model.GetScoresRankingRequest) (*model.GetScoresRankingResponse, error) { + return &model.GetScoresRankingResponse{ + Rankings: []*model.ScoreRanking{ + { + Rank: 1, + Score: model.Score{ + ID: "score-1", + UserID: "user-1", + Keystrokes: 300, + Accuracy: 0.95, + CreatedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + User: model.User{ + ID: "1", + StudentNumber: "k20000", + HandleName: "テストユーザー", + CreatedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + }, + }, + }, + }, + TotalCount: 1, + }, nil + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "/scores/ranking?sort_by=keystrokes&start=1&limit=10", nil), + }, + wantStatus: http.StatusOK, + wantBody: `{"rankings":[{"rank":1,"score":{"id":"score-1","user_id":"user-1","keystrokes":300,"accuracy":0.95,"created_at":"2021-01-01T00:00:00Z","user":{"id":"1","student_number":"k20000","handle_name":"テストユーザー","created_at":"2021-01-01T00:00:00Z","updated_at":"2021-01-01T00:00:00Z"}}}],"total_count":1}`, + }, + { + name: "正常系: スコアランキングが取得できる場合(accuracy)", + h: &ScoreHandler{ + scoreUseCase: &mockScoreUseCase{ + getScoresRanking: func(ctx context.Context, request *model.GetScoresRankingRequest) (*model.GetScoresRankingResponse, error) { + return &model.GetScoresRankingResponse{ + Rankings: []*model.ScoreRanking{ + { + Rank: 1, + Score: model.Score{ + ID: "score-1", + UserID: "user-1", + Keystrokes: 300, + Accuracy: 0.95, + CreatedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + User: model.User{ + ID: "1", + StudentNumber: "k20000", + HandleName: "テストユーザー", + CreatedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + }, + }, + }, + }, + TotalCount: 1, + }, nil + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "/scores/ranking?sort_by=accuracy&start=1&limit=10", nil), + }, + wantStatus: http.StatusOK, + wantBody: `{"rankings":[{"rank":1,"score":{"id":"score-1","user_id":"user-1","keystrokes":300,"accuracy":0.95,"created_at":"2021-01-01T00:00:00Z","user":{"id":"1","student_number":"k20000","handle_name":"テストユーザー","created_at":"2021-01-01T00:00:00Z","updated_at":"2021-01-01T00:00:00Z"}}}],"total_count":1}`, + }, + { + name: "異常系: sort_byが不正な場合", + h: &ScoreHandler{ + scoreUseCase: &mockScoreUseCase{ + getScoresRanking: func(ctx context.Context, request *model.GetScoresRankingRequest) (*model.GetScoresRankingResponse, error) { + t.Error("不正なsort_byの場合、ユースケースは呼び出されるべきではない") + return nil, nil + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "/scores/ranking?sort_by=invalid&start=1&limit=10", nil), + }, + wantStatus: http.StatusBadRequest, + wantBody: "Invalid sort_by parameter", + }, + { + name: "異常系: startが不正(数字変換エラー)の場合", h: &ScoreHandler{ - scoreUseCase: &usecase.ScoreUseCase{}, // 必要に応じてモック等に差し替え + scoreUseCase: &mockScoreUseCase{ + getScoresRanking: func(ctx context.Context, request *model.GetScoresRankingRequest) (*model.GetScoresRankingResponse, error) { + t.Error("不正なstartの場合、ユースケースは呼び出されるべきではない") + return nil, nil + }, + }, }, args: args{ w: httptest.NewRecorder(), - // 例: sort_by=invalid をセットし、不正パラメータにしている - r: httptest.NewRequest("GET", "/scores?sort_by=invalid&start=1&limit=10", nil), + // start=abc は数字変換失敗 + r: httptest.NewRequest("GET", "/scores/ranking?sort_by=accuracy&start=abc&limit=10", nil), }, wantStatus: http.StatusBadRequest, - wantBody: "Invalid sort_by parameter\n", + wantBody: "Invalid start parameter", + }, + { + name: "異常系: startが不正(0以下)の場合", + h: &ScoreHandler{ + scoreUseCase: &mockScoreUseCase{ + getScoresRanking: func(ctx context.Context, request *model.GetScoresRankingRequest) (*model.GetScoresRankingResponse, error) { + t.Error("不正なstartの場合、ユースケースは呼び出されるべきではない") + return nil, nil + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + // start=-1 は 0以下 + r: httptest.NewRequest("GET", "/scores/ranking?sort_by=accuracy&start=-1&limit=10", nil), + }, + wantStatus: http.StatusBadRequest, + wantBody: "Invalid start parameter", + }, + { + name: "異常系: limitが不正(数字変換エラー)の場合", + h: &ScoreHandler{ + scoreUseCase: &mockScoreUseCase{ + getScoresRanking: func(ctx context.Context, request *model.GetScoresRankingRequest) (*model.GetScoresRankingResponse, error) { + t.Error("不正なlimitの場合、ユースケースは呼び出されるべきではない") + return nil, nil + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "/scores/ranking?sort_by=accuracy&start=1&limit=abc", nil), + }, + wantStatus: http.StatusBadRequest, + wantBody: "Invalid limit parameter", + }, + { + name: "異常系: limitが不正(0以下)の場合", + h: &ScoreHandler{ + scoreUseCase: &mockScoreUseCase{ + getScoresRanking: func(ctx context.Context, request *model.GetScoresRankingRequest) (*model.GetScoresRankingResponse, error) { + t.Error("不正なlimitの場合、ユースケースは呼び出されるべきではない") + return nil, nil + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "/scores/ranking?sort_by=accuracy&start=1&limit=0", nil), + }, + wantStatus: http.StatusBadRequest, + wantBody: "Invalid limit parameter", + }, + { + name: "異常系: GetScoresRankingをしたときユースケースからエラーが返る場合", + h: &ScoreHandler{ + scoreUseCase: &mockScoreUseCase{ + getScoresRanking: func(ctx context.Context, request *model.GetScoresRankingRequest) (*model.GetScoresRankingResponse, error) { + return nil, errors.New("ErrGetScoresRanking") // ここで適当なエラーを返す + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "/scores?sort_by=accuracy&start=1&limit=10", nil), + }, + wantStatus: http.StatusInternalServerError, + wantBody: "Failed to fetch ranking", + }, + { + name: "異常系: レスポンスのエンコードが失敗したとき", + h: &ScoreHandler{ + scoreUseCase: &mockScoreUseCase{ + getScoresRanking: func(ctx context.Context, request *model.GetScoresRankingRequest) (*model.GetScoresRankingResponse, error) { + return &model.GetScoresRankingResponse{ + Rankings: []*model.ScoreRanking{ + { + Rank: 1, + Score: model.Score{ + ID: "score-1", + UserID: "user-1", + Keystrokes: 300, + Accuracy: 0.95, + CreatedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + User: model.User{ + ID: "1", + StudentNumber: "k20000", + HandleName: "テストユーザー", + CreatedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + }, + }, + }, + }, + TotalCount: 1, + }, nil + }, + }, + }, + args: args{ + w: testutils.NewFakeResponseWriter(), + r: httptest.NewRequest("GET", "/scores/ranking?sort_by=keystrokes&start=1&limit=10", nil), + }, + wantStatus: http.StatusInternalServerError, + wantBody: "Failed to encode response", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tt.h.GetScoresRanking(tt.args.w, tt.args.r) - rr := tt.args.w.(*httptest.ResponseRecorder) - + var code int + var body string + switch w := tt.args.w.(type) { + case *testutils.FakeResponseWriter: + code = w.StatusCode + body = w.Body.String() + case *httptest.ResponseRecorder: + code = w.Code + body = w.Body.String() + default: + t.Fatal("unknown ResponseWriter type") + } // ステータスコードの検証 - if rr.Code != tt.wantStatus { + if code != tt.wantStatus { t.Errorf("GetScoresRanking() status code = %v, want %v", - rr.Code, tt.wantStatus) + code, tt.wantStatus) } - gotBody := rr.Body.String() - if gotBody != tt.wantBody { - t.Errorf("GetScoresRanking() body = %q, want %q", gotBody, tt.wantBody) + if tt.wantStatus == http.StatusOK { + var got, want model.Score + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Errorf("Failed to parse response body: %v", err) + } + if err := json.Unmarshal([]byte(tt.wantBody), &want); err != nil { + t.Errorf("Failed to parse expected body: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("GetScoresRanking() = %+v, want %+v", got, want) + } + } else { + if !strings.Contains(body, strings.TrimSpace(tt.wantBody)) { + t.Errorf("GetScoresRanking() body = %q, want to contain %q", body, tt.wantBody) + } } }) } @@ -92,15 +335,173 @@ func TestScoreHandler_RegisterScore(t *testing.T) { r *http.Request } tests := []struct { - name string - h *ScoreHandler - args args + name string + h *ScoreHandler + args args + wantStatus int + wantBody string }{ - // TODO: Add test cases. + { + name: "正常系: スコア登録が成功する場合", + h: &ScoreHandler{ + scoreUseCase: &mockScoreUseCase{ + registerScore: func(ctx context.Context, userID uuid.UUID, keystrokes int, accuracy float64) error { + return nil + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: func() *http.Request { + bodyMap := map[string]interface{}{ + "user_id": "b110a730-93d6-4dac-a8b4-c9a9fc5cb1bf", // 正しい UUID の例 + "keystrokes": 300, + "accuracy": 0.95, + } + jsonBody, _ := json.Marshal(bodyMap) + req := httptest.NewRequest("POST", "/scores", bytes.NewReader(jsonBody)) + req.Header.Set("Content-Type", "application/json") + return req + }(), + }, + wantStatus: http.StatusCreated, + wantBody: "Score registered successfully", + }, + { + name: "異常系: JSONパースエラーの場合", + h: &ScoreHandler{ + scoreUseCase: &mockScoreUseCase{ + registerScore: func(ctx context.Context, userID uuid.UUID, keystrokes int, accuracy float64) error { + t.Error("JSONパースに失敗した場合、ユースケースは呼び出されるべきではない") + return nil + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + // 不正なJSON (キーがダブルクォートで囲まれていないなど) + r: httptest.NewRequest("POST", "/scores", strings.NewReader(`{user_id:"xxx"}`)), + }, + wantStatus: http.StatusBadRequest, + wantBody: "Invalid request body\n", + }, + { + name: "異常系: UUIDのバリデーションエラーの場合", + h: &ScoreHandler{ + scoreUseCase: &mockScoreUseCase{ + registerScore: func(ctx context.Context, userID uuid.UUID, keystrokes int, accuracy float64) error { + t.Error("UUID不正の場合、ユースケースは呼び出されるべきではない") + return nil + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + // 不正な user_id + r: func() *http.Request { + bodyMap := map[string]interface{}{ + "user_id": "invalid-uuid", + "keystrokes": 300, + "accuracy": 0.95, + } + jsonBody, _ := json.Marshal(bodyMap) + req := httptest.NewRequest("POST", "/scores", bytes.NewReader(jsonBody)) + req.Header.Set("Content-Type", "application/json") + return req + }(), + }, + wantStatus: http.StatusBadRequest, + wantBody: "Invalid user_id format\n", + }, + { + name: "異常系: ユースケースがエラーを返す場合", + h: &ScoreHandler{ + scoreUseCase: &mockScoreUseCase{ + registerScore: func(ctx context.Context, userID uuid.UUID, keystrokes int, accuracy float64) error { + return errors.New("hoge") + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: func() *http.Request { + bodyMap := map[string]interface{}{ + "user_id": "b110a730-93d6-4dac-a8b4-c9a9fc5cb1bf", + "keystrokes": 300, + "accuracy": 0.95, + } + jsonBody, _ := json.Marshal(bodyMap) + req := httptest.NewRequest("POST", "/scores", bytes.NewReader(jsonBody)) + req.Header.Set("Content-Type", "application/json") + return req + }(), + }, + wantStatus: http.StatusInternalServerError, + wantBody: "Failed to register score\n", + }, + { + name: "異常系: レスポンスの書き込みが失敗したとき", + h: &ScoreHandler{ + scoreUseCase: &mockScoreUseCase{ + registerScore: func(ctx context.Context, userID uuid.UUID, keystrokes int, accuracy float64) error { + return nil + }, + }, + }, + args: args{ + w: testutils.NewFakeResponseWriter(), + r: func() *http.Request { + bodyMap := map[string]interface{}{ + "user_id": "b110a730-93d6-4dac-a8b4-c9a9fc5cb1bf", // 正しい UUID の例 + "keystrokes": 300, + "accuracy": 0.95, + } + jsonBody, _ := json.Marshal(bodyMap) + req := httptest.NewRequest("POST", "/scores", bytes.NewReader(jsonBody)) + req.Header.Set("Content-Type", "application/json") + return req + }(), + }, + wantStatus: http.StatusInternalServerError, + wantBody: "Failed to write response\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tt.h.RegisterScore(tt.args.w, tt.args.r) + var code int + var body string + switch w := tt.args.w.(type) { + case *testutils.FakeResponseWriter: + code = w.StatusCode + body = w.Body.String() + case *httptest.ResponseRecorder: + code = w.Code + body = w.Body.String() + default: + t.Fatal("unknown ResponseWriter type") + } + // ステータスコードの検証 + if code != tt.wantStatus { + t.Errorf("RegisterScore() status code = %v, want %v", + code, tt.wantStatus) + } + if tt.wantStatus == http.StatusOK { + var got, want model.Score + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Errorf("Failed to parse response body: %v", err) + } + if err := json.Unmarshal([]byte(tt.wantBody), &want); err != nil { + t.Errorf("Failed to parse expected body: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("RegisterScore() = %+v, want %+v", got, want) + } + } else { + if !strings.Contains(body, strings.TrimSpace(tt.wantBody)) { + t.Errorf("RegisterScore() body = %q, want to contain %q", body, tt.wantBody) + } + } }) } } diff --git a/typing-server/internal/interfaces/handler/user_handler.go b/typing-server/internal/interfaces/handler/user_handler.go index abc5af97..d448f0d0 100644 --- a/typing-server/internal/interfaces/handler/user_handler.go +++ b/typing-server/internal/interfaces/handler/user_handler.go @@ -22,9 +22,9 @@ func NewUserHandler(userUseCase usecase.IUserUseCase) *UserHandler { const ( ErrMsgStudentNumberRequired = "student_numberが指定されていません" - ErrMsgUserNotFound = "ユーザーが見つかりません" - ErrMsgInternalServer = "内部サーバーエラーが発生しました" - ErrMsgEncodeResponse = "レスポンスのエンコードに失敗しました" + ErrMsgUserNotFound = "ユーザーが見つかりません" + ErrMsgInternalServer = "内部サーバーエラーが発生しました" + ErrMsgEncodeResponse = "レスポンスのエンコードに失敗しました" ) // GetUserByStudentNumber は学籍番号をクエリパラメータとして受け取り、該当するユーザー情報を取得する