Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/create handler edition test #1152

Open
wants to merge 18 commits into
base: main
Choose a base branch
from

Conversation

mathsuky
Copy link
Contributor

@mathsuky mathsuky commented Feb 23, 2025

User description

handler/v2/edition.goのテストedition_test.goを追加しました。

あまりテストの書き方に自信がなく,別の色々なテストを参考にしながら書いたので書き方に統一感がない部分があるかもしれないので,そういった部分があれば指摘いただきたいです:bow-nya:


PR Type

Tests


Description

  • edition_test.goファイルに新しいテストケースを追加

  • エディション関連のCRUD操作を網羅するテストを実装

  • ゲームバージョンや関連データのテストケースを拡充

  • エラーハンドリングや異常系のテストを強化


Changes walkthrough 📝

Relevant files
Tests
edition_test.go
エディションとゲーム関連のテストケースを追加                                                                     

src/handler/v2/edition_test.go

  • エディション取得、作成、更新、削除のテストを追加
  • ゲームバージョン関連のテストケースを実装
  • 異常系テスト(無効なIDや重複データなど)を追加
  • モックを使用したサービス層のテストを強化
  • +1928/-0

    Need help?
  • Type /help how to ... in the comments thread for any questions about PR-Agent usage.
  • Check out the documentation for more information.
  • @mathsuky mathsuky requested a review from ikura-hamu February 23, 2025 13:14
    Copy link

    PR Reviewer Guide 🔍

    Here are some key observations to aid the review process:

    ⏱️ Estimated effort to review: 5 🔵🔵🔵🔵🔵
    🧪 PR contains tests
    🔒 No security concerns identified
    ⚡ Recommended focus areas for review

    Test Case Consistency

    The test cases in edition_test.go cover a wide range of scenarios, but there may be inconsistencies in naming conventions and structure. Ensure that test descriptions and structures are uniform for better readability and maintainability.

    func TestGetEditions(t *testing.T) {
    	t.Parallel()
    
    	ctrl := gomock.NewController(t)
    	defer ctrl.Finish()
    
    	mockEditionService := mock.NewMockEdition(ctrl)
    	edition := NewEdition(mockEditionService)
    
    	type test struct {
    		description    string
    		editions       []*domain.LauncherVersion
    		getEditionsErr error
    		expectEditions []openapi.Edition
    		isErr          bool
    		statusCode     int
    	}
    
    	now := time.Now()
    	editionID1 := values.NewLauncherVersionIDFromUUID(uuid.New())
    	editionID2 := values.NewLauncherVersionIDFromUUID(uuid.New())
    	editionName1 := values.NewLauncherVersionName("テストエディション")
    	editionName2 := values.NewLauncherVersionName("テストエディション2")
    	strURL := "https://example.com/questionnaire"
    	questionnaireURL, err := url.Parse(strURL)
    	if err != nil {
    		t.Fatalf("failed to parse url: %v", err)
    	}
    
    	testCases := []test{
    		{
    			description: "特に問題ないのでエラーなし",
    			editions: []*domain.LauncherVersion{
    				domain.NewLauncherVersionWithQuestionnaire(
    					editionID1,
    					editionName1,
    					values.NewLauncherVersionQuestionnaireURL(questionnaireURL),
    					now,
    				),
    			},
    			expectEditions: []openapi.Edition{
    				{
    					Id:            uuid.UUID(editionID1),
    					Name:          string(editionName1),
    					Questionnaire: &strURL,
    					CreatedAt:     now,
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "アンケートURLが無くてもエラーなし",
    			editions: []*domain.LauncherVersion{
    				domain.NewLauncherVersionWithoutQuestionnaire(
    					editionID1,
    					editionName1,
    					now,
    				),
    			},
    			expectEditions: []openapi.Edition{
    				{
    					Id:            uuid.UUID(editionID1),
    					Name:          string(editionName1),
    					Questionnaire: nil,
    					CreatedAt:     now,
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description:    "GetEditionsがエラーなので500",
    			getEditionsErr: errors.New("error"),
    			isErr:          true,
    			statusCode:     http.StatusInternalServerError,
    		},
    		{
    			description: "複数エディションでもエラーなし",
    			editions: []*domain.LauncherVersion{
    				domain.NewLauncherVersionWithQuestionnaire(
    					editionID1,
    					editionName1,
    					values.NewLauncherVersionQuestionnaireURL(questionnaireURL),
    					now,
    				),
    				domain.NewLauncherVersionWithoutQuestionnaire(
    					editionID2,
    					editionName2,
    					now,
    				),
    			},
    			expectEditions: []openapi.Edition{
    				{
    					Id:            uuid.UUID(editionID1),
    					Name:          string(editionName1),
    					Questionnaire: &strURL,
    					CreatedAt:     now,
    				},
    				{
    					Id:            uuid.UUID(editionID2),
    					Name:          string(editionName2),
    					Questionnaire: nil,
    					CreatedAt:     now,
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "エディションが存在しなくてもでもエラーなし",
    			editions:    []*domain.LauncherVersion{},
    			statusCode:  http.StatusOK,
    		},
    	}
    
    	for _, testCase := range testCases {
    		t.Run(testCase.description, func(t *testing.T) {
    			e := echo.New()
    			req := httptest.NewRequest(http.MethodGet, "/api/v2/editions", nil)
    			rec := httptest.NewRecorder()
    			c := e.NewContext(req, rec)
    
    			mockEditionService.
    				EXPECT().
    				GetEditions(gomock.Any()).
    				Return(testCase.editions, testCase.getEditionsErr)
    
    			err := edition.GetEditions(c)
    
    			if testCase.isErr {
    				if testCase.statusCode != 0 {
    					var httpErr *echo.HTTPError
    					if errors.As(err, &httpErr) {
    						assert.Equal(t, testCase.statusCode, httpErr.Code)
    					} else {
    						t.Errorf("error should be *echo.HTTPError, but got %T", err)
    					}
    				} else {
    					assert.Error(t, err)
    				}
    				return
    			} else {
    				assert.NoError(t, err)
    			}
    			if err != nil || testCase.isErr {
    				return
    			}
    
    			assert.Equal(t, testCase.statusCode, rec.Code)
    
    			var res []openapi.Edition
    			err = json.NewDecoder(rec.Body).Decode(&res)
    			if err != nil {
    				t.Fatalf("failed to decode response body: %v", err)
    			}
    
    			assert.Len(t, res, len(testCase.expectEditions))
    			for i, ed := range res {
    				assert.Equal(t, testCase.expectEditions[i].Id, ed.Id)
    				assert.Equal(t, testCase.expectEditions[i].Name, ed.Name)
    				assert.Equal(t, testCase.expectEditions[i].Questionnaire, ed.Questionnaire)
    				assert.WithinDuration(t, testCase.expectEditions[i].CreatedAt, ed.CreatedAt, 2*time.Second)
    			}
    		})
    	}
    }
    
    func TestPostEdition(t *testing.T) {
    	t.Parallel()
    
    	ctrl := gomock.NewController(t)
    	defer ctrl.Finish()
    
    	mockEditionService := mock.NewMockEdition(ctrl)
    	edition := NewEdition(mockEditionService)
    
    	type test struct {
    		description          string
    		reqBody              *openapi.NewEdition
    		invalidBody          bool
    		executeCreateEdition bool
    		name                 values.LauncherVersionName
    		questionnaireURL     types.Option[values.LauncherVersionQuestionnaireURL]
    		gameVersionIDs       []values.GameVersionID
    		createEditionErr     error
    		resultEdition        *domain.LauncherVersion
    		isErr                bool
    		statusCode           int
    		expectEdition        *openapi.Edition
    	}
    
    	now := time.Now()
    	editionUUID := uuid.New()
    	editionID := values.NewLauncherVersionIDFromUUID(editionUUID)
    	editionName := "テストエディション"
    	strURL := "https://example.com/questionnaire"
    	invalidURL := " https://example.com/questionnaire"
    	longName := strings.Repeat("あ", 33)
    	questionnaireURL, err := url.Parse(strURL)
    	if err != nil {
    		t.Fatalf("failed to parse url: %v", err)
    	}
    	gameVersionUUID1 := uuid.New()
    	gameVersionUUID2 := uuid.New()
    
    	testCases := []test{
    		{
    			description: "特に問題ないのでエラーなし",
    			reqBody: &openapi.NewEdition{
    				Name:          editionName,
    				Questionnaire: &strURL,
    				GameVersions:  []uuid.UUID{gameVersionUUID1, gameVersionUUID2},
    			},
    			executeCreateEdition: true,
    			name:                 values.NewLauncherVersionName(editionName),
    			questionnaireURL:     types.NewOption(values.NewLauncherVersionQuestionnaireURL(questionnaireURL)),
    			gameVersionIDs: []values.GameVersionID{
    				values.NewGameVersionIDFromUUID(gameVersionUUID1),
    				values.NewGameVersionIDFromUUID(gameVersionUUID2),
    			},
    			resultEdition: domain.NewLauncherVersionWithQuestionnaire(
    				editionID,
    				values.NewLauncherVersionName(editionName),
    				values.NewLauncherVersionQuestionnaireURL(questionnaireURL),
    				now,
    			),
    			expectEdition: &openapi.Edition{
    				Id:            editionUUID,
    				Name:          editionName,
    				Questionnaire: &strURL,
    				CreatedAt:     now,
    			},
    			statusCode: http.StatusCreated,
    		},
    		{
    			description: "アンケートURLがなくてもエラーなし",
    			reqBody: &openapi.NewEdition{
    				Name:         editionName,
    				GameVersions: []uuid.UUID{gameVersionUUID1},
    			},
    			executeCreateEdition: true,
    			name:                 values.NewLauncherVersionName(editionName),
    			questionnaireURL:     types.Option[values.LauncherVersionQuestionnaireURL]{},
    			gameVersionIDs:       []values.GameVersionID{values.NewGameVersionIDFromUUID(gameVersionUUID1)},
    			resultEdition: domain.NewLauncherVersionWithoutQuestionnaire(
    				editionID,
    				values.NewLauncherVersionName(editionName),
    				now,
    			),
    			expectEdition: &openapi.Edition{
    				Id:            editionUUID,
    				Name:          editionName,
    				Questionnaire: nil,
    				CreatedAt:     now,
    			},
    			statusCode: http.StatusCreated,
    		},
    		{
    			description: "Edition名が空文字なので400",
    			reqBody: &openapi.NewEdition{
    				Name:         "",
    				GameVersions: []uuid.UUID{gameVersionUUID1},
    			},
    			isErr:      true,
    			statusCode: http.StatusBadRequest,
    		},
    		{
    			description: "Edition名が長すぎるので400",
    			reqBody: &openapi.NewEdition{
    				Name:         longName,
    				GameVersions: []uuid.UUID{gameVersionUUID1},
    			},
    			isErr:      true,
    			statusCode: http.StatusBadRequest,
    		},
    		{
    			description: "URLが正しくないので400",
    			reqBody: &openapi.NewEdition{
    				Name:          editionName,
    				Questionnaire: &invalidURL,
    				GameVersions:  []uuid.UUID{gameVersionUUID1},
    			},
    			isErr:      true,
    			statusCode: http.StatusBadRequest,
    		},
    		{
    			description: "ゲームバージョンが重複しているので400",
    			reqBody: &openapi.NewEdition{
    				Name:         editionName,
    				GameVersions: []uuid.UUID{gameVersionUUID1, gameVersionUUID1},
    			},
    			executeCreateEdition: true,
    			name:                 values.NewLauncherVersionName(editionName),
    			questionnaireURL:     types.Option[values.LauncherVersionQuestionnaireURL]{},
    			gameVersionIDs: []values.GameVersionID{
    				values.NewGameVersionIDFromUUID(gameVersionUUID1),
    				values.NewGameVersionIDFromUUID(gameVersionUUID1),
    			},
    			createEditionErr: service.ErrDuplicateGameVersion,
    			isErr:            true,
    			statusCode:       http.StatusBadRequest,
    		},
    		{
    			description: "無効なゲームバージョンIDが含まれているので400",
    			reqBody: &openapi.NewEdition{
    				Name:         editionName,
    				GameVersions: []uuid.UUID{gameVersionUUID1},
    			},
    			executeCreateEdition: true,
    			name:                 values.NewLauncherVersionName(editionName),
    			questionnaireURL:     types.Option[values.LauncherVersionQuestionnaireURL]{},
    			gameVersionIDs:       []values.GameVersionID{values.NewGameVersionIDFromUUID(gameVersionUUID1)},
    			createEditionErr:     service.ErrInvalidGameVersionID,
    			isErr:                true,
    			statusCode:           http.StatusBadRequest,
    		},
    		{
    			description: "サービス層でエラーが発生したので500",
    			reqBody: &openapi.NewEdition{
    				Name:         editionName,
    				GameVersions: []uuid.UUID{gameVersionUUID1},
    			},
    			executeCreateEdition: true,
    			name:                 values.NewLauncherVersionName(editionName),
    			questionnaireURL:     types.Option[values.LauncherVersionQuestionnaireURL]{},
    			gameVersionIDs:       []values.GameVersionID{values.NewGameVersionIDFromUUID(gameVersionUUID1)},
    			createEditionErr:     errors.New("internal error"),
    			isErr:                true,
    			statusCode:           http.StatusInternalServerError,
    		},
    	}
    
    	for _, testCase := range testCases {
    		t.Run(testCase.description, func(t *testing.T) {
    			e := echo.New()
    			var req *http.Request
    			if testCase.invalidBody {
    				reqBody := bytes.NewBuffer([]byte("invalid"))
    				req = httptest.NewRequest(http.MethodPost, "/api/v2/editions", reqBody)
    				req.Header.Set("Content-Type", echo.MIMETextPlain)
    			} else {
    				reqBody := bytes.NewBuffer(nil)
    				if err := json.NewEncoder(reqBody).Encode(testCase.reqBody); err != nil {
    					t.Fatalf("failed to encode request body: %v", err)
    				}
    				req = httptest.NewRequest(http.MethodPost, "/api/v2/editions", reqBody)
    				req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
    			}
    			rec := httptest.NewRecorder()
    			c := e.NewContext(req, rec)
    
    			if testCase.executeCreateEdition {
    				mockEditionService.
    					EXPECT().
    					CreateEdition(
    						gomock.Any(),
    						testCase.name,
    						testCase.questionnaireURL,
    						testCase.gameVersionIDs,
    					).
    					Return(testCase.resultEdition, testCase.createEditionErr)
    			}
    
    			err := edition.PostEdition(c)
    
    			if testCase.isErr {
    				if testCase.statusCode != 0 {
    					var httpErr *echo.HTTPError
    					if errors.As(err, &httpErr) {
    						assert.Equal(t, testCase.statusCode, httpErr.Code)
    					} else {
    						t.Errorf("error is not *echo.HTTPError")
    					}
    				} else {
    					assert.Error(t, err)
    				}
    				return
    			}
    
    			assert.NoError(t, err)
    			assert.Equal(t, testCase.statusCode, rec.Code)
    
    			if testCase.expectEdition != nil {
    				var res openapi.Edition
    				if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
    					t.Fatalf("failed to decode response body: %v", err)
    				}
    
    				assert.Equal(t, testCase.expectEdition.Id, res.Id)
    				assert.Equal(t, testCase.expectEdition.Name, res.Name)
    				assert.Equal(t, testCase.expectEdition.Questionnaire, res.Questionnaire)
    				assert.WithinDuration(t, testCase.expectEdition.CreatedAt, res.CreatedAt, time.Second)
    			}
    		})
    	}
    }
    
    func TestDeleteEdition(t *testing.T) {
    	t.Parallel()
    
    	ctrl := gomock.NewController(t)
    	defer ctrl.Finish()
    
    	mockEditionService := mock.NewMockEdition(ctrl)
    	edition := NewEdition(mockEditionService)
    
    	editionID := uuid.New()
    
    	type test struct {
    		description       string
    		editionID         openapi.EditionIDInPath
    		executeDeleteMock bool
    		launcherVersionID values.LauncherVersionID
    		deleteEditionErr  error
    		isErr             bool
    		statusCode        int
    	}
    
    	testCases := []test{
    		{
    			description:       "特に問題ないのでエラー無し",
    			editionID:         editionID,
    			executeDeleteMock: true,
    			launcherVersionID: values.NewLauncherVersionIDFromUUID(editionID),
    			statusCode:        http.StatusOK,
    		},
    		{
    			description:       "存在しないエディションIDなので400",
    			editionID:         editionID,
    			executeDeleteMock: true,
    			launcherVersionID: values.NewLauncherVersionIDFromUUID(editionID),
    			deleteEditionErr:  service.ErrInvalidEditionID,
    			isErr:             true,
    			statusCode:        http.StatusBadRequest,
    		},
    		{
    			description:       "DeleteEditionがエラーなので500",
    			editionID:         editionID,
    			executeDeleteMock: true,
    			launcherVersionID: values.NewLauncherVersionIDFromUUID(editionID),
    			deleteEditionErr:  errors.New("internal error"),
    			isErr:             true,
    			statusCode:        http.StatusInternalServerError,
    		},
    	}
    
    	for _, testCase := range testCases {
    		t.Run(testCase.description, func(t *testing.T) {
    			if testCase.executeDeleteMock {
    				mockEditionService.
    					EXPECT().
    					DeleteEdition(gomock.Any(), testCase.launcherVersionID).
    					Return(testCase.deleteEditionErr)
    			}
    
    			e := echo.New()
    			req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v2/editions/%s", testCase.editionID), nil)
    			rec := httptest.NewRecorder()
    			c := e.NewContext(req, rec)
    
    			err := edition.DeleteEdition(c, testCase.editionID)
    
    			if testCase.isErr {
    				if testCase.statusCode != 0 {
    					var httpErr *echo.HTTPError
    					if errors.As(err, &httpErr) {
    						assert.Equal(t, testCase.statusCode, httpErr.Code)
    					} else {
    						t.Errorf("error should be *echo.HTTPError, but got %T", err)
    					}
    				} else {
    					assert.Error(t, err)
    				}
    				return
    			}
    
    			assert.NoError(t, err)
    			assert.Equal(t, http.StatusOK, rec.Code)
    		})
    	}
    }
    
    func TestGetEdition(t *testing.T) {
    	t.Parallel()
    
    	ctrl := gomock.NewController(t)
    	defer ctrl.Finish()
    
    	mockEditionService := mock.NewMockEdition(ctrl)
    	edition := NewEdition(mockEditionService)
    
    	type test struct {
    		description   string
    		editionID     openapi.EditionIDInPath
    		resultEdition *domain.LauncherVersion
    		getEditionErr error
    		expectEdition *openapi.Edition
    		isErr         bool
    		statusCode    int
    	}
    
    	now := time.Now()
    	editionUUID := uuid.New()
    	editionID := values.NewLauncherVersionIDFromUUID(editionUUID)
    	editionName := values.NewLauncherVersionName("テストエディション")
    	strURL := "https://example.com/questionnaire"
    	questionnaireURL, err := url.Parse(strURL)
    	if err != nil {
    		t.Fatalf("failed to parse url: %v", err)
    	}
    
    	testCases := []test{
    		{
    			description: "アンケートURLありのエディションが取得できる",
    			editionID:   editionUUID,
    			resultEdition: domain.NewLauncherVersionWithQuestionnaire(
    				editionID,
    				editionName,
    				values.NewLauncherVersionQuestionnaireURL(questionnaireURL),
    				now,
    			),
    			expectEdition: &openapi.Edition{
    				Id:            editionUUID,
    				Name:          string(editionName),
    				Questionnaire: &strURL,
    				CreatedAt:     now,
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "アンケートURLなしのエディションが取得できる",
    			editionID:   editionUUID,
    			resultEdition: domain.NewLauncherVersionWithoutQuestionnaire(
    				editionID,
    				editionName,
    				now,
    			),
    			expectEdition: &openapi.Edition{
    				Id:            editionUUID,
    				Name:          string(editionName),
    				Questionnaire: nil,
    				CreatedAt:     now,
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description:   "存在しないエディションIDなので400",
    			editionID:     editionUUID,
    			getEditionErr: service.ErrInvalidEditionID,
    			isErr:         true,
    			statusCode:    http.StatusBadRequest,
    		},
    		{
    			description:   "GetEditionがエラーなので500",
    			editionID:     editionUUID,
    			getEditionErr: errors.New("internal error"),
    			isErr:         true,
    			statusCode:    http.StatusInternalServerError,
    		},
    	}
    
    	for _, testCase := range testCases {
    		t.Run(testCase.description, func(t *testing.T) {
    			mockEditionService.
    				EXPECT().
    				GetEdition(gomock.Any(), values.NewLauncherVersionIDFromUUID(testCase.editionID)).
    				Return(testCase.resultEdition, testCase.getEditionErr)
    
    			e := echo.New()
    			req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v2/editions/%s", testCase.editionID), nil)
    			rec := httptest.NewRecorder()
    			c := e.NewContext(req, rec)
    
    			err := edition.GetEdition(c, testCase.editionID)
    
    			if testCase.isErr {
    				if testCase.statusCode != 0 {
    					var httpErr *echo.HTTPError
    					if errors.As(err, &httpErr) {
    						assert.Equal(t, testCase.statusCode, httpErr.Code)
    					} else {
    						t.Errorf("error should be *echo.HTTPError, but got %T", err)
    					}
    				} else {
    					assert.Error(t, err)
    				}
    				return
    			}
    
    			assert.NoError(t, err)
    			assert.Equal(t, testCase.statusCode, rec.Code)
    
    			var res openapi.Edition
    			err = json.NewDecoder(rec.Body).Decode(&res)
    			assert.NoError(t, err)
    
    			assert.Equal(t, testCase.expectEdition.Id, res.Id)
    			assert.Equal(t, testCase.expectEdition.Name, res.Name)
    			assert.Equal(t, testCase.expectEdition.Questionnaire, res.Questionnaire)
    			assert.WithinDuration(t, testCase.expectEdition.CreatedAt, res.CreatedAt, time.Second)
    		})
    	}
    }
    
    func TestPatchEdition(t *testing.T) {
    	t.Parallel()
    
    	ctrl := gomock.NewController(t)
    	defer ctrl.Finish()
    
    	mockEditionService := mock.NewMockEdition(ctrl)
    	edition := NewEdition(mockEditionService)
    
    	type test struct {
    		description       string
    		editionID         openapi.EditionIDInPath
    		reqBody           *openapi.PatchEdition
    		invalidBody       bool
    		executeUpdateMock bool
    		launcherVersionID values.LauncherVersionID
    		name              values.LauncherVersionName
    		questionnaireURL  types.Option[values.LauncherVersionQuestionnaireURL]
    		updateEditionErr  error
    		resultEdition     *domain.LauncherVersion
    		isErr             bool
    		statusCode        int
    		expectedRes       *openapi.Edition
    	}
    
    	now := time.Now()
    	editionUUID := uuid.New()
    	editionID := values.NewLauncherVersionIDFromUUID(editionUUID)
    	editionName := "テストエディション"
    	strURL := "https://example.com/questionnaire"
    	invalidURL := " https://example.com/questionnaire"
    	longName := strings.Repeat("あ", 33)
    	questionnaireURL, err := url.Parse(strURL)
    	if err != nil {
    		t.Fatalf("failed to parse url: %v", err)
    	}
    
    	testCases := []test{
    		{
    			description: "特に問題ないのでエラーなし",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEdition{
    				Name:          editionName,
    				Questionnaire: &strURL,
    			},
    			executeUpdateMock: true,
    			launcherVersionID: editionID,
    			name:              values.NewLauncherVersionName(editionName),
    			questionnaireURL:  types.NewOption(values.NewLauncherVersionQuestionnaireURL(questionnaireURL)),
    			resultEdition: domain.NewLauncherVersionWithQuestionnaire(
    				editionID,
    				values.NewLauncherVersionName(editionName),
    				values.NewLauncherVersionQuestionnaireURL(questionnaireURL),
    				now,
    			),
    			expectedRes: &openapi.Edition{
    				Id:            editionUUID,
    				Name:          editionName,
    				Questionnaire: &strURL,
    				CreatedAt:     now,
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "URLがなくてもエラーなし",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEdition{
    				Name: editionName,
    			},
    			executeUpdateMock: true,
    			launcherVersionID: editionID,
    			name:              values.NewLauncherVersionName(editionName),
    			questionnaireURL:  types.Option[values.LauncherVersionQuestionnaireURL]{},
    			resultEdition: domain.NewLauncherVersionWithoutQuestionnaire(
    				editionID,
    				values.NewLauncherVersionName(editionName),
    				now,
    			),
    			expectedRes: &openapi.Edition{
    				Id:            editionUUID,
    				Name:          editionName,
    				Questionnaire: nil,
    				CreatedAt:     now,
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "リクエストボディが不正なので400",
    			editionID:   editionUUID,
    			invalidBody: true,
    			isErr:       true,
    			statusCode:  http.StatusBadRequest,
    		},
    		{
    			description: "名前が空文字なので400",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEdition{
    				Name: "",
    			},
    			isErr:      true,
    			statusCode: http.StatusBadRequest,
    		},
    		{
    			description: "名前が長すぎるので400",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEdition{
    				Name: longName,
    			},
    			isErr:      true,
    			statusCode: http.StatusBadRequest,
    		},
    		{
    			description: "URLが正しくないので400",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEdition{
    				Name:          editionName,
    				Questionnaire: &invalidURL,
    			},
    			isErr:      true,
    			statusCode: http.StatusBadRequest,
    		},
    		{
    			description: "ErrInvalidEditionIDなので400",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEdition{
    				Name: editionName,
    			},
    			executeUpdateMock: true,
    			launcherVersionID: editionID,
    			name:              values.NewLauncherVersionName(editionName),
    			questionnaireURL:  types.Option[values.LauncherVersionQuestionnaireURL]{},
    			updateEditionErr:  service.ErrInvalidEditionID,
    			isErr:             true,
    			statusCode:        http.StatusBadRequest,
    		},
    		{
    			description: "ErrDuplicateGameVersionなので500",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEdition{
    				Name: editionName,
    			},
    			executeUpdateMock: true,
    			launcherVersionID: editionID,
    			name:              values.NewLauncherVersionName(editionName),
    			questionnaireURL:  types.Option[values.LauncherVersionQuestionnaireURL]{},
    			updateEditionErr:  service.ErrDuplicateGameVersion,
    			isErr:             true,
    			statusCode:        http.StatusInternalServerError,
    		},
    		{
    			description: "ErrDuplicateGameなので500",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEdition{
    				Name: editionName,
    			},
    			executeUpdateMock: true,
    			launcherVersionID: editionID,
    			name:              values.NewLauncherVersionName(editionName),
    			questionnaireURL:  types.Option[values.LauncherVersionQuestionnaireURL]{},
    			updateEditionErr:  service.ErrDuplicateGame,
    			isErr:             true,
    			statusCode:        http.StatusInternalServerError,
    		},
    		{
    			description: "サービス層でエラーなので500",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEdition{
    				Name: editionName,
    			},
    			executeUpdateMock: true,
    			launcherVersionID: editionID,
    			name:              values.NewLauncherVersionName(editionName),
    			questionnaireURL:  types.Option[values.LauncherVersionQuestionnaireURL]{},
    			updateEditionErr:  errors.New("internal error"),
    			isErr:             true,
    			statusCode:        http.StatusInternalServerError,
    		},
    	}
    
    	for _, testCase := range testCases {
    		t.Run(testCase.description, func(t *testing.T) {
    			if !testCase.invalidBody && testCase.executeUpdateMock {
    				mockEditionService.
    					EXPECT().
    					UpdateEdition(
    						gomock.Any(),
    						testCase.launcherVersionID,
    						testCase.name,
    						testCase.questionnaireURL,
    					).
    					Return(testCase.resultEdition, testCase.updateEditionErr)
    			}
    
    			var reqBody []byte
    			var err error
    			if !testCase.invalidBody {
    				reqBody, err = json.Marshal(testCase.reqBody)
    				assert.NoError(t, err)
    			} else {
    				reqBody = []byte("invalid json")
    			}
    
    			e := echo.New()
    			req := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v2/editions/%s", testCase.editionID), bytes.NewReader(reqBody))
    			req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
    			rec := httptest.NewRecorder()
    			c := e.NewContext(req, rec)
    
    			err = edition.PatchEdition(c, testCase.editionID)
    
    			if testCase.isErr {
    				if testCase.statusCode != 0 {
    					var httpErr *echo.HTTPError
    					if errors.As(err, &httpErr) {
    						assert.Equal(t, testCase.statusCode, httpErr.Code)
    					} else {
    						t.Errorf("error should be *echo.HTTPError, but got %T", err)
    					}
    				} else {
    					assert.Error(t, err)
    				}
    				return
    			}
    
    			assert.NoError(t, err)
    			assert.Equal(t, testCase.statusCode, rec.Code)
    
    			if testCase.expectedRes != nil {
    				var res openapi.Edition
    				err = json.NewDecoder(rec.Body).Decode(&res)
    				assert.NoError(t, err)
    
    				assert.Equal(t, testCase.expectedRes.Id, res.Id)
    				assert.Equal(t, testCase.expectedRes.Name, res.Name)
    				assert.Equal(t, testCase.expectedRes.Questionnaire, res.Questionnaire)
    				assert.WithinDuration(t, testCase.expectedRes.CreatedAt, res.CreatedAt, time.Second)
    			}
    		})
    	}
    }
    
    func TestGetEditionGames(t *testing.T) {
    	t.Parallel()
    
    	ctrl := gomock.NewController(t)
    	defer ctrl.Finish()
    
    	mockEditionService := mock.NewMockEdition(ctrl)
    	edition := NewEdition(mockEditionService)
    
    	type test struct {
    		description        string
    		editionID          openapi.EditionIDInPath
    		gameVersions       []*service.GameVersionWithGame
    		getEditionGamesErr error
    		expectGames        []openapi.EditionGameResponse
    		isErr              bool
    		statusCode         int
    	}
    
    	now := time.Now()
    	editionUUID := uuid.New()
    	gameID := values.NewGameID()
    	gameID2 := values.NewGameID()
    	gameVersionID := values.NewGameVersionID()
    	gameVersionID2 := values.NewGameVersionID()
    	imageID := values.NewGameImageID()
    	videoID := values.NewGameVideoID()
    	fileID1 := values.NewGameFileID()
    	fileID2 := values.NewGameFileID()
    	fileID1UUID := uuid.UUID(fileID1)
    	fileID2UUID := uuid.UUID(fileID2)
    	strURL := "https://example.com"
    	questionnaireURL, err := url.Parse(strURL)
    	if err != nil {
    		t.Fatalf("failed to parse url: %v", err)
    	}
    	urlValue := values.NewGameURLLink(questionnaireURL)
    
    	testCases := []test{
    		{
    			description: "特に問題ないのでエラーなし",
    			editionID:   editionUUID,
    			gameVersions: []*service.GameVersionWithGame{
    				{
    					Game: domain.NewGame(
    						gameID,
    						values.NewGameName("テストゲーム"),
    						values.NewGameDescription("テスト説明"),
    						values.GameVisibilityTypePublic,
    						now,
    					),
    					GameVersion: service.GameVersionInfo{
    						GameVersion: domain.NewGameVersion(
    							gameVersionID,
    							values.NewGameVersionName("v1.0.0"),
    							values.NewGameVersionDescription("リリース"),
    							now,
    						),
    						Assets: &service.Assets{
    							URL: types.NewOption(urlValue),
    						},
    						ImageID: imageID,
    						VideoID: videoID,
    					},
    				},
    			},
    			expectGames: []openapi.EditionGameResponse{
    				{
    					Id:          uuid.UUID(gameID),
    					Name:        "テストゲーム",
    					Description: "テスト説明",
    					CreatedAt:   now,
    					Version: openapi.GameVersion{
    						Id:          uuid.UUID(gameVersionID),
    						Name:        "v1.0.0",
    						Description: "リリース",
    						CreatedAt:   now,
    						ImageID:     uuid.UUID(imageID),
    						VideoID:     uuid.UUID(videoID),
    						Url:         &strURL,
    					},
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "ゲームURLとプラットフォームファイルがnullでもエラーなし",
    			editionID:   editionUUID,
    			gameVersions: []*service.GameVersionWithGame{
    				{
    					Game: domain.NewGame(
    						gameID,
    						values.NewGameName("テストゲーム"),
    						values.NewGameDescription("テスト説明"),
    						values.GameVisibilityTypeLimited,
    						now,
    					),
    					GameVersion: service.GameVersionInfo{
    						GameVersion: domain.NewGameVersion(
    							gameVersionID,
    							values.NewGameVersionName("v1.0.0"),
    							values.NewGameVersionDescription("リリース"),
    							now,
    						),
    						Assets:  &service.Assets{},
    						ImageID: imageID,
    						VideoID: videoID,
    					},
    				},
    			},
    			expectGames: []openapi.EditionGameResponse{
    				{
    					Id:          uuid.UUID(gameID),
    					Name:        "テストゲーム",
    					Description: "テスト説明",
    					CreatedAt:   now,
    					Version: openapi.GameVersion{
    						Id:          uuid.UUID(gameVersionID),
    						Name:        "v1.0.0",
    						Description: "リリース",
    						CreatedAt:   now,
    						ImageID:     uuid.UUID(imageID),
    						VideoID:     uuid.UUID(videoID),
    					},
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "windowsでもエラーなし",
    			editionID:   editionUUID,
    			gameVersions: []*service.GameVersionWithGame{
    				{
    					Game: domain.NewGame(
    						gameID,
    						values.NewGameName("テストゲーム"),
    						values.NewGameDescription("テスト説明"),
    						values.GameVisibilityTypePublic,
    						now,
    					),
    					GameVersion: service.GameVersionInfo{
    						GameVersion: domain.NewGameVersion(
    							gameVersionID,
    							values.NewGameVersionName("v1.0.0"),
    							values.NewGameVersionDescription("リリース"),
    							now,
    						),
    						Assets: &service.Assets{
    							Windows: types.NewOption(fileID1),
    						},
    						ImageID: imageID,
    						VideoID: videoID,
    					},
    				},
    			},
    			expectGames: []openapi.EditionGameResponse{
    				{
    					Id:          uuid.UUID(gameID),
    					Name:        "テストゲーム",
    					Description: "テスト説明",
    					CreatedAt:   now,
    					Version: openapi.GameVersion{
    						Id:          uuid.UUID(gameVersionID),
    						Name:        "v1.0.0",
    						Description: "リリース",
    						CreatedAt:   now,
    						ImageID:     uuid.UUID(imageID),
    						VideoID:     uuid.UUID(videoID),
    						Files: &openapi.GameVersionFiles{
    							Win32: &fileID1UUID,
    						},
    					},
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "macでもエラーなし",
    			editionID:   editionUUID,
    			gameVersions: []*service.GameVersionWithGame{
    				{
    					Game: domain.NewGame(
    						gameID,
    						values.NewGameName("テストゲーム"),
    						values.NewGameDescription("テスト説明"),
    						values.GameVisibilityTypePublic,
    						now,
    					),
    					GameVersion: service.GameVersionInfo{
    						GameVersion: domain.NewGameVersion(
    							gameVersionID,
    							values.NewGameVersionName("v1.0.0"),
    							values.NewGameVersionDescription("リリース"),
    							now,
    						),
    						Assets: &service.Assets{
    							Mac: types.NewOption(fileID1),
    						},
    						ImageID: imageID,
    						VideoID: videoID,
    					},
    				},
    			},
    			expectGames: []openapi.EditionGameResponse{
    				{
    					Id:          uuid.UUID(gameID),
    					Name:        "テストゲーム",
    					Description: "テスト説明",
    					CreatedAt:   now,
    					Version: openapi.GameVersion{
    						Id:          uuid.UUID(gameVersionID),
    						Name:        "v1.0.0",
    						Description: "リリース",
    						CreatedAt:   now,
    						ImageID:     uuid.UUID(imageID),
    						VideoID:     uuid.UUID(videoID),
    						Files: &openapi.GameVersionFiles{
    							Darwin: &fileID1UUID,
    						},
    					},
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "jarでもエラーなし",
    			editionID:   editionUUID,
    			gameVersions: []*service.GameVersionWithGame{
    				{
    					Game: domain.NewGame(
    						gameID,
    						values.NewGameName("テストゲーム"),
    						values.NewGameDescription("テスト説明"),
    						values.GameVisibilityTypePublic,
    						now,
    					),
    					GameVersion: service.GameVersionInfo{
    						GameVersion: domain.NewGameVersion(
    							gameVersionID,
    							values.NewGameVersionName("v1.0.0"),
    							values.NewGameVersionDescription("リリース"),
    							now,
    						),
    						Assets: &service.Assets{
    							Jar: types.NewOption(fileID1),
    						},
    						ImageID: imageID,
    						VideoID: videoID,
    					},
    				},
    			},
    			expectGames: []openapi.EditionGameResponse{
    				{
    					Id:          uuid.UUID(gameID),
    					Name:        "テストゲーム",
    					Description: "テスト説明",
    					CreatedAt:   now,
    					Version: openapi.GameVersion{
    						Id:          uuid.UUID(gameVersionID),
    						Name:        "v1.0.0",
    						Description: "リリース",
    						CreatedAt:   now,
    						ImageID:     uuid.UUID(imageID),
    						VideoID:     uuid.UUID(videoID),
    						Files: &openapi.GameVersionFiles{
    							Jar: &fileID1UUID,
    						},
    					},
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "ファイルが複数あってももエラーなし",
    			editionID:   editionUUID,
    			gameVersions: []*service.GameVersionWithGame{
    				{
    					Game: domain.NewGame(
    						gameID,
    						values.NewGameName("テストゲーム"),
    						values.NewGameDescription("テスト説明"),
    						values.GameVisibilityTypePublic,
    						now,
    					),
    					GameVersion: service.GameVersionInfo{
    						GameVersion: domain.NewGameVersion(
    							gameVersionID,
    							values.NewGameVersionName("v1.0.0"),
    							values.NewGameVersionDescription("リリース"),
    							now,
    						),
    						Assets: &service.Assets{
    							Jar:     types.NewOption(fileID1),
    							Windows: types.NewOption(fileID2),
    						},
    						ImageID: imageID,
    						VideoID: videoID,
    					},
    				},
    			},
    			expectGames: []openapi.EditionGameResponse{
    				{
    					Id:          uuid.UUID(gameID),
    					Name:        "テストゲーム",
    					Description: "テスト説明",
    					CreatedAt:   now,
    					Version: openapi.GameVersion{
    						Id:          uuid.UUID(gameVersionID),
    						Name:        "v1.0.0",
    						Description: "リリース",
    						CreatedAt:   now,
    						ImageID:     uuid.UUID(imageID),
    						VideoID:     uuid.UUID(videoID),
    						Files: &openapi.GameVersionFiles{
    							Jar:   &fileID1UUID,
    							Win32: &fileID2UUID,
    						},
    					},
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "ファイルとurlが両方あってもエラーなし",
    			editionID:   editionUUID,
    			gameVersions: []*service.GameVersionWithGame{
    				{
    					Game: domain.NewGame(
    						gameID,
    						values.NewGameName("テストゲーム"),
    						values.NewGameDescription("テスト説明"),
    						values.GameVisibilityTypePublic,
    						now,
    					),
    					GameVersion: service.GameVersionInfo{
    						GameVersion: domain.NewGameVersion(
    							gameVersionID,
    							values.NewGameVersionName("v1.0.0"),
    							values.NewGameVersionDescription("リリース"),
    							now,
    						),
    						Assets: &service.Assets{
    							Windows: types.NewOption(fileID1),
    							URL:     types.NewOption(urlValue),
    						},
    						ImageID: imageID,
    						VideoID: videoID,
    					},
    				},
    			},
    			expectGames: []openapi.EditionGameResponse{
    				{
    					Id:          uuid.UUID(gameID),
    					Name:        "テストゲーム",
    					Description: "テスト説明",
    					CreatedAt:   now,
    					Version: openapi.GameVersion{
    						Id:          uuid.UUID(gameVersionID),
    						Name:        "v1.0.0",
    						Description: "リリース",
    						CreatedAt:   now,
    						ImageID:     uuid.UUID(imageID),
    						VideoID:     uuid.UUID(videoID),
    						Files: &openapi.GameVersionFiles{
    							Win32: &fileID1UUID,
    						},
    						Url: &strURL,
    					},
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "2つ以上のゲームがリストに含まれていてもエラーなし",
    			editionID:   editionUUID,
    			gameVersions: []*service.GameVersionWithGame{
    				{
    					Game: domain.NewGame(
    						gameID,
    						values.NewGameName("テストゲーム1"),
    						values.NewGameDescription("テスト説明1"),
    						values.GameVisibilityTypePublic,
    						now,
    					),
    					GameVersion: service.GameVersionInfo{
    						GameVersion: domain.NewGameVersion(
    							gameVersionID,
    							values.NewGameVersionName("v1.0.0"),
    							values.NewGameVersionDescription("リリース"),
    							now,
    						),
    						Assets: &service.Assets{
    							URL: types.NewOption(urlValue),
    						},
    						ImageID: imageID,
    						VideoID: videoID,
    					},
    				},
    				{
    					Game: domain.NewGame(
    						gameID2,
    						values.NewGameName("テストゲーム2"),
    						values.NewGameDescription("テスト説明2"),
    						values.GameVisibilityTypePrivate,
    						now,
    					),
    					GameVersion: service.GameVersionInfo{
    						GameVersion: domain.NewGameVersion(
    							gameVersionID2,
    							values.NewGameVersionName("v1.0.0"),
    							values.NewGameVersionDescription("リリース"),
    							now,
    						),
    						Assets: &service.Assets{
    							URL: types.NewOption(urlValue),
    						},
    						ImageID: imageID,
    						VideoID: videoID,
    					},
    				},
    			},
    			expectGames: []openapi.EditionGameResponse{
    				{
    					Id:          uuid.UUID(gameID),
    					Name:        "テストゲーム1",
    					Description: "テスト説明1",
    					CreatedAt:   now,
    					Version: openapi.GameVersion{
    						Id:          uuid.UUID(gameVersionID),
    						Name:        "v1.0.0",
    						Description: "リリース",
    						CreatedAt:   now,
    						ImageID:     uuid.UUID(imageID),
    						VideoID:     uuid.UUID(videoID),
    						Url:         &strURL,
    					},
    				},
    				{
    					Id:          uuid.UUID(gameID2),
    					Name:        "テストゲーム2",
    					Description: "テスト説明2",
    					CreatedAt:   now,
    					Version: openapi.GameVersion{
    						Id:          uuid.UUID(gameVersionID2),
    						Name:        "v1.0.0",
    						Description: "リリース",
    						CreatedAt:   now,
    						ImageID:     uuid.UUID(imageID),
    						VideoID:     uuid.UUID(videoID),
    						Url:         &strURL,
    					},
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description:        "不正なeditionIDなので400",
    			editionID:          editionUUID,
    			getEditionGamesErr: service.ErrInvalidEditionID,
    			isErr:              true,
    			statusCode:         http.StatusBadRequest,
    		},
    		{
    			description:        "サービス層でエラーが発生したので500",
    			editionID:          editionUUID,
    			getEditionGamesErr: errors.New("internal error"),
    			isErr:              true,
    			statusCode:         http.StatusInternalServerError,
    		},
    	}
    
    	for _, testCase := range testCases {
    		t.Run(testCase.description, func(t *testing.T) {
    			mockEditionService.
    				EXPECT().
    				GetEditionGameVersions(
    					gomock.Any(),
    					values.NewLauncherVersionIDFromUUID(testCase.editionID),
    				).
    				Return(testCase.gameVersions, testCase.getEditionGamesErr)
    
    			e := echo.New()
    			req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v2/editions/%s/games", testCase.editionID), nil)
    			rec := httptest.NewRecorder()
    			c := e.NewContext(req, rec)
    
    			err := edition.GetEditionGames(c, testCase.editionID)
    
    			if testCase.isErr {
    				if testCase.statusCode != 0 {
    					var httpErr *echo.HTTPError
    					if errors.As(err, &httpErr) {
    						assert.Equal(t, testCase.statusCode, httpErr.Code)
    					} else {
    						t.Errorf("error should be *echo.HTTPError, but got %T", err)
    					}
    				} else {
    					assert.Error(t, err)
    				}
    				return
    			}
    
    			assert.NoError(t, err)
    			assert.Equal(t, testCase.statusCode, rec.Code)
    
    			var res []openapi.EditionGameResponse
    			err = json.NewDecoder(rec.Body).Decode(&res)
    			if err != nil {
    				t.Fatalf("failed to decode response body: %v", err)
    			}
    
    			assert.Len(t, res, len(testCase.expectGames))
    			for i, game := range res {
    				assert.Equal(t, testCase.expectGames[i].Id, game.Id)
    				assert.Equal(t, testCase.expectGames[i].Name, game.Name)
    				assert.Equal(t, testCase.expectGames[i].Description, game.Description)
    				assert.WithinDuration(t, testCase.expectGames[i].CreatedAt, game.CreatedAt, 2*time.Second)
    
    				assert.Equal(t, testCase.expectGames[i].Version.Id, game.Version.Id)
    				assert.Equal(t, testCase.expectGames[i].Version.Name, game.Version.Name)
    				assert.Equal(t, testCase.expectGames[i].Version.Description, game.Version.Description)
    				assert.WithinDuration(t, testCase.expectGames[i].Version.CreatedAt, game.Version.CreatedAt, 2*time.Second)
    				assert.Equal(t, testCase.expectGames[i].Version.Url, game.Version.Url)
    				assert.Equal(t, testCase.expectGames[i].Version.Files, game.Version.Files)
    				assert.Equal(t, testCase.expectGames[i].Version.ImageID, game.Version.ImageID)
    				assert.Equal(t, testCase.expectGames[i].Version.VideoID, game.Version.VideoID)
    			}
    		})
    	}
    }
    
    func TestPatchEditionGame(t *testing.T) {
    	t.Parallel()
    
    	ctrl := gomock.NewController(t)
    	defer ctrl.Finish()
    
    	mockEditionService := mock.NewMockEdition(ctrl)
    	edition := NewEdition(mockEditionService)
    
    	type test struct {
    		description           string
    		editionID             openapi.EditionIDInPath
    		reqBody               *openapi.PatchEditionGameRequest
    		invalidBody           bool
    		executeMock           bool
    		launcherVersionID     values.LauncherVersionID
    		gameVersionIDs        []values.GameVersionID
    		updateEditionGamesErr error
    		resultGameVersions    []*service.GameVersionWithGame
    		expectGames           []openapi.EditionGameResponse
    		isErr                 bool
    		statusCode            int
    	}
    
    	now := time.Now()
    	editionUUID := uuid.New()
    	gameID := values.NewGameID()
    	gameVersionID1 := values.NewGameVersionID()
    	gameVersionID2 := values.NewGameVersionID()
    	fileID1 := values.NewGameFileID()
    	fileID2 := values.NewGameFileID()
    	fileID1UUID := uuid.UUID(fileID1)
    	fileID2UUID := uuid.UUID(fileID2)
    	imageID := values.NewGameImageID()
    	videoID := values.NewGameVideoID()
    	strURL := "https://example.com"
    	questionnaireURL, err := url.Parse(strURL)
    	if err != nil {
    		t.Fatalf("failed to parse url: %v", err)
    	}
    	urlValue := values.NewGameURLLink(questionnaireURL)
    
    	testCases := []test{
    		{
    			description: "特に問題ないのでエラーなし",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEditionGameRequest{
    				GameVersionIDs: []uuid.UUID{
    					uuid.UUID(gameVersionID1),
    					uuid.UUID(gameVersionID2),
    				},
    			},
    			executeMock:       true,
    			launcherVersionID: values.NewLauncherVersionIDFromUUID(editionUUID),
    			gameVersionIDs: []values.GameVersionID{
    				gameVersionID1,
    				gameVersionID2,
    			},
    			resultGameVersions: []*service.GameVersionWithGame{
    				{
    					Game: domain.NewGame(
    						gameID,
    						values.NewGameName("テストゲーム"),
    						values.NewGameDescription("テスト説明"),
    						values.GameVisibilityTypePublic,
    						now,
    					),
    					GameVersion: service.GameVersionInfo{
    						GameVersion: domain.NewGameVersion(
    							gameVersionID1,
    							values.NewGameVersionName("v1.0.0"),
    							values.NewGameVersionDescription("リリース"),
    							now,
    						),
    						Assets:  &service.Assets{},
    						ImageID: imageID,
    						VideoID: videoID,
    					},
    				},
    			},
    			expectGames: []openapi.EditionGameResponse{
    				{
    					Id:          uuid.UUID(gameID),
    					Name:        "テストゲーム",
    					Description: "テスト説明",
    					CreatedAt:   now,
    					Version: openapi.GameVersion{
    						Id:          uuid.UUID(gameVersionID1),
    						Name:        "v1.0.0",
    						Description: "リリース",
    						CreatedAt:   now,
    						ImageID:     uuid.UUID(imageID),
    						VideoID:     uuid.UUID(videoID),
    					},
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "空のゲームバージョン一覧でもエラーなし",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEditionGameRequest{
    				GameVersionIDs: []uuid.UUID{},
    			},
    			executeMock:        true,
    			launcherVersionID:  values.NewLauncherVersionIDFromUUID(editionUUID),
    			gameVersionIDs:     []values.GameVersionID{},
    			resultGameVersions: []*service.GameVersionWithGame{},
    			expectGames:        []openapi.EditionGameResponse{},
    			statusCode:         http.StatusOK,
    		},
    		{
    			description: "不正なリクエストボディなので400",
    			editionID:   editionUUID,
    			invalidBody: true,
    			isErr:       true,
    			statusCode:  http.StatusBadRequest,
    		},
    		{
    			description: "不正なゲームバージョンIDなので400",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEditionGameRequest{
    				GameVersionIDs: []uuid.UUID{uuid.UUID(gameVersionID1)},
    			},
    			executeMock:           true,
    			launcherVersionID:     values.NewLauncherVersionIDFromUUID(editionUUID),
    			gameVersionIDs:        []values.GameVersionID{gameVersionID1},
    			updateEditionGamesErr: service.ErrInvalidEditionID,
    			isErr:                 true,
    			statusCode:            http.StatusBadRequest,
    		},
    		{
    			description: "ErrDuplicateGameVersionなので400",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEditionGameRequest{
    				GameVersionIDs: []uuid.UUID{
    					uuid.UUID(gameVersionID1),
    					uuid.UUID(gameVersionID1),
    				},
    			},
    			executeMock:           true,
    			launcherVersionID:     values.NewLauncherVersionIDFromUUID(editionUUID),
    			gameVersionIDs:        []values.GameVersionID{gameVersionID1, gameVersionID1},
    			updateEditionGamesErr: service.ErrDuplicateGameVersion,
    			isErr:                 true,
    			statusCode:            http.StatusBadRequest,
    		},
    		{
    			description: "ErrDuplicateGameなので400",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEditionGameRequest{
    				GameVersionIDs: []uuid.UUID{uuid.UUID(gameVersionID1)},
    			},
    			executeMock:           true,
    			launcherVersionID:     values.NewLauncherVersionIDFromUUID(editionUUID),
    			gameVersionIDs:        []values.GameVersionID{gameVersionID1},
    			updateEditionGamesErr: service.ErrDuplicateGame,
    			isErr:                 true,
    			statusCode:            http.StatusBadRequest,
    		},
    		{
    			description: "サービス層でエラーが発生したので500",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEditionGameRequest{
    				GameVersionIDs: []uuid.UUID{uuid.UUID(gameVersionID1)},
    			},
    			executeMock:           true,
    			launcherVersionID:     values.NewLauncherVersionIDFromUUID(editionUUID),
    			gameVersionIDs:        []values.GameVersionID{gameVersionID1},
    			updateEditionGamesErr: errors.New("internal error"),
    			isErr:                 true,
    			statusCode:            http.StatusInternalServerError,
    		},
    		{
    			description: "windowsでもエラーなし",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEditionGameRequest{
    				GameVersionIDs: []uuid.UUID{
    					uuid.UUID(gameVersionID1),
    					uuid.UUID(gameVersionID2),
    				},
    			},
    			executeMock:       true,
    			launcherVersionID: values.NewLauncherVersionIDFromUUID(editionUUID),
    			gameVersionIDs: []values.GameVersionID{
    				gameVersionID1,
    				gameVersionID2,
    			},
    			resultGameVersions: []*service.GameVersionWithGame{
    				{
    					Game: domain.NewGame(
    						gameID,
    						values.NewGameName("テストゲーム"),
    						values.NewGameDescription("テスト説明"),
    						values.GameVisibilityTypePublic,
    						now,
    					),
    					GameVersion: service.GameVersionInfo{
    						GameVersion: domain.NewGameVersion(
    							gameVersionID1,
    							values.NewGameVersionName("v1.0.0"),
    							values.NewGameVersionDescription("リリース"),
    							now,
    						),
    						Assets: &service.Assets{
    							Windows: types.NewOption(fileID1),
    						},
    						ImageID: imageID,
    						VideoID: videoID,
    					},
    				},
    			},
    			expectGames: []openapi.EditionGameResponse{
    				{
    					Id:          uuid.UUID(gameID),
    					Name:        "テストゲーム",
    					Description: "テスト説明",
    					CreatedAt:   now,
    					Version: openapi.GameVersion{
    						Id:          uuid.UUID(gameVersionID1),
    						Name:        "v1.0.0",
    						Description: "リリース",
    						CreatedAt:   now,
    						ImageID:     uuid.UUID(imageID),
    						VideoID:     uuid.UUID(videoID),
    						Files: &openapi.GameVersionFiles{
    							Win32: &fileID1UUID,
    						},
    					},
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "macファイルでもエラーなし",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEditionGameRequest{
    				GameVersionIDs: []uuid.UUID{uuid.UUID(gameVersionID1)},
    			},
    			executeMock:       true,
    			launcherVersionID: values.NewLauncherVersionIDFromUUID(editionUUID),
    			gameVersionIDs:    []values.GameVersionID{gameVersionID1},
    			resultGameVersions: []*service.GameVersionWithGame{
    				{
    					Game: domain.NewGame(
    						gameID,
    						values.NewGameName("テストゲーム"),
    						values.NewGameDescription("テスト説明"),
    						values.GameVisibilityTypePublic,
    						now,
    					),
    					GameVersion: service.GameVersionInfo{
    						GameVersion: domain.NewGameVersion(
    							gameVersionID1,
    							values.NewGameVersionName("v1.0.0"),
    							values.NewGameVersionDescription("リリース"),
    							now,
    						),
    						Assets: &service.Assets{
    							Mac: types.NewOption(fileID2),
    						},
    						ImageID: imageID,
    						VideoID: videoID,
    					},
    				},
    			},
    			expectGames: []openapi.EditionGameResponse{
    				{
    					Id:          uuid.UUID(gameID),
    					Name:        "テストゲーム",
    					Description: "テスト説明",
    					CreatedAt:   now,
    					Version: openapi.GameVersion{
    						Id:          uuid.UUID(gameVersionID1),
    						Name:        "v1.0.0",
    						Description: "リリース",
    						CreatedAt:   now,
    						ImageID:     uuid.UUID(imageID),
    						VideoID:     uuid.UUID(videoID),
    						Files: &openapi.GameVersionFiles{
    							Darwin: &fileID2UUID,
    						},
    					},
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "jarファイルでもエラーなし",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEditionGameRequest{
    				GameVersionIDs: []uuid.UUID{uuid.UUID(gameVersionID1)},
    			},
    			executeMock:       true,
    			launcherVersionID: values.NewLauncherVersionIDFromUUID(editionUUID),
    			gameVersionIDs:    []values.GameVersionID{gameVersionID1},
    			resultGameVersions: []*service.GameVersionWithGame{
    				{
    					Game: domain.NewGame(
    						gameID,
    						values.NewGameName("テストゲーム"),
    						values.NewGameDescription("テスト説明"),
    						values.GameVisibilityTypePublic,
    						now,
    					),
    					GameVersion: service.GameVersionInfo{
    						GameVersion: domain.NewGameVersion(
    							gameVersionID1,
    							values.NewGameVersionName("v1.0.0"),
    							values.NewGameVersionDescription("リリース"),
    							now,
    						),
    						Assets: &service.Assets{
    							Jar: types.NewOption(fileID1),
    						},
    						ImageID: imageID,
    						VideoID: videoID,
    					},
    				},
    			},
    			expectGames: []openapi.EditionGameResponse{
    				{
    					Id:          uuid.UUID(gameID),
    					Name:        "テストゲーム",
    					Description: "テスト説明",
    					CreatedAt:   now,
    					Version: openapi.GameVersion{
    						Id:          uuid.UUID(gameVersionID1),
    						Name:        "v1.0.0",
    						Description: "リリース",
    						CreatedAt:   now,
    						ImageID:     uuid.UUID(imageID),
    						VideoID:     uuid.UUID(videoID),
    						Files: &openapi.GameVersionFiles{
    							Jar: &fileID1UUID,
    						},
    					},
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "ファイルが複数でもエラーなし",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEditionGameRequest{
    				GameVersionIDs: []uuid.UUID{uuid.UUID(gameVersionID1)},
    			},
    			executeMock:       true,
    			launcherVersionID: values.NewLauncherVersionIDFromUUID(editionUUID),
    			gameVersionIDs:    []values.GameVersionID{gameVersionID1},
    			resultGameVersions: []*service.GameVersionWithGame{
    				{
    					Game: domain.NewGame(
    						gameID,
    						values.NewGameName("テストゲーム"),
    						values.NewGameDescription("テスト説明"),
    						values.GameVisibilityTypePublic,
    						now,
    					),
    					GameVersion: service.GameVersionInfo{
    						GameVersion: domain.NewGameVersion(
    							gameVersionID1,
    							values.NewGameVersionName("v1.0.0"),
    							values.NewGameVersionDescription("リリース"),
    							now,
    						),
    						Assets: &service.Assets{
    							Windows: types.NewOption(fileID1),
    							Mac:     types.NewOption(fileID2),
    						},
    						ImageID: imageID,
    						VideoID: videoID,
    					},
    				},
    			},
    			expectGames: []openapi.EditionGameResponse{
    				{
    					Id:          uuid.UUID(gameID),
    					Name:        "テストゲーム",
    					Description: "テスト説明",
    					CreatedAt:   now,
    					Version: openapi.GameVersion{
    						Id:          uuid.UUID(gameVersionID1),
    						Name:        "v1.0.0",
    						Description: "リリース",
    						CreatedAt:   now,
    						ImageID:     uuid.UUID(imageID),
    						VideoID:     uuid.UUID(videoID),
    						Files: &openapi.GameVersionFiles{
    							Win32:  &fileID1UUID,
    							Darwin: &fileID2UUID,
    						},
    					},
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "urlとファイルでもエラーなし",
    			editionID:   editionUUID,
    			reqBody: &openapi.PatchEditionGameRequest{
    				GameVersionIDs: []uuid.UUID{uuid.UUID(gameVersionID1)},
    			},
    			executeMock:       true,
    			launcherVersionID: values.NewLauncherVersionIDFromUUID(editionUUID),
    			gameVersionIDs:    []values.GameVersionID{gameVersionID1},
    			resultGameVersions: []*service.GameVersionWithGame{
    				{
    					Game: domain.NewGame(
    						gameID,
    						values.NewGameName("テストゲーム"),
    						values.NewGameDescription("テスト説明"),
    						values.GameVisibilityTypePublic,
    						now,
    					),
    					GameVersion: service.GameVersionInfo{
    						GameVersion: domain.NewGameVersion(
    							gameVersionID1,
    							values.NewGameVersionName("v1.0.0"),
    							values.NewGameVersionDescription("リリース"),
    							now,
    						),
    						Assets: &service.Assets{
    							URL:     types.NewOption(urlValue),
    							Windows: types.NewOption(fileID1),
    						},
    						ImageID: imageID,
    						VideoID: videoID,
    					},
    				},
    			},
    			expectGames: []openapi.EditionGameResponse{
    				{
    					Id:          uuid.UUID(gameID),
    					Name:        "テストゲーム",
    					Description: "テスト説明",
    					CreatedAt:   now,
    					Version: openapi.GameVersion{
    						Id:          uuid.UUID(gameVersionID1),
    						Name:        "v1.0.0",
    						Description: "リリース",
    						CreatedAt:   now,
    						ImageID:     uuid.UUID(imageID),
    						VideoID:     uuid.UUID(videoID),
    						Url:         &strURL,
    						Files: &openapi.GameVersionFiles{
    							Win32: &fileID1UUID,
    						},
    					},
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    	}
    
    	for _, testCase := range testCases {
    		t.Run(testCase.description, func(t *testing.T) {
    			var reqBody []byte
    			var err error
    			if !testCase.invalidBody {
    				reqBody, err = json.Marshal(testCase.reqBody)
    				assert.NoError(t, err)
    			} else {
    				reqBody = []byte("invalid json")
    			}
    
    			e := echo.New()
    			req := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v2/editions/%s/games", testCase.editionID), bytes.NewReader(reqBody))
    			req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
    			rec := httptest.NewRecorder()
    			c := e.NewContext(req, rec)
    
    			if testCase.executeMock {
    				mockEditionService.
    					EXPECT().
    					UpdateEditionGameVersions(
    						gomock.Any(),
    						testCase.launcherVersionID,
    						testCase.gameVersionIDs,
    					).
    					Return(testCase.resultGameVersions, testCase.updateEditionGamesErr)
    			}
    
    			err = edition.PostEditionGame(c, testCase.editionID)
    
    			if testCase.isErr {
    				if testCase.statusCode != 0 {
    					var httpErr *echo.HTTPError
    					if errors.As(err, &httpErr) {
    						assert.Equal(t, testCase.statusCode, httpErr.Code)
    					} else {
    						t.Errorf("error should be *echo.HTTPError, but got %T", err)
    					}
    				} else {
    					assert.Error(t, err)
    				}
    				return
    			}
    
    			assert.NoError(t, err)
    			assert.Equal(t, testCase.statusCode, rec.Code)
    
    			if testCase.expectGames != nil {
    				var res []openapi.EditionGameResponse
    				err = json.NewDecoder(rec.Body).Decode(&res)
    				assert.NoError(t, err)
    
    				assert.Len(t, res, len(testCase.expectGames))
    				for i, game := range res {
    					assert.Equal(t, testCase.expectGames[i].Id, game.Id)
    					assert.Equal(t, testCase.expectGames[i].Name, game.Name)
    					assert.Equal(t, testCase.expectGames[i].Description, game.Description)
    					assert.WithinDuration(t, testCase.expectGames[i].CreatedAt, game.CreatedAt, 2*time.Second)
    
    					assert.Equal(t, testCase.expectGames[i].Version.Id, game.Version.Id)
    					assert.Equal(t, testCase.expectGames[i].Version.Name, game.Version.Name)
    					assert.Equal(t, testCase.expectGames[i].Version.Description, game.Version.Description)
    					assert.WithinDuration(t, testCase.expectGames[i].Version.CreatedAt, game.Version.CreatedAt, 2*time.Second)
    					assert.Equal(t, testCase.expectGames[i].Version.Url, game.Version.Url)
    					assert.Equal(t, testCase.expectGames[i].Version.Files, game.Version.Files)
    					assert.Equal(t, testCase.expectGames[i].Version.ImageID, game.Version.ImageID)
    					assert.Equal(t, testCase.expectGames[i].Version.VideoID, game.Version.VideoID)
    				}
    			}
    		})
    	}
    }
    Error Handling Validation

    Verify that all error scenarios, especially edge cases, are adequately tested and handled. Some error cases might not be fully explored or could benefit from additional assertions.

    func TestGetEditions(t *testing.T) {
    	t.Parallel()
    
    	ctrl := gomock.NewController(t)
    	defer ctrl.Finish()
    
    	mockEditionService := mock.NewMockEdition(ctrl)
    	edition := NewEdition(mockEditionService)
    
    	type test struct {
    		description    string
    		editions       []*domain.LauncherVersion
    		getEditionsErr error
    		expectEditions []openapi.Edition
    		isErr          bool
    		statusCode     int
    	}
    
    	now := time.Now()
    	editionID1 := values.NewLauncherVersionIDFromUUID(uuid.New())
    	editionID2 := values.NewLauncherVersionIDFromUUID(uuid.New())
    	editionName1 := values.NewLauncherVersionName("テストエディション")
    	editionName2 := values.NewLauncherVersionName("テストエディション2")
    	strURL := "https://example.com/questionnaire"
    	questionnaireURL, err := url.Parse(strURL)
    	if err != nil {
    		t.Fatalf("failed to parse url: %v", err)
    	}
    
    	testCases := []test{
    		{
    			description: "特に問題ないのでエラーなし",
    			editions: []*domain.LauncherVersion{
    				domain.NewLauncherVersionWithQuestionnaire(
    					editionID1,
    					editionName1,
    					values.NewLauncherVersionQuestionnaireURL(questionnaireURL),
    					now,
    				),
    			},
    			expectEditions: []openapi.Edition{
    				{
    					Id:            uuid.UUID(editionID1),
    					Name:          string(editionName1),
    					Questionnaire: &strURL,
    					CreatedAt:     now,
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "アンケートURLが無くてもエラーなし",
    			editions: []*domain.LauncherVersion{
    				domain.NewLauncherVersionWithoutQuestionnaire(
    					editionID1,
    					editionName1,
    					now,
    				),
    			},
    			expectEditions: []openapi.Edition{
    				{
    					Id:            uuid.UUID(editionID1),
    					Name:          string(editionName1),
    					Questionnaire: nil,
    					CreatedAt:     now,
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description:    "GetEditionsがエラーなので500",
    			getEditionsErr: errors.New("error"),
    			isErr:          true,
    			statusCode:     http.StatusInternalServerError,
    		},
    		{
    			description: "複数エディションでもエラーなし",
    			editions: []*domain.LauncherVersion{
    				domain.NewLauncherVersionWithQuestionnaire(
    					editionID1,
    					editionName1,
    					values.NewLauncherVersionQuestionnaireURL(questionnaireURL),
    					now,
    				),
    				domain.NewLauncherVersionWithoutQuestionnaire(
    					editionID2,
    					editionName2,
    					now,
    				),
    			},
    			expectEditions: []openapi.Edition{
    				{
    					Id:            uuid.UUID(editionID1),
    					Name:          string(editionName1),
    					Questionnaire: &strURL,
    					CreatedAt:     now,
    				},
    				{
    					Id:            uuid.UUID(editionID2),
    					Name:          string(editionName2),
    					Questionnaire: nil,
    					CreatedAt:     now,
    				},
    			},
    			statusCode: http.StatusOK,
    		},
    		{
    			description: "エディションが存在しなくてもでもエラーなし",
    			editions:    []*domain.LauncherVersion{},
    			statusCode:  http.StatusOK,
    		},
    	}
    
    	for _, testCase := range testCases {
    		t.Run(testCase.description, func(t *testing.T) {
    			e := echo.New()
    			req := httptest.NewRequest(http.MethodGet, "/api/v2/editions", nil)
    			rec := httptest.NewRecorder()
    			c := e.NewContext(req, rec)
    
    			mockEditionService.
    				EXPECT().
    				GetEditions(gomock.Any()).
    				Return(testCase.editions, testCase.getEditionsErr)
    
    			err := edition.GetEditions(c)
    
    			if testCase.isErr {
    				if testCase.statusCode != 0 {
    					var httpErr *echo.HTTPError
    					if errors.As(err, &httpErr) {
    						assert.Equal(t, testCase.statusCode, httpErr.Code)
    					} else {
    						t.Errorf("error should be *echo.HTTPError, but got %T", err)
    					}
    				} else {
    					assert.Error(t, err)
    				}
    				return
    			} else {
    				assert.NoError(t, err)
    			}
    			if err != nil || testCase.isErr {
    				return
    			}
    
    			assert.Equal(t, testCase.statusCode, rec.Code)
    
    			var res []openapi.Edition
    			err = json.NewDecoder(rec.Body).Decode(&res)
    			if err != nil {
    				t.Fatalf("failed to decode response body: %v", err)
    			}
    
    			assert.Len(t, res, len(testCase.expectEditions))
    			for i, ed := range res {
    				assert.Equal(t, testCase.expectEditions[i].Id, ed.Id)
    				assert.Equal(t, testCase.expectEditions[i].Name, ed.Name)
    				assert.Equal(t, testCase.expectEditions[i].Questionnaire, ed.Questionnaire)
    				assert.WithinDuration(t, testCase.expectEditions[i].CreatedAt, ed.CreatedAt, 2*time.Second)
    			}
    		})
    	}
    }
    
    func TestPostEdition(t *testing.T) {
    	t.Parallel()
    
    	ctrl := gomock.NewController(t)
    	defer ctrl.Finish()
    
    	mockEditionService := mock.NewMockEdition(ctrl)
    	edition := NewEdition(mockEditionService)
    
    	type test struct {
    		description          string
    		reqBody              *openapi.NewEdition
    		invalidBody          bool
    		executeCreateEdition bool
    		name                 values.LauncherVersionName
    		questionnaireURL     types.Option[values.LauncherVersionQuestionnaireURL]
    		gameVersionIDs       []values.GameVersionID
    		createEditionErr     error
    		resultEdition        *domain.LauncherVersion
    		isErr                bool
    		statusCode           int
    		expectEdition        *openapi.Edition
    	}
    
    	now := time.Now()
    	editionUUID := uuid.New()
    	editionID := values.NewLauncherVersionIDFromUUID(editionUUID)
    	editionName := "テストエディション"
    	strURL := "https://example.com/questionnaire"
    	invalidURL := " https://example.com/questionnaire"
    	longName := strings.Repeat("あ", 33)
    	questionnaireURL, err := url.Parse(strURL)
    	if err != nil {
    		t.Fatalf("failed to parse url: %v", err)
    	}
    	gameVersionUUID1 := uuid.New()
    	gameVersionUUID2 := uuid.New()
    
    	testCases := []test{
    		{
    			description: "特に問題ないのでエラーなし",
    			reqBody: &openapi.NewEdition{
    				Name:          editionName,
    				Questionnaire: &strURL,
    				GameVersions:  []uuid.UUID{gameVersionUUID1, gameVersionUUID2},
    			},
    			executeCreateEdition: true,
    			name:                 values.NewLauncherVersionName(editionName),
    			questionnaireURL:     types.NewOption(values.NewLauncherVersionQuestionnaireURL(questionnaireURL)),
    			gameVersionIDs: []values.GameVersionID{
    				values.NewGameVersionIDFromUUID(gameVersionUUID1),
    				values.NewGameVersionIDFromUUID(gameVersionUUID2),
    			},
    			resultEdition: domain.NewLauncherVersionWithQuestionnaire(
    				editionID,
    				values.NewLauncherVersionName(editionName),
    				values.NewLauncherVersionQuestionnaireURL(questionnaireURL),
    				now,
    			),
    			expectEdition: &openapi.Edition{
    				Id:            editionUUID,
    				Name:          editionName,
    				Questionnaire: &strURL,
    				CreatedAt:     now,
    			},
    			statusCode: http.StatusCreated,
    		},
    		{
    			description: "アンケートURLがなくてもエラーなし",
    			reqBody: &openapi.NewEdition{
    				Name:         editionName,
    				GameVersions: []uuid.UUID{gameVersionUUID1},
    			},
    			executeCreateEdition: true,
    			name:                 values.NewLauncherVersionName(editionName),
    			questionnaireURL:     types.Option[values.LauncherVersionQuestionnaireURL]{},
    			gameVersionIDs:       []values.GameVersionID{values.NewGameVersionIDFromUUID(gameVersionUUID1)},
    			resultEdition: domain.NewLauncherVersionWithoutQuestionnaire(
    				editionID,
    				values.NewLauncherVersionName(editionName),
    				now,
    			),
    			expectEdition: &openapi.Edition{
    				Id:            editionUUID,
    				Name:          editionName,
    				Questionnaire: nil,
    				CreatedAt:     now,
    			},
    			statusCode: http.StatusCreated,
    		},
    		{
    			description: "Edition名が空文字なので400",
    			reqBody: &openapi.NewEdition{
    				Name:         "",
    				GameVersions: []uuid.UUID{gameVersionUUID1},
    			},
    			isErr:      true,
    			statusCode: http.StatusBadRequest,
    		},
    		{
    			description: "Edition名が長すぎるので400",
    			reqBody: &openapi.NewEdition{
    				Name:         longName,
    				GameVersions: []uuid.UUID{gameVersionUUID1},
    			},
    			isErr:      true,
    			statusCode: http.StatusBadRequest,
    		},
    		{
    			description: "URLが正しくないので400",
    			reqBody: &openapi.NewEdition{
    				Name:          editionName,
    				Questionnaire: &invalidURL,
    				GameVersions:  []uuid.UUID{gameVersionUUID1},
    			},
    			isErr:      true,
    			statusCode: http.StatusBadRequest,
    		},
    		{
    			description: "ゲームバージョンが重複しているので400",
    			reqBody: &openapi.NewEdition{
    				Name:         editionName,
    				GameVersions: []uuid.UUID{gameVersionUUID1, gameVersionUUID1},
    			},
    			executeCreateEdition: true,
    			name:                 values.NewLauncherVersionName(editionName),
    			questionnaireURL:     types.Option[values.LauncherVersionQuestionnaireURL]{},
    			gameVersionIDs: []values.GameVersionID{
    				values.NewGameVersionIDFromUUID(gameVersionUUID1),
    				values.NewGameVersionIDFromUUID(gameVersionUUID1),
    			},
    			createEditionErr: service.ErrDuplicateGameVersion,
    			isErr:            true,
    			statusCode:       http.StatusBadRequest,
    		},
    		{
    			description: "無効なゲームバージョンIDが含まれているので400",
    			reqBody: &openapi.NewEdition{
    				Name:         editionName,
    				GameVersions: []uuid.UUID{gameVersionUUID1},
    			},
    			executeCreateEdition: true,
    			name:                 values.NewLauncherVersionName(editionName),
    			questionnaireURL:     types.Option[values.LauncherVersionQuestionnaireURL]{},
    			gameVersionIDs:       []values.GameVersionID{values.NewGameVersionIDFromUUID(gameVersionUUID1)},
    			createEditionErr:     service.ErrInvalidGameVersionID,
    			isErr:                true,
    			statusCode:           http.StatusBadRequest,
    		},
    		{
    			description: "サービス層でエラーが発生したので500",
    			reqBody: &openapi.NewEdition{
    				Name:         editionName,
    				GameVersions: []uuid.UUID{gameVersionUUID1},
    			},
    			executeCreateEdition: true,
    			name:                 values.NewLauncherVersionName(editionName),
    			questionnaireURL:     types.Option[values.LauncherVersionQuestionnaireURL]{},
    			gameVersionIDs:       []values.GameVersionID{values.NewGameVersionIDFromUUID(gameVersionUUID1)},
    			createEditionErr:     err...

    Copy link

    PR Code Suggestions ✨

    Explore these optional code suggestions:

    CategorySuggestion                                                                                                                                    Impact
    General
    リクエストヘッダーの設定を追加。


    httptest.NewRequestで作成されたリクエストに適切なヘッダーが設定されていない場合、テストが失敗する可能性があります。リクエストヘッダーの設定を確認し、必要に応じて追加してください。

    src/handler/v2/edition_test.go [144]

     req := httptest.NewRequest(http.MethodGet, "/api/v2/editions", nil)
    +req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
    Suggestion importance[1-10]: 8

    __

    Why: Adding the Content-Type header ensures the request is correctly interpreted as JSON, preventing potential test failures. This is a significant improvement for test accuracy and reliability.

    Medium
    テストケースのスキップ処理を追加。


    テストケースでquestionnaireURLが正しく解析されない場合、t.Fatalfを使用してテストを終了していますが、これにより他のテストケースが実行されなくなる可能性があります。代わりに、エラーをログに記録し、テストケースをスキップする方法を検討してください。

    src/handler/v2/edition_test.go [532-535]

     questionnaireURL, err := url.Parse(strURL)
     if err != nil {
    -    t.Fatalf("failed to parse url: %v", err)
    +    t.Logf("failed to parse url: %v", err)
    +    t.Skip("Skipping test due to invalid URL")
     }
    Suggestion importance[1-10]: 7

    __

    Why: The suggestion improves test robustness by skipping the test case when the URL parsing fails, instead of terminating all tests. This ensures other test cases are executed, enhancing reliability. However, skipping tests might hide issues if URL parsing is critical.

    Medium
    エラー確認処理を簡潔化。

    テストケースでエラーが発生した場合、エラーの種類を確認する処理が複雑です。assert.ErrorIsを使用してエラーの種類を簡潔に確認する方法を検討してください。

    src/handler/v2/edition_test.go [158-161]

    -if errors.As(err, &httpErr) {
    +assert.ErrorIs(t, err, &echo.HTTPError{})
    +if httpErr, ok := err.(*echo.HTTPError); ok {
         assert.Equal(t, testCase.statusCode, httpErr.Code)
    -} else {
    -    t.Errorf("error should be *echo.HTTPError, but got %T", err)
     }
    Suggestion importance[1-10]: 7

    __

    Why: Simplifying error type checking with assert.ErrorIs makes the code more concise and readable while maintaining functionality. This enhances maintainability without altering test behavior.

    Medium
    許容時間を広げて安定性向上。


    テストケースのassert.WithinDurationで許容時間を2秒に設定していますが、環境によってはこれが不十分な場合があります。より広い許容範囲を設定するか、環境依存の影響を最小限に抑える方法を検討してください。

    src/handler/v2/edition_test.go [187]

    -assert.WithinDuration(t, testCase.expectEditions[i].CreatedAt, ed.CreatedAt, 2*time.Second)
    +assert.WithinDuration(t, testCase.expectEditions[i].CreatedAt, ed.CreatedAt, 5*time.Second)
    Suggestion importance[1-10]: 6

    __

    Why: Increasing the allowed time difference in assert.WithinDuration improves test stability in environments with potential time discrepancies. However, it could reduce the precision of the test.

    Low

    Copy link

    codecov bot commented Feb 24, 2025

    Codecov Report

    All modified and coverable lines are covered by tests ✅

    Project coverage is 48.28%. Comparing base (f1ecf75) to head (7a2d313).
    Report is 26 commits behind head on main.

    Additional details and impacted files
    @@            Coverage Diff             @@
    ##             main    #1152      +/-   ##
    ==========================================
    - Coverage   51.09%   48.28%   -2.82%     
    ==========================================
      Files         123      123              
      Lines       11116    11118       +2     
    ==========================================
    - Hits         5680     5368     -312     
    - Misses       5092     5455     +363     
    + Partials      344      295      -49     

    ☔ View full report in Codecov by Sentry.
    📢 Have feedback on the report? Share it here.

    @ikura-hamu
    Copy link
    Member

    ありがとう。まだ中身ちゃんと見てないんですが、CIのlintが落ちてるので、それだけ直しといてください。
    これを機にgolangci-lintを入れてエディタ上で注意が出るようにするといいと思います。

    Copy link
    Member

    @ikura-hamu ikura-hamu left a comment

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    とてもよく書けていると思います。テストケースが抜けている部分もほぼ無くてとてもいいです。細かいところですが修正お願いします。

    Goのテストは縦に長くなってしまいがちなので、可読性が失われない範囲で短くするよう工夫できるといいと思います。今回のコメントで言えば、あらかじめdomain.NewGame()を呼ぶとか、if ... { t.Fatal() }の代わりにrequire.*を使うなどです。

    t.Parallel()

    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    Copy link
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Go1.14から、gomock.NewController()*testing.Tを渡していればctrl.Finish() を呼ばなくてもよくなっています。あっても問題ないですが、新しいテストコードには書かない方針で行きたいです。

    Suggested change
    defer ctrl.Finish()

    Comment on lines 158 to 162
    if errors.As(err, &httpErr) {
    assert.Equal(t, testCase.statusCode, httpErr.Code)
    } else {
    t.Errorf("error should be *echo.HTTPError, but got %T", err)
    }
    Copy link
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    assert.ErrorAsを使うと、簡潔に書けるようになります。

    strURL := "https://example.com/questionnaire"
    questionnaireURL, err := url.Parse(strURL)
    if err != nil {
    t.Fatalf("failed to parse url: %v", err)
    Copy link
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    t.Fatal系とt.Error系の使い分けができていていいと思います。t.Fatal系の処理を簡単に書けるrequire.Errorとかがassert.Errorとかと似たAPIで使えます。ただのラッパーで処理はほぼ変わらないので、書き換えなくても問題ないです。今後書くときに覚えとくと幸せになると思います。

    assert.Equal(t, testCase.expectEditions[i].Name, ed.Name)
    assert.Equal(t, testCase.expectEditions[i].Questionnaire, ed.Questionnaire)
    assert.WithinDuration(t, testCase.expectEditions[i].CreatedAt, ed.CreatedAt, 2*time.Second)
    }
    Copy link
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    ここが2秒になってるのはどんな意図がありますか?

    Copy link
    Contributor Author

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    あまり覚えていないので,おそらくどこかのコピペの修正漏れだと思います。すみません:pray:
    1sにしておきました。

    editionName := "テストエディション"
    strURL := "https://example.com/questionnaire"
    invalidURL := " https://example.com/questionnaire"
    longName := strings.Repeat("あ", 33)
    Copy link
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    これもっと明らかにinvalidなURLにしちゃってもいいと思います。最初どこがinvalidなのかわからなかった

    },
    {
    description: "ゲームURLとプラットフォームファイルがnullでもエラーなし",
    editionID: editionUUID,
    Copy link
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    プラットフォームファイルって何だろう。ゲームファイルかと思います

    gameVersions: []*service.GameVersionWithGame{
    {
    Game: domain.NewGame(
    gameID,
    Copy link
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    毎回domain.NewGame()domain.NewGameVersion()を呼び出していてテストケース部分が縦に長くなってしまっているので、他の変数のように前もって定義しておくと見やすくなると思います。

    }

    func TestPatchEditionGame(t *testing.T) {
    t.Parallel()
    Copy link
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    TestPatchEditionGamePostEditionGameのテストをやってそう。
    そもそもコード本体のコメントが間違ってそうなので、そっちも直しちゃってください

    reqBody *openapi.PatchEditionGameRequest
    invalidBody bool
    executeMock bool
    launcherVersionID values.LauncherVersionID
    Copy link
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    この変数名だとなにを実行するのかわかりにくいので、他のところと同様にexecute{関数名}にして欲しいです

    resultGameVersions: []*service.GameVersionWithGame{
    {
    Game: domain.NewGame(
    gameID,
    Copy link
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    こちらもあらかじめ定義しておくと見やすくなると思います。

    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
    Projects
    None yet
    Development

    Successfully merging this pull request may close these issues.

    2 participants