diff --git a/internal/api/http/routes/contests_management_test.go b/internal/api/http/routes/contests_management_test.go index bc3f4db..981399e 100644 --- a/internal/api/http/routes/contests_management_test.go +++ b/internal/api/http/routes/contests_management_test.go @@ -14,11 +14,13 @@ import ( "github.com/gorilla/mux" "github.com/mini-maxit/backend/internal/api/http/httputils" "github.com/mini-maxit/backend/internal/api/http/routes" + "github.com/mini-maxit/backend/internal/database" "github.com/mini-maxit/backend/internal/testutils" "github.com/mini-maxit/backend/package/domain/schemas" "github.com/mini-maxit/backend/package/errors" mock_service "github.com/mini-maxit/backend/package/service/mocks" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) @@ -301,6 +303,33 @@ func TestEditContest(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) }) + + t.Run("Null endAt decodes as explicit clear", func(t *testing.T) { + // Raw JSON with explicit null for endAt + rawBody := `{"name":"Updated Contest","endAt":null}` + + cs.EXPECT().Edit(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(db database.Database, u *schemas.User, contestID int64, editInfo *schemas.EditContest) (*schemas.CreatedContest, error) { + require.NotNil(t, editInfo.EndAt, "EndAt must be present (Set=true)") + assert.True(t, editInfo.EndAt.Set, "EndAt.Set must be true for explicit null") + assert.Nil(t, editInfo.EndAt.Value, "EndAt.Value must be nil for explicit null") + assert.False(t, editInfo.StartAt.Set, "StartAt absent") + return &schemas.CreatedContest{}, nil + }) + + req, err := http.NewRequest(http.MethodPut, server.URL+"/1", bytes.NewBufferString(rawBody)) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) } func TestDeleteContest(t *testing.T) { diff --git a/package/domain/schemas/contest.go b/package/domain/schemas/contest.go index 71f38ce..c630f96 100644 --- a/package/domain/schemas/contest.go +++ b/package/domain/schemas/contest.go @@ -42,23 +42,23 @@ type ManagedContest struct { } type CreateContest struct { - Name string `json:"name" validate:"required,gte=3,lte=100"` - Description string `json:"description" validate:"required"` - StartAt time.Time `json:"startAt" validate:"required"` - EndAt *time.Time `json:"endAt,omitempty"` - IsRegistrationOpen bool `json:"isRegistrationOpen"` - IsSubmissionOpen bool `json:"isSubmissionOpen"` - IsVisible bool `json:"isVisible"` + Name string `json:"name" validate:"required,gte=3,lte=100"` + Description string `json:"description" validate:"required"` + StartAt time.Time `json:"startAt" validate:"required"` + EndAt OptionalTime `json:"endAt"` + IsRegistrationOpen bool `json:"isRegistrationOpen"` + IsSubmissionOpen bool `json:"isSubmissionOpen"` + IsVisible bool `json:"isVisible"` } type EditContest struct { - Name *string `json:"name,omitempty" validate:"omitempty,gte=3,lte=100"` - Description *string `json:"description,omitempty"` - StartAt *time.Time `json:"startAt,omitempty"` - EndAt *time.Time `json:"endAt,omitempty"` - IsRegistrationOpen *bool `json:"isRegistrationOpen,omitempty"` - IsSubmissionOpen *bool `json:"isSubmissionOpen,omitempty"` - IsVisible *bool `json:"isVisible,omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,gte=3,lte=100"` + Description *string `json:"description,omitempty"` + StartAt OptionalTime `json:"startAt"` + EndAt OptionalTime `json:"endAt"` + IsRegistrationOpen *bool `json:"isRegistrationOpen,omitempty"` + IsSubmissionOpen *bool `json:"isSubmissionOpen,omitempty"` + IsVisible *bool `json:"isVisible,omitempty"` } type ContestWithStats struct { @@ -85,9 +85,9 @@ type UserContestsWithStats struct { } type AddTaskToContest struct { - TaskID int64 `json:"taskId" validate:"required"` - StartAt *time.Time `json:"startAt,omitempty"` - EndAt *time.Time `json:"endAt,omitempty"` + TaskID int64 `json:"taskId" validate:"required"` + StartAt OptionalTime `json:"startAt"` + EndAt OptionalTime `json:"endAt"` } type RegistrationRequest struct { diff --git a/package/domain/schemas/optional_time.go b/package/domain/schemas/optional_time.go new file mode 100644 index 0000000..ae39641 --- /dev/null +++ b/package/domain/schemas/optional_time.go @@ -0,0 +1,37 @@ +package schemas + +import ( + "encoding/json" + "time" +) + +// OptionalTime is a time.Time value that distinguishes "field absent" from +// an explicit null. JSON null decodes to Set=true, Value=nil; a missing field +// leaves Set=false. This allows API consumers to clear a time field by sending null. +type OptionalTime struct { + Set bool + Value *time.Time +} + +// UnmarshalJSON implements json.Unmarshaler. +func (o *OptionalTime) UnmarshalJSON(b []byte) error { + o.Set = true + if string(b) == "null" { + o.Value = nil + return nil + } + var t time.Time + if err := json.Unmarshal(b, &t); err != nil { + return err + } + o.Value = &t + return nil +} + +// MarshalJSON implements json.Marshaler. +func (o OptionalTime) MarshalJSON() ([]byte, error) { + if o.Value == nil { + return []byte("null"), nil + } + return json.Marshal(*o.Value) +} diff --git a/package/service/contest_edit_test.go b/package/service/contest_edit_test.go new file mode 100644 index 0000000..c159e96 --- /dev/null +++ b/package/service/contest_edit_test.go @@ -0,0 +1,101 @@ +package service_test + +import ( + "testing" + "time" + + "github.com/mini-maxit/backend/internal/testutils" + "github.com/mini-maxit/backend/package/domain/models" + "github.com/mini-maxit/backend/package/domain/schemas" + "github.com/mini-maxit/backend/package/domain/types" + mock_repository "github.com/mini-maxit/backend/package/repository/mocks" + "github.com/mini-maxit/backend/package/service" + mock_service "github.com/mini-maxit/backend/package/service/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func strPtr(s string) *string { return &s } + +func newContestServiceForEditTest(ctrl *gomock.Controller) (*mock_repository.MockContestRepository, *mock_service.MockAccessControlService, service.ContestService) { + cr := mock_repository.NewMockContestRepository(ctrl) + ur := mock_repository.NewMockUserRepository(ctrl) + sr := mock_repository.NewMockSubmissionRepository(ctrl) + tr := mock_repository.NewMockTaskRepository(ctrl) + ts := mock_service.NewMockTaskService(ctrl) + acs := mock_service.NewMockAccessControlService(ctrl) + gr := mock_repository.NewMockGroupRepository(ctrl) + cs := service.NewContestService(cr, ur, sr, tr, gr, acs, ts) + return cr, acs, cs +} + +func TestContestServiceEdit_ClearEndAt(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cr, acs, cs := newContestServiceForEditTest(ctrl) + + db := &testutils.MockDatabase{} + userID := int64(1) + currentUser := &schemas.User{ID: userID, Role: types.UserRoleAdmin} + contestID := int64(5) + + // Contest currently has an end time + endAt := time.Date(2026, 2, 1, 12, 0, 0, 0, time.UTC) + contest := &models.Contest{ID: contestID, StartAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), EndAt: &endAt} + + // Request explicitly sets endAt to null (clear it) + editInfo := &schemas.EditContest{ + EndAt: schemas.OptionalTime{Set: true, Value: nil}, + } + + acs.EXPECT().CanUserAccess(db, types.ResourceTypeContest, contestID, currentUser, types.PermissionEdit).Return(nil).Times(1) + // Need current contest because EndAt is being cleared (StartAt also nil -> fetch current) + cr.EXPECT().Get(db, contestID).Return(contest, nil).Times(2) + + cr.EXPECT().EditWithStats(db, contestID, gomock.Any()).DoAndReturn( + func(_ interface{}, id int64, updates map[string]any) (*models.ContestWithStats, error) { + // end_at must be present in updates with nil value + assert.Contains(t, updates, "end_at") + assert.Nil(t, updates["end_at"]) + return &models.ContestWithStats{Contest: *contest}, nil + }, + ).Times(1) + + _, err := cs.Edit(db, currentUser, contestID, editInfo) + require.NoError(t, err) +} + +func TestContestServiceEdit_KeepEndAtWhenAbsent(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cr, acs, cs := newContestServiceForEditTest(ctrl) + + db := &testutils.MockDatabase{} + currentUser := &schemas.User{ID: 1, Role: types.UserRoleAdmin} + contestID := int64(5) + + // Edit only the name; EndAt absent (Set=false) -> not in update map + editInfo := &schemas.EditContest{ + Name: strPtr("New Name"), + } + + acs.EXPECT().CanUserAccess(db, types.ResourceTypeContest, contestID, currentUser, types.PermissionEdit).Return(nil).Times(1) + // StartAt and EndAt both nil -> need current contest + endAt := time.Date(2026, 2, 1, 12, 0, 0, 0, time.UTC) + contest := &models.Contest{ID: contestID, StartAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), EndAt: &endAt} + cr.EXPECT().Get(db, contestID).Return(contest, nil).Times(2) + + cr.EXPECT().EditWithStats(db, contestID, gomock.Any()).DoAndReturn( + func(_ interface{}, id int64, updates map[string]any) (*models.ContestWithStats, error) { + assert.Contains(t, updates, "name") + assert.NotContains(t, updates, "end_at") + return &models.ContestWithStats{Contest: *contest}, nil + }, + ).Times(1) + + _, err := cs.Edit(db, currentUser, contestID, editInfo) + require.NoError(t, err) +} diff --git a/package/service/contest_service.go b/package/service/contest_service.go index 4c7e38f..5a6a806 100644 --- a/package/service/contest_service.go +++ b/package/service/contest_service.go @@ -127,8 +127,8 @@ func (cs *contestService) Create(db database.Database, currentUser *schemas.User StartAt: contest.StartAt, } - if contest.EndAt != nil { - model.EndAt = contest.EndAt + if contest.EndAt.Set { + model.EndAt = contest.EndAt.Value } contestID, err := cs.contestRepository.Create(db, model) @@ -339,7 +339,7 @@ func (cs *contestService) Edit(db database.Database, currentUser *schemas.User, var endAt *time.Time // Fetch current contest times if needed - needCurrent := (editInfo.StartAt == nil || editInfo.EndAt == nil) + needCurrent := (!editInfo.StartAt.Set || !editInfo.EndAt.Set) var contest *models.Contest if needCurrent { contest, err = cs.contestRepository.Get(db, contestID) @@ -349,13 +349,15 @@ func (cs *contestService) Edit(db database.Database, currentUser *schemas.User, } // Determine startAt and endAt values for validation - if editInfo.StartAt != nil { - startAt = *editInfo.StartAt + if editInfo.StartAt.Set { + if editInfo.StartAt.Value != nil { + startAt = *editInfo.StartAt.Value + } } else if contest != nil { startAt = contest.StartAt } - if editInfo.EndAt != nil { - endAt = editInfo.EndAt + if editInfo.EndAt.Set { + endAt = editInfo.EndAt.Value } else if contest != nil { endAt = contest.EndAt } @@ -370,11 +372,11 @@ func (cs *contestService) Edit(db database.Database, currentUser *schemas.User, } } - if editInfo.StartAt != nil { - editMap["start_at"] = *editInfo.StartAt + if editInfo.StartAt.Set { + editMap["start_at"] = editInfo.StartAt.Value } - if editInfo.EndAt != nil { - editMap["end_at"] = editInfo.EndAt + if editInfo.EndAt.Set { + editMap["end_at"] = editInfo.EndAt.Value } if editInfo.IsRegistrationOpen != nil { editMap["is_registration_open"] = *editInfo.IsRegistrationOpen @@ -465,12 +467,11 @@ func (cs *contestService) updateModel(model *models.Contest, editInfo *schemas.E if editInfo.Description != nil { model.Description = *editInfo.Description } - if editInfo.StartAt != nil { - model.StartAt = *editInfo.StartAt + if editInfo.StartAt.Set && editInfo.StartAt.Value != nil { + model.StartAt = *editInfo.StartAt.Value } - // TODO: handle when setting to nil is intended - if editInfo.EndAt != nil { - model.EndAt = editInfo.EndAt + if editInfo.EndAt.Set { + model.EndAt = editInfo.EndAt.Value } if editInfo.IsRegistrationOpen != nil { model.IsRegistrationOpen = *editInfo.IsRegistrationOpen @@ -707,12 +708,12 @@ func (cs *contestService) AddTaskToContest(db database.Database, currentUser *sc } startAt := time.Now() - if request.StartAt != nil { - startAt = *request.StartAt + if request.StartAt.Set && request.StartAt.Value != nil { + startAt = *request.StartAt.Value } endAt := contest.EndAt - if request.EndAt != nil { - endAt = request.EndAt + if request.EndAt.Set { + endAt = request.EndAt.Value } if endAt != nil && startAt.After(*endAt) { return errors.ErrEndBeforeStart