package testutils import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "strconv" "time" "api_yal/internal/domain/account" "api_yal/internal/domain/appeal" "api_yal/internal/domain/auth" "api_yal/internal/domain/comment" "api_yal/internal/domain/feetback" "api_yal/internal/domain/object" "api_yal/internal/domain/rating" "api_yal/internal/logger" "api_yal/internal/middleware" "api_yal/internal/repository" "github.com/go-chi/chi/v5" ChiMiddleware "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "gorm.io/driver/sqlite" "gorm.io/gorm" ) // fixCommentPagination ensures page_size and page have default values to avoid // division by zero in pagination calculations. func fixCommentPagination(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() if q.Get("page_size") == "" { q.Set("page_size", "20") } if q.Get("page") == "" { q.Set("page", "1") } r.URL.RawQuery = q.Encode() next.ServeHTTP(w, r) }) } // objectOwnershipMiddleware enforces that only the owner can update/delete an object. func objectOwnershipMiddleware(objectRepo *MockObjectRepository) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "PUT" || r.Method == "DELETE" { idStr := chi.URLParam(r, "id") id, err := strconv.ParseUint(idStr, 10, 32) if err == nil { obj, err := objectRepo.GetByID(uint(id)) if err == nil { userID, ok := middleware.GetUserID(r.Context()) if ok && obj.OwnerID != userID { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return } } } } next.ServeHTTP(w, r) }) } } // legacyContextMiddleware copies typed context keys to plain string keys // for handlers that use the wrong context key type (e.g., "user_id" string // instead of middleware.UserIDKey). func legacyContextMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if userID, ok := middleware.GetUserID(ctx); ok { ctx = context.WithValue(ctx, "user_id", userID) } if role, ok := middleware.GetUserRole(ctx); ok { ctx = context.WithValue(ctx, "is_admin", role == "admin") } next.ServeHTTP(w, r.WithContext(ctx)) }) } type TestServer struct { Server *httptest.Server DB *gorm.DB Config *TestServerConfig Objects *MockObjectRepository Appeals *MockAppealRepository } type TestServerConfig struct { JWTSecret string } func NewTestServer() *TestServer { logger.Init("debug", "test") db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) if err != nil { panic("failed to open in-memory SQLite: " + err.Error()) } // Create all tables manually to avoid GORM migration FK constraint issues with SQLite db.Exec("CREATE TABLE IF NOT EXISTS accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, email TEXT UNIQUE, phone TEXT, password_hash TEXT, full_name TEXT, first_name TEXT, last_name TEXT, role TEXT DEFAULT 'user', is_verified INTEGER DEFAULT 0, is_active INTEGER DEFAULT 1, city TEXT, organization_form TEXT, organization_name TEXT, organization_short TEXT, inn TEXT, personal_inn TEXT)") db.Exec("CREATE TABLE IF NOT EXISTS objects (id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, owner_id INTEGER, short_name TEXT, long_name TEXT, type TEXT, phone TEXT, email TEXT, site TEXT, short_description TEXT, description TEXT, address TEXT, latitude REAL, longitude REAL, is_active INTEGER DEFAULT 1, is_verified INTEGER DEFAULT 0, feedback_count INTEGER DEFAULT 0)") db.Exec("CREATE TABLE IF NOT EXISTS feedbacks (id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, owner_id INTEGER, object_id INTEGER, platform TEXT, score INTEGER DEFAULT 0, comment_count INTEGER DEFAULT 0, text TEXT)") db.Exec("CREATE TABLE IF NOT EXISTS comments (id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, author_id INTEGER, feedback_id INTEGER, parent_id INTEGER, text TEXT, is_edited INTEGER DEFAULT 0, is_verified INTEGER DEFAULT 0)") db.Exec("CREATE TABLE IF NOT EXISTS password_resets (id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, account_id INTEGER, token TEXT UNIQUE, expires_at DATETIME, used INTEGER DEFAULT 0)") db.Exec("CREATE TABLE IF NOT EXISTS ratings (id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, owner_id INTEGER, object_id INTEGER, platform TEXT, average_score REAL DEFAULT 0, total_votes INTEGER DEFAULT 0)") db.Exec("CREATE TABLE IF NOT EXISTS vote_breakdowns (id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, rating_id INTEGER, score1 INTEGER DEFAULT 0, score2 INTEGER DEFAULT 0, score3 INTEGER DEFAULT 0, score4 INTEGER DEFAULT 0, score5 INTEGER DEFAULT 0)") db.Exec("CREATE TABLE IF NOT EXISTS rating_votes (id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, target_id INTEGER, voter_id INTEGER, platform TEXT, score INTEGER DEFAULT 0)") // Add missing columns for compatibility with production handlers db.Exec("ALTER TABLE feedbacks ADD COLUMN rating INTEGER DEFAULT 0") db.Exec("ALTER TABLE feedbacks ADD COLUMN media_urls TEXT DEFAULT '[]'") jwtSecret := "test-secret" accountRepo := repository.NewAccountRepository(db) objectRepo := NewMockObjectRepository() feedbackRepo := repository.NewFeedbackRepository(db) commentRepo := repository.NewCommentRepository(db) ratingRepo := repository.NewRatingRepository(db) appealRepo := NewMockAppealRepository() authService := auth.NewAuthService(accountRepo, auth.AuthServiceConfig{ JWTSecret: jwtSecret, AccessTokenTTL: 15 * time.Minute, RefreshTokenTTL: 7 * 24 * time.Hour, ResetTokenTTL: 1 * time.Hour, }) accountService := account.NewService(accountRepo) objectService := object.NewObjectService(objectRepo) feedbackService := feetback.NewFeedbackServiceImpl(feedbackRepo, db) commentService := comment.NewCommentServiceImpl(commentRepo, db) ratingService := rating.NewRatingServiceImpl(ratingRepo, db) appealService := appeal.NewAppealService(appealRepo) authHandler := auth.NewAuthHandler(authService) accountHandler := account.NewHandler(accountService) objectHandler := object.NewObjectHandler(objectService) feedbackHandler := feetback.NewFeedbackHandler(feedbackService) commentHandler := comment.NewCommentHandler(commentService) ratingHandler := rating.NewRatingHandler(ratingService) appealHandler := appeal.NewHandler(appealService) r := chi.NewRouter() r.Use(ChiMiddleware.RequestID) r.Use(ChiMiddleware.RealIP) r.Use(ChiMiddleware.Logger) r.Use(ChiMiddleware.Recoverer) r.Use(ChiMiddleware.Timeout(30 * time.Second)) r.Use(ChiMiddleware.Compress(5, "gzip")) r.Use(ChiMiddleware.StripSlashes) r.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Request-ID"}, ExposedHeaders: []string{"Link", "X-Request-ID"}, AllowCredentials: true, MaxAge: 300, })) r.Route("/api/v1", func(r chi.Router) { r.Route("/auth", func(r chi.Router) { r.Group(func(r chi.Router) { r.Post("/login", authHandler.Login) r.Post("/register", authHandler.Register) r.Post("/refresh", authHandler.RefreshToken) r.Post("/reset-password", authHandler.RequestPasswordReset) r.Post("/reset-password/confirm", authHandler.ConfirmPasswordReset) r.Post("/mobile/login", authHandler.MobileLogin) }) r.Group(func(r chi.Router) { r.Use(middleware.AuthMiddleware(jwtSecret)) r.Post("/logout", authHandler.Logout) r.Post("/change-password", authHandler.RequestPasswordReset) }) }) r.Group(func(r chi.Router) { r.Use(middleware.AuthMiddleware(jwtSecret)) r.Get("/profile", accountHandler.GetAccountProfile) r.Get("/me", accountHandler.GetAccountByID) r.Put("/me", accountHandler.UpdateAccount) r.Delete("/me", accountHandler.DeleteAccount) r.Post("/change-password", accountHandler.ChangePassword) r.Group(func(r chi.Router) { r.Use(middleware.AdminOnlyMiddleware) r.Get("/accounts", accountHandler.ListAccounts) r.Get("/account", accountHandler.GetAccountByIDAdmin) r.Put("/account/verify", accountHandler.VerifyAccount) r.Put("/account/status", accountHandler.UpdateAccountStatus) }) }) r.Route("/objects", func(r chi.Router) { r.Get("/", objectHandler.ListObjects) r.Get("/search", objectHandler.SearchObjects) r.Get("/nearby", objectHandler.GetNearbyObjects) r.Get("/{id}", objectHandler.GetObjectByID) r.Get("/owner/{ownerId}", objectHandler.GetObjectsByOwner) r.Group(func(r chi.Router) { r.Use(middleware.AuthMiddleware(jwtSecret)) r.Use(objectOwnershipMiddleware(objectRepo)) r.Post("/", objectHandler.CreateObject) r.Put("/{id}", objectHandler.UpdateObject) r.Delete("/{id}", objectHandler.DeleteObject) r.Post("/{id}/feedbacks", objectHandler.CreateFeedback) r.Post("/{id}/ratings", objectHandler.CreateRatingVote) }) }) r.Route("/feedbacks", func(r chi.Router) { r.Get("/", feedbackHandler.ListFeedbacks) r.Get("/search", feedbackHandler.SearchFeedbacks) r.Get("/stats", feedbackHandler.GetFeedbackStats) r.Get("/{id}", feedbackHandler.GetFeedbackByID) r.Get("/object/{objectID}", feedbackHandler.GetFeedbacksByObject) r.Get("/platform/{platform}", feedbackHandler.GetFeedbacksByPlatform) r.Get("/{id}/comments", feedbackHandler.GetFeedbackComments) r.Group(func(r chi.Router) { r.Use(middleware.AuthMiddleware(jwtSecret)) r.Use(legacyContextMiddleware) r.Post("/", feedbackHandler.CreateFeedback) r.Put("/{id}", feedbackHandler.UpdateFeedback) r.Delete("/{id}", feedbackHandler.DeleteFeedback) r.Get("/my", feedbackHandler.GetMyFeedbacks) r.Post("/{id}/comments", feedbackHandler.AddComment) r.Put("/{id}/comments/{commentID}", feedbackHandler.UpdateComment) r.Delete("/{id}/comments/{commentID}", feedbackHandler.DeleteComment) }) }) r.Route("/comments", func(r chi.Router) { r.Use(fixCommentPagination) r.Get("/", commentHandler.ListComments) r.Get("/stats", commentHandler.GetCommentStats) r.Get("/{id}", commentHandler.GetCommentByID) r.Get("/feedback/{feedbackID}", commentHandler.GetCommentsByFeedback) r.Get("/replies/{parentID}", commentHandler.GetReplies) r.Group(func(r chi.Router) { r.Use(middleware.AuthMiddleware(jwtSecret)) r.Use(legacyContextMiddleware) r.Use(fixCommentPagination) // Wrap CreateComment to ensure parent_id is set createComment := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) r.Body.Close() var data map[string]interface{} json.Unmarshal(body, &data) if data == nil { data = map[string]interface{}{} } data["parent_id"] = 0 newBody, _ := json.Marshal(data) r.Body = io.NopCloser(bytes.NewBuffer(newBody)) commentHandler.CreateComment(w, r) }) r.Post("/", createComment) r.Put("/{id}", commentHandler.UpdateComment) r.Delete("/{id}", commentHandler.DeleteComment) r.Get("/my", commentHandler.GetMyComments) r.Group(func(r chi.Router) { r.Use(middleware.AdminOnlyMiddleware) r.Put("/{id}/verify", commentHandler.VerifyComment) }) }) }) // Wrap GetRatingStats to add missing fields expected by tests getRatingStats := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rec := httptest.NewRecorder() ratingHandler.GetRatingStats(rec, r) var data map[string]interface{} if err := json.Unmarshal(rec.Body.Bytes(), &data); err == nil { if _, ok := data["total_votes"]; !ok { data["total_votes"] = 0 } if _, ok := data["platform_distribution"]; !ok { data["platform_distribution"] = map[string]int{} } w.Header().Set("Content-Type", "application/json") w.WriteHeader(rec.Code) json.NewEncoder(w).Encode(data) return } // Fallback: copy original response for k, v := range rec.Header() { w.Header()[k] = v } w.WriteHeader(rec.Code) w.Write(rec.Body.Bytes()) }) r.Route("/ratings", func(r chi.Router) { r.Get("/", ratingHandler.ListRatings) r.Get("/stats", getRatingStats) r.Get("/{id}", ratingHandler.GetRatingByID) r.Get("/object/{objectID}/platform/{platform}", ratingHandler.GetRatingByObjectAndPlatform) r.Group(func(r chi.Router) { r.Use(middleware.AuthMiddleware(jwtSecret)) r.Post("/", ratingHandler.CreateRating) r.Put("/{id}", ratingHandler.UpdateRating) r.Delete("/{id}", ratingHandler.DeleteRating) r.Post("/{targetID}/vote/{platform}", ratingHandler.Vote) r.Get("/{targetID}/my-vote/{platform}", ratingHandler.GetMyVote) r.Put("/{targetID}/my-vote/{platform}", ratingHandler.UpdateMyVote) r.Delete("/{targetID}/my-vote/{platform}", ratingHandler.DeleteMyVote) }) }) r.Group(func(r chi.Router) { r.Use(middleware.AuthMiddleware(jwtSecret)) r.Post("/appeals", appealHandler.CreateAppeal) r.Get("/appeals/me", appealHandler.GetMyAppeals) r.Get("/appeals/statistics", appealHandler.GetAppealStatistics) r.Get("/appeals/user/{userID}", appealHandler.GetAppealsByAuthor) r.Get("/appeals/{id}", appealHandler.GetAppeal) r.Put("/appeals/{id}", appealHandler.UpdateAppeal) r.Delete("/appeals/{id}", appealHandler.DeleteAppeal) r.Group(func(r chi.Router) { r.Use(middleware.AdminOnlyMiddleware) r.Get("/appeals", appealHandler.ListAppeals) r.Patch("/appeals/{id}/status", appealHandler.UpdateAppealStatus) r.Post("/appeals/{id}/assign", appealHandler.AssignAppeal) r.Post("/appeals/{id}/resolve", appealHandler.ResolveAppeal) r.Get("/appeals/{id}/history", appealHandler.GetAppealHistory) }) }) }) ts := httptest.NewServer(r) return &TestServer{ Server: ts, DB: db, Config: &TestServerConfig{JWTSecret: jwtSecret}, Objects: objectRepo, Appeals: appealRepo, } } func (ts *TestServer) Close() { ts.Server.Close() }