diff --git a/integration_tests/handler/project_test.go b/integration_tests/handler/project_test.go index e4870488..b4f47645 100644 --- a/integration_tests/handler/project_test.go +++ b/integration_tests/handler/project_test.go @@ -86,6 +86,7 @@ func TestCreateProject(t *testing.T) { tooLongName = strings.Repeat("亜", 33) tooLongDescriptionKanji = strings.Repeat("亜", 257) duration = schema.ConvertDuration(random.Duration()) + conflictedProject = random.CreateProjectArgs() ) t.Parallel() @@ -179,6 +180,15 @@ func TestCreateProject(t *testing.T) { }, httpError(t, "Bad Request: argument error"), }, + "400 project already exists": { + http.StatusBadRequest, + schema.CreateProjectRequest{ + Name: conflictedProject.Name, + Link: &link, + Description: description, + }, + httpError(t, "Bad Request: argument error"), + }, } e := echo.New() @@ -186,6 +196,7 @@ func TestCreateProject(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { t.Parallel() + _ = doRequest(t, e, http.MethodPost, e.URL(api.Project.CreateProject), &conflictedProject) res := doRequest(t, e, http.MethodPost, e.URL(api.Project.CreateProject), &tt.reqBody) switch want := tt.want.(type) { case schema.Project: diff --git a/internal/infrastructure/migration/current.go b/internal/infrastructure/migration/current.go index 3b205e2e..08e562fe 100644 --- a/internal/infrastructure/migration/current.go +++ b/internal/infrastructure/migration/current.go @@ -9,6 +9,7 @@ import ( func Migrations() []*gormigrate.Migration { return []*gormigrate.Migration{ v1(), + v2(), // プロジェクト名とコンテスト名の重複禁止と文字数制限増加(32->128) } } diff --git a/internal/infrastructure/migration/v2.go b/internal/infrastructure/migration/v2.go new file mode 100644 index 00000000..272bd368 --- /dev/null +++ b/internal/infrastructure/migration/v2.go @@ -0,0 +1,169 @@ +// Package migration migrate current struct +package migration + +import ( + "fmt" + "time" + + "github.com/go-gormigrate/gormigrate/v2" + "github.com/gofrs/uuid" + "github.com/traPtitech/traPortfolio/internal/infrastructure/repository/model" + "gorm.io/gorm" +) + +// v1 unique_index:idx_room_uniqueの削除 +func v2() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "2", + Migrate: func(db *gorm.DB) error { + if err := db.AutoMigrate(&v2Project{}, &v2Contest{}, &v2ContestTeam{}); err != nil { + return err + } + + // プロジェクト名の重複禁止 + { + projects := make([]*model.Project, 0) + if err := db.Find(&projects).Error; err != nil { + return err + } + + projectMap := make(map[string][]uuid.UUID, len(projects)) + for _, p := range projects { + projectMap[p.Name] = append(projectMap[p.Name], p.ID) + } + + updates := make(map[uuid.UUID]string, len(projects)) + for { + noDuplicate := true + for name, ids := range projectMap { + if len(ids) <= 1 { + continue + } + noDuplicate = false + for i, pid := range ids { + if i == 0 { + projectMap[name] = []uuid.UUID{pid} + continue + } + nameNew := fmt.Sprintf("%s (%d)", name, i) + updates[pid] = nameNew + projectMap[nameNew] = append(projectMap[nameNew], pid) + } + } + if noDuplicate { + break + } + } + + for id, nameNew := range updates { + err := db. + Model(&model.Project{}). + Where(&model.Project{ID: id}). + Update("name", nameNew). + Error + if err != nil { + return err + } + } + } + + // コンテスト名の重複禁止 + { + contests := make([]*model.Contest, 0) + if err := db.Find(&contests).Error; err != nil { + return err + } + + contestMap := make(map[string][]uuid.UUID, len(contests)) + for _, c := range contests { + contestMap[c.Name] = append(contestMap[c.Name], c.ID) + } + + updates := make(map[uuid.UUID]string, len(contests)) + noDuplicate := false + for !noDuplicate { + noDuplicate = true + for name, ids := range contestMap { + if len(ids) <= 1 { + continue + } + noDuplicate = false + for i, cid := range ids { + if i == 0 { + contestMap[name] = []uuid.UUID{cid} + continue + } + nameNew := fmt.Sprintf("%s (%d)", name, i) + updates[cid] = nameNew + contestMap[nameNew] = append(contestMap[nameNew], cid) + } + } + } + + for id, nameNew := range updates { + err := db. + Model(&model.Contest{}). + Where(&model.Contest{ID: id}). + Update("name", nameNew). + Error + if err != nil { + return err + } + } + } + + return db. + Table("portfolio"). + Error + }, + } +} + +type v2Project struct { + ID uuid.UUID `gorm:"type:char(36);not null;primaryKey"` + Name string `gorm:"type:varchar(128)"` // 制限増加 (32->128) + Description string `gorm:"type:text"` + Link string `gorm:"type:text"` + SinceYear int `gorm:"type:smallint(4);not null"` + SinceSemester int `gorm:"type:tinyint(1);not null"` + UntilYear int `gorm:"type:smallint(4);not null"` + UntilSemester int `gorm:"type:tinyint(1);not null"` + CreatedAt time.Time `gorm:"precision:6"` + UpdatedAt time.Time `gorm:"precision:6"` +} + +func (*v2Project) TableName() string { + return "projects" +} + +type v2Contest struct { + ID uuid.UUID `gorm:"type:char(36);not null;primaryKey"` + Name string `gorm:"type:varchar(128)"` // 制限増加 (32->128) + Description string `gorm:"type:text"` + Link string `gorm:"type:text"` + Since time.Time `gorm:"precision:6"` + Until time.Time `gorm:"precision:6"` + CreatedAt time.Time `gorm:"precision:6"` + UpdatedAt time.Time `gorm:"precision:6"` +} + +func (*v2Contest) TableName() string { + return "contests" +} + +type v2ContestTeam struct { + ID uuid.UUID `gorm:"type:char(36);not null;primaryKey"` + ContestID uuid.UUID `gorm:"type:char(36);not null"` + Name string `gorm:"type:varchar(128)"` // 制限増加 (32->128) + Description string `gorm:"type:text"` + Result string `gorm:"type:text"` + Link string `gorm:"type:text"` + CreatedAt time.Time `gorm:"precision:6"` + UpdatedAt time.Time `gorm:"precision:6"` + + Contest model.Contest `gorm:"foreignKey:ContestID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (*v2ContestTeam) TableName() string { + return "contest_teams" +} diff --git a/internal/infrastructure/repository/contest_impl.go b/internal/infrastructure/repository/contest_impl.go index 11af9beb..6971ce4f 100644 --- a/internal/infrastructure/repository/contest_impl.go +++ b/internal/infrastructure/repository/contest_impl.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "github.com/gofrs/uuid" "github.com/traPtitech/traPortfolio/internal/domain" @@ -80,7 +81,19 @@ func (r *ContestRepository) CreateContest(ctx context.Context, args *repository. Until: args.Until.ValueOrZero(), } - err := r.h.WithContext(ctx).Create(contest).Error + // 既に同名のコンテストが存在するか + err := r.h. + WithContext(ctx). + Where(&model.Contest{Name: contest.Name}). + First(&model.Contest{}). + Error + if err == nil { + return nil, repository.ErrAlreadyExists + } else if !errors.Is(err, repository.ErrNotFound) { + return nil, err + } + + err = r.h.WithContext(ctx).Create(contest).Error if err != nil { return nil, err } diff --git a/internal/infrastructure/repository/contest_test.go b/internal/infrastructure/repository/contest_test.go index 571cb467..d1285e65 100644 --- a/internal/infrastructure/repository/contest_test.go +++ b/internal/infrastructure/repository/contest_test.go @@ -62,7 +62,37 @@ func Test_GetContest(t *testing.T) { }) } -func Test_CreateContest(t *testing.T) {} +func Test_CreateContest(t *testing.T) { + t.Parallel() + + db := SetupTestGormDB(t) + portalAPI := mock_external.NewMockPortalAPI(gomock.NewController(t)) + repo := NewContestRepository(db, portalAPI) + + t.Run("create a contest", func(t *testing.T) { + ctx := context.Background() + args := random.CreateContestArgs() + contest, err := repo.CreateContest(ctx, args) + assert.NoError(t, err) + + gotContest, err := repo.GetContest(ctx, contest.ID) + assert.NoError(t, err) + assert.Equal(t, contest, gotContest) + }) + + t.Run("create contests which name duplicated", func(t *testing.T) { + ctx := context.Background() + arg1 := random.CreateContestArgs() + arg2 := random.CreateContestArgs() + arg2.Name = arg1.Name + + _, err := repo.CreateContest(ctx, arg1) + assert.NoError(t, err) + + _, err = repo.CreateContest(ctx, arg2) + assert.Error(t, err) + }) +} func Test_UpdateContest(t *testing.T) { t.Parallel() diff --git a/internal/infrastructure/repository/model/contests.go b/internal/infrastructure/repository/model/contests.go index 2925d63b..2631e85e 100644 --- a/internal/infrastructure/repository/model/contests.go +++ b/internal/infrastructure/repository/model/contests.go @@ -8,7 +8,7 @@ import ( type Contest struct { ID uuid.UUID `gorm:"type:char(36);not null;primaryKey"` - Name string `gorm:"type:varchar(32)"` + Name string `gorm:"type:varchar(128)"` Description string `gorm:"type:text"` Link string `gorm:"type:text"` Since time.Time `gorm:"precision:6"` @@ -24,7 +24,7 @@ func (*Contest) TableName() string { type ContestTeam struct { ID uuid.UUID `gorm:"type:char(36);not null;primaryKey"` ContestID uuid.UUID `gorm:"type:char(36);not null"` - Name string `gorm:"type:varchar(32)"` + Name string `gorm:"type:varchar(128)"` Description string `gorm:"type:text"` Result string `gorm:"type:text"` Link string `gorm:"type:text"` diff --git a/internal/infrastructure/repository/model/project.go b/internal/infrastructure/repository/model/project.go index 97eb506f..9222ac08 100644 --- a/internal/infrastructure/repository/model/project.go +++ b/internal/infrastructure/repository/model/project.go @@ -8,7 +8,7 @@ import ( type Project struct { ID uuid.UUID `gorm:"type:char(36);not null;primaryKey"` - Name string `gorm:"type:varchar(32)"` + Name string `gorm:"type:varchar(128)"` Description string `gorm:"type:text"` Link string `gorm:"type:text"` SinceYear int `gorm:"type:smallint(4);not null"` diff --git a/internal/infrastructure/repository/project_impl.go b/internal/infrastructure/repository/project_impl.go index bbacd71a..abd1ae8f 100644 --- a/internal/infrastructure/repository/project_impl.go +++ b/internal/infrastructure/repository/project_impl.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "fmt" "github.com/gofrs/uuid" @@ -106,7 +107,19 @@ func (r *ProjectRepository) CreateProject(ctx context.Context, args *repository. } p.Link = args.Link.ValueOr(p.Link) - err := r.h.WithContext(ctx).Create(&p).Error + // 既に同名のプロジェクトが存在するか + err := r.h. + WithContext(ctx). + Where(&model.Project{Name: p.Name}). + First(&model.Project{}). + Error + if err == nil { + return nil, repository.ErrAlreadyExists + } else if !errors.Is(err, repository.ErrNotFound) { + return nil, err + } + + err = r.h.WithContext(ctx).Create(&p).Error if err != nil { return nil, err } diff --git a/internal/infrastructure/repository/project_test.go b/internal/infrastructure/repository/project_test.go index d7534312..64e95179 100644 --- a/internal/infrastructure/repository/project_test.go +++ b/internal/infrastructure/repository/project_test.go @@ -60,8 +60,53 @@ func TestProjectRepository_GetProject(t *testing.T) { } } -// func TestProjectRepository_CreateProject(t *testing.T) { -// } +func TestProjectRepository_CreateProject(t *testing.T) { + t.Parallel() + + db := SetupTestGormDB(t) + repo := NewProjectRepository(db, mock_external_e2e.NewMockPortalAPI()) + + t.Run("create project success", func(t *testing.T) { + ctx := context.Background() + arg := random.CreateProjectArgs() + + project, err := repo.CreateProject(ctx, arg) + assert.NoError(t, err) + + got, err := repo.GetProject(ctx, project.ID) + assert.NoError(t, err) + + opts := []cmp.Option{ + cmpopts.EquateEmpty(), + cmp.AllowUnexported(optional.Of[domain.YearWithSemester]{}), + } + if diff := cmp.Diff(project, got, opts...); diff != "" { + t.Error(diff) + } + }) + + t.Run("create project with invalid args", func(t *testing.T) { + ctx := context.Background() + arg := random.CreateProjectArgs() + arg.Name = "" + + _, err := repo.CreateProject(ctx, arg) + assert.Error(t, err) + }) + + t.Run("create projects which name duplicated", func(t *testing.T) { + ctx := context.Background() + arg1 := random.CreateProjectArgs() + arg2 := random.CreateProjectArgs() + arg2.Name = arg1.Name + + _, err := repo.CreateProject(ctx, arg1) + assert.NoError(t, err) + + _, err = repo.CreateProject(ctx, arg2) + assert.Error(t, err) + }) +} func TestProjectRepository_UpdateProject(t *testing.T) { t.Parallel() diff --git a/internal/pkgs/random/repository.go b/internal/pkgs/random/repository.go index d345cf6b..a355d09f 100644 --- a/internal/pkgs/random/repository.go +++ b/internal/pkgs/random/repository.go @@ -64,6 +64,19 @@ func OptUpdateContestTeamArgs() *repository.UpdateContestTeamArgs { return &a } +// CreateProjectArgs 全てのフィールドがvalidなCreateProjectArgsを生成します +func CreateProjectArgs() *repository.CreateProjectArgs { + return &repository.CreateProjectArgs{ + Name: AlphaNumeric(), + Description: AlphaNumeric(), + Link: Optional(RandURLString()), + SinceYear: 2100, + SinceSemester: 0, + UntilYear: 2100, + UntilSemester: 1, + } +} + // UpdateProjectArgs 全てのフィールドがvalidなUpdateProjectArgsを生成します func UpdateProjectArgs() *repository.UpdateProjectArgs { a := repository.UpdateProjectArgs{