diff --git a/backend-go/cmd/api/main.go b/backend-go/cmd/api/main.go index 8dbe60e..7de304d 100644 --- a/backend-go/cmd/api/main.go +++ b/backend-go/cmd/api/main.go @@ -35,9 +35,13 @@ func main() { projectRepo := persistence.NewProjectRepository(db) projectUseCase := usecase.NewProjectUseCase(projectRepo) projectHandler := handler.NewProjectHandler(projectUseCase) + snippetRepo := persistence.NewSnippetRepository(db) + snippetUseCase := usecase.NewSnippetUseCase(snippetRepo, projectRepo) + snippetHandler := handler.NewSnippetHandler(snippetUseCase) handlers := &web.Handlers{ Project: projectHandler, + Snippet: snippetHandler, } r := web.SetupRouter(handlers, devaultyInternalToken) diff --git a/backend-go/docs/openapi.yaml b/backend-go/docs/openapi.yaml index bfaaeac..a4c5b1b 100644 --- a/backend-go/docs/openapi.yaml +++ b/backend-go/docs/openapi.yaml @@ -151,6 +151,209 @@ components: type: integer example: 1 + SnippetType: + type: string + enum: + - COMMAND + - CODE + example: "CODE" + + SnippetLanguage: + type: string + enum: + - BASH + - FISH + - ZSH + - SH + - POWERSHELL + - BATCH + - JAVA + - KOTLIN + - JAVASCRIPT + - TYPESCRIPT + - PYTHON + - GO + - RUST + - C + - CPP + - CSHARP + - PHP + - RUBY + - SWIFT + - DART + - SCALA + - LUA + - PERL + - R + - ELIXIR + - HASKELL + - CLOJURE + - GROOVY + - HTML + - CSS + - SCSS + - LESS + - JSX + - TSX + - VUE + - SVELTE + - JSON + - YAML + - XML + - TOML + - INI + - ENV + - CSV + - MARKDOWN + - PROPERTIES + - DOCKERFILE + - DOCKER_COMPOSE + - NGINX + - APACHE + - TERRAFORM + - ANSIBLE + - KUBERNETES_YAML + - HELM + - MAKEFILE + - CMAKE + - GRADLE + - MAVEN_POM + - SQL + - PLSQL + - GRAPHQL + - MONGODB + - GITHUB_ACTIONS + - GITLAB_CI + - JENKINSFILE + - REGEX + - DIFF + - LOG + - PLAIN_TEXT + example: "GO" + + Snippet: + type: object + required: + - id + - projectId + - title + - content + - snippetType + - createdAt + properties: + id: + type: string + format: uuid + example: "784f6f5c-57af-4313-b160-688a3e0d283b" + projectId: + type: string + format: uuid + example: "06f891ae-368b-4bd6-b1e6-d957f1496f9a" + title: + type: string + example: "Print Hello World" + description: + type: string + nullable: true + example: "Simple print example in Go" + content: + type: string + example: "fmt.Println(\"Hello World\")" + language: + $ref: '#/components/schemas/SnippetLanguage' + snippetType: + $ref: '#/components/schemas/SnippetType' + createdAt: + type: string + format: date-time + example: "2026-08-03T12:35:07.84097684-03:00" + updatedAt: + type: string + format: date-time + nullable: true + example: "2026-08-03T16:22:46.12345678-03:00" + + CreateSnippetCommand: + type: object + required: + - title + - content + - language + - snippetType + properties: + title: + type: string + minLength: 2 + maxLength: 255 + example: "Print Hello World" + description: + type: string + minLength: 1 + maxLength: 255 + nullable: true + example: "Simple print example in Go" + content: + type: string + minLength: 1 + example: "fmt.Println(\"Hello World\")" + language: + $ref: '#/components/schemas/SnippetLanguage' + snippetType: + $ref: '#/components/schemas/SnippetType' + + UpdateSnippetCommand: + type: object + properties: + title: + type: string + minLength: 2 + maxLength: 255 + nullable: true + example: "Updated Snippet Title" + description: + type: string + minLength: 1 + maxLength: 255 + nullable: true + example: "Updated description" + content: + type: string + minLength: 1 + nullable: true + example: "log.Println(\"Updated Content\")" + language: + $ref: '#/components/schemas/SnippetLanguage' + snippetType: + $ref: '#/components/schemas/SnippetType' + + SnippetPage: + type: object + required: + - content + - size + - number + - totalElements + - totalPages + properties: + content: + type: array + maxItems: 100 + items: + $ref: '#/components/schemas/Snippet' + size: + type: integer + example: 10 + number: + type: integer + example: 0 + totalElements: + type: integer + format: int64 + example: 1 + totalPages: + type: integer + example: 1 + security: - ApiKeyAuth: [] @@ -192,6 +395,12 @@ paths: 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 projects @@ -235,6 +444,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /projects/{id}: get: @@ -275,6 +490,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' patch: summary: Update project (Partial Update) @@ -320,6 +541,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' delete: summary: Delete project by ID @@ -349,6 +576,18 @@ paths: 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/{id}/archive: patch: @@ -389,6 +628,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /projects/{id}/unarchive: patch: @@ -429,3 +674,288 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/snippets: + post: + summary: Create a new snippet in a project + description: Creates a new code or command snippet linked to the specified project. + tags: + - Snippets + 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/CreateSnippetCommand' + responses: + '201': + description: Snippet created successfully + headers: + Location: + schema: + type: string + description: Relative URL path to the newly created snippet + example: "/api/v1/projects/06f891ae-368b-4bd6-b1e6-d957f1496f9a/snippets/784f6f5c-57af-4313-b160-688a3e0d283b" + content: + application/json: + schema: + $ref: '#/components/schemas/Snippet' + '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 snippets for a project + description: Fetches a paginated list of snippets for the specified project ordered by creation date descending. + tags: + - Snippets + 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 snippets retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SnippetPage' + '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}/snippets/{snippet_id}: + get: + summary: Get snippet by ID + description: Retrieves a single snippet entity by its UUID and parent project UUID. + tags: + - Snippets + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: snippet_id + in: path + description: Snippet UUID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Snippet details retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Snippet' + '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 Snippet 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 snippet (Partial Update) + description: Performs a partial update (PATCH) on snippet fields specified in the request body. + tags: + - Snippets + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: snippet_id + in: path + description: Snippet UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSnippetCommand' + responses: + '200': + description: Snippet updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Snippet' + '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 Snippet 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 snippet by ID + description: Permanently deletes a snippet by its UUID within a project. + tags: + - Snippets + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: snippet_id + in: path + description: Snippet UUID + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Snippet 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 Snippet 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/common/params.go b/backend-go/internal/adapter/in/web/common/params.go new file mode 100644 index 0000000..78b52cb --- /dev/null +++ b/backend-go/internal/adapter/in/web/common/params.go @@ -0,0 +1,22 @@ +package common + +import ( + "fmt" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func ExtractUUIDParam(c *gin.Context, paramName string) (uuid.UUID, error) { + paramVal := c.Param(paramName) + if paramVal == "" { + return uuid.Nil, fmt.Errorf("missing %s param", paramName) + } + + id, err := uuid.Parse(paramVal) + if err != nil { + return uuid.Nil, fmt.Errorf("invalid %s format", paramName) + } + + return id, nil +} diff --git a/backend-go/internal/adapter/in/web/handler/project_handler.go b/backend-go/internal/adapter/in/web/handler/project_handler.go index e6de069..327c267 100644 --- a/backend-go/internal/adapter/in/web/handler/project_handler.go +++ b/backend-go/internal/adapter/in/web/handler/project_handler.go @@ -3,13 +3,13 @@ package handler import ( "errors" "fmt" + "log" "net/http" "devaulty-backend/internal/adapter/in/web/common" "devaulty-backend/internal/usecase" "github.com/gin-gonic/gin" - "github.com/google/uuid" ) type ProjectHandler struct { @@ -24,21 +24,19 @@ func (h *ProjectHandler) Create(c *gin.Context) { var cmd usecase.CreateProjectCommand err := c.ShouldBindJSON(&cmd) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": err.Error(), - }) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } project, err := h.projectUseCase.Create(c.Request.Context(), cmd) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": err.Error(), - }) + log.Printf("[ProjectHandler.Create] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) return } location := fmt.Sprintf("%s/%s", c.Request.URL.Path, project.ID) + c.Header("Location", location) c.JSON(http.StatusCreated, project) } @@ -54,40 +52,42 @@ func (h *ProjectHandler) GetAll(c *gin.Context) { pagedProjects, err := h.projectUseCase.GetAll(c.Request.Context(), query.PageNumber, query.PageSize) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": err.Error(), - }) + log.Printf("[ProjectHandler.GetAll] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) return } c.JSON(http.StatusOK, pagedProjects) } func (h *ProjectHandler) Get(c *gin.Context) { - idParam := c.Param("id") - id, err := uuid.Parse(idParam) + id, err := common.ExtractUUIDParam(c, "project_id") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } project, err := h.projectUseCase.GetByID(c.Request.Context(), id) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProjectHandler.Get] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) return } - if project == nil { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } + c.JSON(http.StatusOK, project) } func (h *ProjectHandler) Update(c *gin.Context) { - idParam := c.Param("id") - id, err := uuid.Parse(idParam) + id, err := common.ExtractUUIDParam(c, "project_id") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -107,17 +107,17 @@ func (h *ProjectHandler) Update(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + log.Printf("[ProjectHandler.Update] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) return } c.JSON(http.StatusOK, project) } func (h *ProjectHandler) Archive(c *gin.Context) { - idParam := c.Param("id") - id, err := uuid.Parse(idParam) + id, err := common.ExtractUUIDParam(c, "project_id") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -127,17 +127,21 @@ func (h *ProjectHandler) Archive(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + if errors.Is(err, usecase.ErrProjectAlreadyArchived) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProjectHandler.Archive] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) return } c.JSON(http.StatusOK, gin.H{"message": "Project archived"}) } func (h *ProjectHandler) Unarchive(c *gin.Context) { - idParam := c.Param("id") - id, err := uuid.Parse(idParam) + id, err := common.ExtractUUIDParam(c, "project_id") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -147,23 +151,32 @@ func (h *ProjectHandler) Unarchive(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + if errors.Is(err, usecase.ErrProjectAlreadyUnarchived) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProjectHandler.Unarchive] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) return } c.JSON(http.StatusOK, gin.H{"message": "Project unarchived"}) } func (h *ProjectHandler) Delete(c *gin.Context) { - idParam := c.Param("id") - id, err := uuid.Parse(idParam) + id, err := common.ExtractUUIDParam(c, "project_id") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } err = h.projectUseCase.Delete(c.Request.Context(), id) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProjectHandler.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/project_handler_test.go b/backend-go/internal/adapter/in/web/handler/project_handler_test.go index 2321cc7..1c8ab1c 100644 --- a/backend-go/internal/adapter/in/web/handler/project_handler_test.go +++ b/backend-go/internal/adapter/in/web/handler/project_handler_test.go @@ -194,6 +194,15 @@ func TestProjectHandler_Delete(t *testing.T) { t.Run("Delete success", func(t *testing.T) { resp := app.DoRequest(t, http.MethodDelete, "/api/v1/projects/"+projectID, nil, true) assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Assert persistence: GET on deleted project should return 404 Not Found + respGet := app.DoRequest(t, http.MethodGet, "/api/v1/projects/"+projectID, nil, true) + assert.Equal(t, http.StatusNotFound, respGet.StatusCode) + }) + + t.Run("Delete failure - project not found", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodDelete, "/api/v1/projects/00000000-0000-0000-0000-000000000000", nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) }) t.Run("Delete failure - invalid UUID", func(t *testing.T) { diff --git a/backend-go/internal/adapter/in/web/handler/snippet_handler.go b/backend-go/internal/adapter/in/web/handler/snippet_handler.go new file mode 100644 index 0000000..bb45d00 --- /dev/null +++ b/backend-go/internal/adapter/in/web/handler/snippet_handler.go @@ -0,0 +1,174 @@ +package handler + +import ( + "errors" + "fmt" + "log" + "net/http" + + "devaulty-backend/internal/adapter/in/web/common" + "devaulty-backend/internal/usecase" + + "github.com/gin-gonic/gin" +) + +type SnippetHandler struct { + snippetUseCase *usecase.SnippetUseCase +} + +func NewSnippetHandler(snippetUseCase *usecase.SnippetUseCase) *SnippetHandler { + return &SnippetHandler{snippetUseCase: snippetUseCase} +} + +func (h *SnippetHandler) Create(c *gin.Context) { + var cmd usecase.CreateSnippetCommand + 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 + + snippet, err := h.snippetUseCase.Create(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[SnippetHandler.Create] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + location := fmt.Sprintf("%s/%s", c.Request.URL.Path, snippet.ID) + c.Header("Location", location) + c.JSON(http.StatusCreated, snippet) +} + +func (h *SnippetHandler) 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 + } + + pagedSnippets, err := h.snippetUseCase.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("[SnippetHandler.GetAll] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + c.JSON(http.StatusOK, pagedSnippets) +} + +func (h *SnippetHandler) 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, "snippet_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + snippet, err := h.snippetUseCase.GetByID(c.Request.Context(), projectID, id) + if err != nil { + if errors.Is(err, usecase.ErrSnippetNotFound) || errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[SnippetHandler.Get] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + if snippet == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Snippet not found"}) + return + } + + c.JSON(http.StatusOK, snippet) +} + +func (h *SnippetHandler) 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, "snippet_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var cmd usecase.UpdateSnippetCommand + err = c.ShouldBindJSON(&cmd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ID = id + cmd.ProjectID = projectID + + snippet, err := h.snippetUseCase.Update(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrSnippetNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[SnippetHandler.Update] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + c.JSON(http.StatusOK, snippet) +} + +func (h *SnippetHandler) 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, "snippet_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.snippetUseCase.Delete(c.Request.Context(), projectID, id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrSnippetNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[SnippetHandler.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/snippet_handler_test.go b/backend-go/internal/adapter/in/web/handler/snippet_handler_test.go new file mode 100644 index 0000000..e38eaea --- /dev/null +++ b/backend-go/internal/adapter/in/web/handler/snippet_handler_test.go @@ -0,0 +1,364 @@ +package handler_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSnippetHandler_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) { + snippetBody := []byte(`{ + "title": "Print Hello World", + "description": "Simple Go print snippet", + "content": "fmt.Println(\"Hello World\")", + "language": "GO", + "snippetType": "CODE" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, snippetBody, 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, "Print Hello World", result["title"]) + assert.Equal(t, projectID, result["projectId"]) + assert.NotEmpty(t, result["id"]) + }) + + t.Run("Create failure - project not found", func(t *testing.T) { + snippetBody := []byte(`{ + "title": "Valid Title", + "content": "some content", + "language": "BASH", + "snippetType": "COMMAND" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/00000000-0000-0000-0000-000000000000/snippets", snippetBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Create failure - invalid project UUID format", func(t *testing.T) { + snippetBody := []byte(`{ + "title": "Valid Title", + "content": "some content", + "language": "BASH", + "snippetType": "COMMAND" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/invalid-project-id/snippets", snippetBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - missing required fields", func(t *testing.T) { + snippetBody := []byte(`{"description": "No title or content"}`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, snippetBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - title too short", func(t *testing.T) { + snippetBody := []byte(`{ + "title": "A", + "content": "some content", + "language": "BASH", + "snippetType": "COMMAND" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, snippetBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - unauthorized", func(t *testing.T) { + snippetBody := []byte(`{ + "title": "Valid Title", + "content": "some content", + "language": "BASH", + "snippetType": "COMMAND" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, snippetBody, false) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +func TestSnippetHandler_Get(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed a project and a snippet + 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) + + snippetBody := []byte(`{ + "title": "Seeded Snippet", + "content": "echo 'hello'", + "language": "BASH", + "snippetType": "COMMAND" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + respSnippet := app.DoRequest(t, http.MethodPost, urlCreate, snippetBody, true) + var createdSnippet map[string]interface{} + _ = json.NewDecoder(respSnippet.Body).Decode(&createdSnippet) + snippetID := createdSnippet["id"].(string) + + t.Run("Get success", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/snippets/%s", projectID, snippetID) + 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, snippetID, result["id"]) + assert.Equal(t, "Seeded Snippet", result["title"]) + }) + + t.Run("Get failure - snippet not found", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/snippets/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 - invalid project UUID format", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/invalid-project-id/snippets/%s", snippetID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Get failure - invalid snippet UUID format", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/snippets/invalid-snippet-id", projectID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestSnippetHandler_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 snippets in Project A + for i := 1; i <= 12; i++ { + snippetBody := []byte(fmt.Sprintf(`{ + "title": "Project A Snippet %d", + "content": "echo %d", + "language": "BASH", + "snippetType": "COMMAND" + }`, i, i)) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/snippets", projectAID) + _ = app.DoRequest(t, http.MethodPost, urlCreate, snippetBody, true) + } + + // Seed 3 snippets in Project B + for i := 1; i <= 3; i++ { + snippetBody := []byte(fmt.Sprintf(`{ + "title": "Project B Snippet %d", + "content": "echo %d", + "language": "GO", + "snippetType": "CODE" + }`, i, i)) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/snippets", projectBID) + _ = app.DoRequest(t, http.MethodPost, urlCreate, snippetBody, true) + } + + t.Run("GetAll success - default pagination", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/snippets", 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 { + snippetMap := item.(map[string]interface{}) + assert.Equal(t, projectAID, snippetMap["projectId"]) + } + }) + + t.Run("GetAll success - custom page and size", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/snippets?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 { + snippetMap := item.(map[string]interface{}) + assert.Equal(t, projectAID, snippetMap["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/snippets", 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/snippets", 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/snippets?page=0&size=101", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestSnippetHandler_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) + + snippetBody := []byte(`{ + "title": "Original Title", + "content": "Original Content", + "language": "GO", + "snippetType": "CODE" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + respSnippet := app.DoRequest(t, http.MethodPost, urlCreate, snippetBody, true) + var createdSnippet map[string]interface{} + _ = json.NewDecoder(respSnippet.Body).Decode(&createdSnippet) + snippetID := createdSnippet["id"].(string) + + t.Run("Update success", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/snippets/%s", projectID, snippetID) + 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"]) + }) + + t.Run("Update failure - snippet not found", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/snippets/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/snippets/%s", snippetID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Update failure - invalid snippet UUID format", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/snippets/invalid-snippet-id", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestSnippetHandler_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) + + snippetBody := []byte(`{ + "title": "Snippet To Delete", + "content": "rm -rf /tmp", + "language": "BASH", + "snippetType": "COMMAND" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + respSnippet := app.DoRequest(t, http.MethodPost, urlCreate, snippetBody, true) + var createdSnippet map[string]interface{} + _ = json.NewDecoder(respSnippet.Body).Decode(&createdSnippet) + snippetID := createdSnippet["id"].(string) + + t.Run("Delete success", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/snippets/%s", projectID, snippetID) + 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 - snippet not found", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/snippets/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 snippet UUID", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/snippets/invalid-snippet-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/snippets/%s", snippetID) + 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 d491187..373cdcb 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 @@ -27,8 +27,13 @@ func SetupTestApp(t *testing.T) *TestApp { projectUseCase := usecase.NewProjectUseCase(projectRepo) projectHandler := handler.NewProjectHandler(projectUseCase) + snippetRepo := persistence.NewSnippetRepository(db) + snippetUseCase := usecase.NewSnippetUseCase(snippetRepo, projectRepo) + snippetHandler := handler.NewSnippetHandler(snippetUseCase) + handlers := &web.Handlers{ Project: projectHandler, + Snippet: snippetHandler, } 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 7e614ba..89a1354 100644 --- a/backend-go/internal/adapter/in/web/router.go +++ b/backend-go/internal/adapter/in/web/router.go @@ -12,6 +12,7 @@ import ( type Handlers struct { Project *handler.ProjectHandler + Snippet *handler.SnippetHandler } func SetupRouter(h *Handlers, apiToken string) *gin.Engine { @@ -30,6 +31,7 @@ func SetupRouter(h *Handlers, apiToken string) *gin.Engine { protected.Use(middleware.AuthMiddleware(apiToken)) { mapProjectRoutes(protected, h.Project) + mapSnippetRoutes(protected, h.Snippet) } } return r @@ -40,11 +42,22 @@ func mapProjectRoutes(rg *gin.RouterGroup, h *handler.ProjectHandler) { { projects.POST("", h.Create) projects.GET("", h.GetAll) - projects.GET("/:id", h.Get) - projects.PATCH("/:id", h.Update) - projects.PATCH("/:id/archive", h.Archive) - projects.PATCH("/:id/unarchive", h.Unarchive) - projects.DELETE("/:id", h.Delete) + projects.GET("/:project_id", h.Get) + projects.PATCH("/:project_id", h.Update) + projects.PATCH("/:project_id/archive", h.Archive) + projects.PATCH("/:project_id/unarchive", h.Unarchive) + projects.DELETE("/:project_id", h.Delete) + } +} + +func mapSnippetRoutes(rg *gin.RouterGroup, h *handler.SnippetHandler) { + snippets := rg.Group("/projects/:project_id/snippets") + { + snippets.POST("", h.Create) + snippets.GET("", h.GetAll) + snippets.GET("/:snippet_id", h.Get) + snippets.PATCH("/:snippet_id", h.Update) + snippets.DELETE("/:snippet_id", h.Delete) } } diff --git a/backend-go/internal/adapter/out/persistence/project_repository.go b/backend-go/internal/adapter/out/persistence/project_repository.go index 6506ecb..8f4292c 100644 --- a/backend-go/internal/adapter/out/persistence/project_repository.go +++ b/backend-go/internal/adapter/out/persistence/project_repository.go @@ -51,13 +51,17 @@ func (r *ProjectRepositoryAdapter) FindByID(ctx context.Context, id uuid.UUID) ( return &project, nil } -func (r *ProjectRepositoryAdapter) DeleteByID(ctx context.Context, id uuid.UUID) error { +func (r *ProjectRepositoryAdapter) DeleteByID(ctx context.Context, id uuid.UUID) (bool, error) { query := `DELETE FROM projects WHERE id = ?` - _, err := r.db.ExecContext(ctx, query, id) + res, err := r.db.ExecContext(ctx, query, id) if err != nil { - return err + return false, err + } + rows, err := res.RowsAffected() + if err != nil { + return false, err } - return nil + return rows > 0, nil } func (r *ProjectRepositoryAdapter) ExistsByID(ctx context.Context, id uuid.UUID) (bool, error) { diff --git a/backend-go/internal/adapter/out/persistence/snippet_repository.go b/backend-go/internal/adapter/out/persistence/snippet_repository.go index 92096b3..b24877e 100644 --- a/backend-go/internal/adapter/out/persistence/snippet_repository.go +++ b/backend-go/internal/adapter/out/persistence/snippet_repository.go @@ -35,10 +35,10 @@ func (r *SnippetRepositoryAdapter) Save(ctx context.Context, snippet *model.Snip return snippet, nil } -func (r *SnippetRepositoryAdapter) FindByID(ctx context.Context, id uuid.UUID) (*model.Snippet, error) { - query := `SELECT * FROM snippets WHERE id = ?` +func (r *SnippetRepositoryAdapter) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Snippet, error) { + query := `SELECT * FROM snippets WHERE id = ? AND project_id = ?` var snippet model.Snippet - err := r.db.GetContext(ctx, &snippet, query, id) + err := r.db.GetContext(ctx, &snippet, query, id, projectID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -54,13 +54,17 @@ func (r *SnippetRepositoryAdapter) FindAllByProjectID(ctx context.Context, proje return PaginateExec[model.Snippet](ctx, r.db, countQuery, selectQuery, page, size, projectID) } -func (r *SnippetRepositoryAdapter) DeleteByID(ctx context.Context, id uuid.UUID) error { - query := `DELETE FROM snippets WHERE id = ?` - _, err := r.db.ExecContext(ctx, query, id) +func (r *SnippetRepositoryAdapter) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + query := `DELETE FROM snippets WHERE id = ? AND project_id = ?` + res, err := r.db.ExecContext(ctx, query, id, projectID) if err != nil { - return fmt.Errorf("error trying to delete snippet: %w", err) + return false, fmt.Errorf("error trying to delete snippet: %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 *SnippetRepositoryAdapter) ExistsByIDAndProjectID(ctx context.Context, id uuid.UUID, projectID uuid.UUID) (bool, error) { diff --git a/backend-go/internal/domain/port/project_repository.go b/backend-go/internal/domain/port/project_repository.go index ab925c7..25c9268 100644 --- a/backend-go/internal/domain/port/project_repository.go +++ b/backend-go/internal/domain/port/project_repository.go @@ -11,6 +11,6 @@ type ProjectRepository interface { Save(ctx context.Context, project *model.Project) (*model.Project, error) FindAll(ctx context.Context, page int, size int) (model.Page[model.Project], error) FindByID(ctx context.Context, id uuid.UUID) (*model.Project, error) - DeleteByID(ctx context.Context, id uuid.UUID) error + DeleteByID(ctx context.Context, id uuid.UUID) (bool, error) ExistsByID(ctx context.Context, id uuid.UUID) (bool, error) } diff --git a/backend-go/internal/domain/port/snippet_repository.go b/backend-go/internal/domain/port/snippet_repository.go index 6b1f6a2..56f8d1b 100644 --- a/backend-go/internal/domain/port/snippet_repository.go +++ b/backend-go/internal/domain/port/snippet_repository.go @@ -9,8 +9,8 @@ import ( type SnippetRepository interface { Save(ctx context.Context, snippet *model.Snippet) (*model.Snippet, error) - FindByID(ctx context.Context, id uuid.UUID) (*model.Snippet, error) + FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Snippet, error) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[model.Snippet], error) - DeleteByID(ctx context.Context, id uuid.UUID) error + DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) ProjectScopedRepository } diff --git a/backend-go/internal/usecase/project_usecase.go b/backend-go/internal/usecase/project_usecase.go index 3c02549..a90a71f 100644 --- a/backend-go/internal/usecase/project_usecase.go +++ b/backend-go/internal/usecase/project_usecase.go @@ -30,10 +30,10 @@ type CreateProjectCommand struct { type UpdateProjectCommand struct { ID uuid.UUID // Is passed as a path variable in the controller - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Icon *string `json:"icon,omitempty"` - Color *string `json:"color,omitempty"` + Name *string `json:"name,omitempty" binding:"omitempty,min=2,max=255"` + Description *string `json:"description,omitempty" binding:"omitempty,min=1,max=255"` + Icon *string `json:"icon,omitempty" binding:"omitempty,max=100"` + Color *string `json:"color,omitempty" binding:"omitempty,hexcolor"` } func NewProjectUseCase(projectRepo port.ProjectRepository) *ProjectUseCase { @@ -57,7 +57,14 @@ func (uc *ProjectUseCase) Create(ctx context.Context, cmd CreateProjectCommand) } func (uc *ProjectUseCase) GetByID(ctx context.Context, id uuid.UUID) (*model.Project, error) { - return uc.projectRepo.FindByID(ctx, id) + project, err := uc.projectRepo.FindByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("error trying to find project: %w", err) + } + if project == nil { + return nil, ErrProjectNotFound + } + return project, nil } func (uc *ProjectUseCase) GetAll(ctx context.Context, page, size int) (model.Page[model.Project], error) { @@ -91,7 +98,14 @@ func (uc *ProjectUseCase) Update(ctx context.Context, cmd UpdateProjectCommand) } func (uc *ProjectUseCase) Delete(ctx context.Context, id uuid.UUID) error { - return uc.projectRepo.DeleteByID(ctx, id) + deleted, err := uc.projectRepo.DeleteByID(ctx, id) + if err != nil { + return fmt.Errorf("error trying to delete project: %w", err) + } + if !deleted { + return ErrProjectNotFound + } + return nil } func (uc *ProjectUseCase) Archive(ctx context.Context, id uuid.UUID) error { @@ -137,3 +151,17 @@ func (uc *ProjectUseCase) Unarchive(ctx context.Context, id uuid.UUID) error { } return nil } + +// ---- Auxiliary functions + +// Used for other's use cases to check if a project exists +func ensureProjectExists(ctx context.Context, projectRepo port.ProjectRepository, id uuid.UUID) error { + exists, err := projectRepo.ExistsByID(ctx, id) + if err != nil { + return fmt.Errorf("error trying to check if project exists: %w", err) + } + if !exists { + return ErrProjectNotFound + } + return nil +} diff --git a/backend-go/internal/usecase/project_usecase_test.go b/backend-go/internal/usecase/project_usecase_test.go index cd5d1cf..34ae684 100644 --- a/backend-go/internal/usecase/project_usecase_test.go +++ b/backend-go/internal/usecase/project_usecase_test.go @@ -37,9 +37,9 @@ func (m *MockProjectRepository) FindAll(ctx context.Context, page, size int) (mo return args.Get(0).(model.Page[model.Project]), args.Error(1) } -func (m *MockProjectRepository) DeleteByID(ctx context.Context, id uuid.UUID) error { +func (m *MockProjectRepository) DeleteByID(ctx context.Context, id uuid.UUID) (bool, error) { args := m.Called(ctx, id) - return args.Error(0) + return args.Bool(0), args.Error(1) } func (m *MockProjectRepository) ExistsByID(ctx context.Context, id uuid.UUID) (bool, error) { @@ -109,8 +109,8 @@ func TestProjectUseCase_GetByID_NotFound(t *testing.T) { result, err := uc.GetByID(ctx, projectID) - assert.NoError(t, err) assert.Nil(t, result) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) mockRepo.AssertExpectations(t) } @@ -194,7 +194,7 @@ func TestProjectUseCase_Delete_Success(t *testing.T) { ctx := context.Background() projectID := uuid.New() - mockRepo.On("DeleteByID", ctx, projectID).Return(nil) + mockRepo.On("DeleteByID", ctx, projectID).Return(true, nil) err := uc.Delete(ctx, projectID) @@ -202,6 +202,20 @@ func TestProjectUseCase_Delete_Success(t *testing.T) { mockRepo.AssertExpectations(t) } +func TestProjectUseCase_Delete_NotFound(t *testing.T) { + mockRepo := new(MockProjectRepository) + uc := usecase.NewProjectUseCase(mockRepo) + ctx := context.Background() + projectID := uuid.New() + + mockRepo.On("DeleteByID", ctx, projectID).Return(false, nil) + + err := uc.Delete(ctx, projectID) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockRepo.AssertExpectations(t) +} + func TestProjectUseCase_Archive_Success(t *testing.T) { mockRepo := new(MockProjectRepository) uc := usecase.NewProjectUseCase(mockRepo) diff --git a/backend-go/internal/usecase/snippet_usecase.go b/backend-go/internal/usecase/snippet_usecase.go new file mode 100644 index 0000000..8172470 --- /dev/null +++ b/backend-go/internal/usecase/snippet_usecase.go @@ -0,0 +1,135 @@ +package usecase + +import ( + "context" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +var ( + ErrSnippetNotFound = errors.New("snippet not found") +) + +type SnippetUseCase struct { + snippetRepo port.SnippetRepository + projectRepo port.ProjectRepository +} + +type CreateSnippetCommand struct { + ProjectID uuid.UUID + Title string `json:"title" binding:"required,min=2,max=255"` + Description *string `json:"description,omitempty" binding:"omitempty,min=1,max=255"` + Content string `json:"content" binding:"required,min=1"` + Language model.SnippetLanguage `json:"language" binding:"required"` + SnippetType model.SnippetType `json:"snippetType" binding:"required"` +} + +type UpdateSnippetCommand struct { + ProjectID uuid.UUID + ID uuid.UUID + Title *string `json:"title,omitempty" binding:"omitempty,min=2,max=255"` + Description *string `json:"description,omitempty" binding:"omitempty,min=1,max=255"` + Content *string `json:"content,omitempty" binding:"omitempty,min=1"` + Language *model.SnippetLanguage `json:"language,omitempty" binding:"omitempty"` + SnippetType *model.SnippetType `json:"snippetType" binding:"omitempty"` +} + +func NewSnippetUseCase(snippetRepo port.SnippetRepository, projectRepo port.ProjectRepository) *SnippetUseCase { + return &SnippetUseCase{ + snippetRepo: snippetRepo, + projectRepo: projectRepo, + } +} + +func (uc *SnippetUseCase) Create(ctx context.Context, cmd CreateSnippetCommand) (*model.Snippet, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + + snippet := model.Snippet{ + ID: uuid.New(), + ProjectID: cmd.ProjectID, + Title: cmd.Title, + Description: cmd.Description, + Content: cmd.Content, + Language: &cmd.Language, + SnippetType: cmd.SnippetType, + BaseEntity: model.BaseEntity{ + CreatedAt: time.Now(), + UpdatedAt: nil, + }, + } + return uc.snippetRepo.Save(ctx, &snippet) +} + +func (uc *SnippetUseCase) GetByID(ctx context.Context, projectID, id uuid.UUID) (*model.Snippet, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return nil, err + } + snippet, err := uc.snippetRepo.FindByIDAndProjectID(ctx, projectID, id) + if err != nil { + return nil, fmt.Errorf("error trying to find snippet: %w", err) + } + if snippet == nil { + return nil, ErrSnippetNotFound + } + return snippet, nil +} + +func (uc *SnippetUseCase) GetAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[model.Snippet], error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return model.Page[model.Snippet]{}, err + } + return uc.snippetRepo.FindAllByProjectID(ctx, projectID, page, size) +} + +func (uc *SnippetUseCase) Update(ctx context.Context, cmd UpdateSnippetCommand) (*model.Snippet, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + + snippet, err := uc.snippetRepo.FindByIDAndProjectID(ctx, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error trying to find snippet: %w", err) + } + if snippet == nil { + return nil, ErrSnippetNotFound + } + if cmd.Title != nil { + snippet.Title = *cmd.Title + } + if cmd.Description != nil { + snippet.Description = cmd.Description + } + if cmd.Content != nil { + snippet.Content = *cmd.Content + } + if cmd.Language != nil { + snippet.Language = cmd.Language + } + if cmd.SnippetType != nil { + snippet.SnippetType = *cmd.SnippetType + } + now := time.Now() + snippet.UpdatedAt = &now + return uc.snippetRepo.Save(ctx, snippet) +} + +func (uc *SnippetUseCase) Delete(ctx context.Context, projectID, id uuid.UUID) error { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return err + } + deleted, err := uc.snippetRepo.DeleteByIDAndProjectID(ctx, projectID, id) + if err != nil { + return fmt.Errorf("error deleting snippet: %w", err) + } + if !deleted { + return ErrSnippetNotFound + } + return nil +} diff --git a/backend-go/internal/usecase/snippet_usecase_test.go b/backend-go/internal/usecase/snippet_usecase_test.go new file mode 100644 index 0000000..34e09ba --- /dev/null +++ b/backend-go/internal/usecase/snippet_usecase_test.go @@ -0,0 +1,372 @@ +package usecase_test + +import ( + "context" + "testing" + + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/usecase" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockSnippetRepository struct { + mock.Mock +} + +func (m *MockSnippetRepository) Save(ctx context.Context, snippet *model.Snippet) (*model.Snippet, error) { + args := m.Called(ctx, snippet) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Snippet), args.Error(1) +} + +func (m *MockSnippetRepository) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Snippet, error) { + args := m.Called(ctx, projectID, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Snippet), args.Error(1) +} + +func (m *MockSnippetRepository) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[model.Snippet], error) { + args := m.Called(ctx, projectID, page, size) + return args.Get(0).(model.Page[model.Snippet]), args.Error(1) +} + +func (m *MockSnippetRepository) 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 *MockSnippetRepository) 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 *MockSnippetRepository) 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 TestSnippetUseCase_Create_Success(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + desc := "Snippet description" + lang := model.SnippetLangGo + + cmd := usecase.CreateSnippetCommand{ + ProjectID: projectID, + Title: "Print Hello World", + Description: &desc, + Content: `fmt.Println("Hello World")`, + Language: lang, + SnippetType: model.SnippetTypeCode, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("Save", ctx, mock.MatchedBy(func(s *model.Snippet) bool { + return s.ProjectID == projectID && s.Title == "Print Hello World" && s.Content == `fmt.Println("Hello World")` + })).Return(&model.Snippet{ + ID: uuid.New(), + ProjectID: projectID, + Title: "Print Hello World", + Description: &desc, + Content: `fmt.Println("Hello World")`, + Language: &lang, + SnippetType: model.SnippetTypeCode, + }, nil) + + created, err := uc.Create(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, created) + assert.Equal(t, "Print Hello World", created.Title) + mockProjectRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_Create_ProjectNotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + cmd := usecase.CreateSnippetCommand{ + ProjectID: projectID, + Title: "Print Hello World", + Content: `fmt.Println("Hello World")`, + Language: model.SnippetLangGo, + SnippetType: model.SnippetTypeCode, + } + + 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 TestSnippetUseCase_GetByID_Success(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + expectedSnippet := &model.Snippet{ + ID: snippetID, + ProjectID: projectID, + Title: "Sample Snippet", + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("FindByIDAndProjectID", ctx, projectID, snippetID).Return(expectedSnippet, nil) + + result, err := uc.GetByID(ctx, projectID, snippetID) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, snippetID, result.ID) + assert.Equal(t, projectID, result.ProjectID) + mockProjectRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_GetByID_ProjectNotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + result, err := uc.GetByID(ctx, projectID, snippetID) + + assert.Nil(t, result) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_GetByID_NotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("FindByIDAndProjectID", ctx, projectID, snippetID).Return(nil, nil) + + result, err := uc.GetByID(ctx, projectID, snippetID) + + assert.Nil(t, result) + assert.ErrorIs(t, err, usecase.ErrSnippetNotFound) + mockProjectRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_GetAllByProjectID_Success(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + expectedPage := model.NewPage([]model.Snippet{ + {ID: uuid.New(), ProjectID: projectID, Title: "Snippet 1"}, + {ID: uuid.New(), ProjectID: projectID, Title: "Snippet 2"}, + }, 0, 10, 2) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.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) + mockSnippetRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_GetAllByProjectID_ProjectNotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, 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 TestSnippetUseCase_Update_Success(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + + existingSnippet := &model.Snippet{ + ID: snippetID, + ProjectID: projectID, + Title: "Old Title", + Content: "Old Content", + } + + newTitle := "Updated Title" + cmd := usecase.UpdateSnippetCommand{ + ProjectID: projectID, + ID: snippetID, + Title: &newTitle, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("FindByIDAndProjectID", ctx, projectID, snippetID).Return(existingSnippet, nil) + mockSnippetRepo.On("Save", ctx, mock.MatchedBy(func(s *model.Snippet) bool { + return s.Title == "Updated Title" && s.UpdatedAt != nil + })).Return(&model.Snippet{ + ID: snippetID, + ProjectID: projectID, + Title: "Updated Title", + }, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, updated) + assert.Equal(t, "Updated Title", updated.Title) + mockProjectRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_Update_ProjectNotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + newTitle := "Updated Title" + cmd := usecase.UpdateSnippetCommand{ + ProjectID: projectID, + ID: snippetID, + 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 TestSnippetUseCase_Update_SnippetNotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + newTitle := "Updated Title" + cmd := usecase.UpdateSnippetCommand{ + ProjectID: projectID, + ID: snippetID, + Title: &newTitle, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("FindByIDAndProjectID", ctx, projectID, snippetID).Return(nil, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.Nil(t, updated) + assert.ErrorIs(t, err, usecase.ErrSnippetNotFound) + mockProjectRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_Delete_Success(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("DeleteByIDAndProjectID", ctx, projectID, snippetID).Return(true, nil) + + err := uc.Delete(ctx, projectID, snippetID) + + assert.NoError(t, err) + mockProjectRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_Delete_ProjectNotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + err := uc.Delete(ctx, projectID, snippetID) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_Delete_SnippetNotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("DeleteByIDAndProjectID", ctx, projectID, snippetID).Return(false, nil) + + err := uc.Delete(ctx, projectID, snippetID) + + assert.ErrorIs(t, err, usecase.ErrSnippetNotFound) + mockProjectRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) +}