diff --git a/backend-go/cmd/api/main.go b/backend-go/cmd/api/main.go index 8bc69c5..f0a75d2 100644 --- a/backend-go/cmd/api/main.go +++ b/backend-go/cmd/api/main.go @@ -41,11 +41,15 @@ func main() { linkRepo := persistence.NewLinkRepository(db) linkUseCase := usecase.NewLinkUseCase(linkRepo, projectRepo) linkHandler := handler.NewLinkHandler(linkUseCase) + problemRepo := persistence.NewProblemRepository(db) + problemUseCase := usecase.NewProblemUseCase(problemRepo, projectRepo) + problemHandler := handler.NewProblemHandler(problemUseCase) handlers := &web.Handlers{ Project: projectHandler, Snippet: snippetHandler, Link: linkHandler, + Problem: problemHandler, } r := web.SetupRouter(handlers, devaultyInternalToken) diff --git a/backend-go/docs/openapi.yaml b/backend-go/docs/openapi.yaml index 3888b7f..1c581c0 100644 --- a/backend-go/docs/openapi.yaml +++ b/backend-go/docs/openapi.yaml @@ -456,6 +456,188 @@ components: type: integer example: 1 + ProblemStatus: + type: string + enum: + - OPEN + - WORKING_ON + - RESOLVED + - WONT_FIX + example: "OPEN" + + ProblemSeverity: + type: string + enum: + - LOW + - MEDIUM + - HIGH + - CRITICAL + example: "HIGH" + + Problem: + type: object + required: + - id + - projectId + - title + - errorDescription + - status + - severity + - createdAt + properties: + id: + type: string + format: uuid + example: "24199dbd-36c4-43c6-aeb2-680155af8293" + projectId: + type: string + format: uuid + example: "70d68fba-be62-46bb-8e6c-e4c4b6912e91" + title: + type: string + example: "Database Connection Failure" + errorDescription: + type: string + example: "Connection refused at localhost:5432" + solution: + type: string + nullable: true + example: "Restart PostgreSQL database container" + status: + $ref: '#/components/schemas/ProblemStatus' + severity: + $ref: '#/components/schemas/ProblemSeverity' + createdAt: + type: string + format: date-time + example: "2026-08-05T19:29:23.61280878-03:00" + updatedAt: + type: string + format: date-time + nullable: true + example: "2026-08-05T21:07:31.992077709-03:00" + + ProblemSummary: + type: object + required: + - id + - projectId + - title + - status + - severity + - createdAt + properties: + id: + type: string + format: uuid + example: "24199dbd-36c4-43c6-aeb2-680155af8293" + projectId: + type: string + format: uuid + example: "70d68fba-be62-46bb-8e6c-e4c4b6912e91" + title: + type: string + example: "Database Connection Failure" + status: + $ref: '#/components/schemas/ProblemStatus' + severity: + $ref: '#/components/schemas/ProblemSeverity' + createdAt: + type: string + format: date-time + example: "2026-08-05T19:29:23.61280878-03:00" + updatedAt: + type: string + format: date-time + nullable: true + example: "2026-08-05T21:07:31.992077709-03:00" + + CreateProblemCommand: + type: object + required: + - title + - errorDescription + - status + - severity + properties: + title: + type: string + minLength: 2 + maxLength: 255 + example: "Database Connection Failure" + errorDescription: + type: string + minLength: 2 + maxLength: 255 + example: "Connection refused at localhost:5432" + solution: + type: string + minLength: 2 + maxLength: 255 + nullable: true + example: "Restart PostgreSQL database container" + status: + $ref: '#/components/schemas/ProblemStatus' + severity: + $ref: '#/components/schemas/ProblemSeverity' + + UpdateProblemCommand: + type: object + properties: + title: + type: string + minLength: 2 + maxLength: 255 + example: "Updated Problem Title" + errorDescription: + type: string + minLength: 2 + maxLength: 255 + example: "Updated error details" + solution: + type: string + minLength: 2 + maxLength: 255 + example: "Updated solution steps" + severity: + $ref: '#/components/schemas/ProblemSeverity' + + UpdateProblemStatusCommand: + type: object + required: + - status + properties: + status: + $ref: '#/components/schemas/ProblemStatus' + + ProblemSummaryPage: + type: object + required: + - content + - size + - number + - totalElements + - totalPages + properties: + content: + type: array + maxItems: 100 + items: + $ref: '#/components/schemas/ProblemSummary' + size: + type: integer + example: 10 + number: + type: integer + example: 0 + totalElements: + type: integer + format: int64 + example: 1 + totalPages: + type: integer + example: 1 + security: - ApiKeyAuth: [] @@ -1340,3 +1522,341 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/problems: + post: + summary: Create a new problem in a project + description: Creates a new problem entity linked to the specified project. + tags: + - Problems + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProblemCommand' + responses: + '201': + description: Problem created successfully + headers: + Location: + schema: + type: string + description: Relative URL path to the newly created problem + example: "/api/v1/projects/70d68fba-be62-46bb-8e6c-e4c4b6912e91/problems/24199dbd-36c4-43c6-aeb2-680155af8293" + content: + application/json: + schema: + $ref: '#/components/schemas/Problem' + '400': + description: Invalid JSON format or validation constraints failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + get: + summary: Get paginated list of problems for a project + description: Fetches a paginated list of problem summaries for the specified project ordered by creation date descending. + tags: + - Problems + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: page + in: query + description: Zero-based page index + required: false + schema: + type: integer + default: 0 + minimum: 0 + - name: size + in: query + description: Page size limit + required: false + schema: + type: integer + default: 10 + minimum: 1 + maximum: 100 + responses: + '200': + description: Paginated list of problem summaries retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemSummaryPage' + '400': + description: Invalid query parameters or UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/problems/{problem_id}: + get: + summary: Get problem by ID + description: Retrieves a single problem entity by its UUID and parent project UUID. + tags: + - Problems + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: problem_id + in: path + description: Problem UUID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Problem details retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Problem' + '400': + description: Invalid UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Problem not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + patch: + summary: Update problem (Partial Update) + description: Performs a partial update (PATCH) on problem fields specified in the request body (title, errorDescription, solution, severity). + tags: + - Problems + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: problem_id + in: path + description: Problem UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProblemCommand' + responses: + '200': + description: Problem updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Problem' + '400': + description: Invalid UUID format or invalid JSON + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Problem not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + summary: Delete problem by ID + description: Permanently deletes a problem by its UUID within a project. + tags: + - Problems + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: problem_id + in: path + description: Problem UUID + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Problem deleted successfully (No Content) + '400': + description: Invalid UUID format or delete error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Problem not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/problems/{problem_id}/status: + patch: + summary: Update problem status + description: Updates problem status (OPEN, WORKING_ON, RESOLVED, WONT_FIX). + tags: + - Problems + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: problem_id + in: path + description: Problem UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProblemStatusCommand' + responses: + '200': + description: Problem status updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Problem' + '400': + description: Invalid UUID format or missing status field + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Problem not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' diff --git a/backend-go/internal/adapter/in/web/handler/problem_handler.go b/backend-go/internal/adapter/in/web/handler/problem_handler.go new file mode 100644 index 0000000..c51eeb9 --- /dev/null +++ b/backend-go/internal/adapter/in/web/handler/problem_handler.go @@ -0,0 +1,204 @@ +package handler + +import ( + "devaulty-backend/internal/adapter/in/web/common" + "devaulty-backend/internal/usecase" + "errors" + "fmt" + "log" + "net/http" + + "github.com/gin-gonic/gin" +) + +type ProblemHandler struct { + problemUseCase *usecase.ProblemUseCase +} + +func NewProblemHandler(problemUseCase *usecase.ProblemUseCase) *ProblemHandler { + return &ProblemHandler{problemUseCase: problemUseCase} +} + +func (h *ProblemHandler) Create(c *gin.Context) { + var cmd usecase.CreateProblemCommand + err := c.ShouldBindJSON(&cmd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ProjectID = projectID + + problem, err := h.problemUseCase.Create(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + location := fmt.Sprintf("%s/%s", c.Request.URL.Path, problem.ID) + c.Header("Location", location) + c.JSON(http.StatusCreated, problem) +} + +func (h *ProblemHandler) GetAll(c *gin.Context) { + var query common.PaginationQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + paginatedProblems, err := h.problemUseCase.GetAllByProjectID(c.Request.Context(), projectID, query.PageNumber, query.PageSize) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProblemHandler.GetAll] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + c.JSON(http.StatusOK, paginatedProblems) +} + +func (h *ProblemHandler) Get(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + id, err := common.ExtractUUIDParam(c, "problem_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + problem, err := h.problemUseCase.GetByID(c.Request.Context(), projectID, id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrProblemNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProblemHandler.Get] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + if problem == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Problem not found"}) + return + } + + c.JSON(http.StatusOK, problem) +} + +func (h *ProblemHandler) Update(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id, err := common.ExtractUUIDParam(c, "problem_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var cmd usecase.UpdateProblemCommand + if err := c.ShouldBindJSON(&cmd); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ProjectID = projectID + cmd.ID = id + + problem, err := h.problemUseCase.Update(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrProblemNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProblemHandler.Update] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, problem) +} + +func (h *ProblemHandler) UpdateStatus(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + id, err := common.ExtractUUIDParam(c, "problem_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var cmd usecase.UpdateProblemStatusCommand + if err := c.ShouldBindJSON(&cmd); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ProjectID = projectID + cmd.ID = id + + problem, err := h.problemUseCase.UpdateStatus(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrProblemNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProblemHandler.UpdateStatus] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, problem) +} + +func (h *ProblemHandler) Delete(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id, err := common.ExtractUUIDParam(c, "problem_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.problemUseCase.Delete(c.Request.Context(), projectID, id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrProblemNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProblemHandler.Delete] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/backend-go/internal/adapter/in/web/handler/problem_handler_test.go b/backend-go/internal/adapter/in/web/handler/problem_handler_test.go new file mode 100644 index 0000000..748bcc9 --- /dev/null +++ b/backend-go/internal/adapter/in/web/handler/problem_handler_test.go @@ -0,0 +1,450 @@ +package handler_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProblemHandler_Create(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed a project first + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + require.Equal(t, http.StatusCreated, respProject.StatusCode) + + var createdProject map[string]interface{} + err := json.NewDecoder(respProject.Body).Decode(&createdProject) + require.NoError(t, err) + projectID := createdProject["id"].(string) + + t.Run("Create success", func(t *testing.T) { + problemBody := []byte(`{ + "title": "Database Connection Timeout", + "errorDescription": "Failed to connect to database at localhost:5432 after 30s", + "solution": "Restart PostgreSQL container", + "status": "OPEN", + "severity": "HIGH" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, problemBody, true) + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + assert.NotEmpty(t, resp.Header.Get("Location")) + + var result map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, "Database Connection Timeout", result["title"]) + assert.Equal(t, "Failed to connect to database at localhost:5432 after 30s", result["errorDescription"]) + assert.Equal(t, "Restart PostgreSQL container", result["solution"]) + assert.Equal(t, "OPEN", result["status"]) + assert.Equal(t, "HIGH", result["severity"]) + assert.Equal(t, projectID, result["projectId"]) + assert.NotEmpty(t, result["id"]) + }) + + t.Run("Create failure - project not found", func(t *testing.T) { + problemBody := []byte(`{ + "title": "Valid Title", + "errorDescription": "Valid Error Description", + "status": "OPEN", + "severity": "LOW" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/00000000-0000-0000-0000-000000000000/problems", problemBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Create failure - invalid project UUID format", func(t *testing.T) { + problemBody := []byte(`{ + "title": "Valid Title", + "errorDescription": "Valid Error Description", + "status": "OPEN", + "severity": "LOW" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/invalid-project-id/problems", problemBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - missing required fields", func(t *testing.T) { + problemBody := []byte(`{"title": "Only Title"}`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, problemBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - title too short", func(t *testing.T) { + problemBody := []byte(`{ + "title": "A", + "errorDescription": "Valid Error Description", + "status": "OPEN", + "severity": "LOW" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, problemBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - unauthorized", func(t *testing.T) { + problemBody := []byte(`{ + "title": "Valid Title", + "errorDescription": "Valid Error Description", + "status": "OPEN", + "severity": "LOW" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, problemBody, false) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +func TestProblemHandler_Get(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed a project and a problem + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + problemBody := []byte(`{ + "title": "Seeded Problem", + "errorDescription": "Seeded Error Description", + "status": "OPEN", + "severity": "MEDIUM" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + respProblem := app.DoRequest(t, http.MethodPost, urlCreate, problemBody, true) + var createdProblem map[string]interface{} + _ = json.NewDecoder(respProblem.Body).Decode(&createdProblem) + problemID := createdProblem["id"].(string) + + t.Run("Get success", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/problems/%s", projectID, problemID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, problemID, result["id"]) + assert.Equal(t, "Seeded Problem", result["title"]) + assert.Equal(t, "OPEN", result["status"]) + assert.Equal(t, "MEDIUM", result["severity"]) + }) + + t.Run("Get failure - problem not found", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/problems/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Get failure - project not found", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/problems/%s", problemID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Get failure - invalid project UUID format", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/invalid-project-id/problems/%s", problemID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Get failure - invalid problem UUID format", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/problems/invalid-problem-id", projectID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestProblemHandler_GetAll(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Create Project A + respProjA := app.DoRequest(t, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Project A"}`), true) + var projA map[string]interface{} + _ = json.NewDecoder(respProjA.Body).Decode(&projA) + projectAID := projA["id"].(string) + + // Create Project B (for isolation checks) + respProjB := app.DoRequest(t, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Project B"}`), true) + var projB map[string]interface{} + _ = json.NewDecoder(respProjB.Body).Decode(&projB) + projectBID := projB["id"].(string) + + // Seed 12 problems in Project A + for i := 1; i <= 12; i++ { + problemBody := []byte(fmt.Sprintf(`{ + "title": "Project A Problem %d", + "errorDescription": "Error description %d", + "status": "OPEN", + "severity": "LOW" + }`, i, i)) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/problems", projectAID) + _ = app.DoRequest(t, http.MethodPost, urlCreate, problemBody, true) + } + + // Seed 3 problems in Project B + for i := 1; i <= 3; i++ { + problemBody := []byte(fmt.Sprintf(`{ + "title": "Project B Problem %d", + "errorDescription": "Error description %d", + "status": "OPEN", + "severity": "HIGH" + }`, i, i)) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/problems", projectBID) + _ = app.DoRequest(t, http.MethodPost, urlCreate, problemBody, true) + } + + t.Run("GetAll success - default pagination", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/problems", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var page map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&page) + require.NoError(t, err) + + content := page["content"].([]interface{}) + assert.Len(t, content, 10) // default page size is 10 + assert.Equal(t, float64(0), page["number"]) + assert.Equal(t, float64(10), page["size"]) + assert.Equal(t, float64(12), page["totalElements"]) + assert.Equal(t, float64(2), page["totalPages"]) + + // Ensure project isolation: all items belong to projectAID + for _, item := range content { + problemMap := item.(map[string]interface{}) + assert.Equal(t, projectAID, problemMap["projectId"]) + } + }) + + t.Run("GetAll success - custom page and size", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/problems?page=1&size=5", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var page map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&page) + require.NoError(t, err) + + content := page["content"].([]interface{}) + assert.Len(t, content, 5) // page 1 with size 5 + assert.Equal(t, float64(1), page["number"]) + assert.Equal(t, float64(5), page["size"]) + assert.Equal(t, float64(12), page["totalElements"]) + assert.Equal(t, float64(3), page["totalPages"]) + + for _, item := range content { + problemMap := item.(map[string]interface{}) + assert.Equal(t, projectAID, problemMap["projectId"]) + } + }) + + t.Run("GetAll failure - project not found", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects/00000000-0000-0000-0000-000000000000/problems", nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("GetAll failure - invalid project UUID", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects/invalid-id/problems", nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetAll failure - size greater than 100", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/problems?page=0&size=101", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestProblemHandler_Update(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + problemBody := []byte(`{ + "title": "Original Title", + "errorDescription": "Original Error", + "status": "OPEN", + "severity": "LOW" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + respProblem := app.DoRequest(t, http.MethodPost, urlCreate, problemBody, true) + var createdProblem map[string]interface{} + _ = json.NewDecoder(respProblem.Body).Decode(&createdProblem) + problemID := createdProblem["id"].(string) + + t.Run("Update success", func(t *testing.T) { + updateBody := []byte(`{ + "title": "Updated Title", + "severity": "CRITICAL" + }`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/problems/%s", projectID, problemID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, "Updated Title", result["title"]) + assert.Equal(t, "CRITICAL", result["severity"]) + }) + + t.Run("Update failure - problem not found", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/problems/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Update failure - project not found", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/problems/%s", problemID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Update failure - invalid problem UUID format", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/problems/invalid-problem-id", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Update failure - invalid project UUID format", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/invalid-project-id/problems/%s", problemID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestProblemHandler_UpdateStatus(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + problemBody := []byte(`{ + "title": "Problem To Update Status", + "errorDescription": "Error description", + "status": "OPEN", + "severity": "MEDIUM" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + respProblem := app.DoRequest(t, http.MethodPost, urlCreate, problemBody, true) + var createdProblem map[string]interface{} + _ = json.NewDecoder(respProblem.Body).Decode(&createdProblem) + problemID := createdProblem["id"].(string) + + t.Run("UpdateStatus success", func(t *testing.T) { + updateBody := []byte(`{"status": "RESOLVED"}`) + urlUpdateStatus := fmt.Sprintf("/api/v1/projects/%s/problems/%s/status", projectID, problemID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdateStatus, updateBody, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, "RESOLVED", result["status"]) + }) + + t.Run("UpdateStatus failure - problem not found", func(t *testing.T) { + updateBody := []byte(`{"status": "RESOLVED"}`) + urlUpdateStatus := fmt.Sprintf("/api/v1/projects/%s/problems/00000000-0000-0000-0000-000000000000/status", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdateStatus, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("UpdateStatus failure - project not found", func(t *testing.T) { + updateBody := []byte(`{"status": "RESOLVED"}`) + urlUpdateStatus := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/problems/%s/status", problemID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdateStatus, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("UpdateStatus failure - missing status field", func(t *testing.T) { + updateBody := []byte(`{}`) + urlUpdateStatus := fmt.Sprintf("/api/v1/projects/%s/problems/%s/status", projectID, problemID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdateStatus, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("UpdateStatus failure - invalid problem UUID format", func(t *testing.T) { + updateBody := []byte(`{"status": "RESOLVED"}`) + urlUpdateStatus := fmt.Sprintf("/api/v1/projects/%s/problems/invalid-problem-id/status", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdateStatus, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestProblemHandler_Delete(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + problemBody := []byte(`{ + "title": "Problem To Delete", + "errorDescription": "Error description", + "status": "OPEN", + "severity": "LOW" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + respProblem := app.DoRequest(t, http.MethodPost, urlCreate, problemBody, true) + var createdProblem map[string]interface{} + _ = json.NewDecoder(respProblem.Body).Decode(&createdProblem) + problemID := createdProblem["id"].(string) + + t.Run("Delete success", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/problems/%s", projectID, problemID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Assert persistence: issuing a GET request should now return 404 Not Found + respGet := app.DoRequest(t, http.MethodGet, urlDelete, nil, true) + assert.Equal(t, http.StatusNotFound, respGet.StatusCode) + }) + + t.Run("Delete failure - problem not found", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/problems/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Delete failure - invalid problem UUID", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/problems/invalid-problem-id", projectID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Delete failure - invalid project UUID", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/invalid-project-id/problems/%s", problemID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} diff --git a/backend-go/internal/adapter/in/web/handler/test_helper_test.go b/backend-go/internal/adapter/in/web/handler/test_helper_test.go index 69bc1ab..6057447 100644 --- a/backend-go/internal/adapter/in/web/handler/test_helper_test.go +++ b/backend-go/internal/adapter/in/web/handler/test_helper_test.go @@ -35,10 +35,15 @@ func SetupTestApp(t *testing.T) *TestApp { linkUseCase := usecase.NewLinkUseCase(linkRepo, projectRepo) linkHandler := handler.NewLinkHandler(linkUseCase) + problemRepo := persistence.NewProblemRepository(db) + problemUseCase := usecase.NewProblemUseCase(problemRepo, projectRepo) + problemHandler := handler.NewProblemHandler(problemUseCase) + handlers := &web.Handlers{ Project: projectHandler, Snippet: snippetHandler, Link: linkHandler, + Problem: problemHandler, } token := "test-internal-token-12345" diff --git a/backend-go/internal/adapter/in/web/router.go b/backend-go/internal/adapter/in/web/router.go index 84aa8c6..c89f847 100644 --- a/backend-go/internal/adapter/in/web/router.go +++ b/backend-go/internal/adapter/in/web/router.go @@ -14,6 +14,7 @@ type Handlers struct { Project *handler.ProjectHandler Snippet *handler.SnippetHandler Link *handler.LinkHandler + Problem *handler.ProblemHandler } func SetupRouter(h *Handlers, apiToken string) *gin.Engine { @@ -34,6 +35,7 @@ func SetupRouter(h *Handlers, apiToken string) *gin.Engine { mapProjectRoutes(protected, h.Project) mapSnippetRoutes(protected, h.Snippet) mapLinkRoutes(protected, h.Link) + mapProblemRoutes(protected, h.Problem) } } return r @@ -74,6 +76,18 @@ func mapLinkRoutes(rg *gin.RouterGroup, h *handler.LinkHandler) { } } +func mapProblemRoutes(rg *gin.RouterGroup, h *handler.ProblemHandler) { + problems := rg.Group("/projects/:project_id/problems") + { + problems.POST("", h.Create) + problems.GET("", h.GetAll) + problems.GET("/:problem_id", h.Get) + problems.PATCH("/:problem_id", h.Update) + problems.PATCH("/:problem_id/status", h.UpdateStatus) + problems.DELETE("/:problem_id", h.Delete) + } +} + func registerDocsRoutes(r *gin.Engine) { if os.Getenv("APP_ENV") != "dev" { return diff --git a/backend-go/internal/adapter/out/persistence/problem_repository.go b/backend-go/internal/adapter/out/persistence/problem_repository.go index adf965e..c7d647c 100644 --- a/backend-go/internal/adapter/out/persistence/problem_repository.go +++ b/backend-go/internal/adapter/out/persistence/problem_repository.go @@ -35,10 +35,10 @@ func (r *ProblemRepositoryAdapter) Save(ctx context.Context, problem *model.Prob return problem, nil } -func (r *ProblemRepositoryAdapter) FindByID(ctx context.Context, id uuid.UUID) (*model.Problem, error) { - query := `SELECT * FROM problems WHERE id = ?` +func (r *ProblemRepositoryAdapter) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Problem, error) { + query := `SELECT * FROM problems WHERE id = ? AND project_id = ?` var problem model.Problem - err := r.db.GetContext(ctx, &problem, query, id) + err := r.db.GetContext(ctx, &problem, query, id, projectID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -48,19 +48,25 @@ func (r *ProblemRepositoryAdapter) FindByID(ctx context.Context, id uuid.UUID) ( return &problem, nil } -func (r *ProblemRepositoryAdapter) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[model.Problem], error) { +func (r *ProblemRepositoryAdapter) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[port.ProblemSummary], error) { countQuery := `SELECT COUNT(*) FROM problems WHERE project_id = ?` - selectQuery := `SELECT * FROM problems WHERE project_id = ? ORDER BY created_at DESC` - return PaginateExec[model.Problem](ctx, r.db, countQuery, selectQuery, page, size, projectID) + selectQuery := `SELECT id, project_id, title, status, severity, created_at, updated_at FROM problems + WHERE project_id = ? ORDER BY created_at DESC` + return PaginateExec[port.ProblemSummary](ctx, r.db, countQuery, selectQuery, page, size, projectID) } -func (r *ProblemRepositoryAdapter) DeleteByID(ctx context.Context, id uuid.UUID) error { - query := `DELETE FROM problems WHERE id = ?` - _, err := r.db.ExecContext(ctx, query, id) +func (r *ProblemRepositoryAdapter) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + query := `DELETE FROM problems WHERE id = ? AND project_id = ?` + res, err := r.db.ExecContext(ctx, query, id, projectID) if err != nil { - return fmt.Errorf("error trying to delete problem: %w", err) + return false, fmt.Errorf("error trying to delete problem: %w", err) } - return nil + rows, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("error checking deleted rows: %w", err) + } + + return rows > 0, nil } func (r *ProblemRepositoryAdapter) ExistsByIDAndProjectID(ctx context.Context, id uuid.UUID, projectID uuid.UUID) (bool, error) { diff --git a/backend-go/internal/domain/port/problem_repository.go b/backend-go/internal/domain/port/problem_repository.go index 5a6197b..e68168c 100644 --- a/backend-go/internal/domain/port/problem_repository.go +++ b/backend-go/internal/domain/port/problem_repository.go @@ -7,10 +7,19 @@ import ( "github.com/google/uuid" ) +type ProblemSummary struct { + ID uuid.UUID `json:"id" db:"id"` + ProjectID uuid.UUID `json:"projectId" db:"project_id"` + Title string `json:"title" db:"title"` + Status model.ProblemStatus `json:"status" db:"status"` + Severity model.ProblemSeverity `json:"severity" db:"severity"` + model.BaseEntity +} + type ProblemRepository interface { Save(ctx context.Context, problem *model.Problem) (*model.Problem, error) - FindByID(ctx context.Context, id uuid.UUID) (*model.Problem, error) - FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[model.Problem], error) - DeleteByID(ctx context.Context, id uuid.UUID) error + FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Problem, error) + FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[ProblemSummary], error) + DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) ProjectScopedRepository } diff --git a/backend-go/internal/usecase/problem_usecase.go b/backend-go/internal/usecase/problem_usecase.go new file mode 100644 index 0000000..e6a7db6 --- /dev/null +++ b/backend-go/internal/usecase/problem_usecase.go @@ -0,0 +1,157 @@ +package usecase + +import ( + "context" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +var ( + ErrProblemNotFound = errors.New("problem not found") +) + +type ProblemUseCase struct { + problemRepo port.ProblemRepository + projectRepo port.ProjectRepository +} + +type CreateProblemCommand struct { + ProjectID uuid.UUID + Title string `json:"title" binding:"required,min=2,max=255"` + ErrorDescription string `json:"errorDescription" binding:"required,min=2,max=255"` + Solution *string `json:"solution,omitempty" binding:"omitempty,min=2,max=255"` + Status model.ProblemStatus `json:"status" binding:"required"` + Severity model.ProblemSeverity `json:"severity" binding:"required"` +} + +type UpdateProblemCommand struct { + ProjectID uuid.UUID + ID uuid.UUID + Title *string `json:"title,omitempty" binding:"omitempty,min=2,max=255"` + ErrorDescription *string `json:"errorDescription,omitempty" binding:"omitempty,min=2,max=255"` + Solution *string `json:"solution,omitempty" binding:"omitempty,min=2,max=255"` + Severity *model.ProblemSeverity `json:"severity,omitempty" binding:"omitempty"` +} + +type UpdateProblemStatusCommand struct { + ProjectID uuid.UUID + ID uuid.UUID + Status model.ProblemStatus `json:"status" binding:"required"` +} + +func NewProblemUseCase(problemRepo port.ProblemRepository, projectRepo port.ProjectRepository) *ProblemUseCase { + return &ProblemUseCase{ + problemRepo: problemRepo, + projectRepo: projectRepo, + } +} + +func (uc *ProblemUseCase) Create(ctx context.Context, cmd CreateProblemCommand) (*model.Problem, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + + problem := model.Problem{ + ID: uuid.New(), + ProjectID: cmd.ProjectID, + Title: cmd.Title, + ErrorDescription: cmd.ErrorDescription, + Solution: cmd.Solution, + Status: cmd.Status, + Severity: cmd.Severity, + BaseEntity: model.BaseEntity{ + CreatedAt: time.Now(), + UpdatedAt: nil, + }, + } + + return uc.problemRepo.Save(ctx, &problem) +} + +func (uc *ProblemUseCase) GetByID(ctx context.Context, projectID, id uuid.UUID) (*model.Problem, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return nil, err + } + problem, err := uc.problemRepo.FindByIDAndProjectID(ctx, projectID, id) + if err != nil { + return nil, fmt.Errorf("error trying to find problem: %w", err) + } + if problem == nil { + return nil, ErrProblemNotFound + } + return problem, nil +} + +func (uc *ProblemUseCase) GetAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[port.ProblemSummary], error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return model.Page[port.ProblemSummary]{}, err + } + return uc.problemRepo.FindAllByProjectID(ctx, projectID, page, size) +} + +func (uc *ProblemUseCase) Update(ctx context.Context, cmd UpdateProblemCommand) (*model.Problem, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + problem, err := uc.problemRepo.FindByIDAndProjectID(ctx, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error trying to find problem: %w", err) + } + if problem == nil { + return nil, ErrProblemNotFound + } + + if cmd.Title != nil { + problem.Title = *cmd.Title + } + if cmd.ErrorDescription != nil { + problem.ErrorDescription = *cmd.ErrorDescription + } + if cmd.Solution != nil { + problem.Solution = cmd.Solution + } + if cmd.Severity != nil { + problem.Severity = *cmd.Severity + } + now := time.Now() + problem.UpdatedAt = &now + return uc.problemRepo.Save(ctx, problem) +} + +func (uc *ProblemUseCase) UpdateStatus(ctx context.Context, cmd UpdateProblemStatusCommand) (*model.Problem, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + problem, err := uc.problemRepo.FindByIDAndProjectID(ctx, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error trying to find problem: %w", err) + } + if problem == nil { + return nil, ErrProblemNotFound + } + + problem.Status = cmd.Status + now := time.Now() + problem.UpdatedAt = &now + return uc.problemRepo.Save(ctx, problem) +} + +func (uc *ProblemUseCase) Delete(ctx context.Context, projectID, id uuid.UUID) error { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return err + } + + deleted, err := uc.problemRepo.DeleteByIDAndProjectID(ctx, projectID, id) + if err != nil { + return fmt.Errorf("error deleting problem: %w", err) + } + if !deleted { + return ErrProblemNotFound + } + return nil +} diff --git a/backend-go/internal/usecase/problem_usecase_test.go b/backend-go/internal/usecase/problem_usecase_test.go new file mode 100644 index 0000000..804ecf1 --- /dev/null +++ b/backend-go/internal/usecase/problem_usecase_test.go @@ -0,0 +1,475 @@ +package usecase_test + +import ( + "context" + "testing" + + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "devaulty-backend/internal/usecase" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockProblemRepository struct { + mock.Mock +} + +func (m *MockProblemRepository) Save(ctx context.Context, problem *model.Problem) (*model.Problem, error) { + args := m.Called(ctx, problem) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Problem), args.Error(1) +} + +func (m *MockProblemRepository) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Problem, error) { + args := m.Called(ctx, projectID, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Problem), args.Error(1) +} + +func (m *MockProblemRepository) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[port.ProblemSummary], error) { + args := m.Called(ctx, projectID, page, size) + return args.Get(0).(model.Page[port.ProblemSummary]), args.Error(1) +} + +func (m *MockProblemRepository) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + args := m.Called(ctx, projectID, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockProblemRepository) ExistsByIDAndProjectID(ctx context.Context, id, projectID uuid.UUID) (bool, error) { + args := m.Called(ctx, id, projectID) + return args.Bool(0), args.Error(1) +} + +func (m *MockProblemRepository) FindExistingIDsByProjectID(ctx context.Context, ids []uuid.UUID, projectID uuid.UUID) ([]uuid.UUID, error) { + args := m.Called(ctx, ids, projectID) + return args.Get(0).([]uuid.UUID), args.Error(1) +} + +// --- UNIT TESTS --- + +func TestProblemUseCase_Create_Success(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + solution := "Restart database service" + + cmd := usecase.CreateProblemCommand{ + ProjectID: projectID, + Title: "Connection Refused", + ErrorDescription: "Failed to connect to database at localhost:5432", + Solution: &solution, + Status: model.ProblemStatusOpen, + Severity: model.ProblemSeverityHigh, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("Save", ctx, mock.MatchedBy(func(p *model.Problem) bool { + return p.ProjectID == projectID && p.Title == "Connection Refused" && p.Severity == model.ProblemSeverityHigh + })).Return(&model.Problem{ + ID: uuid.New(), + ProjectID: projectID, + Title: "Connection Refused", + ErrorDescription: "Failed to connect to database at localhost:5432", + Solution: &solution, + Status: model.ProblemStatusOpen, + Severity: model.ProblemSeverityHigh, + }, nil) + + created, err := uc.Create(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, created) + assert.Equal(t, "Connection Refused", created.Title) + assert.Equal(t, model.ProblemSeverityHigh, created.Severity) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) +} + +func TestProblemUseCase_Create_ProjectNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + cmd := usecase.CreateProblemCommand{ + ProjectID: projectID, + Title: "Connection Refused", + ErrorDescription: "Failed to connect", + Status: model.ProblemStatusOpen, + Severity: model.ProblemSeverityHigh, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + created, err := uc.Create(ctx, cmd) + + assert.Nil(t, created) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestProblemUseCase_GetByID_Success(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + expectedProblem := &model.Problem{ + ID: problemID, + ProjectID: projectID, + Title: "Memory Leak", + ErrorDescription: "Process OOM killed", + Status: model.ProblemStatusWorkingOnIt, + Severity: model.ProblemSeverityCritical, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("FindByIDAndProjectID", ctx, projectID, problemID).Return(expectedProblem, nil) + + result, err := uc.GetByID(ctx, projectID, problemID) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, problemID, result.ID) + assert.Equal(t, projectID, result.ProjectID) + assert.Equal(t, model.ProblemSeverityCritical, result.Severity) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) +} + +func TestProblemUseCase_GetByID_ProjectNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + result, err := uc.GetByID(ctx, projectID, problemID) + + assert.Nil(t, result) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestProblemUseCase_GetByID_NotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("FindByIDAndProjectID", ctx, projectID, problemID).Return(nil, nil) + + result, err := uc.GetByID(ctx, projectID, problemID) + + assert.Nil(t, result) + assert.ErrorIs(t, err, usecase.ErrProblemNotFound) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) +} + +func TestProblemUseCase_GetAllByProjectID_Success(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + expectedPage := model.NewPage([]port.ProblemSummary{ + {ID: uuid.New(), ProjectID: projectID, Title: "Problem 1", Severity: model.ProblemSeverityLow}, + {ID: uuid.New(), ProjectID: projectID, Title: "Problem 2", Severity: model.ProblemSeverityMedium}, + }, 0, 10, 2) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("FindAllByProjectID", ctx, projectID, 0, 10).Return(expectedPage, nil) + + result, err := uc.GetAllByProjectID(ctx, projectID, 0, 10) + + assert.NoError(t, err) + assert.Equal(t, 2, len(result.Content)) + assert.Equal(t, int64(2), result.TotalElements) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) +} + +func TestProblemUseCase_GetAllByProjectID_ProjectNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + _, err := uc.GetAllByProjectID(ctx, projectID, 0, 10) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestProblemUseCase_Update_Success(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + existingProblem := &model.Problem{ + ID: problemID, + ProjectID: projectID, + Title: "Old Title", + ErrorDescription: "Old Desc", + Status: model.ProblemStatusOpen, + Severity: model.ProblemSeverityLow, + } + + newTitle := "Updated Title" + newSeverity := model.ProblemSeverityCritical + cmd := usecase.UpdateProblemCommand{ + ProjectID: projectID, + ID: problemID, + Title: &newTitle, + Severity: &newSeverity, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("FindByIDAndProjectID", ctx, projectID, problemID).Return(existingProblem, nil) + mockProblemRepo.On("Save", ctx, mock.MatchedBy(func(p *model.Problem) bool { + return p.Title == "Updated Title" && p.Severity == model.ProblemSeverityCritical && p.UpdatedAt != nil + })).Return(&model.Problem{ + ID: problemID, + ProjectID: projectID, + Title: "Updated Title", + Severity: model.ProblemSeverityCritical, + }, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, updated) + assert.Equal(t, "Updated Title", updated.Title) + assert.Equal(t, model.ProblemSeverityCritical, updated.Severity) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) +} + +func TestProblemUseCase_Update_ProjectNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + newTitle := "Updated Title" + cmd := usecase.UpdateProblemCommand{ + ProjectID: projectID, + ID: problemID, + Title: &newTitle, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.Nil(t, updated) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestProblemUseCase_Update_ProblemNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + newTitle := "Updated Title" + cmd := usecase.UpdateProblemCommand{ + ProjectID: projectID, + ID: problemID, + Title: &newTitle, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("FindByIDAndProjectID", ctx, projectID, problemID).Return(nil, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.Nil(t, updated) + assert.ErrorIs(t, err, usecase.ErrProblemNotFound) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) +} + +func TestProblemUseCase_UpdateStatus_Success(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + existingProblem := &model.Problem{ + ID: problemID, + ProjectID: projectID, + Title: "Sample Problem", + Status: model.ProblemStatusOpen, + } + + cmd := usecase.UpdateProblemStatusCommand{ + ProjectID: projectID, + ID: problemID, + Status: model.ProblemStatusResolved, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("FindByIDAndProjectID", ctx, projectID, problemID).Return(existingProblem, nil) + mockProblemRepo.On("Save", ctx, mock.MatchedBy(func(p *model.Problem) bool { + return p.Status == model.ProblemStatusResolved && p.UpdatedAt != nil + })).Return(&model.Problem{ + ID: problemID, + ProjectID: projectID, + Title: "Sample Problem", + Status: model.ProblemStatusResolved, + }, nil) + + updated, err := uc.UpdateStatus(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, updated) + assert.Equal(t, model.ProblemStatusResolved, updated.Status) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) +} + +func TestProblemUseCase_UpdateStatus_ProjectNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + cmd := usecase.UpdateProblemStatusCommand{ + ProjectID: projectID, + ID: problemID, + Status: model.ProblemStatusResolved, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + updated, err := uc.UpdateStatus(ctx, cmd) + + assert.Nil(t, updated) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestProblemUseCase_UpdateStatus_ProblemNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + cmd := usecase.UpdateProblemStatusCommand{ + ProjectID: projectID, + ID: problemID, + Status: model.ProblemStatusResolved, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("FindByIDAndProjectID", ctx, projectID, problemID).Return(nil, nil) + + updated, err := uc.UpdateStatus(ctx, cmd) + + assert.Nil(t, updated) + assert.ErrorIs(t, err, usecase.ErrProblemNotFound) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) +} + +func TestProblemUseCase_Delete_Success(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("DeleteByIDAndProjectID", ctx, projectID, problemID).Return(true, nil) + + err := uc.Delete(ctx, projectID, problemID) + + assert.NoError(t, err) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) +} + +func TestProblemUseCase_Delete_ProjectNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + err := uc.Delete(ctx, projectID, problemID) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestProblemUseCase_Delete_ProblemNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("DeleteByIDAndProjectID", ctx, projectID, problemID).Return(false, nil) + + err := uc.Delete(ctx, projectID, problemID) + + assert.ErrorIs(t, err, usecase.ErrProblemNotFound) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) +}