From bb970f4a74506aee943113216a1d2d98bd09420c Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Wed, 24 Dec 2025 17:48:06 -0300 Subject: [PATCH] fix(backend): resolve 500 errors on jobs, notifications and secure routes - Fix CreateJob 500 error by extracting user ID correctly - Secure Create/Update/Delete Job routes with AuthGuard - Fix Notifications/Tickets/Profile 500 error (UUID vs Int mismatch) - Add E2E test for CreateJob --- .../internal/api/handlers/core_handlers.go | 153 +++++------------- backend/internal/handlers/job_handler.go | 12 +- backend/internal/handlers/job_handler_test.go | 35 +++- backend/internal/models/notification.go | 2 +- backend/internal/models/ticket.go | 4 +- backend/internal/router/router.go | 6 +- backend/internal/services/job_service.go | 9 +- backend/internal/services/job_service_test.go | 4 +- .../internal/services/notification_service.go | 8 +- backend/internal/services/ticket_service.go | 8 +- backend/tests/e2e/jobs_e2e_test.go | 95 +++++++++++ 11 files changed, 195 insertions(+), 141 deletions(-) diff --git a/backend/internal/api/handlers/core_handlers.go b/backend/internal/api/handlers/core_handlers.go index 8bf28e9..47b4527 100644 --- a/backend/internal/api/handlers/core_handlers.go +++ b/backend/internal/api/handlers/core_handlers.go @@ -417,40 +417,14 @@ func extractClientIP(r *http.Request) *string { // @Failure 500 {string} string "Internal Server Error" // @Router /api/v1/notifications [get] func (h *CoreHandlers) ListNotifications(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - // Assuming ContextUserID is "user_id" string or int? - // Looking at CreateUser handler, it gets tenantID which is string. - // But auditService.RecordLogin uses resp.User.ID (int). - // Typically auth middleware sets user_id. Let's assume middleware.ContextUserID is the key. - // I need to check middleware keys ideally. - // But commonly it's set. - // Let's check how AuditService uses user ID. It gets it from Login response. - // Wait, ListUsers doesn't use user ID, only TenantID. - // I need to know how to get current User ID. - // Using middleware.ContextUserID. userIDVal := r.Context().Value(middleware.ContextUserID) - if userIDVal == nil { - http.Error(w, "User ID not found", http.StatusUnauthorized) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) return } - // Convert to int - var userID int - switch v := userIDVal.(type) { - case int: - userID = v - case string: - var err error - userID, err = strconv.Atoi(v) - if err != nil { - http.Error(w, "Invalid User ID format", http.StatusInternalServerError) - return - } - case float64: - userID = int(v) - } - - notifications, err := h.notificationService.ListNotifications(ctx, userID) + notifications, err := h.notificationService.ListNotifications(r.Context(), userID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -472,18 +446,12 @@ func (h *CoreHandlers) ListNotifications(w http.ResponseWriter, r *http.Request) // @Failure 500 {string} string "Internal Server Error" // @Router /api/v1/notifications/{id}/read [patch] func (h *CoreHandlers) MarkNotificationAsRead(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - if userIDVal == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) return } - var userID int - if v, ok := userIDVal.(string); ok { - userID, _ = strconv.Atoi(v) - } else if v, ok := userIDVal.(int); ok { - userID = v - } id := r.PathValue("id") if id == "" { @@ -494,7 +462,7 @@ func (h *CoreHandlers) MarkNotificationAsRead(w http.ResponseWriter, r *http.Req } } - if err := h.notificationService.MarkAsRead(ctx, id, userID); err != nil { + if err := h.notificationService.MarkAsRead(r.Context(), id, userID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -512,20 +480,14 @@ func (h *CoreHandlers) MarkNotificationAsRead(w http.ResponseWriter, r *http.Req // @Failure 500 {string} string "Internal Server Error" // @Router /api/v1/notifications/read-all [patch] func (h *CoreHandlers) MarkAllNotificationsAsRead(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - if userIDVal == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) return } - var userID int - if v, ok := userIDVal.(string); ok { - userID, _ = strconv.Atoi(v) - } else if v, ok := userIDVal.(int); ok { - userID = v - } - if err := h.notificationService.MarkAllAsRead(ctx, userID); err != nil { + if err := h.notificationService.MarkAllAsRead(r.Context(), userID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -545,18 +507,12 @@ func (h *CoreHandlers) MarkAllNotificationsAsRead(w http.ResponseWriter, r *http // @Failure 500 {string} string "Internal Server Error" // @Router /api/v1/support/tickets [post] func (h *CoreHandlers) CreateTicket(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - if userIDVal == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) return } - var userID int - if v, ok := userIDVal.(string); ok { - userID, _ = strconv.Atoi(v) - } else if v, ok := userIDVal.(int); ok { - userID = v - } var req dto.CreateTicketRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -564,7 +520,7 @@ func (h *CoreHandlers) CreateTicket(w http.ResponseWriter, r *http.Request) { return } - ticket, err := h.ticketService.CreateTicket(ctx, userID, req.Subject, req.Priority) + ticket, err := h.ticketService.CreateTicket(r.Context(), userID, req.Subject, req.Priority) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -572,7 +528,7 @@ func (h *CoreHandlers) CreateTicket(w http.ResponseWriter, r *http.Request) { // Create initial message if provided if req.Message != "" { - _, _ = h.ticketService.AddMessage(ctx, ticket.ID, userID, req.Message) + _, _ = h.ticketService.AddMessage(r.Context(), ticket.ID, userID, req.Message) } w.WriteHeader(http.StatusCreated) @@ -591,20 +547,14 @@ func (h *CoreHandlers) CreateTicket(w http.ResponseWriter, r *http.Request) { // @Failure 500 {string} string "Internal Server Error" // @Router /api/v1/support/tickets [get] func (h *CoreHandlers) ListTickets(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - if userIDVal == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) return } - var userID int - if v, ok := userIDVal.(string); ok { - userID, _ = strconv.Atoi(v) - } else if v, ok := userIDVal.(int); ok { - userID = v - } - tickets, err := h.ticketService.ListTickets(ctx, userID) + tickets, err := h.ticketService.ListTickets(r.Context(), userID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -627,18 +577,12 @@ func (h *CoreHandlers) ListTickets(w http.ResponseWriter, r *http.Request) { // @Failure 500 {string} string "Internal Server Error" // @Router /api/v1/support/tickets/{id} [get] func (h *CoreHandlers) GetTicket(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - if userIDVal == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) return } - var userID int - if v, ok := userIDVal.(string); ok { - userID, _ = strconv.Atoi(v) - } else if v, ok := userIDVal.(int); ok { - userID = v - } id := r.PathValue("id") if id == "" { @@ -648,7 +592,7 @@ func (h *CoreHandlers) GetTicket(w http.ResponseWriter, r *http.Request) { } } - ticket, messages, err := h.ticketService.GetTicket(ctx, id, userID) + ticket, messages, err := h.ticketService.GetTicket(r.Context(), id, userID) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return @@ -677,18 +621,12 @@ func (h *CoreHandlers) GetTicket(w http.ResponseWriter, r *http.Request) { // @Failure 500 {string} string "Internal Server Error" // @Router /api/v1/support/tickets/{id}/messages [post] func (h *CoreHandlers) AddMessage(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - if userIDVal == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) return } - var userID int - if v, ok := userIDVal.(string); ok { - userID, _ = strconv.Atoi(v) - } else if v, ok := userIDVal.(int); ok { - userID = v - } id := r.PathValue("id") if id == "" { @@ -705,7 +643,7 @@ func (h *CoreHandlers) AddMessage(w http.ResponseWriter, r *http.Request) { return } - msg, err := h.ticketService.AddMessage(ctx, id, userID, req.Message) + msg, err := h.ticketService.AddMessage(r.Context(), id, userID, req.Message) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -731,19 +669,12 @@ func (h *CoreHandlers) AddMessage(w http.ResponseWriter, r *http.Request) { func (h *CoreHandlers) UpdateMyProfile(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userIDVal := ctx.Value(middleware.ContextUserID) - if userIDVal == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) return } - var userID int - if v, ok := userIDVal.(string); ok { - userID, _ = strconv.Atoi(v) - } else if v, ok := userIDVal.(int); ok { - userID = v - } - // Convert userID to string if updateUC needs string (it does for ID) - idStr := strconv.Itoa(userID) // TenantID needed? updateUC takes tenantID. tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) @@ -753,12 +684,8 @@ func (h *CoreHandlers) UpdateMyProfile(w http.ResponseWriter, r *http.Request) { return } - // Reuse existing UpdateUserUseCase but ensuring ID matches Me. - // Assuming UpdateUserUseCase handles generic updates. - // Wait, UpdateUserUseCase might require Admin role if it checks permissions. - // If UpdateUserUseCase is simple DB update, it's fine. - // Let's assume for now. If it fails due to RBAC, we need a separate usecase. - resp, err := h.updateUserUC.Execute(ctx, idStr, tenantID, req) + // userID (string) passed directly as first arg + resp, err := h.updateUserUC.Execute(ctx, userID, tenantID, req) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/backend/internal/handlers/job_handler.go b/backend/internal/handlers/job_handler.go index 69f6ca8..f214a7a 100755 --- a/backend/internal/handlers/job_handler.go +++ b/backend/internal/handlers/job_handler.go @@ -5,6 +5,7 @@ import ( "net/http" "strconv" + "github.com/rede5/gohorsejobs/backend/internal/api/middleware" "github.com/rede5/gohorsejobs/backend/internal/dto" "github.com/rede5/gohorsejobs/backend/internal/models" "github.com/rede5/gohorsejobs/backend/internal/services" @@ -103,6 +104,7 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) { // @Param job body dto.CreateJobRequest true "Job data" // @Success 201 {object} models.Job // @Failure 400 {string} string "Bad Request" +// @Failure 401 {string} string "Unauthorized" // @Failure 500 {string} string "Internal Server Error" // @Router /api/v1/jobs [post] func (h *JobHandler) CreateJob(w http.ResponseWriter, r *http.Request) { @@ -114,7 +116,15 @@ func (h *JobHandler) CreateJob(w http.ResponseWriter, r *http.Request) { // Validate request (omitted for brevity, assume validation middleware or service validation) - job, err := h.Service.CreateJob(req) + // Extract UserID from context + val := r.Context().Value(middleware.ContextUserID) + userID, ok := val.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) + return + } + + job, err := h.Service.CreateJob(req, userID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/backend/internal/handlers/job_handler_test.go b/backend/internal/handlers/job_handler_test.go index 640d6d3..7e7695f 100644 --- a/backend/internal/handlers/job_handler_test.go +++ b/backend/internal/handlers/job_handler_test.go @@ -2,12 +2,14 @@ package handlers import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" + "github.com/rede5/gohorsejobs/backend/internal/api/middleware" "github.com/rede5/gohorsejobs/backend/internal/dto" "github.com/rede5/gohorsejobs/backend/internal/models" "github.com/stretchr/testify/assert" @@ -16,7 +18,7 @@ import ( // mockJobService is a mock implementation of the job service for testing type mockJobService struct { getJobsFunc func(filter dto.JobFilterQuery) ([]models.JobWithCompany, int, error) - createJobFunc func(req dto.CreateJobRequest) (*models.Job, error) + createJobFunc func(req dto.CreateJobRequest, createdBy string) (*models.Job, error) getJobByIDFunc func(id string) (*models.Job, error) updateJobFunc func(id string, req dto.UpdateJobRequest) (*models.Job, error) deleteJobFunc func(id string) error @@ -29,9 +31,9 @@ func (m *mockJobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCom return nil, 0, nil } -func (m *mockJobService) CreateJob(req dto.CreateJobRequest) (*models.Job, error) { +func (m *mockJobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*models.Job, error) { if m.createJobFunc != nil { - return m.createJobFunc(req) + return m.createJobFunc(req, createdBy) } return nil, nil } @@ -60,7 +62,7 @@ func (m *mockJobService) DeleteJob(id string) error { // JobServiceInterface defines the interface for job service operations type JobServiceInterface interface { GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany, int, error) - CreateJob(req dto.CreateJobRequest) (*models.Job, error) + CreateJob(req dto.CreateJobRequest, createdBy string) (*models.Job, error) GetJobByID(id string) (*models.Job, error) UpdateJob(id string, req dto.UpdateJobRequest) (*models.Job, error) DeleteJob(id string) error @@ -100,7 +102,15 @@ func (h *testableJobHandler) CreateJob(w http.ResponseWriter, r *http.Request) { return } - job, err := h.service.CreateJob(req) + // Extract UserID from context + val := r.Context().Value(middleware.ContextUserID) + userID, ok := val.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) + return + } + + job, err := h.service.CreateJob(req, userID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -219,7 +229,8 @@ func TestGetJobs_Error(t *testing.T) { func TestCreateJob_Success(t *testing.T) { mockService := &mockJobService{ - createJobFunc: func(req dto.CreateJobRequest) (*models.Job, error) { + createJobFunc: func(req dto.CreateJobRequest, createdBy string) (*models.Job, error) { + assert.Equal(t, "user-123", createdBy) return &models.Job{ ID: "1", CompanyID: req.CompanyID, @@ -244,6 +255,11 @@ func TestCreateJob_Success(t *testing.T) { req := httptest.NewRequest("POST", "/jobs", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") + + // Inject Context + ctx := context.WithValue(req.Context(), middleware.ContextUserID, "user-123") + req = req.WithContext(ctx) + rr := httptest.NewRecorder() handler.CreateJob(rr, req) @@ -272,7 +288,7 @@ func TestCreateJob_InvalidJSON(t *testing.T) { func TestCreateJob_ServiceError(t *testing.T) { mockService := &mockJobService{ - createJobFunc: func(req dto.CreateJobRequest) (*models.Job, error) { + createJobFunc: func(req dto.CreateJobRequest, createdBy string) (*models.Job, error) { return nil, assert.AnError }, } @@ -289,6 +305,11 @@ func TestCreateJob_ServiceError(t *testing.T) { req := httptest.NewRequest("POST", "/jobs", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") + + // Inject Context + ctx := context.WithValue(req.Context(), middleware.ContextUserID, "user-123") + req = req.WithContext(ctx) + rr := httptest.NewRecorder() handler.CreateJob(rr, req) diff --git a/backend/internal/models/notification.go b/backend/internal/models/notification.go index ede872c..bfefe7f 100644 --- a/backend/internal/models/notification.go +++ b/backend/internal/models/notification.go @@ -6,7 +6,7 @@ import ( type Notification struct { ID string `json:"id"` - UserID int `json:"userId"` + UserID string `json:"userId"` Type string `json:"type"` // info, success, warning, error Title string `json:"title"` Message string `json:"message"` diff --git a/backend/internal/models/ticket.go b/backend/internal/models/ticket.go index c9b2d40..19172b9 100644 --- a/backend/internal/models/ticket.go +++ b/backend/internal/models/ticket.go @@ -6,7 +6,7 @@ import ( type Ticket struct { ID string `json:"id"` - UserID int `json:"userId"` + UserID string `json:"userId"` Subject string `json:"subject"` Status string `json:"status"` // open, in_progress, closed Priority string `json:"priority"` // low, medium, high @@ -17,7 +17,7 @@ type Ticket struct { type TicketMessage struct { ID string `json:"id"` TicketID string `json:"ticketId"` - UserID int `json:"userId"` + UserID string `json:"userId"` Message string `json:"message"` CreatedAt time.Time `json:"createdAt"` } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 5433ca1..6bfbf85 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -143,10 +143,10 @@ func NewRouter() http.Handler { // Job Routes mux.HandleFunc("GET /api/v1/jobs", jobHandler.GetJobs) - mux.HandleFunc("POST /api/v1/jobs", jobHandler.CreateJob) + mux.Handle("POST /api/v1/jobs", authMiddleware.HeaderAuthGuard(http.HandlerFunc(jobHandler.CreateJob))) mux.HandleFunc("GET /api/v1/jobs/{id}", jobHandler.GetJobByID) - mux.HandleFunc("PUT /api/v1/jobs/{id}", jobHandler.UpdateJob) - mux.HandleFunc("DELETE /api/v1/jobs/{id}", jobHandler.DeleteJob) + mux.Handle("PUT /api/v1/jobs/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(jobHandler.UpdateJob))) + mux.Handle("DELETE /api/v1/jobs/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(jobHandler.DeleteJob))) // --- ADMIN ROUTES (Consolidated to Standard Paths with RBAC) --- // /api/v1/admin/access/roles -> /api/v1/users/roles diff --git a/backend/internal/services/job_service.go b/backend/internal/services/job_service.go index d5ef9d2..724fa39 100644 --- a/backend/internal/services/job_service.go +++ b/backend/internal/services/job_service.go @@ -18,18 +18,19 @@ func NewJobService(db *sql.DB) *JobService { return &JobService{DB: db} } -func (s *JobService) CreateJob(req dto.CreateJobRequest) (*models.Job, error) { +func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*models.Job, error) { query := ` INSERT INTO jobs ( - company_id, title, description, salary_min, salary_max, salary_type, + company_id, created_by, title, description, salary_min, salary_max, salary_type, employment_type, working_hours, location, region_id, city_id, requirements, benefits, visa_support, language_level, status, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING id, created_at, updated_at ` job := &models.Job{ CompanyID: req.CompanyID, + CreatedBy: createdBy, Title: req.Title, Description: req.Description, SalaryMin: req.SalaryMin, @@ -51,7 +52,7 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest) (*models.Job, error) { err := s.DB.QueryRow( query, - job.CompanyID, job.Title, job.Description, job.SalaryMin, job.SalaryMax, job.SalaryType, + job.CompanyID, job.CreatedBy, job.Title, job.Description, job.SalaryMin, job.SalaryMax, job.SalaryType, job.EmploymentType, job.WorkingHours, job.Location, job.RegionID, job.CityID, job.Requirements, job.Benefits, job.VisaSupport, job.LanguageLevel, job.Status, job.CreatedAt, job.UpdatedAt, ).Scan(&job.ID, &job.CreatedAt, &job.UpdatedAt) diff --git a/backend/internal/services/job_service_test.go b/backend/internal/services/job_service_test.go index 14c9dd7..0fd9cb4 100644 --- a/backend/internal/services/job_service_test.go +++ b/backend/internal/services/job_service_test.go @@ -35,7 +35,7 @@ func TestCreateJob(t *testing.T) { }, mockRun: func() { mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO jobs`)). - WithArgs("1", "Go Developer", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "published", sqlmock.AnyArg(), sqlmock.AnyArg()). + WithArgs("1", "user-123", "Go Developer", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "published", sqlmock.AnyArg(), sqlmock.AnyArg()). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow("100", time.Now(), time.Now())) }, wantErr: false, @@ -57,7 +57,7 @@ func TestCreateJob(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tt.mockRun() - got, err := service.CreateJob(tt.req) + got, err := service.CreateJob(tt.req, "user-123") if (err != nil) != tt.wantErr { t.Errorf("JobService.CreateJob() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 120059d..ad0e551 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -15,7 +15,7 @@ func NewNotificationService(db *sql.DB) *NotificationService { return &NotificationService{DB: db} } -func (s *NotificationService) CreateNotification(ctx context.Context, userID int, nType, title, message string, link *string) error { +func (s *NotificationService) CreateNotification(ctx context.Context, userID string, nType, title, message string, link *string) error { query := ` INSERT INTO notifications (user_id, type, title, message, link, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) @@ -24,7 +24,7 @@ func (s *NotificationService) CreateNotification(ctx context.Context, userID int return err } -func (s *NotificationService) ListNotifications(ctx context.Context, userID int) ([]models.Notification, error) { +func (s *NotificationService) ListNotifications(ctx context.Context, userID string) ([]models.Notification, error) { query := ` SELECT id, user_id, type, title, message, link, read_at, created_at, updated_at FROM notifications @@ -59,7 +59,7 @@ func (s *NotificationService) ListNotifications(ctx context.Context, userID int) return notifications, nil } -func (s *NotificationService) MarkAsRead(ctx context.Context, id string, userID int) error { +func (s *NotificationService) MarkAsRead(ctx context.Context, id string, userID string) error { query := ` UPDATE notifications SET read_at = NOW(), updated_at = NOW() @@ -69,7 +69,7 @@ func (s *NotificationService) MarkAsRead(ctx context.Context, id string, userID return err } -func (s *NotificationService) MarkAllAsRead(ctx context.Context, userID int) error { +func (s *NotificationService) MarkAllAsRead(ctx context.Context, userID string) error { query := ` UPDATE notifications SET read_at = NOW(), updated_at = NOW() diff --git a/backend/internal/services/ticket_service.go b/backend/internal/services/ticket_service.go index 3e9cf76..67d67fc 100644 --- a/backend/internal/services/ticket_service.go +++ b/backend/internal/services/ticket_service.go @@ -16,7 +16,7 @@ func NewTicketService(db *sql.DB) *TicketService { return &TicketService{DB: db} } -func (s *TicketService) CreateTicket(ctx context.Context, userID int, subject, priority string) (*models.Ticket, error) { +func (s *TicketService) CreateTicket(ctx context.Context, userID string, subject, priority string) (*models.Ticket, error) { if priority == "" { priority = "medium" } @@ -35,7 +35,7 @@ func (s *TicketService) CreateTicket(ctx context.Context, userID int, subject, p return &t, nil } -func (s *TicketService) ListTickets(ctx context.Context, userID int) ([]models.Ticket, error) { +func (s *TicketService) ListTickets(ctx context.Context, userID string) ([]models.Ticket, error) { query := ` SELECT id, user_id, subject, status, priority, created_at, updated_at FROM tickets @@ -61,7 +61,7 @@ func (s *TicketService) ListTickets(ctx context.Context, userID int) ([]models.T return tickets, nil } -func (s *TicketService) GetTicket(ctx context.Context, ticketID string, userID int) (*models.Ticket, []models.TicketMessage, error) { +func (s *TicketService) GetTicket(ctx context.Context, ticketID string, userID string) (*models.Ticket, []models.TicketMessage, error) { // 1. Get Ticket queryTicket := ` SELECT id, user_id, subject, status, priority, created_at, updated_at @@ -106,7 +106,7 @@ func (s *TicketService) GetTicket(ctx context.Context, ticketID string, userID i return &t, messages, nil } -func (s *TicketService) AddMessage(ctx context.Context, ticketID string, userID int, message string) (*models.TicketMessage, error) { +func (s *TicketService) AddMessage(ctx context.Context, ticketID string, userID string, message string) (*models.TicketMessage, error) { // Verify ticket ownership first (or admin access, but keeping simple for now) var count int err := s.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM tickets WHERE id = $1 AND user_id = $2", ticketID, userID).Scan(&count) diff --git a/backend/tests/e2e/jobs_e2e_test.go b/backend/tests/e2e/jobs_e2e_test.go index 323a812..e58e67f 100644 --- a/backend/tests/e2e/jobs_e2e_test.go +++ b/backend/tests/e2e/jobs_e2e_test.go @@ -5,6 +5,7 @@ package e2e import ( "fmt" + "io" "net/http" "testing" "time" @@ -12,8 +13,35 @@ import ( "github.com/rede5/gohorsejobs/backend/internal/database" "github.com/rede5/gohorsejobs/backend/internal/dto" "github.com/rede5/gohorsejobs/backend/internal/models" + + "os" + + "github.com/golang-jwt/jwt/v5" ) +// createAuthToken generates a JWT token for testing +func createAuthToken(t *testing.T, userID, tenantID string) string { + t.Helper() + secret := os.Getenv("JWT_SECRET") + if secret == "" { + secret = "gohorse-super-secret-key-2024-production" + } + claims := jwt.MapClaims{ + "sub": userID, + "tenant": tenantID, + "roles": []string{"superadmin"}, + "iss": "gohorse-jobs", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenStr, err := token.SignedString([]byte(secret)) + if err != nil { + t.Fatalf("Failed to generate token: %v", err) + } + return tokenStr +} + // setupTestCompanyAndUser creates a test company and user in the database and returns their IDs func setupTestCompanyAndUser(t *testing.T) (companyID, userID string) { t.Helper() @@ -112,6 +140,61 @@ func TestE2E_Jobs_Read(t *testing.T) { jobID := createTestJob(t, companyID, userID, "E2E Test Software Engineer") defer database.DB.Exec("DELETE FROM jobs WHERE id = $1", jobID) + // ===================== + // 0. CREATE JOB (POST) + // ===================== + t.Run("CreateJob", func(t *testing.T) { + // Generate token for auth + token := createAuthToken(t, userID, companyID) + client.setAuthToken(token) + + title := "New Created Job E2E" + desc := "This is a new job created via API E2E test." + salaryMin := 5000.0 + salaryMax := 8000.0 + salaryType := "monthly" + employmentType := "full-time" + // workMode := "remote" + location := "Sao Paulo, SP" + + createReq := dto.CreateJobRequest{ + CompanyID: companyID, + Title: title, + Description: desc, + SalaryMin: &salaryMin, + SalaryMax: &salaryMax, + SalaryType: &salaryType, + EmploymentType: &employmentType, + WorkingHours: nil, + Location: &location, + // WorkMode: &workMode, // Temporarily removed as DTO doesn't support it yet + Status: "open", + } + + resp, err := client.post("/api/v1/jobs", createReq) + if err != nil { + t.Fatalf("Failed to create job: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + t.Errorf("Expected status 201, got %d. Body: %s", resp.StatusCode, string(body)) + } + + var job models.Job + if err := parseJSON(resp, &job); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if job.Title != title { + t.Errorf("Expected title '%s', got '%s'", title, job.Title) + } + + // Cleanup created job + database.DB.Exec("DELETE FROM jobs WHERE id = $1", job.ID) + }) + // ===================== // 1. GET JOB BY ID // ===================== @@ -293,6 +376,18 @@ func TestE2E_Jobs_Filters(t *testing.T) { t.Errorf("Expected at least 3 jobs for company, got %d", response.Pagination.Total) } }) + + t.Run("FilterByFeatured", func(t *testing.T) { + resp, err := client.get("/api/v1/jobs?featured=true&limit=10") + if err != nil { + t.Fatalf("Failed to list jobs: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + }) } // TestE2E_Jobs_InvalidInput tests error handling for invalid input