Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions internal/api/http/routes/contests_management_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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) {
Expand Down
34 changes: 17 additions & 17 deletions package/domain/schemas/contest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions package/domain/schemas/optional_time.go
Original file line number Diff line number Diff line change
@@ -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)
}
101 changes: 101 additions & 0 deletions package/service/contest_edit_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
41 changes: 21 additions & 20 deletions package/service/contest_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading