diff --git a/internal/api/http/routes/languages_management.go b/internal/api/http/routes/languages_management.go new file mode 100644 index 0000000..dd4575e --- /dev/null +++ b/internal/api/http/routes/languages_management.go @@ -0,0 +1,112 @@ +package routes + +import ( + "log" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/mini-maxit/backend/internal/api/http/httputils" + _ "github.com/mini-maxit/backend/package/domain/schemas" + "github.com/mini-maxit/backend/package/service" + "github.com/mini-maxit/backend/package/utils" + "go.uber.org/zap" +) + +type LanguagesManagementRoute interface { + GetAllLanguages(w http.ResponseWriter, r *http.Request) + ToggleLanguageVisibility(w http.ResponseWriter, r *http.Request) +} + +type languagesManagementRoute struct { + languageService service.LanguageService + logger *zap.SugaredLogger +} + +// GetAllLanguages godoc +// +// @Tags languages-management +// @Summary Get all languages +// @Description Get all language configurations including visibility state +// @Produce json +// @Failure 500 {object} httputils.APIError +// @Success 200 {object} httputils.APIResponse[[]schemas.LanguageConfig] +// @Router /languages-management/languages [get] +func (lr *languagesManagementRoute) GetAllLanguages(w http.ResponseWriter, r *http.Request) { + db := httputils.GetDatabase(r) + + languages, err := lr.languageService.GetAll(db) + if err != nil { + httputils.HandleServiceError(w, err, db, lr.logger) + return + } + + httputils.ReturnSuccess(w, http.StatusOK, languages) +} + +// ToggleLanguageVisibility godoc +// +// @Tags languages-management +// @Summary Toggle language visibility +// @Description Toggle the enabled/disabled state of a language +// @Produce json +// @Param id path int true "Language ID" +// @Failure 400 {object} httputils.APIError +// @Failure 404 {object} httputils.APIError +// @Failure 500 {object} httputils.APIError +// @Success 200 {object} httputils.APIResponse[httputils.MessageResponse] +// @Router /languages-management/languages/{id} [patch] +func (lr *languagesManagementRoute) ToggleLanguageVisibility(w http.ResponseWriter, r *http.Request) { + languageIDStr := httputils.GetPathValue(r, "id") + if languageIDStr == "" { + httputils.ReturnError(w, http.StatusBadRequest, "Language ID is required.") + return + } + + languageID, err := strconv.ParseInt(languageIDStr, 10, 64) + if err != nil { + httputils.ReturnError(w, http.StatusBadRequest, "Invalid language ID.") + return + } + + db := httputils.GetDatabase(r) + + err = lr.languageService.ToggleLanguageVisibility(db, languageID) + if err != nil { + httputils.HandleServiceError(w, err, db, lr.logger) + return + } + + httputils.ReturnSuccess(w, http.StatusOK, httputils.NewMessageResponse("Language visibility toggled successfully")) +} + +func NewLanguagesManagementRoute(languageService service.LanguageService) LanguagesManagementRoute { + route := &languagesManagementRoute{ + languageService: languageService, + logger: utils.NewNamedLogger("languages-management-route"), + } + + if err := utils.ValidateStruct(*route); err != nil { + log.Panicf("LanguagesManagementRoute struct is not valid: %s", err.Error()) + } + return route +} + +func RegisterLanguagesManagementRoutes(mux *mux.Router, route LanguagesManagementRoute) { + mux.HandleFunc("/languages", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + route.GetAllLanguages(w, r) + default: + httputils.ReturnError(w, http.StatusMethodNotAllowed, "Method not allowed") + } + }) + mux.HandleFunc("/languages/{id}", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPatch: + route.ToggleLanguageVisibility(w, r) + default: + httputils.ReturnError(w, http.StatusMethodNotAllowed, "Method not allowed") + } + }) +} diff --git a/internal/api/http/routes/languages_management_test.go b/internal/api/http/routes/languages_management_test.go new file mode 100644 index 0000000..67c1f29 --- /dev/null +++ b/internal/api/http/routes/languages_management_test.go @@ -0,0 +1,110 @@ +package routes_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "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/testutils" + "github.com/mini-maxit/backend/package/domain/schemas" + 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 TestLanguagesManagement_GetAllLanguages(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ls := mock_service.NewMockLanguageService(ctrl) + route := routes.NewLanguagesManagementRoute(ls) + db := &testutils.MockDatabase{} + handler := httputils.MockDatabaseMiddleware(http.HandlerFunc(route.GetAllLanguages), db) + server := httptest.NewServer(handler) + defer server.Close() + + expected := []schemas.LanguageConfig{ + {ID: 1, Type: "python", Version: "3.10", FileExtension: ".py", IsDisabled: false}, + {ID: 2, Type: "CPP", Version: "20", FileExtension: ".cpp", IsDisabled: true}, + } + ls.EXPECT().GetAll(gomock.Any()).Return(expected, nil).Times(1) + + resp, err := http.Get(server.URL) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body httputils.APIResponse[[]schemas.LanguageConfig] + err = json.NewDecoder(resp.Body).Decode(&body) + require.NoError(t, err) + assert.Len(t, body.Data, 2) + assert.True(t, body.Data[1].IsDisabled) +} + +func TestLanguagesManagement_GetAllLanguages_Error(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ls := mock_service.NewMockLanguageService(ctrl) + route := routes.NewLanguagesManagementRoute(ls) + db := &testutils.MockDatabase{} + handler := httputils.MockDatabaseMiddleware(http.HandlerFunc(route.GetAllLanguages), db) + server := httptest.NewServer(handler) + defer server.Close() + + ls.EXPECT().GetAll(gomock.Any()).Return(nil, assert.AnError).Times(1) + + resp, err := http.Get(server.URL) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) +} + +func TestLanguagesManagement_ToggleLanguageVisibility(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ls := mock_service.NewMockLanguageService(ctrl) + route := routes.NewLanguagesManagementRoute(ls) + db := &testutils.MockDatabase{} + handler := httputils.MockDatabaseMiddleware(http.HandlerFunc(route.ToggleLanguageVisibility), db) + + t.Run("Toggle valid language", func(t *testing.T) { + ls.EXPECT().ToggleLanguageVisibility(gomock.Any(), int64(3)).Return(nil).Times(1) + + req := httptest.NewRequest(http.MethodPatch, "/languages/3", bytes.NewBufferString(`{}`)) + req = mux.SetURLVars(req, map[string]string{"id": "3"}) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("Invalid language ID", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPatch, "/languages/abc", bytes.NewBufferString(`{}`)) + req = mux.SetURLVars(req, map[string]string{"id": "abc"}) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("Missing language ID", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPatch, "/languages/", bytes.NewBufferString(`{}`)) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) +} diff --git a/internal/api/http/server/server.go b/internal/api/http/server/server.go index 94ffc39..b8f601f 100644 --- a/internal/api/http/server/server.go +++ b/internal/api/http/server/server.go @@ -93,6 +93,10 @@ func NewServer(init *initialization.Initialization, log *zap.SugaredLogger) *Ser contestManagementMux := mux.NewRouter() routes.RegisterContestsManagementRoute(contestManagementMux, init.ContestManagementRoute) + // Languages management routes + languagesManagementMux := mux.NewRouter() + routes.RegisterLanguagesManagementRoutes(languagesManagementMux, init.LanguagesManagementRoute) + // Worker routes workerMux := mux.NewRouter() routes.RegisterWorkerRoutes(workerMux, init.WorkerRoute) @@ -106,6 +110,7 @@ func NewServer(init *initialization.Initialization, log *zap.SugaredLogger) *Ser secureMux.PathPrefix("/users").Handler(userMux) secureMux.PathPrefix("/groups-management/").Handler(http.StripPrefix("/groups-management", groupMux)) secureMux.PathPrefix("/contests-management/").Handler(http.StripPrefix("/contests-management", contestManagementMux)) + secureMux.PathPrefix("/languages-management/").Handler(http.StripPrefix("/languages-management", languagesManagementMux)) secureMux.PathPrefix("/contests").Handler(contestMux) secureMux.PathPrefix("/workers/").Handler(http.StripPrefix("/workers", workerMux)) diff --git a/internal/initialization/initialization.go b/internal/initialization/initialization.go index e355418..20d41bb 100644 --- a/internal/initialization/initialization.go +++ b/internal/initialization/initialization.go @@ -24,16 +24,17 @@ type Initialization struct { JWTService service.JWTService QueueService service.QueueService - AuthRoute routes.AuthRoute - ContestRoute routes.ContestRoute - ContestManagementRoute routes.ContestsManagementRoute - GroupRoute routes.GroupRoute - SubmissionRoute routes.SubmissionRoutes - TaskRoute routes.TaskRoute - TaskManagementRoute routes.TasksManagementRoute - AccessControlRoute routes.AccessControlRoute - UserRoute routes.UserRoute - WorkerRoute routes.WorkerRoute + AuthRoute routes.AuthRoute + ContestRoute routes.ContestRoute + ContestManagementRoute routes.ContestsManagementRoute + GroupRoute routes.GroupRoute + LanguagesManagementRoute routes.LanguagesManagementRoute + SubmissionRoute routes.SubmissionRoutes + TaskRoute routes.TaskRoute + TaskManagementRoute routes.TasksManagementRoute + AccessControlRoute routes.AccessControlRoute + UserRoute routes.UserRoute + WorkerRoute routes.WorkerRoute QueueListener queue.Listener @@ -151,6 +152,7 @@ func NewInitialization(cfg *config.Config) *Initialization { accessControlRoute := routes.NewAccessControlRoute(accessControlService) userRoute := routes.NewUserRoute(userService) workerRoute := routes.NewWorkerRoute(workerService) + languagesManagementRoute := routes.NewLanguagesManagementRoute(langService) // Queue listener - uses the same queue client as queue service queueListener := queue.NewListener( @@ -175,16 +177,17 @@ func NewInitialization(cfg *config.Config) *Initialization { JWTService: jwtService, QueueService: queueService, - AuthRoute: authRoute, - ContestRoute: contestRoute, - ContestManagementRoute: contestManagementRoute, - GroupRoute: groupRoute, - SubmissionRoute: submissionRoute, - TaskRoute: taskRoute, - TaskManagementRoute: tasksManagementRoute, - AccessControlRoute: accessControlRoute, - UserRoute: userRoute, - WorkerRoute: workerRoute, + AuthRoute: authRoute, + ContestRoute: contestRoute, + ContestManagementRoute: contestManagementRoute, + GroupRoute: groupRoute, + LanguagesManagementRoute: languagesManagementRoute, + SubmissionRoute: submissionRoute, + TaskRoute: taskRoute, + TaskManagementRoute: tasksManagementRoute, + AccessControlRoute: accessControlRoute, + UserRoute: userRoute, + WorkerRoute: workerRoute, QueueListener: queueListener, } diff --git a/package/domain/schemas/language_config.go b/package/domain/schemas/language_config.go index 6dc9349..8e56a24 100644 --- a/package/domain/schemas/language_config.go +++ b/package/domain/schemas/language_config.go @@ -5,4 +5,5 @@ type LanguageConfig struct { Type string `json:"language"` Version string `json:"version"` FileExtension string `json:"fileExtension"` + IsDisabled bool `json:"isDisabled"` } diff --git a/package/service/language_service.go b/package/service/language_service.go index 48a57fa..2426dad 100644 --- a/package/service/language_service.go +++ b/package/service/language_service.go @@ -1,9 +1,12 @@ package service import ( + "sort" + "github.com/mini-maxit/backend/internal/database" "github.com/mini-maxit/backend/package/domain/models" "github.com/mini-maxit/backend/package/domain/schemas" + "github.com/mini-maxit/backend/package/errors" "github.com/mini-maxit/backend/package/repository" "github.com/mini-maxit/backend/package/utils" "go.uber.org/zap" @@ -15,6 +18,8 @@ type LanguageService interface { GetAll(db database.Database) ([]schemas.LanguageConfig, error) // GetAllEnabled retrieves all enabled language configurations from the database. GetAllEnabled(db database.Database) ([]schemas.LanguageConfig, error) + // ToggleLanguageVisibility toggles the enabled/disabled state of a language. + ToggleLanguageVisibility(db database.Database, languageID int64) error // Init initializes languages in the database // // It should be called during application initialization. @@ -86,6 +91,13 @@ func (l *languageService) GetAll(db database.Database) ([]schemas.LanguageConfig for _, language := range languages { result = append(result, *LanguageToSchema(&language)) } + // Deterministic ordering independent of enablement state: by type, then version. + sort.Slice(result, func(i, j int) bool { + if result[i].Type != result[j].Type { + return result[i].Type < result[j].Type + } + return result[i].Version < result[j].Version + }) return result, nil } @@ -103,12 +115,55 @@ func (l *languageService) GetAllEnabled(db database.Database) ([]schemas.Languag } func LanguageToSchema(language *models.LanguageConfig) *schemas.LanguageConfig { + isDisabled := false + if language.IsDisabled != nil { + isDisabled = *language.IsDisabled + } return &schemas.LanguageConfig{ ID: language.ID, Type: language.Type, Version: language.Version, FileExtension: language.FileExtension, + IsDisabled: isDisabled, + } +} + +func (l *languageService) ToggleLanguageVisibility(db database.Database, languageID int64) error { + languages, err := l.languageRepository.GetAll(db) + if err != nil { + l.logger.Errorf("Error getting languages: %v", err.Error()) + return err } + + var found bool + var currentlyDisabled bool + for _, lang := range languages { + if lang.ID == languageID { + found = true + if lang.IsDisabled != nil { + currentlyDisabled = *lang.IsDisabled + } + break + } + } + + if !found { + l.logger.Errorf("Language with ID %d not found", languageID) + return errors.ErrNotFound + } + + if currentlyDisabled { + err = l.languageRepository.MarkEnabled(db, languageID) + } else { + err = l.languageRepository.MarkDisabled(db, languageID) + } + if err != nil { + l.logger.Errorf("Error toggling language visibility: %v", err.Error()) + return err + } + + l.logger.Infof("Language %d visibility toggled successfully", languageID) + return nil } func NewLanguageService(languageRepository repository.LanguageRepository) LanguageService { diff --git a/package/service/language_service_test.go b/package/service/language_service_test.go index d32f78f..819a1be 100644 --- a/package/service/language_service_test.go +++ b/package/service/language_service_test.go @@ -21,6 +21,8 @@ const ( testPythonExtension = ".py" testJSName = "javascript" testJSExtension = ".js" + testCPPName = "CPP" + testCPPExtension = "cpp" ) var trueValue = true @@ -228,14 +230,15 @@ func TestLanguageServiceGetAll(t *testing.T) { result, err := ls.GetAll(db) require.NoError(t, err) assert.Len(t, result, 2) - assert.Equal(t, int64(1), result[0].ID) - assert.Equal(t, testPythonName, result[0].Type) - assert.Equal(t, testPythonVersion39, result[0].Version) - assert.Equal(t, testPythonExtension, result[0].FileExtension) - assert.Equal(t, int64(2), result[1].ID) - assert.Equal(t, testJSName, result[1].Type) - assert.Equal(t, "18", result[1].Version) - assert.Equal(t, testJSExtension, result[1].FileExtension) + // Sorted by type asc: javascript before python. + assert.Equal(t, int64(2), result[0].ID) + assert.Equal(t, testJSName, result[0].Type) + assert.Equal(t, "18", result[0].Version) + assert.Equal(t, testJSExtension, result[0].FileExtension) + assert.Equal(t, int64(1), result[1].ID) + assert.Equal(t, testPythonName, result[1].Type) + assert.Equal(t, testPythonVersion39, result[1].Version) + assert.Equal(t, testPythonExtension, result[1].FileExtension) }) t.Run("Success with no languages", func(t *testing.T) { @@ -256,6 +259,34 @@ func TestLanguageServiceGetAll(t *testing.T) { }) } +func TestLanguageServiceGetAll_DeterministicOrder(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + lr := mock_repository.NewMockLanguageRepository(ctrl) + ls := service.NewLanguageService(lr) + db := &testutils.MockDatabase{} + + // Repository returns unsorted rows; the service must order by type then version. + languages := []models.LanguageConfig{ + {ID: 1, Type: testCPPName, Version: "20", FileExtension: testCPPExtension, IsDisabled: &trueValue}, + {ID: 2, Type: testPythonName, Version: testPythonVersion310, FileExtension: testPythonExtension, IsDisabled: &falseValue}, + {ID: 3, Type: testCPPName, Version: "11", FileExtension: testCPPExtension, IsDisabled: &falseValue}, + {ID: 4, Type: testPythonName, Version: testPythonVersion310, FileExtension: testPythonExtension, IsDisabled: &falseValue}, + } + lr.EXPECT().GetAll(db).Return(languages, nil).Times(1) + + result, err := ls.GetAll(db) + require.NoError(t, err) + require.Len(t, result, 4) + + // Sorted by type (asc), then version (asc). + assert.Equal(t, int64(3), result[0].ID, "CPP 11 first") + assert.Equal(t, int64(1), result[1].ID, "CPP 20 second") + assert.Equal(t, int64(2), result[2].ID, "python 3.10 third") + assert.Equal(t, int64(4), result[3].ID, "python 3.12 last") +} + func TestLanguageServiceGetAllEnabled(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -333,3 +364,72 @@ func TestNewLanguageService(t *testing.T) { assert.NotNil(t, ls) }) } + +func TestLanguageServiceToggleLanguageVisibility(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + lr := mock_repository.NewMockLanguageRepository(ctrl) + ls := service.NewLanguageService(lr) + db := &testutils.MockDatabase{} + + disabled := true + enabled := false + + t.Run("Toggle disabled language to enabled", func(t *testing.T) { + languages := []models.LanguageConfig{ + {ID: 1, Type: testPythonName, Version: testPythonVersion39, FileExtension: testPythonExtension, IsDisabled: &disabled}, + } + lr.EXPECT().GetAll(db).Return(languages, nil).Times(1) + lr.EXPECT().MarkEnabled(db, int64(1)).Return(nil).Times(1) + + err := ls.ToggleLanguageVisibility(db, 1) + require.NoError(t, err) + }) + + t.Run("Toggle enabled language to disabled", func(t *testing.T) { + languages := []models.LanguageConfig{ + {ID: 2, Type: testJSName, Version: "18", FileExtension: testJSExtension, IsDisabled: &enabled}, + } + lr.EXPECT().GetAll(db).Return(languages, nil).Times(1) + lr.EXPECT().MarkDisabled(db, int64(2)).Return(nil).Times(1) + + err := ls.ToggleLanguageVisibility(db, 2) + require.NoError(t, err) + }) + + t.Run("Language not found", func(t *testing.T) { + lr.EXPECT().GetAll(db).Return([]models.LanguageConfig{}, nil).Times(1) + + err := ls.ToggleLanguageVisibility(db, 99) + require.Error(t, err) + }) + + t.Run("GetAll error", func(t *testing.T) { + lr.EXPECT().GetAll(db).Return(nil, assert.AnError).Times(1) + + err := ls.ToggleLanguageVisibility(db, 1) + require.Error(t, err) + }) +} + +func TestLanguageToSchema_IsDisabled(t *testing.T) { + disabled := true + + t.Run("Disabled language mapped", func(t *testing.T) { + language := &models.LanguageConfig{ + ID: 1, Type: testPythonName, Version: testPythonVersion39, + FileExtension: testPythonExtension, IsDisabled: &disabled, + } + result := service.LanguageToSchema(language) + assert.True(t, result.IsDisabled) + }) + + t.Run("Nil IsDisabled defaults to false", func(t *testing.T) { + language := &models.LanguageConfig{ + ID: 2, Type: testJSName, Version: "18", FileExtension: testJSExtension, + } + result := service.LanguageToSchema(language) + assert.False(t, result.IsDisabled) + }) +} diff --git a/package/service/mocks/mockgen.go b/package/service/mocks/mockgen.go index ace9165..6e021b2 100644 --- a/package/service/mocks/mockgen.go +++ b/package/service/mocks/mockgen.go @@ -1546,6 +1546,20 @@ func (mr *MockLanguageServiceMockRecorder) Init(db, enabledLanguages any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockLanguageService)(nil).Init), db, enabledLanguages) } +// ToggleLanguageVisibility mocks base method. +func (m *MockLanguageService) ToggleLanguageVisibility(db database.Database, languageID int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ToggleLanguageVisibility", db, languageID) + ret0, _ := ret[0].(error) + return ret0 +} + +// ToggleLanguageVisibility indicates an expected call of ToggleLanguageVisibility. +func (mr *MockLanguageServiceMockRecorder) ToggleLanguageVisibility(db, languageID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ToggleLanguageVisibility", reflect.TypeOf((*MockLanguageService)(nil).ToggleLanguageVisibility), db, languageID) +} + // MockJWTService is a mock of JWTService interface. type MockJWTService struct { ctrl *gomock.Controller