To gitea and beyond, let's go(-yco)

This commit is contained in:
2025-11-10 19:12:09 +01:00
parent 8f6133392d
commit 71a031342b
245 changed files with 83994 additions and 0 deletions

130
internal/server/router.go Normal file
View File

@@ -0,0 +1,130 @@
package server
import (
"net/http"
"path/filepath"
"time"
"github.com/go-chi/chi/v5"
httpSwagger "github.com/swaggo/http-swagger"
"goyco/internal/config"
"goyco/internal/handlers"
"goyco/internal/middleware"
)
type RouterConfig struct {
AuthHandler *handlers.AuthHandler
PostHandler *handlers.PostHandler
VoteHandler *handlers.VoteHandler
UserHandler *handlers.UserHandler
APIHandler *handlers.APIHandler
AuthService middleware.TokenVerifier
PageHandler *handlers.PageHandler
StaticDir string
Debug bool
DisableCache bool
DisableCompression bool
DBMonitor middleware.DBMonitor
RateLimitConfig config.RateLimitConfig
}
func NewRouter(cfg RouterConfig) http.Handler {
middleware.SetTrustProxyHeaders(cfg.RateLimitConfig.TrustProxyHeaders)
router := chi.NewRouter()
router.Use(middleware.Logging(cfg.Debug))
router.Use(middleware.SecurityHeadersMiddleware())
router.Use(middleware.HSTSMiddleware())
router.Use(middleware.CORS)
if !cfg.DisableCompression {
router.Use(middleware.CompressionMiddleware())
router.Use(middleware.DecompressionMiddleware())
}
router.Use(middleware.DefaultRequestSizeLimitMiddleware())
if !cfg.DisableCache {
cache := middleware.NewInMemoryCache()
cacheConfig := middleware.DefaultCacheConfig()
router.Use(middleware.CacheMiddleware(cache, cacheConfig))
router.Use(middleware.CacheInvalidationMiddleware(cache))
}
var dbMonitor middleware.DBMonitor
if cfg.DBMonitor != nil {
dbMonitor = cfg.DBMonitor
} else {
dbMonitor = middleware.NewInMemoryDBMonitor()
}
router.Use(middleware.DBMonitoringMiddleware(dbMonitor, 100*time.Millisecond))
metricsCollector := middleware.NewMetricsCollector(dbMonitor)
router.Use(middleware.MetricsMiddleware(metricsCollector))
routeConfig := handlers.RouteModuleConfig{
AuthService: cfg.AuthService,
GeneralRateLimit: func(r chi.Router) chi.Router {
return r.With(middleware.GeneralRateLimitMiddlewareWithLimit(cfg.RateLimitConfig.GeneralLimit))
},
AuthRateLimit: func(r chi.Router) chi.Router {
return r.With(middleware.AuthRateLimitMiddlewareWithLimit(cfg.RateLimitConfig.AuthLimit))
},
CSRFMiddleware: middleware.CSRFMiddleware(),
AuthMiddleware: middleware.NewAuth(cfg.AuthService),
}
if cfg.PageHandler != nil {
cfg.PageHandler.MountRoutes(router, routeConfig)
}
if cfg.APIHandler != nil {
healthRateLimited := router.With(middleware.HealthRateLimitMiddleware(cfg.RateLimitConfig.HealthLimit))
healthRateLimited.Get("/health", cfg.APIHandler.GetHealth)
metricsRateLimited := router.With(middleware.MetricsRateLimitMiddleware(cfg.RateLimitConfig.MetricsLimit))
metricsRateLimited.Get("/metrics", cfg.APIHandler.GetMetrics)
}
swaggerRateLimited := router.With(middleware.GeneralRateLimitMiddlewareWithLimit(cfg.RateLimitConfig.GeneralLimit))
swaggerRateLimited.Get("/swagger/*", httpSwagger.Handler())
router.Get("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, filepath.Join(cfg.StaticDir, "robots.txt"))
})
router.Route("/api", func(api chi.Router) {
modules := []handlers.RouteModule{}
if cfg.AuthHandler != nil {
modules = append(modules, cfg.AuthHandler)
}
if cfg.PostHandler != nil {
modules = append(modules, cfg.PostHandler)
}
if cfg.VoteHandler != nil {
modules = append(modules, cfg.VoteHandler)
}
if cfg.UserHandler != nil {
modules = append(modules, cfg.UserHandler)
}
for _, module := range modules {
module.MountRoutes(api, routeConfig)
}
if cfg.APIHandler != nil {
apiRateLimited := api.With(middleware.GeneralRateLimitMiddlewareWithLimit(cfg.RateLimitConfig.GeneralLimit))
apiRateLimited.Get("/", cfg.APIHandler.GetAPIInfo)
}
})
staticDir := cfg.StaticDir
if staticDir == "" {
staticDir = "./internal/static/"
}
router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
return router
}

View File

@@ -0,0 +1,515 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"goyco/internal/config"
"goyco/internal/database"
"goyco/internal/handlers"
"goyco/internal/middleware"
"goyco/internal/services"
"goyco/internal/testutils"
"gorm.io/gorm"
)
type mockTokenVerifier struct{}
func (m *mockTokenVerifier) VerifyToken(token string) (uint, error) {
return 0, nil
}
type mockRefreshTokenRepository struct{}
func (m *mockRefreshTokenRepository) Create(token *database.RefreshToken) error {
return nil
}
func (m *mockRefreshTokenRepository) GetByTokenHash(tokenHash string) (*database.RefreshToken, error) {
return nil, gorm.ErrRecordNotFound
}
func (m *mockRefreshTokenRepository) DeleteByUserID(userID uint) error {
return nil
}
func (m *mockRefreshTokenRepository) DeleteExpired() error {
return nil
}
func (m *mockRefreshTokenRepository) DeleteByID(id uint) error {
return nil
}
func (m *mockRefreshTokenRepository) GetByUserID(userID uint) ([]database.RefreshToken, error) {
return []database.RefreshToken{}, nil
}
func (m *mockRefreshTokenRepository) CountByUserID(userID uint) (int64, error) {
return 0, nil
}
type mockAccountDeletionRepository struct{}
func (m *mockAccountDeletionRepository) Create(req *database.AccountDeletionRequest) error {
return nil
}
func (m *mockAccountDeletionRepository) GetByTokenHash(hash string) (*database.AccountDeletionRequest, error) {
return nil, gorm.ErrRecordNotFound
}
func (m *mockAccountDeletionRepository) DeleteByID(id uint) error {
return nil
}
func (m *mockAccountDeletionRepository) DeleteByUserID(userID uint) error {
return nil
}
func setupTestHandlers() (*handlers.AuthHandler, *handlers.PostHandler, *handlers.VoteHandler, *handlers.UserHandler, *handlers.APIHandler, middleware.TokenVerifier) {
userRepo := testutils.NewMockUserRepository()
postRepo := testutils.NewMockPostRepository()
voteRepo := testutils.NewMockVoteRepository()
emailSender := &testutils.MockEmailSender{}
voteService := services.NewVoteService(voteRepo, postRepo, nil)
metadataService := services.NewURLMetadataService()
mockRefreshRepo := &mockRefreshTokenRepository{}
mockDeletionRepo := &mockAccountDeletionRepository{}
authFacade, err := services.NewAuthFacadeForTest(
testutils.AppTestConfig,
userRepo,
postRepo,
mockDeletionRepo,
mockRefreshRepo,
emailSender,
)
if err != nil {
panic("Failed to create auth facade: " + err.Error())
}
authHandler := handlers.NewAuthHandler(authFacade, userRepo)
postHandler := handlers.NewPostHandler(postRepo, metadataService, voteService)
voteHandler := handlers.NewVoteHandler(voteService)
userHandler := handlers.NewUserHandler(userRepo, authFacade)
apiHandler := handlers.NewAPIHandler(testutils.AppTestConfig, postRepo, userRepo, voteService)
return authHandler, postHandler, voteHandler, userHandler, apiHandler, &mockTokenVerifier{}
}
func defaultRateLimitConfig() config.RateLimitConfig {
return testutils.AppTestConfig.RateLimit
}
func TestAPIRootRouting(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, apiHandler, authService := setupTestHandlers()
router := NewRouter(RouterConfig{
APIHandler: apiHandler,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
RateLimitConfig: defaultRateLimitConfig(),
})
testCases := []struct {
name string
path string
wantStatus int
}{
{name: "without trailing slash", path: "/api", wantStatus: http.StatusOK},
{name: "with trailing slash", path: "/api/", wantStatus: http.StatusNotFound},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, tc.path, nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != tc.wantStatus {
t.Fatalf("expected status %d, got %d", tc.wantStatus, recorder.Code)
}
})
}
}
func TestProtectedRoutesRequireAuth(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, apiHandler, authService := setupTestHandlers()
router := NewRouter(RouterConfig{
APIHandler: apiHandler,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
RateLimitConfig: defaultRateLimitConfig(),
})
protectedRoutes := []struct {
method string
path string
}{
{http.MethodGet, "/api/auth/me"},
{http.MethodPost, "/api/posts"},
{http.MethodPost, "/api/posts/1/vote"},
{http.MethodDelete, "/api/posts/1/vote"},
{http.MethodGet, "/api/posts/1/vote"},
{http.MethodGet, "/api/posts/1/votes"},
{http.MethodGet, "/api/users"},
{http.MethodPost, "/api/users"},
{http.MethodGet, "/api/users/1"},
{http.MethodGet, "/api/users/1/posts"},
}
for _, route := range protectedRoutes {
t.Run(route.method+" "+route.path, func(t *testing.T) {
request := httptest.NewRequest(route.method, route.path, nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401 for protected route %s %s, got %d", route.method, route.path, recorder.Code)
}
})
}
}
func TestRouterWithDebugMode(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, apiHandler, authService := setupTestHandlers()
router := NewRouter(RouterConfig{
Debug: true,
APIHandler: apiHandler,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
RateLimitConfig: defaultRateLimitConfig(),
})
request := httptest.NewRequest(http.MethodGet, "/api", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", recorder.Code)
}
}
func TestRouterWithCacheDisabled(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, apiHandler, authService := setupTestHandlers()
router := NewRouter(RouterConfig{
DisableCache: true,
APIHandler: apiHandler,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
})
request := httptest.NewRequest(http.MethodGet, "/api", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", recorder.Code)
}
}
func TestRouterWithCompressionDisabled(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, apiHandler, authService := setupTestHandlers()
router := NewRouter(RouterConfig{
DisableCompression: true,
APIHandler: apiHandler,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
RateLimitConfig: defaultRateLimitConfig(),
})
request := httptest.NewRequest(http.MethodGet, "/api", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", recorder.Code)
}
}
func TestRouterWithCustomDBMonitor(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, apiHandler, authService := setupTestHandlers()
customDBMonitor := middleware.NewInMemoryDBMonitor()
router := NewRouter(RouterConfig{
DBMonitor: customDBMonitor,
APIHandler: apiHandler,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
RateLimitConfig: defaultRateLimitConfig(),
})
request := httptest.NewRequest(http.MethodGet, "/api", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", recorder.Code)
}
}
func TestRouterWithPageHandler(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, apiHandler, authService := setupTestHandlers()
pageHandler := &handlers.PageHandler{}
router := NewRouter(RouterConfig{
PageHandler: pageHandler,
APIHandler: apiHandler,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
RateLimitConfig: defaultRateLimitConfig(),
})
if router == nil {
t.Error("Router should not be nil")
}
}
func TestRouterWithStaticDir(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, apiHandler, authService := setupTestHandlers()
router := NewRouter(RouterConfig{
StaticDir: "/custom/static/path",
APIHandler: apiHandler,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
RateLimitConfig: defaultRateLimitConfig(),
})
request := httptest.NewRequest(http.MethodGet, "/api", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", recorder.Code)
}
}
func TestRouterWithEmptyStaticDir(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, apiHandler, authService := setupTestHandlers()
router := NewRouter(RouterConfig{
StaticDir: "",
APIHandler: apiHandler,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
RateLimitConfig: defaultRateLimitConfig(),
})
request := httptest.NewRequest(http.MethodGet, "/api", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", recorder.Code)
}
}
func TestRouterWithAllFeaturesDisabled(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, apiHandler, authService := setupTestHandlers()
router := NewRouter(RouterConfig{
Debug: true,
DisableCache: true,
DisableCompression: true,
APIHandler: apiHandler,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
RateLimitConfig: defaultRateLimitConfig(),
})
request := httptest.NewRequest(http.MethodGet, "/api", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", recorder.Code)
}
}
func TestRouterWithoutAPIHandler(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, _, authService := setupTestHandlers()
router := NewRouter(RouterConfig{
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
RateLimitConfig: defaultRateLimitConfig(),
})
request := httptest.NewRequest(http.MethodGet, "/api", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", recorder.Code)
}
}
func TestRouterWithoutPageHandler(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, apiHandler, authService := setupTestHandlers()
router := NewRouter(RouterConfig{
APIHandler: apiHandler,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
RateLimitConfig: defaultRateLimitConfig(),
})
request := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", recorder.Code)
}
}
func TestSwaggerRoute(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, apiHandler, authService := setupTestHandlers()
router := NewRouter(RouterConfig{
APIHandler: apiHandler,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
RateLimitConfig: defaultRateLimitConfig(),
})
request := httptest.NewRequest(http.MethodGet, "/swagger/", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK && recorder.Code != http.StatusMovedPermanently {
t.Errorf("Expected status 200 or 301 for swagger, got %d", recorder.Code)
}
}
func TestStaticFileRoute(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, apiHandler, authService := setupTestHandlers()
router := NewRouter(RouterConfig{
StaticDir: "../../internal/static/",
APIHandler: apiHandler,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
RateLimitConfig: defaultRateLimitConfig(),
})
request := httptest.NewRequest(http.MethodGet, "/static/css/main.css", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound && recorder.Code != http.StatusOK {
t.Errorf("Expected status 200 or 404 for static files, got %d", recorder.Code)
}
}
func TestRouterConfiguration(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, apiHandler, authService := setupTestHandlers()
router := NewRouter(RouterConfig{
APIHandler: apiHandler,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
RateLimitConfig: defaultRateLimitConfig(),
})
if router == nil {
t.Error("Router should not be nil")
}
request := httptest.NewRequest(http.MethodGet, "/api", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code == 0 {
t.Error("Router should return a status code")
}
}
func TestRouterMiddlewareIntegration(t *testing.T) {
authHandler, postHandler, voteHandler, userHandler, apiHandler, authService := setupTestHandlers()
router := NewRouter(RouterConfig{
APIHandler: apiHandler,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
AuthService: authService,
RateLimitConfig: defaultRateLimitConfig(),
})
if router == nil {
t.Error("Router should not be nil")
}
request := httptest.NewRequest(http.MethodGet, "/api", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code == 0 {
t.Error("Router should return a status code")
}
}