diff --git a/backend/internal/api/handlers/core_handlers_test.go b/backend/internal/api/handlers/core_handlers_test.go index 0753fff..ebac4e7 100644 --- a/backend/internal/api/handlers/core_handlers_test.go +++ b/backend/internal/api/handlers/core_handlers_test.go @@ -3,17 +3,23 @@ package handlers_test import ( "bytes" "context" + "database/sql" "encoding/json" "net/http" "net/http/httptest" + "regexp" "testing" + "time" + "github.com/DATA-DOG/go-sqlmock" "github.com/rede5/gohorsejobs/backend/internal/api/handlers" + "github.com/rede5/gohorsejobs/backend/internal/api/middleware" "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity" "github.com/rede5/gohorsejobs/backend/internal/core/dto" auth "github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth" tenant "github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant" user "github.com/rede5/gohorsejobs/backend/internal/core/usecases/user" + "github.com/rede5/gohorsejobs/backend/internal/services" ) // --- Mock Implementations --- @@ -69,7 +75,7 @@ func TestRegisterCandidateHandler_Success(t *testing.T) { } func TestRegisterCandidateHandler_InvalidPayload(t *testing.T) { - coreHandlers := createTestCoreHandlers(t, nil) + coreHandlers := createTestCoreHandlers(t, nil, nil) body := bytes.NewBufferString("{invalid json}") req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", body) @@ -84,7 +90,7 @@ func TestRegisterCandidateHandler_InvalidPayload(t *testing.T) { } func TestRegisterCandidateHandler_MissingFields(t *testing.T) { - coreHandlers := createTestCoreHandlers(t, nil) + coreHandlers := createTestCoreHandlers(t, nil, nil) testCases := []struct { name string @@ -130,7 +136,7 @@ func TestRegisterCandidateHandler_MissingFields(t *testing.T) { } func TestLoginHandler_InvalidPayload(t *testing.T) { - coreHandlers := createTestCoreHandlers(t, nil) + coreHandlers := createTestCoreHandlers(t, nil, nil) body := bytes.NewBufferString("{invalid}") req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", body) @@ -164,7 +170,7 @@ func TestLoginHandler_Success(t *testing.T) { // Real UseCase with Mocks loginUC := auth.NewLoginUseCase(mockRepo, mockAuth) - coreHandlers := createTestCoreHandlers(t, loginUC) + coreHandlers := createTestCoreHandlers(t, nil, loginUC) // Request payload := dto.LoginRequest{Email: "john@example.com", Password: "123456"} @@ -201,9 +207,25 @@ func TestLoginHandler_Success(t *testing.T) { } } -// createTestCoreHandlers creates handlers with mocks -func createTestCoreHandlers(t *testing.T, loginUC *auth.LoginUseCase) *handlers.CoreHandlers { +// createTestCoreHandlers creates handlers with mocks and optional DB +func createTestCoreHandlers(t *testing.T, db *sql.DB, loginUC *auth.LoginUseCase) *handlers.CoreHandlers { t.Helper() + + // Init services if DB provided + var auditSvc *services.AuditService + var notifSvc *services.NotificationService + var ticketSvc *services.TicketService + var adminSvc *services.AdminService + var credSvc *services.CredentialsService + + if db != nil { + auditSvc = services.NewAuditService(db) + notifSvc = services.NewNotificationService(db, nil) + ticketSvc = services.NewTicketService(db) + adminSvc = services.NewAdminService(db) + credSvc = services.NewCredentialsService(db) + } + return handlers.NewCoreHandlers( loginUC, (*auth.RegisterCandidateUseCase)(nil), @@ -213,10 +235,116 @@ func createTestCoreHandlers(t *testing.T, loginUC *auth.LoginUseCase) *handlers. (*user.DeleteUserUseCase)(nil), (*user.UpdateUserUseCase)(nil), (*tenant.ListCompaniesUseCase)(nil), - nil, - nil, - nil, - nil, - nil, + auditSvc, + notifSvc, + ticketSvc, + adminSvc, + credSvc, ) } + +func TestCoreHandlers_ListNotifications(t *testing.T) { + // Setup DB Mock + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + // Setup Handlers with DB + handlers := createTestCoreHandlers(t, db, nil) + + // User ID + userID := "user-123" + + // Mock DB Query for ListNotifications + mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, type, title, message, link, read_at, created_at, updated_at FROM notifications`)). + WithArgs(userID). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "type", "title", "message", "link", "read_at", "created_at", "updated_at"}). + AddRow("1", userID, "info", "Welcome", "Hello", nil, nil, time.Now(), time.Now())) + + // Request + req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications", nil) + + // Inject Context + ctx := context.WithValue(req.Context(), middleware.ContextUserID, userID) + req = req.WithContext(ctx) + + rec := httptest.NewRecorder() + + // Execute + handlers.ListNotifications(rec, req) + + // Assert + if rec.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) + } + + // Check Body (simple check) + if !bytes.Contains(rec.Body.Bytes(), []byte("Welcome")) { + t.Errorf("Expected body to contain 'Welcome'") + } +} + +func TestCoreHandlers_Tickets(t *testing.T) { + userID := "user-123" + + t.Run("CreateTicket", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + handlers := createTestCoreHandlers(t, db, nil) + + // Mock Insert: user_id, subject, priority + mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO tickets`)). + WithArgs(userID, "Issue", "low"). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "status", "priority", "created_at", "updated_at"}). + AddRow("ticket-1", userID, "Issue", "open", "low", time.Now(), time.Now())) + + payload := map[string]string{ + "subject": "Issue", + "message": "Help", + "priority": "low", + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/support/tickets", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + ctx := context.WithValue(req.Context(), middleware.ContextUserID, userID) + rec := httptest.NewRecorder() + + handlers.CreateTicket(rec, req.WithContext(ctx)) + + if rec.Code != http.StatusCreated { + t.Errorf("CreateTicket status = %d, want %d. Body: %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + }) + + t.Run("ListTickets", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + handlers := createTestCoreHandlers(t, db, nil) + + // Mock Select + mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, subject, status, priority, created_at, updated_at FROM tickets`)). + WithArgs(userID). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "status", "priority", "created_at", "updated_at"}). + AddRow("ticket-1", userID, "Issue", "open", "low", time.Now(), time.Now())) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/support/tickets", nil) + ctx := context.WithValue(req.Context(), middleware.ContextUserID, userID) + rec := httptest.NewRecorder() + + handlers.ListTickets(rec, req.WithContext(ctx)) + + if rec.Code != http.StatusOK { + t.Errorf("ListTickets status = %d, want %d. Body: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + }) +} diff --git a/backend/internal/middleware/middleware_test.go b/backend/internal/middleware/middleware_test.go index f9636a4..6ff645c 100644 --- a/backend/internal/middleware/middleware_test.go +++ b/backend/internal/middleware/middleware_test.go @@ -1,12 +1,63 @@ package middleware import ( + "context" + "encoding/json" "net/http" "net/http/httptest" + "os" + "strings" "testing" "time" + + "github.com/rede5/gohorsejobs/backend/internal/utils" ) +func TestLoggingMiddleware(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + mw := LoggingMiddleware(handler) + + req := httptest.NewRequest("GET", "/test", nil) + rr := httptest.NewRecorder() + + mw.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rr.Code) + } +} + +func TestAuthMiddleware_Success(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := r.Context().Value(UserKey).(*utils.Claims) + if !ok { + t.Error("Claims not found in context") + w.WriteHeader(http.StatusUnauthorized) + return + } + if claims.UserID != 1 { + t.Errorf("Expected userID 1, got %d", claims.UserID) + } + w.WriteHeader(http.StatusOK) + }) + + mw := AuthMiddleware(handler) + + token, _ := utils.GenerateJWT(1, "test-user", "user") + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + + mw.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rr.Code) + } +} + func TestRateLimiter_isAllowed(t *testing.T) { limiter := NewRateLimiter(3, time.Minute) @@ -33,7 +84,7 @@ func TestRateLimitMiddleware(t *testing.T) { w.WriteHeader(http.StatusOK) }) - middleware := RateLimitMiddleware(2, time.Minute)(handler) + mw := RateLimitMiddleware(2, time.Minute)(handler) // Create test requests for i := 0; i < 3; i++ { @@ -41,7 +92,7 @@ func TestRateLimitMiddleware(t *testing.T) { req.RemoteAddr = "192.168.1.100:12345" rr := httptest.NewRecorder() - middleware.ServeHTTP(rr, req) + mw.ServeHTTP(rr, req) if i < 2 { if rr.Code != http.StatusOK { @@ -60,12 +111,12 @@ func TestSecurityHeadersMiddleware(t *testing.T) { w.WriteHeader(http.StatusOK) }) - middleware := SecurityHeadersMiddleware(handler) + mw := SecurityHeadersMiddleware(handler) req := httptest.NewRequest("GET", "/test", nil) rr := httptest.NewRecorder() - middleware.ServeHTTP(rr, req) + mw.ServeHTTP(rr, req) expectedHeaders := map[string]string{ "X-Frame-Options": "DENY", @@ -86,12 +137,12 @@ func TestAuthMiddleware_NoAuthHeader(t *testing.T) { w.WriteHeader(http.StatusOK) }) - middleware := AuthMiddleware(handler) + mw := AuthMiddleware(handler) req := httptest.NewRequest("GET", "/test", nil) rr := httptest.NewRecorder() - middleware.ServeHTTP(rr, req) + mw.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("Expected status 401, got %d", rr.Code) @@ -103,13 +154,13 @@ func TestAuthMiddleware_InvalidFormat(t *testing.T) { w.WriteHeader(http.StatusOK) }) - middleware := AuthMiddleware(handler) + mw := AuthMiddleware(handler) req := httptest.NewRequest("GET", "/test", nil) req.Header.Set("Authorization", "InvalidFormat") rr := httptest.NewRecorder() - middleware.ServeHTTP(rr, req) + mw.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("Expected status 401, got %d", rr.Code) @@ -121,13 +172,13 @@ func TestAuthMiddleware_InvalidToken(t *testing.T) { w.WriteHeader(http.StatusOK) }) - middleware := AuthMiddleware(handler) + mw := AuthMiddleware(handler) req := httptest.NewRequest("GET", "/test", nil) req.Header.Set("Authorization", "Bearer invalid.token.here") rr := httptest.NewRecorder() - middleware.ServeHTTP(rr, req) + mw.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("Expected status 401, got %d", rr.Code) @@ -139,14 +190,136 @@ func TestRequireRole_NoClaims(t *testing.T) { w.WriteHeader(http.StatusOK) }) - middleware := RequireRole("admin")(handler) + mw := RequireRole("admin")(handler) req := httptest.NewRequest("GET", "/test", nil) rr := httptest.NewRecorder() - middleware.ServeHTTP(rr, req) + mw.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("Expected status 401, got %d", rr.Code) } } + +func TestCORSMiddleware(t *testing.T) { + os.Setenv("CORS_ORIGINS", "http://allowed.com,http://another.com") + defer os.Unsetenv("CORS_ORIGINS") + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + mw := CORSMiddleware(handler) + + // Test allowed origin + req := httptest.NewRequest("OPTIONS", "/test", nil) + req.Header.Set("Origin", "http://allowed.com") + rr := httptest.NewRecorder() + + mw.ServeHTTP(rr, req) + + if rr.Header().Get("Access-Control-Allow-Origin") != "http://allowed.com" { + t.Errorf("Expected allow origin http://allowed.com, got %s", rr.Header().Get("Access-Control-Allow-Origin")) + } + + // Test disallowed origin + req = httptest.NewRequest("OPTIONS", "/test", nil) + req.Header.Set("Origin", "http://hacker.com") + rr = httptest.NewRecorder() + + mw.ServeHTTP(rr, req) + + if rr.Header().Get("Access-Control-Allow-Origin") != "" { + t.Errorf("Expected empty allow origin, got %s", rr.Header().Get("Access-Control-Allow-Origin")) + } +} + +func TestSanitizeMiddleware(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Read body to verify sanitization + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + w.Header().Set("X-Sanitized-Name", body["name"].(string)) + w.WriteHeader(http.StatusOK) + }) + + mw := SanitizeMiddleware(handler) + + jsonBody := `{"name": ""}` + req := httptest.NewRequest("POST", "/test", strings.NewReader(jsonBody)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + mw.ServeHTTP(rr, req) + + expected := "<script>alert('xss')</script>" + if rr.Header().Get("X-Sanitized-Name") != expected { + t.Errorf("Expected sanitized name %s, got %s", expected, rr.Header().Get("X-Sanitized-Name")) + } +} + +func TestRequireRole_Success(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + mw := RequireRole("admin")(handler) + + req := httptest.NewRequest("GET", "/test", nil) + // Inject claims into context manually to simulate authenticated user + claims := &utils.Claims{ + UserID: 1, + Role: "admin", + } + ctx := context.WithValue(req.Context(), UserKey, claims) + rr := httptest.NewRecorder() + + mw.ServeHTTP(rr, req.WithContext(ctx)) + + if rr.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rr.Code) + } +} + +func TestRequireRole_Forbidden(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + mw := RequireRole("admin")(handler) + + req := httptest.NewRequest("GET", "/test", nil) + claims := &utils.Claims{ + UserID: 1, + Role: "user", // Wrong role + } + ctx := context.WithValue(req.Context(), UserKey, claims) + rr := httptest.NewRecorder() + + mw.ServeHTTP(rr, req.WithContext(ctx)) + + if rr.Code != http.StatusForbidden { + t.Errorf("Expected status 403, got %d", rr.Code) + } +} + +func TestSanitizeMiddleware_InvalidJSON(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + mw := SanitizeMiddleware(handler) + + jsonBody := `{"name": "broken json` + req := httptest.NewRequest("POST", "/test", strings.NewReader(jsonBody)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + mw.ServeHTTP(rr, req) + + // Should pass through if JSON invalid (or handle gracefully) + if rr.Code != http.StatusOK { + t.Errorf("Expected status 200 (pass through), got %d", rr.Code) + } +} diff --git a/backend/internal/services/admin_service_test.go b/backend/internal/services/admin_service_test.go index 1eb7bd4..5d6f752 100644 --- a/backend/internal/services/admin_service_test.go +++ b/backend/internal/services/admin_service_test.go @@ -2,10 +2,12 @@ package services import ( "context" + "regexp" "testing" "time" "github.com/DATA-DOG/go-sqlmock" + "github.com/rede5/gohorsejobs/backend/internal/dto" ) func TestAdminService_ListCompanies(t *testing.T) { @@ -280,3 +282,126 @@ func TestAdminService_UpdateCompanyStatus(t *testing.T) { } }) } + +func TestAdminService_ListUsers(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + service := NewAdminService(db) + + t.Run("returns users list", func(t *testing.T) { + mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM users`)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, COALESCE(name, full_name, identifier, ''), email, role, COALESCE(status, 'active'), created_at FROM users`)). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "role", "status", "created_at"}). + AddRow("1", "User 1", "u1@test.com", "admin", "active", time.Now()). + AddRow("2", "User 2", "u2@test.com", "user", "inactive", time.Now())) + + users, count, err := service.ListUsers(context.Background(), 1, 10, nil) + + if err != nil { + t.Errorf("ListUsers() error = %v", err) + } + if count != 2 { + t.Errorf("ListUsers() count = %d, want 2", count) + } + if len(users) != 2 { + t.Errorf("ListUsers() length = %d, want 2", len(users)) + } + }) +} + +func TestAdminService_DuplicateJob(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + service := NewAdminService(db) + jobID := "job-123" + + // Expect fetch original job + mock.ExpectQuery(regexp.QuoteMeta(`SELECT company_id`)). + WithArgs(jobID). + WillReturnRows(sqlmock.NewRows([]string{ + "company_id", "created_by", "title", "description", "salary_min", "salary_max", "salary_type", + "employment_type", "work_mode", "working_hours", "location", "region_id", "city_id", + "requirements", "benefits", "visa_support", "language_level", + }).AddRow( + "comp-1", "user-1", "Job 1", "Desc", 100, 200, "m", + "ft", "remote", "40h", "Remote", 0, 0, + nil, nil, false, "N2", + )) + + // Expect insert duplicated job + mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO jobs`)). + WithArgs( + "comp-1", "user-1", "Job 1", "Desc", 100.0, 200.0, "m", + "ft", "remote", "40h", "Remote", 0, 0, + sqlmock.AnyArg(), sqlmock.AnyArg(), false, "N2", + "draft", false, sqlmock.AnyArg(), sqlmock.AnyArg(), // Status, IsFeatured, CreatedAt, UpdatedAt + ). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow("new-job-id")) + + job, err := service.DuplicateJob(context.Background(), jobID) + + if err != nil { + t.Errorf("DuplicateJob() error = %v", err) + } + if job.ID != "new-job-id" { + t.Errorf("DuplicateJob() ID = %s, want new-job-id", job.ID) + } +} + +func TestAdminService_EmailTemplates(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + service := NewAdminService(db) + + t.Run("ListEmailTemplates", func(t *testing.T) { + mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, slug, subject, body_html, variables, created_at, updated_at FROM email_templates`)). + WillReturnRows(sqlmock.NewRows([]string{"id", "slug", "subject", "body_html", "variables", "created_at", "updated_at"}). + AddRow("1", "welcome", "Welcome", "