diff --git a/internal/server/healthcheck_test.go b/internal/server/healthcheck_test.go new file mode 100644 index 0000000..f076200 --- /dev/null +++ b/internal/server/healthcheck_test.go @@ -0,0 +1,98 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetSystemInfo(t *testing.T) { + info := getSystemInfo() + + assert.NotEmpty(t, info.Version) + assert.NotEmpty(t, info.GoVersion) + assert.Greater(t, info.NumGoRoutine, 0) + assert.GreaterOrEqual(t, info.MemoryUsage, 0.0) +} + +func TestHealthCheckHandler(t *testing.T) { + // setup test environment + os.Setenv("DB_HOST", "localhost") + os.Setenv("DB_PORT", "5432") + os.Setenv("DB_USER", "postgres") + os.Setenv("DB_PASSWORD", "postgres") + os.Setenv("DB_NAME", "postgres") + os.Setenv("DB_SSLMODE", "disable") + + // create test server + s, err := New(true) + require.NoError(t, err) + defer s.DBPool.Close() + + tests := []struct { + name string + setupFunc func(*Server) + expectedStatus int + expectedHealth string + }{ + { + name: "healthy_system", + setupFunc: nil, // no special setup needed + expectedStatus: http.StatusOK, + expectedHealth: "healthy", + }, + { + name: "unhealthy_system", + setupFunc: func(s *Server) { + s.DBPool.Close() + }, + expectedStatus: http.StatusServiceUnavailable, + expectedHealth: "unhealthy", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // setup + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if tt.setupFunc != nil { + tt.setupFunc(s) + } + + // test + err := s.handleHealthCheck(c) + require.NoError(t, err) + + // sssertions + assert.Equal(t, tt.expectedStatus, rec.Code) + + var response HealthReport + err = json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, tt.expectedHealth, response.Status) + assert.NotEmpty(t, response.Timestamp) + assert.NotNil(t, response.System) + assert.NotNil(t, response.Database) + + if tt.expectedHealth == "unhealthy" { + assert.False(t, response.Database.Connected) + assert.NotEmpty(t, response.Database.Error) + } else { + assert.True(t, response.Database.Connected) + assert.NotEmpty(t, response.Database.PostgresVersion) + assert.GreaterOrEqual(t, response.Database.LatencyMs, 0.0) + } + }) + } +} diff --git a/internal/server/project_comment_test.go b/internal/server/project_comment_test.go new file mode 100644 index 0000000..26e51bf --- /dev/null +++ b/internal/server/project_comment_test.go @@ -0,0 +1,230 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestProjectCommentEndpoints(t *testing.T) { + // setup test environment + os.Setenv("DB_HOST", "localhost") + os.Setenv("DB_PORT", "5432") + os.Setenv("DB_USER", "postgres") + os.Setenv("DB_PASSWORD", "postgres") + os.Setenv("DB_NAME", "postgres") + os.Setenv("DB_SSLMODE", "disable") + + // create server + s, err := New(true) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + defer s.DBPool.Close() + + // clean up database before tests + ctx := context.Background() + _, err = s.DBPool.Exec(ctx, "DELETE FROM project_comments") + if err != nil { + t.Fatalf("failed to clean up project_comments: %v", err) + } + _, err = s.DBPool.Exec(ctx, "DELETE FROM projects") + if err != nil { + t.Fatalf("failed to clean up projects: %v", err) + } + _, err = s.DBPool.Exec(ctx, "DELETE FROM companies WHERE name = $1", "Test Company") + if err != nil { + t.Fatalf("failed to clean up companies: %v", err) + } + _, err = s.DBPool.Exec(ctx, "DELETE FROM users WHERE email = $1", "test@example.com") + if err != nil { + t.Fatalf("failed to clean up test user: %v", err) + } + + // Create a test user directly in the database + userID := uuid.New().String() + _, err = s.DBPool.Exec(ctx, ` + INSERT INTO users (id, email, password_hash, first_name, last_name, role) + VALUES ($1, $2, $3, $4, $5, 'startup_owner') + `, userID, "test@example.com", "hashedpassword", "Test", "User") + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + // Create a company + description := "Test Company Description" + companyPayload := CreateCompanyRequest{ + OwnerUserID: userID, + Name: "Test Company", + Description: &description, + } + companyBody, _ := json.Marshal(companyPayload) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/companies", bytes.NewReader(companyBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + s.echoInstance.ServeHTTP(rec, req) + + t.Logf("Company creation response: %s", rec.Body.String()) + + var companyResponse map[string]interface{} + err = json.NewDecoder(rec.Body).Decode(&companyResponse) + if !assert.NoError(t, err) { + t.Fatalf("Failed to decode company response: %v", err) + } + + companyID, ok := companyResponse["ID"].(string) + if !assert.True(t, ok, "Company ID should be a string") { + t.Fatalf("Failed to get company ID from response: %v", companyResponse) + } + + // Create a project + projectDescription := "Test Description" + projectPayload := CreateProjectRequest{ + CompanyID: companyID, + Title: "Test Project", + Description: &projectDescription, + Status: "draft", + } + projectBody, _ := json.Marshal(projectPayload) + + req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(projectBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + s.echoInstance.ServeHTTP(rec, req) + + t.Logf("Project creation response: %s", rec.Body.String()) + + var projectResponse map[string]interface{} + err = json.NewDecoder(rec.Body).Decode(&projectResponse) + if !assert.NoError(t, err) { + t.Fatalf("Failed to decode project response: %v", err) + } + + projectID, ok := projectResponse["ID"].(string) + if !assert.True(t, ok, "Project ID should be a string") { + t.Fatalf("Failed to get project ID from response: %v", projectResponse) + } + + // test create comment + t.Run("create comment", func(t *testing.T) { + commentPayload := CreateProjectCommentRequest{ + UserID: userID, + Comment: "This is a test comment", + } + body, _ := json.Marshal(commentPayload) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+projectID+"/comments", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + t.Logf("Create comment response: %s", rec.Body.String()) + assert.Equal(t, http.StatusCreated, rec.Code) + + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, projectID, response["ProjectID"]) + assert.Equal(t, commentPayload.Comment, response["Comment"]) + }) + + // test list comments + t.Run("list comments", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+projectID+"/comments", nil) + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + t.Logf("List comments response: %s", rec.Body.String()) + assert.Equal(t, http.StatusOK, rec.Code) + + var response []map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Len(t, response, 1) + assert.Equal(t, "This is a test comment", response[0]["Comment"]) + }) + + // test delete comment + t.Run("delete comment", func(t *testing.T) { + // Get the comment ID from the list response + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+projectID+"/comments", nil) + rec := httptest.NewRecorder() + s.echoInstance.ServeHTTP(rec, req) + + var listResponse []map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&listResponse) + assert.NoError(t, err) + assert.NotEmpty(t, listResponse) + + commentID := listResponse[0]["ID"].(string) + + // Delete the comment + req = httptest.NewRequest(http.MethodDelete, "/api/v1/projects/comments/"+commentID, nil) + rec = httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + t.Logf("Delete comment response: %s", rec.Body.String()) + assert.Equal(t, http.StatusNoContent, rec.Code) + + // Verify deletion + req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+projectID+"/comments", nil) + rec = httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + t.Logf("List comments after delete response: %s", rec.Body.String()) + assert.Equal(t, http.StatusOK, rec.Code) + + var response []map[string]interface{} + err = json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Len(t, response, 0, "Comment list should be empty after deletion") + }) + + // test error cases + t.Run("create comment with invalid project ID", func(t *testing.T) { + commentPayload := CreateProjectCommentRequest{ + UserID: userID, + Comment: "This is a test comment", + } + body, _ := json.Marshal(commentPayload) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/invalid-uuid/comments", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + + t.Run("create comment with invalid user ID", func(t *testing.T) { + commentPayload := CreateProjectCommentRequest{ + UserID: "invalid-uuid", + Comment: "This is a test comment", + } + body, _ := json.Marshal(commentPayload) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+projectID+"/comments", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + + t.Run("delete non-existent comment", func(t *testing.T) { + nonExistentID := uuid.New().String() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/projects/comments/"+nonExistentID, nil) + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + assert.Equal(t, http.StatusNotFound, rec.Code) + }) +} diff --git a/internal/server/project_tag_test.go b/internal/server/project_tag_test.go new file mode 100644 index 0000000..8413fb2 --- /dev/null +++ b/internal/server/project_tag_test.go @@ -0,0 +1,205 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProjectTagEndpoints(t *testing.T) { + // setup test environment + os.Setenv("DB_HOST", "localhost") + os.Setenv("DB_PORT", "5432") + os.Setenv("DB_USER", "postgres") + os.Setenv("DB_PASSWORD", "postgres") + os.Setenv("DB_NAME", "postgres") + os.Setenv("DB_SSLMODE", "disable") + + // create server + s, err := New(true) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + defer s.DBPool.Close() + + // clean up database before tests + ctx := context.Background() + _, err = s.DBPool.Exec(ctx, "DELETE FROM project_tags") + if err != nil { + t.Fatalf("failed to clean up project_tags: %v", err) + } + _, err = s.DBPool.Exec(ctx, "DELETE FROM projects") + if err != nil { + t.Fatalf("failed to clean up projects: %v", err) + } + _, err = s.DBPool.Exec(ctx, "DELETE FROM tags WHERE name = $1", "test-tag") + if err != nil { + t.Fatalf("failed to clean up tags: %v", err) + } + _, err = s.DBPool.Exec(ctx, "DELETE FROM companies WHERE name = $1", "Test Company") + if err != nil { + t.Fatalf("failed to clean up companies: %v", err) + } + + // First create a company + description := "Test Company Description" + companyPayload := CreateCompanyRequest{ + OwnerUserID: "00000000-0000-0000-0000-000000000000", // assuming this is a valid user ID + Name: "Test Company", + Description: &description, + } + companyBody, _ := json.Marshal(companyPayload) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/companies", bytes.NewReader(companyBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + s.echoInstance.ServeHTTP(rec, req) + + t.Logf("Company creation response: %s", rec.Body.String()) + + var companyResponse map[string]interface{} + err = json.NewDecoder(rec.Body).Decode(&companyResponse) + if !assert.NoError(t, err) { + t.Fatalf("Failed to decode company response: %v", err) + } + + if rec.Code != http.StatusCreated { + t.Fatalf("Failed to create company. Status: %d, Response: %s", rec.Code, rec.Body.String()) + } + + companyID, ok := companyResponse["ID"].(string) + if !assert.True(t, ok, "Company ID should be a string") { + t.Fatalf("Failed to get company ID from response: %v", companyResponse) + } + + // Now create a project + projectDescription := "Test Description" + projectPayload := CreateProjectRequest{ + CompanyID: companyID, + Title: "Test Project", + Description: &projectDescription, + Status: "draft", + } + projectBody, _ := json.Marshal(projectPayload) + + req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(projectBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + s.echoInstance.ServeHTTP(rec, req) + + t.Logf("Project creation response: %s", rec.Body.String()) + + var projectResponse map[string]interface{} + err = json.NewDecoder(rec.Body).Decode(&projectResponse) + if !assert.NoError(t, err) { + t.Fatalf("Failed to decode project response: %v", err) + } + + if rec.Code != http.StatusCreated { + t.Fatalf("Failed to create project. Status: %d, Response: %s", rec.Code, rec.Body.String()) + } + + projectID, ok := projectResponse["ID"].(string) + if !assert.True(t, ok, "Project ID should be a string") { + t.Fatalf("Failed to get project ID from response: %v", projectResponse) + } + + // create a test tag + tagPayload := CreateTagRequest{ + Name: "test-tag", + } + tagBody, _ := json.Marshal(tagPayload) + + req = httptest.NewRequest(http.MethodPost, "/api/v1/tags", bytes.NewReader(tagBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + s.echoInstance.ServeHTTP(rec, req) + + t.Logf("Tag creation response: %s", rec.Body.String()) + + var tagResponse map[string]interface{} + err = json.NewDecoder(rec.Body).Decode(&tagResponse) + if !assert.NoError(t, err) { + t.Fatalf("Failed to decode tag response: %v", err) + } + + if rec.Code != http.StatusCreated { + t.Fatalf("Failed to create tag. Status: %d, Response: %s", rec.Code, rec.Body.String()) + } + + tagID, ok := tagResponse["ID"].(string) + if !assert.True(t, ok, "Tag ID should be a string") { + t.Fatalf("Failed to get tag ID from response: %v", tagResponse) + } + + // test add project tag + t.Run("add project tag", func(t *testing.T) { + tagPayload := AddProjectTagRequest{ + TagID: tagID, + } + body, _ := json.Marshal(tagPayload) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+projectID+"/tags", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + t.Logf("Add tag response: %s", rec.Body.String()) + assert.Equal(t, http.StatusCreated, rec.Code) + + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, projectID, response["ProjectID"]) + assert.Equal(t, tagID, response["TagID"]) + }) + + // test list project tags + t.Run("list project tags", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+projectID+"/tags", nil) + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + t.Logf("List tags response: %s", rec.Body.String()) + assert.Equal(t, http.StatusOK, rec.Code) + + var response []map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Len(t, response, 1) + + // The response contains the project_tag ID, not the tag ID directly + projectTag := response[0] + assert.Equal(t, tagID, projectTag["TagID"], "TagID should match the created tag") + assert.Equal(t, projectID, projectTag["ProjectID"], "ProjectID should match the project") + }) + + // test delete project tag + t.Run("delete project tag", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/api/v1/projects/"+projectID+"/tags/"+tagID, nil) + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + t.Logf("Delete tag response: %s", rec.Body.String()) + assert.Equal(t, http.StatusNoContent, rec.Code) + + // verify deletion using list endpoint + req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+projectID+"/tags", nil) + rec = httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + t.Logf("List tags after delete response: %s", rec.Body.String()) + assert.Equal(t, http.StatusOK, rec.Code) + + var response []map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Len(t, response, 0, "Tag list should be empty after deletion") + }) +} diff --git a/internal/server/projects.go b/internal/server/projects.go index 9f0d1dd..936d78c 100644 --- a/internal/server/projects.go +++ b/internal/server/projects.go @@ -225,6 +225,17 @@ func (s *Server) handleDeleteProjectComment(c echo.Context) error { } queries := db.New(s.DBPool) + + // First check if the comment exists using a direct query + var exists bool + err = s.DBPool.QueryRow(context.Background(), "SELECT EXISTS(SELECT 1 FROM project_comments WHERE id = $1)", commentID).Scan(&exists) + if err != nil { + return handleDBError(err, "verify", "project comment") + } + if !exists { + return echo.NewHTTPError(http.StatusNotFound, "project comment not found :(") + } + err = queries.DeleteProjectComment(context.Background(), commentID) if err != nil { return handleDBError(err, "delete", "project comment") diff --git a/internal/server/static.go b/internal/server/static.go index 0445b6e..a2673d6 100644 --- a/internal/server/static.go +++ b/internal/server/static.go @@ -20,13 +20,17 @@ func (s *Server) setupStaticRoutes() { // hardcode static directory staticDir := "static/dist" - // serve static files + // serve static files, excluding API routes s.echoInstance.Use(middleware.StaticWithConfig(middleware.StaticConfig{ Root: staticDir, Index: "index.html", HTML5: true, Browse: false, IgnoreBase: true, + Skipper: func(c echo.Context) bool { + // Skip static file handling for API routes + return strings.HasPrefix(c.Request().URL.Path, "/api/") + }, })) // serve assets with correct mime types diff --git a/internal/server/tag_test.go b/internal/server/tag_test.go new file mode 100644 index 0000000..4fb72c7 --- /dev/null +++ b/internal/server/tag_test.go @@ -0,0 +1,130 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTagEndpoints(t *testing.T) { + // setup test environment + os.Setenv("DB_HOST", "localhost") + os.Setenv("DB_PORT", "5432") + os.Setenv("DB_USER", "postgres") + os.Setenv("DB_PASSWORD", "postgres") + os.Setenv("DB_NAME", "postgres") + os.Setenv("DB_SSLMODE", "disable") + + // create server + s, err := New(true) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + defer s.DBPool.Close() + + // clean up database before tests + ctx := context.Background() + _, err = s.DBPool.Exec(ctx, "DELETE FROM tags WHERE name = $1", "test-tag") + if err != nil { + t.Fatalf("failed to clean up database: %v", err) + } + + // test create tag + t.Run("create tag", func(t *testing.T) { + payload := CreateTagRequest{ + Name: "test-tag", + } + body, _ := json.Marshal(payload) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tags", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + assert.Equal(t, http.StatusCreated, rec.Code) + + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "ID") + assert.Contains(t, response, "Name") + + tagID := response["ID"].(string) + assert.NotEmpty(t, tagID) + assert.Equal(t, payload.Name, response["Name"]) + + // test get tag + t.Run("get tag", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/tags/"+tagID, nil) + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + + var getResponse map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&getResponse) + assert.NoError(t, err) + assert.Equal(t, tagID, getResponse["ID"]) + assert.Equal(t, payload.Name, getResponse["Name"]) + }) + + // test list tags + t.Run("list tags", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/tags", nil) + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + + var listResponse []map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&listResponse) + assert.NoError(t, err) + assert.NotEmpty(t, listResponse) + }) + + // test delete tag + t.Run("delete tag", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/api/v1/tags/"+tagID, nil) + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + assert.Equal(t, http.StatusNoContent, rec.Code) + + // verify deletion using database query + var count int + err := s.DBPool.QueryRow(context.Background(), "SELECT COUNT(*) FROM tags WHERE id = $1", tagID).Scan(&count) + assert.NoError(t, err) + assert.Equal(t, 0, count, "Tag should be deleted from database") + }) + }) + + // test validation errors + t.Run("validation errors", func(t *testing.T) { + payload := CreateTagRequest{ + Name: "", // empty name should fail validation + } + body, _ := json.Marshal(payload) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tags", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + + // test invalid uuid + t.Run("invalid uuid", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/tags/invalid-uuid", nil) + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) +}