Files
tp/main_dc/yalarba/api_yal/tests/testutils/test_server.go
T
valitovgaziz 01e8226c2b Add integration test suite with in-memory SQLite, mock repos, and test server
- Add test_server.go with chi-based router, shared in-memory SQLite DB, mock repositories
- Add mock_object_repository.go and mock_appeal_repository.go for lightweight testing
- Add setup.go with TestConfig/TestUser helpers, HTTP request builder, and fixtures
- Add go-sqlite3 dependency for in-memory test database
- Rewrite all 7 integration test suites (account, appeal, auth, comment, feedback, object, rating)
  using the new test infrastructure
2026-06-12 08:42:04 +05:00

362 lines
14 KiB
Go

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()
}