diff --git a/backend-go/cmd/api/main.go b/backend-go/cmd/api/main.go index 7de304d..8bc69c5 100644 --- a/backend-go/cmd/api/main.go +++ b/backend-go/cmd/api/main.go @@ -38,10 +38,14 @@ func main() { snippetRepo := persistence.NewSnippetRepository(db) snippetUseCase := usecase.NewSnippetUseCase(snippetRepo, projectRepo) snippetHandler := handler.NewSnippetHandler(snippetUseCase) + linkRepo := persistence.NewLinkRepository(db) + linkUseCase := usecase.NewLinkUseCase(linkRepo, projectRepo) + linkHandler := handler.NewLinkHandler(linkUseCase) handlers := &web.Handlers{ Project: projectHandler, Snippet: snippetHandler, + Link: linkHandler, } r := web.SetupRouter(handlers, devaultyInternalToken) diff --git a/backend-go/docs/openapi.yaml b/backend-go/docs/openapi.yaml index a4c5b1b..3888b7f 100644 --- a/backend-go/docs/openapi.yaml +++ b/backend-go/docs/openapi.yaml @@ -354,6 +354,108 @@ components: type: integer example: 1 + Link: + type: object + required: + - id + - projectId + - title + - url + - 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: "Go Documentation" + url: + type: string + format: uri + example: "https://go.dev/doc" + description: + type: string + nullable: true + example: "Official Go Documentation" + 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" + + CreateLinkCommand: + type: object + required: + - title + - url + properties: + title: + type: string + minLength: 2 + maxLength: 255 + example: "Go Documentation" + url: + type: string + format: uri + example: "https://go.dev/doc" + description: + type: string + nullable: true + example: "Official Go Documentation" + + UpdateLinkCommand: + type: object + properties: + title: + type: string + minLength: 2 + maxLength: 255 + example: "Updated Go Documentation" + url: + type: string + format: uri + example: "https://go.dev/doc" + description: + type: string + example: "Updated description" + + LinkPage: + type: object + required: + - content + - size + - number + - totalElements + - totalPages + properties: + content: + type: array + maxItems: 100 + items: + $ref: '#/components/schemas/Link' + size: + type: integer + example: 10 + number: + type: integer + example: 0 + totalElements: + type: integer + format: int64 + example: 1 + totalPages: + type: integer + example: 1 + security: - ApiKeyAuth: [] @@ -959,3 +1061,282 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/links: + post: + summary: Create a new link in a project + description: Creates a new link entity linked to the specified project. + tags: + - Links + 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/CreateLinkCommand' + responses: + '201': + description: Link created successfully + headers: + Location: + schema: + type: string + description: Relative URL path to the newly created link + example: "/api/v1/projects/06f891ae-368b-4bd6-b1e6-d957f1496f9a/links/784f6f5c-57af-4313-b160-688a3e0d283b" + content: + application/json: + schema: + $ref: '#/components/schemas/Link' + '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 links for a project + description: Fetches a paginated list of links for the specified project ordered by creation date descending. + tags: + - Links + 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 links retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/LinkPage' + '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}/links/{link_id}: + get: + summary: Get link by ID + description: Retrieves a single link entity by its UUID and parent project UUID. + tags: + - Links + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: link_id + in: path + description: Link UUID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Link details retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Link' + '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 Link 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 link (Partial Update) + description: Performs a partial update (PATCH) on link fields specified in the request body. + tags: + - Links + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: link_id + in: path + description: Link UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateLinkCommand' + responses: + '200': + description: Link updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Link' + '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 Link 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 link by ID + description: Permanently deletes a link by its UUID within a project. + tags: + - Links + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: link_id + in: path + description: Link UUID + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Link 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 Link 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/link_handler.go b/backend-go/internal/adapter/in/web/handler/link_handler.go new file mode 100644 index 0000000..aaa5c5a --- /dev/null +++ b/backend-go/internal/adapter/in/web/handler/link_handler.go @@ -0,0 +1,165 @@ +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 LinkHandler struct { + linkUseCase *usecase.LinkUseCase +} + +func NewLinkHandler(linkUseCase *usecase.LinkUseCase) *LinkHandler { + return &LinkHandler{linkUseCase: linkUseCase} +} + +func (h *LinkHandler) Create(c *gin.Context) { + var cmd usecase.CreateLinkCommand + 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 + + link, err := h.linkUseCase.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"}) + log.Printf("[LinkHandler.Create] %v", err) + return + } + location := fmt.Sprintf("%s/%s", c.Request.URL.Path, link.ID) + c.Header("Location", location) + c.JSON(http.StatusCreated, link) +} + +func (h *LinkHandler) 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 + } + + pagedLinks, err := h.linkUseCase.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 + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + log.Printf("[LinkHandler.GetAll] %v", err) + return + } + c.JSON(http.StatusOK, pagedLinks) +} + +func (h *LinkHandler) Get(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + linkID, err := common.ExtractUUIDParam(c, "link_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + link, err := h.linkUseCase.GetByID(c.Request.Context(), projectID, linkID) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrLinkNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + log.Printf("[LinkHandler.Get] %v", err) + return + } + c.JSON(http.StatusOK, link) +} + +func (h *LinkHandler) Update(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + linkID, err := common.ExtractUUIDParam(c, "link_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var cmd usecase.UpdateLinkCommand + err = c.ShouldBindJSON(&cmd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ProjectID = projectID + cmd.ID = linkID + + link, err := h.linkUseCase.Update(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrLinkNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + log.Printf("[LinkHandler.Update] %v", err) + return + } + c.JSON(http.StatusOK, link) +} + +func (h *LinkHandler) 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, "link_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.linkUseCase.Delete(c.Request.Context(), projectID, id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrLinkNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[LinkHandler.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/link_handler_test.go b/backend-go/internal/adapter/in/web/handler/link_handler_test.go new file mode 100644 index 0000000..bb1408a --- /dev/null +++ b/backend-go/internal/adapter/in/web/handler/link_handler_test.go @@ -0,0 +1,381 @@ +package handler_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLinkHandler_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) { + linkBody := []byte(`{ + "title": "Go Documentation", + "url": "https://go.dev/doc", + "description": "Official Go Documentation" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, linkBody, 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, "Go Documentation", result["title"]) + assert.Equal(t, "https://go.dev/doc", result["url"]) + assert.Equal(t, "Official Go Documentation", result["description"]) + assert.Equal(t, projectID, result["projectId"]) + assert.NotEmpty(t, result["id"]) + }) + + t.Run("Create failure - project not found", func(t *testing.T) { + linkBody := []byte(`{ + "title": "Go Documentation", + "url": "https://go.dev/doc" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/00000000-0000-0000-0000-000000000000/links", linkBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Create failure - invalid project UUID format", func(t *testing.T) { + linkBody := []byte(`{ + "title": "Go Documentation", + "url": "https://go.dev/doc" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/invalid-project-id/links", linkBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - missing required fields", func(t *testing.T) { + linkBody := []byte(`{"description": "No title or url"}`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, linkBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - title too short", func(t *testing.T) { + linkBody := []byte(`{ + "title": "A", + "url": "https://go.dev/doc" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, linkBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - invalid URL format", func(t *testing.T) { + linkBody := []byte(`{ + "title": "Invalid Link", + "url": "not-a-valid-url" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, linkBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - unauthorized", func(t *testing.T) { + linkBody := []byte(`{ + "title": "Go Documentation", + "url": "https://go.dev/doc" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, linkBody, false) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +func TestLinkHandler_Get(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed a project and a link + 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) + + linkBody := []byte(`{ + "title": "Seeded Link", + "url": "https://example.com" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + respLink := app.DoRequest(t, http.MethodPost, urlCreate, linkBody, true) + var createdLink map[string]interface{} + _ = json.NewDecoder(respLink.Body).Decode(&createdLink) + linkID := createdLink["id"].(string) + + t.Run("Get success", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/links/%s", projectID, linkID) + 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, linkID, result["id"]) + assert.Equal(t, "Seeded Link", result["title"]) + assert.Equal(t, "https://example.com", result["url"]) + }) + + t.Run("Get failure - link not found", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/links/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/links/%s", linkID) + 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/links/%s", linkID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Get failure - invalid link UUID format", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/links/invalid-link-id", projectID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestLinkHandler_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 links in Project A + for i := 1; i <= 12; i++ { + linkBody := []byte(fmt.Sprintf(`{ + "title": "Project A Link %d", + "url": "https://example.com/a/%d" + }`, i, i)) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/links", projectAID) + _ = app.DoRequest(t, http.MethodPost, urlCreate, linkBody, true) + } + + // Seed 3 links in Project B + for i := 1; i <= 3; i++ { + linkBody := []byte(fmt.Sprintf(`{ + "title": "Project B Link %d", + "url": "https://example.com/b/%d" + }`, i, i)) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/links", projectBID) + _ = app.DoRequest(t, http.MethodPost, urlCreate, linkBody, true) + } + + t.Run("GetAll success - default pagination", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/links", 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 { + linkMap := item.(map[string]interface{}) + assert.Equal(t, projectAID, linkMap["projectId"]) + } + }) + + t.Run("GetAll success - custom page and size", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/links?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 { + linkMap := item.(map[string]interface{}) + assert.Equal(t, projectAID, linkMap["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/links", 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/links", 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/links?page=0&size=101", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestLinkHandler_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) + + linkBody := []byte(`{ + "title": "Original Title", + "url": "https://original.com" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + respLink := app.DoRequest(t, http.MethodPost, urlCreate, linkBody, true) + var createdLink map[string]interface{} + _ = json.NewDecoder(respLink.Body).Decode(&createdLink) + linkID := createdLink["id"].(string) + + t.Run("Update success", func(t *testing.T) { + updateBody := []byte(`{ + "title": "Updated Title", + "url": "https://updated.com" + }`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/links/%s", projectID, linkID) + 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, "https://updated.com", result["url"]) + }) + + t.Run("Update failure - link not found", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/links/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/links/%s", linkID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Update failure - invalid link UUID format", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/links/invalid-link-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/links/%s", linkID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Update failure - invalid URL format", func(t *testing.T) { + updateBody := []byte(`{"url": "not-a-valid-url"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/links/%s", projectID, linkID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestLinkHandler_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) + + linkBody := []byte(`{ + "title": "Link To Delete", + "url": "https://delete-me.com" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + respLink := app.DoRequest(t, http.MethodPost, urlCreate, linkBody, true) + var createdLink map[string]interface{} + _ = json.NewDecoder(respLink.Body).Decode(&createdLink) + linkID := createdLink["id"].(string) + + t.Run("Delete success", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/links/%s", projectID, linkID) + 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 - link not found", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/links/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 link UUID", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/links/invalid-link-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/links/%s", linkID) + 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 373cdcb..69bc1ab 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 @@ -31,9 +31,14 @@ func SetupTestApp(t *testing.T) *TestApp { snippetUseCase := usecase.NewSnippetUseCase(snippetRepo, projectRepo) snippetHandler := handler.NewSnippetHandler(snippetUseCase) + linkRepo := persistence.NewLinkRepository(db) + linkUseCase := usecase.NewLinkUseCase(linkRepo, projectRepo) + linkHandler := handler.NewLinkHandler(linkUseCase) + handlers := &web.Handlers{ Project: projectHandler, Snippet: snippetHandler, + Link: linkHandler, } 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 89a1354..84aa8c6 100644 --- a/backend-go/internal/adapter/in/web/router.go +++ b/backend-go/internal/adapter/in/web/router.go @@ -13,6 +13,7 @@ import ( type Handlers struct { Project *handler.ProjectHandler Snippet *handler.SnippetHandler + Link *handler.LinkHandler } func SetupRouter(h *Handlers, apiToken string) *gin.Engine { @@ -32,6 +33,7 @@ func SetupRouter(h *Handlers, apiToken string) *gin.Engine { { mapProjectRoutes(protected, h.Project) mapSnippetRoutes(protected, h.Snippet) + mapLinkRoutes(protected, h.Link) } } return r @@ -61,6 +63,17 @@ func mapSnippetRoutes(rg *gin.RouterGroup, h *handler.SnippetHandler) { } } +func mapLinkRoutes(rg *gin.RouterGroup, h *handler.LinkHandler) { + links := rg.Group("/projects/:project_id/links") + { + links.POST("", h.Create) + links.GET("", h.GetAll) + links.GET("/:link_id", h.Get) + links.PATCH("/:link_id", h.Update) + links.DELETE("/:link_id", h.Delete) + } +} + func registerDocsRoutes(r *gin.Engine) { if os.Getenv("APP_ENV") != "dev" { return diff --git a/backend-go/internal/adapter/out/persistence/link_repository.go b/backend-go/internal/adapter/out/persistence/link_repository.go index b982ffb..04c9df6 100644 --- a/backend-go/internal/adapter/out/persistence/link_repository.go +++ b/backend-go/internal/adapter/out/persistence/link_repository.go @@ -33,10 +33,10 @@ func (r *LinkRepositoryAdapter) Save(ctx context.Context, link *model.Link) (*mo return link, nil } -func (r *LinkRepositoryAdapter) FindByID(ctx context.Context, id uuid.UUID) (*model.Link, error) { - query := `SELECT * FROM links WHERE id = ?` +func (r *LinkRepositoryAdapter) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Link, error) { + query := `SELECT * FROM links WHERE id = ? AND project_id = ?` var link model.Link - err := r.db.GetContext(ctx, &link, query, id) + err := r.db.GetContext(ctx, &link, query, id, projectID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -53,14 +53,17 @@ func (r *LinkRepositoryAdapter) FindAllByProjectID(ctx context.Context, projectI return PaginateExec[model.Link](ctx, r.db, countQuery, selectQuery, page, size, projectID) } -func (r *LinkRepositoryAdapter) DeleteByID(ctx context.Context, id uuid.UUID) error { - query := `DELETE FROM links WHERE id = ?` - _, err := r.db.ExecContext(ctx, query, id) +func (r *LinkRepositoryAdapter) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + query := `DELETE FROM links WHERE id = ? AND project_id = ?` + res, err := r.db.ExecContext(ctx, query, id, projectID) if err != nil { - return fmt.Errorf("error trying to delete link: %w", err) + return false, fmt.Errorf("error trying to delete link: %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 *LinkRepositoryAdapter) ExistsByIDAndProjectID(ctx context.Context, id uuid.UUID, projectID uuid.UUID) (bool, error) { diff --git a/backend-go/internal/domain/port/link_repository.go b/backend-go/internal/domain/port/link_repository.go index 8cd76c6..84c3f64 100644 --- a/backend-go/internal/domain/port/link_repository.go +++ b/backend-go/internal/domain/port/link_repository.go @@ -9,8 +9,8 @@ import ( type LinkRepository interface { Save(ctx context.Context, link *model.Link) (*model.Link, error) - FindByID(ctx context.Context, id uuid.UUID) (*model.Link, error) + FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Link, error) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[model.Link], 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/link_usecase.go b/backend-go/internal/usecase/link_usecase.go new file mode 100644 index 0000000..e4b8011 --- /dev/null +++ b/backend-go/internal/usecase/link_usecase.go @@ -0,0 +1,127 @@ +package usecase + +import ( + "context" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +var ( + ErrLinkNotFound = errors.New("link not found") +) + +type LinkUseCase struct { + linkRepo port.LinkRepository + projectRepo port.ProjectRepository +} + +type CreateLinkCommand struct { + ProjectID uuid.UUID + Title string `json:"title" binding:"required,min=2,max=255"` + URL string `json:"url" binding:"required,url"` + Description *string `json:"description,omitempty" binding:"omitempty"` +} + +type UpdateLinkCommand struct { + ID uuid.UUID + ProjectID uuid.UUID + Title *string `json:"title,omitempty" binding:"omitempty,min=2,max=255"` + URL *string `json:"url,omitempty" binding:"omitempty,url"` + Description *string `json:"description,omitempty" binding:"omitempty"` +} + +func NewLinkUseCase(linkRepo port.LinkRepository, projectRepo port.ProjectRepository) *LinkUseCase { + return &LinkUseCase{ + linkRepo: linkRepo, + projectRepo: projectRepo, + } +} + +func (uc *LinkUseCase) Create(ctx context.Context, cmd CreateLinkCommand) (*model.Link, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + + link := model.Link{ + ID: uuid.New(), + ProjectID: cmd.ProjectID, + Title: cmd.Title, + Url: cmd.URL, + Description: cmd.Description, + BaseEntity: model.BaseEntity{ + CreatedAt: time.Now(), + UpdatedAt: nil, + }, + } + + return uc.linkRepo.Save(ctx, &link) +} + +func (uc *LinkUseCase) GetByID(ctx context.Context, projectID, id uuid.UUID) (*model.Link, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return nil, err + } + link, err := uc.linkRepo.FindByIDAndProjectID(ctx, projectID, id) + if err != nil { + return nil, fmt.Errorf("error trying to find link: %w", err) + } + + if link == nil { + return nil, ErrLinkNotFound + } + + return link, nil +} + +func (uc *LinkUseCase) GetAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[model.Link], error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return model.Page[model.Link]{}, err + } + return uc.linkRepo.FindAllByProjectID(ctx, projectID, page, size) +} + +func (uc *LinkUseCase) Update(ctx context.Context, cmd UpdateLinkCommand) (*model.Link, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + + link, err := uc.linkRepo.FindByIDAndProjectID(ctx, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error trying to find link: %w", err) + } + if link == nil { + return nil, ErrLinkNotFound + } + + if cmd.Title != nil { + link.Title = *cmd.Title + } + if cmd.URL != nil { + link.Url = *cmd.URL + } + if cmd.Description != nil { + link.Description = cmd.Description + } + now := time.Now() + link.UpdatedAt = &now + return uc.linkRepo.Save(ctx, link) +} + +func (uc *LinkUseCase) Delete(ctx context.Context, projectID, id uuid.UUID) error { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return err + } + deleted, err := uc.linkRepo.DeleteByIDAndProjectID(ctx, projectID, id) + if err != nil { + return fmt.Errorf("error deleting link: %w", err) + } + if !deleted { + return ErrLinkNotFound + } + return nil +} diff --git a/backend-go/internal/usecase/link_usecase_test.go b/backend-go/internal/usecase/link_usecase_test.go new file mode 100644 index 0000000..edc133e --- /dev/null +++ b/backend-go/internal/usecase/link_usecase_test.go @@ -0,0 +1,367 @@ +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 MockLinkRepository struct { + mock.Mock +} + +func (m *MockLinkRepository) Save(ctx context.Context, link *model.Link) (*model.Link, error) { + args := m.Called(ctx, link) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Link), args.Error(1) +} + +func (m *MockLinkRepository) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Link, error) { + args := m.Called(ctx, projectID, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Link), args.Error(1) +} + +func (m *MockLinkRepository) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[model.Link], error) { + args := m.Called(ctx, projectID, page, size) + return args.Get(0).(model.Page[model.Link]), args.Error(1) +} + +func (m *MockLinkRepository) 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 *MockLinkRepository) 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 *MockLinkRepository) 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 TestLinkUseCase_Create_Success(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + desc := "Link description" + + cmd := usecase.CreateLinkCommand{ + ProjectID: projectID, + Title: "Go Documentation", + URL: "https://go.dev/doc", + Description: &desc, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.On("Save", ctx, mock.MatchedBy(func(l *model.Link) bool { + return l.ProjectID == projectID && l.Title == "Go Documentation" && l.Url == "https://go.dev/doc" + })).Return(&model.Link{ + ID: uuid.New(), + ProjectID: projectID, + Title: "Go Documentation", + Url: "https://go.dev/doc", + Description: &desc, + }, nil) + + created, err := uc.Create(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, created) + assert.Equal(t, "Go Documentation", created.Title) + mockProjectRepo.AssertExpectations(t) + mockLinkRepo.AssertExpectations(t) +} + +func TestLinkUseCase_Create_ProjectNotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + cmd := usecase.CreateLinkCommand{ + ProjectID: projectID, + Title: "Go Documentation", + URL: "https://go.dev/doc", + } + + 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 TestLinkUseCase_GetByID_Success(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + expectedLink := &model.Link{ + ID: linkID, + ProjectID: projectID, + Title: "Go Documentation", + Url: "https://go.dev/doc", + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.On("FindByIDAndProjectID", ctx, projectID, linkID).Return(expectedLink, nil) + + result, err := uc.GetByID(ctx, projectID, linkID) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, linkID, result.ID) + assert.Equal(t, projectID, result.ProjectID) + mockProjectRepo.AssertExpectations(t) + mockLinkRepo.AssertExpectations(t) +} + +func TestLinkUseCase_GetByID_ProjectNotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + result, err := uc.GetByID(ctx, projectID, linkID) + + assert.Nil(t, result) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestLinkUseCase_GetByID_NotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.On("FindByIDAndProjectID", ctx, projectID, linkID).Return(nil, nil) + + result, err := uc.GetByID(ctx, projectID, linkID) + + assert.Nil(t, result) + assert.ErrorIs(t, err, usecase.ErrLinkNotFound) + mockProjectRepo.AssertExpectations(t) + mockLinkRepo.AssertExpectations(t) +} + +func TestLinkUseCase_GetAllByProjectID_Success(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + expectedPage := model.NewPage([]model.Link{ + {ID: uuid.New(), ProjectID: projectID, Title: "Link 1", Url: "https://link1.com"}, + {ID: uuid.New(), ProjectID: projectID, Title: "Link 2", Url: "https://link2.com"}, + }, 0, 10, 2) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.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) + mockLinkRepo.AssertExpectations(t) +} + +func TestLinkUseCase_GetAllByProjectID_ProjectNotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, 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 TestLinkUseCase_Update_Success(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + + existingLink := &model.Link{ + ID: linkID, + ProjectID: projectID, + Title: "Old Title", + Url: "https://old-url.com", + } + + newTitle := "Updated Title" + cmd := usecase.UpdateLinkCommand{ + ProjectID: projectID, + ID: linkID, + Title: &newTitle, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.On("FindByIDAndProjectID", ctx, projectID, linkID).Return(existingLink, nil) + mockLinkRepo.On("Save", ctx, mock.MatchedBy(func(l *model.Link) bool { + return l.Title == "Updated Title" && l.UpdatedAt != nil + })).Return(&model.Link{ + ID: linkID, + ProjectID: projectID, + Title: "Updated Title", + Url: "https://old-url.com", + }, 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) + mockLinkRepo.AssertExpectations(t) +} + +func TestLinkUseCase_Update_ProjectNotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + newTitle := "Updated Title" + cmd := usecase.UpdateLinkCommand{ + ProjectID: projectID, + ID: linkID, + 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 TestLinkUseCase_Update_LinkNotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + newTitle := "Updated Title" + cmd := usecase.UpdateLinkCommand{ + ProjectID: projectID, + ID: linkID, + Title: &newTitle, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.On("FindByIDAndProjectID", ctx, projectID, linkID).Return(nil, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.Nil(t, updated) + assert.ErrorIs(t, err, usecase.ErrLinkNotFound) + mockProjectRepo.AssertExpectations(t) + mockLinkRepo.AssertExpectations(t) +} + +func TestLinkUseCase_Delete_Success(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.On("DeleteByIDAndProjectID", ctx, projectID, linkID).Return(true, nil) + + err := uc.Delete(ctx, projectID, linkID) + + assert.NoError(t, err) + mockProjectRepo.AssertExpectations(t) + mockLinkRepo.AssertExpectations(t) +} + +func TestLinkUseCase_Delete_ProjectNotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + err := uc.Delete(ctx, projectID, linkID) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestLinkUseCase_Delete_LinkNotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.On("DeleteByIDAndProjectID", ctx, projectID, linkID).Return(false, nil) + + err := uc.Delete(ctx, projectID, linkID) + + assert.ErrorIs(t, err, usecase.ErrLinkNotFound) + mockProjectRepo.AssertExpectations(t) + mockLinkRepo.AssertExpectations(t) +}