diff --git a/cmd/server/main.go b/cmd/server/main.go index 073160e..fbd256f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -4,8 +4,10 @@ import ( "log" "net/http" "os" + "time" _ "github.com/aleksandr/strive-api/docs" + "github.com/aleksandr/strive-api/internal/clients" "github.com/aleksandr/strive-api/internal/config" "github.com/aleksandr/strive-api/internal/database" httphandler "github.com/aleksandr/strive-api/internal/http" @@ -101,41 +103,60 @@ func runMigrations(cfg *config.Config, logger *logger.Logger) { } type Services struct { - Auth services.AuthService - User services.UserService - Calorie services.CalorieService + Auth services.AuthService + User services.UserService + Calorie services.CalorieService + Exercise services.ExerciseService } func setupServices(db *database.Database, cfg *config.Config) *Services { userRepo := repositories.NewUserRepository(db.Pool()) refreshTokenRepo := repositories.NewRefreshTokenRepository(db.Pool()) calorieRepo := repositories.NewCalorieRepository(db.Pool()) + exerciseRepo := repositories.NewExerciseRepository(db.Pool()) authService := services.NewAuthService(userRepo, refreshTokenRepo, &cfg.JWT) userService := services.NewUserService(userRepo) calorieService := services.NewCalorieService(calorieRepo) + var exerciseService services.ExerciseService + if cfg.ExerciseDB.Enabled { + exerciseDBClient := clients.NewExerciseDBClient(&cfg.ExerciseDB, logger.New(cfg.Log.Level, cfg.Log.Format)) + cacheService := services.NewExerciseCacheService(exerciseRepo, exerciseDBClient, logger.New(cfg.Log.Level, cfg.Log.Format), 24*time.Hour) + exerciseService = services.NewExerciseService(exerciseRepo, cacheService, logger.New(cfg.Log.Level, cfg.Log.Format)) + } else { + exerciseService = nil + } + return &Services{ - Auth: authService, - User: userService, - Calorie: calorieService, + Auth: authService, + User: userService, + Calorie: calorieService, + Exercise: exerciseService, } } type Handlers struct { - Auth *httphandler.AuthHandlers - User *httphandler.UserHandlers - Calorie *httphandler.CalorieHandlers - Health *httphandler.DetailedHealthHandler + Auth *httphandler.AuthHandlers + User *httphandler.UserHandlers + Calorie *httphandler.CalorieHandlers + Exercise *httphandler.ExerciseHandlers + Health *httphandler.DetailedHealthHandler } func setupHandlers(services *Services, logger *logger.Logger, db *database.Database, cfg *config.Config) *Handlers { - return &Handlers{ + handlers := &Handlers{ Auth: httphandler.NewAuthHandlers(services.Auth, logger, cfg), User: httphandler.NewUserHandlers(services.User, logger), Calorie: httphandler.NewCalorieHandlers(services.Calorie, logger), Health: httphandler.NewDetailedHealthHandler(logger, db.Pool()), } + + if services.Exercise != nil { + handlers.Exercise = httphandler.NewExerciseHandlers(services.Exercise, logger) + } + + return handlers } func setupRoutes(handlers *Handlers, logger *logger.Logger, authService services.AuthService, cfg *config.Config) http.Handler { @@ -181,6 +202,19 @@ func setupProtectedRoutes(mux *http.ServeMux, authService services.AuthService, calorieProtectedMux.HandleFunc("/last", handlers.Calorie.GetLastCalculation) calorieProtectedHandler := httphandler.AuthMiddleware(authService, logger)(calorieProtectedMux) mux.Handle("/api/v1/calorie/", http.StripPrefix("/api/v1/calorie", calorieProtectedHandler)) + + // Exercise protected routes + if handlers.Exercise != nil { + exerciseProtectedMux := http.NewServeMux() + exerciseProtectedMux.HandleFunc("", handlers.Exercise.GetExercises) + exerciseProtectedMux.HandleFunc("/", handlers.Exercise.GetExerciseByID) + exerciseProtectedMux.HandleFunc("/muscle-groups", handlers.Exercise.GetMuscleGroups) + exerciseProtectedMux.HandleFunc("/equipment", handlers.Exercise.GetEquipment) + exerciseProtectedMux.HandleFunc("/cache/status", handlers.Exercise.GetCacheStatus) + exerciseProtectedMux.HandleFunc("/cache/refresh", handlers.Exercise.RefreshCache) + exerciseProtectedHandler := httphandler.AuthMiddleware(authService, logger)(exerciseProtectedMux) + mux.Handle("/api/v1/exercises", http.StripPrefix("/api/v1/exercises", exerciseProtectedHandler)) + } } func applyMiddleware(mux *http.ServeMux, logger *logger.Logger, cfg *config.Config) http.Handler { diff --git a/docs/exercise-api-documentation.md b/docs/exercise-api-documentation.md new file mode 100644 index 0000000..edc99ee --- /dev/null +++ b/docs/exercise-api-documentation.md @@ -0,0 +1,319 @@ +# Exercise API Documentation + +## Overview + +The Exercise API provides access to a comprehensive database of exercises with filtering, search, and caching capabilities. The API integrates with ExerciseDB to provide up-to-date exercise information. + +## Base URL + +``` +https://strive-api-zjtl.onrender.com/api/v1/exercises +``` + +## Authentication + +**Authentication required** - Exercise API endpoints require valid JWT token. + +Include the JWT token in the Authorization header: +``` +Authorization: Bearer +``` + +## Endpoints + +### 1. Get Exercises List + +**GET** `/api/v1/exercises` + +Returns a paginated list of exercises with optional filtering. + +#### Query Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `muscle_group_id` | UUID | No | Filter by muscle group ID | +| `equipment_id` | UUID | No | Filter by equipment ID | +| `category` | Integer | No | Filter by category (9=strength, 10=bodyweight, 11=weighted) | +| `search` | String | No | Search by exercise name | +| `page` | Integer | No | Page number (default: 1) | +| `limit` | Integer | No | Items per page (default: 20, max: 100) | + +#### Example Request + +```bash +GET /api/v1/exercises?muscle_group_id=123e4567-e89b-12d3-a456-426614174000&category=9&page=1&limit=20 +Authorization: Bearer +``` + +#### Response + +```json +{ + "exercises": [ + { + "id": "123e4567-e89b-12d3-a456-426614174000", + "name": "Push-ups", + "description": "A basic bodyweight exercise for chest and triceps", + "instructions": "Start in plank position...", + "tips": "Keep your core tight...", + "category": 10, + "muscle_groups": [ + { + "id": "456e7890-e89b-12d3-a456-426614174001", + "name": "Chest" + } + ], + "equipment": [], + "alternatives": [ + { + "id": "789e0123-e89b-12d3-a456-426614174002", + "name": "Incline Push-ups" + } + ], + "created_at": "2024-01-15T10:30:00Z", + "cached_at": "2024-01-15T10:30:00Z", + "expires_at": "2024-01-16T10:30:00Z" + } + ], + "total": 150, + "page": 1, + "limit": 20 +} +``` + +### 2. Get Exercise by ID + +**GET** `/api/v1/exercises/{id}` + +Returns detailed information about a specific exercise. + +#### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | UUID | Yes | Exercise ID | + +#### Example Request + +```bash +GET /api/v1/exercises/123e4567-e89b-12d3-a456-426614174000 +Authorization: Bearer +``` + +#### Response + +```json +{ + "id": "123e4567-e89b-12d3-a456-426614174000", + "name": "Push-ups", + "description": "A basic bodyweight exercise for chest and triceps", + "instructions": "Start in plank position with hands slightly wider than shoulders...", + "tips": "Keep your core tight and maintain a straight line from head to heels", + "category": 10, + "language": 7, + "license": 2, + "license_author": "ExerciseDB", + "status": "2", + "name_original": "", + "creation_date": "2015-10-22", + "uuid": "583281c7-2362-48e7-95d5-8fd6c455e0fb", + "muscle_groups": [ + { + "id": "456e7890-e89b-12d3-a456-426614174001", + "name": "Chest", + "created_at": "2024-01-15T10:30:00Z" + }, + { + "id": "789e0123-e89b-12d3-a456-426614174002", + "name": "Triceps", + "created_at": "2024-01-15T10:30:00Z" + } + ], + "equipment": [], + "alternatives": [ + { + "id": "012e3456-e89b-12d3-a456-426614174003", + "name": "Incline Push-ups", + "description": "Easier variation of push-ups", + "category": 10 + } + ], + "created_at": "2024-01-15T10:30:00Z", + "cached_at": "2024-01-15T10:30:00Z", + "expires_at": "2024-01-16T10:30:00Z" +} +``` + +### 3. Get Muscle Groups + +**GET** `/api/v1/exercises/muscle-groups` + +Returns a list of all available muscle groups. + +#### Response + +```json +[ + { + "id": "456e7890-e89b-12d3-a456-426614174001", + "name": "Chest", + "created_at": "2024-01-15T10:30:00Z" + }, + { + "id": "789e0123-e89b-12d3-a456-426614174002", + "name": "Triceps", + "created_at": "2024-01-15T10:30:00Z" + }, + { + "id": "012e3456-e89b-12d3-a456-426614174003", + "name": "Biceps", + "created_at": "2024-01-15T10:30:00Z" + } +] +``` + +### 4. Get Equipment + +**GET** `/api/v1/exercises/equipment` + +Returns a list of all available equipment. + +#### Response + +```json +[ + { + "id": "111e2222-e89b-12d3-a456-426614174001", + "name": "Dumbbells", + "created_at": "2024-01-15T10:30:00Z" + }, + { + "id": "333e4444-e89b-12d3-a456-426614174002", + "name": "Barbell", + "created_at": "2024-01-15T10:30:00Z" + }, + { + "id": "555e6666-e89b-12d3-a456-426614174003", + "name": "Bodyweight", + "created_at": "2024-01-15T10:30:00Z" + } +] +``` + +### 5. Get Cache Status + +**GET** `/api/v1/exercises/cache/status` + +Returns information about the exercise cache status. + +#### Response + +```json +{ + "last_updated": "2024-01-15T10:30:00Z", + "total_exercises": 1250, + "total_muscles": 14, + "total_equipment": 8, + "is_valid": true, + "expires_at": "2024-01-16T10:30:00Z" +} +``` + +### 6. Refresh Cache + +**POST** `/api/v1/exercises/cache/refresh` + +Manually refreshes the exercise cache from ExerciseDB. + +#### Response + +```json +{ + "message": "Cache refreshed successfully" +} +``` + +## Data Models + +### Exercise + +| Field | Type | Description | +|-------|------|-------------| +| `id` | UUID | Unique exercise identifier | +| `name` | String | Exercise name | +| `description` | String? | Exercise description | +| `instructions` | String? | How to perform the exercise | +| `tips` | String? | Tips for proper form | +| `category` | Integer? | Exercise category (9=strength, 10=bodyweight, 11=weighted) | +| `muscle_groups` | Array | Primary and secondary muscle groups | +| `equipment` | Array | Required equipment | +| `alternatives` | Array | Alternative exercises | +| `created_at` | DateTime | When the exercise was cached | +| `cached_at` | DateTime | Cache timestamp | +| `expires_at` | DateTime | Cache expiration time | + +### MuscleGroup + +| Field | Type | Description | +|-------|------|-------------| +| `id` | UUID | Unique muscle group identifier | +| `name` | String | Muscle group name | +| `created_at` | DateTime | Creation timestamp | + +### Equipment + +| Field | Type | Description | +|-------|------|-------------| +| `id` | UUID | Unique equipment identifier | +| `name` | String | Equipment name | +| `created_at` | DateTime | Creation timestamp | + +## Error Responses + +All endpoints return consistent error responses: + +```json +{ + "error": { + "code": "ERROR_CODE", + "message": "Human readable error message" + } +} +``` + +### Common Error Codes + +| Code | Description | +|------|-------------| +| `UNAUTHORIZED` | Missing or invalid JWT token | +| `INVALID_PARAMETER` | Invalid request parameters | +| `EXERCISE_NOT_FOUND` | Exercise not found | +| `INTERNAL_ERROR` | Internal server error | + + +## Caching + +The API uses intelligent caching with the following characteristics: + +- **TTL**: 24 hours for exercises +- **Auto-refresh**: Cache is automatically refreshed when expired +- **Fallback**: Works with stale data if ExerciseDB is unavailable +- **Manual refresh**: Available via `/cache/refresh` endpoint + +## Rate Limiting + +The API respects the same rate limiting as other endpoints: + +- **General**: 60 requests per minute +- **Burst**: 10 requests per burst + +## Performance Tips + +1. **Use pagination**: Always specify `limit` parameter for large datasets +2. **Filter early**: Use `muscle_group_id` and `equipment_id` filters to reduce results +3. **Cache responses**: Consider caching API responses on the frontend +4. **Use search**: Implement search functionality for better UX + +## Support + +For questions or issues with the Exercise API, please refer to the main API documentation or contact the development team. diff --git a/env.example b/env.example index f1d67a9..d30cbb8 100644 --- a/env.example +++ b/env.example @@ -54,6 +54,9 @@ COOKIE_SECURE=true # Use "None" for cross-site cookies (requires Secure=true and HTTPS) # Use "Lax" for same-site cookies (default, recommended for most cases) COOKIE_SAMESITE=Strict +# Cookie domain (empty for current domain, or specify domain like ".example.com") +# Leave empty for development, set to your domain for production +COOKIE_DOMAIN= # Environment Configuration # Set to 'production' for HTTPS cookies, leave empty for development @@ -76,6 +79,16 @@ SECURITY_REFERRER_POLICY=strict-origin-when-cross-origin # X-XSS-Protection (0, 1, 1; mode=block) SECURITY_XSS_PROTECTION=1; mode=block +# ExerciseDB Configuration +# Base URL for ExerciseDB API +EXERCISEDB_BASE_URL=https://exercise.hellogym.io +# Request timeout for ExerciseDB API +EXERCISEDB_TIMEOUT=30s +# Number of retry attempts for failed requests +EXERCISEDB_RETRY_COUNT=3 +# Enable/disable ExerciseDB integration +EXERCISEDB_ENABLED=true + # Security Requirements: # - JWT_SECRET must be at least 32 characters # - Use a cryptographically secure random string diff --git a/internal/clients/exercisedb_client.go b/internal/clients/exercisedb_client.go new file mode 100644 index 0000000..ec12e15 --- /dev/null +++ b/internal/clients/exercisedb_client.go @@ -0,0 +1,218 @@ +package clients + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/aleksandr/strive-api/internal/config" + "github.com/aleksandr/strive-api/internal/logger" +) + +type ExerciseDBClient struct { + baseURL string + httpClient *http.Client + logger *logger.Logger + retryCount int +} + +type ExerciseDBResponse struct { + ID int `json:"id"` + LicenseAuthor string `json:"license_author"` + Status string `json:"status"` + Description string `json:"description"` + Name string `json:"name"` + NameOriginal string `json:"name_original"` + CreationDate string `json:"creation_date"` + UUID string `json:"uuid"` + License int `json:"license"` + Category int `json:"category"` + Language int `json:"language"` + Muscles []int `json:"muscles"` + MusclesSecondary []int `json:"muscles_secondary"` + Equipment []int `json:"equipment"` +} + +type MuscleGroupResponse struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type EquipmentResponse struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type TargetResponse struct { + ID int `json:"id"` + Name string `json:"name"` +} + +func NewExerciseDBClient(cfg *config.ExerciseDBConfig, logger *logger.Logger) *ExerciseDBClient { + return &ExerciseDBClient{ + baseURL: cfg.BaseURL, + httpClient: &http.Client{ + Timeout: cfg.Timeout, + }, + logger: logger, + retryCount: cfg.RetryCount, + } +} + +func (c *ExerciseDBClient) GetAllExercises(ctx context.Context) ([]ExerciseDBResponse, error) { + url := fmt.Sprintf("%s/api/v2/exercise/", c.baseURL) + var result []ExerciseDBResponse + err := c.makeRequest(ctx, url, &result) + if err != nil { + return nil, err + } + return result, nil +} + +func (c *ExerciseDBClient) GetExercisesByCategory(ctx context.Context, category int) ([]ExerciseDBResponse, error) { + url := fmt.Sprintf("%s/api/v2/exercise/?category=%d", c.baseURL, category) + var result []ExerciseDBResponse + err := c.makeRequest(ctx, url, &result) + if err != nil { + return nil, err + } + return result, nil +} + +func (c *ExerciseDBClient) GetExercisesByMuscle(ctx context.Context, muscleID int) ([]ExerciseDBResponse, error) { + url := fmt.Sprintf("%s/api/v2/exercise/?muscles=%d", c.baseURL, muscleID) + var result []ExerciseDBResponse + err := c.makeRequest(ctx, url, &result) + if err != nil { + return nil, err + } + return result, nil +} + +func (c *ExerciseDBClient) GetExercisesByEquipment(ctx context.Context, equipmentID int) ([]ExerciseDBResponse, error) { + url := fmt.Sprintf("%s/api/v2/exercise/?equipment=%d", c.baseURL, equipmentID) + var result []ExerciseDBResponse + err := c.makeRequest(ctx, url, &result) + if err != nil { + return nil, err + } + return result, nil +} + +func (c *ExerciseDBClient) GetExerciseByID(ctx context.Context, id int) (*ExerciseDBResponse, error) { + url := fmt.Sprintf("%s/api/v2/exercise/%d", c.baseURL, id) + var result []ExerciseDBResponse + err := c.makeRequest(ctx, url, &result) + if err != nil { + return nil, err + } + if len(result) == 0 { + return nil, fmt.Errorf("exercise with id %d not found", id) + } + return &result[0], nil +} + +func (c *ExerciseDBClient) GetMuscleGroups(ctx context.Context) ([]MuscleGroupResponse, error) { + url := fmt.Sprintf("%s/api/v2/bodypart/", c.baseURL) + var result []MuscleGroupResponse + err := c.makeRequest(ctx, url, &result) + if err != nil { + return nil, err + } + return result, nil +} + +func (c *ExerciseDBClient) GetEquipment(ctx context.Context) ([]EquipmentResponse, error) { + url := fmt.Sprintf("%s/api/v2/equipment/", c.baseURL) + var result []EquipmentResponse + err := c.makeRequest(ctx, url, &result) + if err != nil { + return nil, err + } + return result, nil +} + +func (c *ExerciseDBClient) GetTargets(ctx context.Context) ([]TargetResponse, error) { + url := fmt.Sprintf("%s/api/v2/target/", c.baseURL) + var result []TargetResponse + err := c.makeRequest(ctx, url, &result) + if err != nil { + return nil, err + } + return result, nil +} + +func (c *ExerciseDBClient) makeRequest(ctx context.Context, url string, result interface{}) error { + var lastErr error + + for attempt := 0; attempt <= c.retryCount; attempt++ { + if attempt > 0 { + backoff := time.Duration(attempt) * time.Second + c.logger.Debug("Retrying request", "attempt", attempt, "backoff", backoff, "url", url) + time.Sleep(backoff) + } + + req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody) + if err != nil { + lastErr = fmt.Errorf("failed to create request: %w", err) + continue + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Strive-API/1.0") + + c.logger.Debug("Making request to ExerciseDB", "url", url, "attempt", attempt+1) + + resp, err := c.httpClient.Do(req) + if err != nil { + lastErr = fmt.Errorf("failed to make request: %w", err) + c.logger.Warn("Request failed, will retry", "error", err, "url", url, "attempt", attempt+1) + continue + } + + if resp.StatusCode == http.StatusOK { + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + if err != nil { + lastErr = fmt.Errorf("failed to read response body: %w", err) + c.logger.Warn("Failed to read response body, will retry", "error", err, "url", url, "attempt", attempt+1) + continue + } + + if err := json.Unmarshal(body, &result); err != nil { + lastErr = fmt.Errorf("failed to unmarshal response: %w", err) + c.logger.Warn("Failed to unmarshal response, will retry", "error", err, "url", url, "attempt", attempt+1) + continue + } + + c.logger.Debug("Successfully received response from ExerciseDB", "url", url, "attempt", attempt+1) + return nil + } + + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + if resp.StatusCode >= 500 { + lastErr = fmt.Errorf("API returned server error %d: %s", resp.StatusCode, string(body)) + c.logger.Warn("Server error, will retry", "status", resp.StatusCode, "url", url, "attempt", attempt+1) + continue + } + + lastErr = fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + c.logger.Error("Client error, will not retry", "status", resp.StatusCode, "body", string(body), "url", url) + break + } + + c.logger.Error("All retry attempts failed", "error", lastErr, "url", url, "retry_count", c.retryCount) + return lastErr +} + +func (c *ExerciseDBClient) HealthCheck(ctx context.Context) error { + url := fmt.Sprintf("%s/api/v2/exercise/?limit=1", c.baseURL) + var result []ExerciseDBResponse + return c.makeRequest(ctx, url, &result) +} diff --git a/internal/clients/exercisedb_client_test.go b/internal/clients/exercisedb_client_test.go new file mode 100644 index 0000000..eb65f50 --- /dev/null +++ b/internal/clients/exercisedb_client_test.go @@ -0,0 +1,213 @@ +package clients + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/aleksandr/strive-api/internal/config" + "github.com/aleksandr/strive-api/internal/logger" +) + +const exerciseAPIPath = "/api/v2/exercise/" + +func TestExerciseDBClient_GetAllExercises(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != exerciseAPIPath { + t.Errorf("Expected path %s, got %s", exerciseAPIPath, r.URL.Path) + } + + exercises := []ExerciseDBResponse{ + { + ID: 1, + Name: "Push-up", + Description: "Basic push-up exercise", + Category: 9, + Muscles: []int{1, 2}, + MusclesSecondary: []int{3}, + Equipment: []int{}, + }, + { + ID: 2, + Name: "Squat", + Description: "Basic squat exercise", + Category: 9, + Muscles: []int{10}, + MusclesSecondary: []int{8}, + Equipment: []int{}, + }, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(exercises) + })) + defer mockServer.Close() + + cfg := &config.ExerciseDBConfig{ + BaseURL: mockServer.URL, + Timeout: 5 * time.Second, + RetryCount: 2, + Enabled: true, + } + + logger := logger.New("DEBUG", "text") + client := NewExerciseDBClient(cfg, logger) + + ctx := context.Background() + exercises, err := client.GetAllExercises(ctx) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(exercises) != 2 { + t.Errorf("Expected 2 exercises, got %d", len(exercises)) + } + + if exercises[0].Name != "Push-up" { + t.Errorf("Expected first exercise to be 'Push-up', got '%s'", exercises[0].Name) + } +} + +func TestExerciseDBClient_GetExercisesByCategory(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := "/api/v2/exercise/" + if r.URL.Path != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) + } + + expectedCategory := "9" + if r.URL.Query().Get("category") != expectedCategory { + t.Errorf("Expected category %s, got %s", expectedCategory, r.URL.Query().Get("category")) + } + + exercises := []ExerciseDBResponse{ + { + ID: 1, + Name: "Bench Press", + Category: 9, + }, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(exercises) + })) + defer mockServer.Close() + + cfg := &config.ExerciseDBConfig{ + BaseURL: mockServer.URL, + Timeout: 5 * time.Second, + RetryCount: 2, + Enabled: true, + } + + logger := logger.New("DEBUG", "text") + client := NewExerciseDBClient(cfg, logger) + + ctx := context.Background() + exercises, err := client.GetExercisesByCategory(ctx, 9) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(exercises) != 1 { + t.Errorf("Expected 1 exercise, got %d", len(exercises)) + } + + if exercises[0].Name != "Bench Press" { + t.Errorf("Expected exercise to be 'Bench Press', got '%s'", exercises[0].Name) + } +} + +func TestExerciseDBClient_RetryLogic(t *testing.T) { + attemptCount := 0 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attemptCount++ + if attemptCount <= 2 { + w.WriteHeader(http.StatusInternalServerError) + return + } + + exercises := []ExerciseDBResponse{ + { + ID: 1, + Name: "Success Exercise", + }, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(exercises) + })) + defer mockServer.Close() + + cfg := &config.ExerciseDBConfig{ + BaseURL: mockServer.URL, + Timeout: 5 * time.Second, + RetryCount: 3, + Enabled: true, + } + + logger := logger.New("DEBUG", "text") + client := NewExerciseDBClient(cfg, logger) + + ctx := context.Background() + exercises, err := client.GetAllExercises(ctx) + if err != nil { + t.Fatalf("Expected no error after retries, got %v", err) + } + + if len(exercises) != 1 { + t.Errorf("Expected 1 exercise, got %d", len(exercises)) + } + + if exercises[0].Name != "Success Exercise" { + t.Errorf("Expected exercise to be 'Success Exercise', got '%s'", exercises[0].Name) + } + + if attemptCount != 3 { + t.Errorf("Expected 3 attempts, got %d", attemptCount) + } +} + +func TestExerciseDBClient_HealthCheck(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := "/api/v2/exercise/" + if r.URL.Path != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) + } + + expectedLimit := "1" + if r.URL.Query().Get("limit") != expectedLimit { + t.Errorf("Expected limit %s, got %s", expectedLimit, r.URL.Query().Get("limit")) + } + + exercises := []ExerciseDBResponse{ + { + ID: 1, + Name: "Health Check Exercise", + }, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(exercises) + })) + defer mockServer.Close() + + cfg := &config.ExerciseDBConfig{ + BaseURL: mockServer.URL, + Timeout: 5 * time.Second, + RetryCount: 2, + Enabled: true, + } + + logger := logger.New("DEBUG", "text") + client := NewExerciseDBClient(cfg, logger) + + ctx := context.Background() + err := client.HealthCheck(ctx) + if err != nil { + t.Fatalf("Expected no error from health check, got %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index d8ebbd4..67b2004 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ type Config struct { CORS CORSConfig SecurityHeaders SecurityHeadersConfig Cookie CookieConfig + ExerciseDB ExerciseDBConfig } type ServerConfig struct { @@ -83,6 +84,14 @@ type SecurityHeadersConfig struct { type CookieConfig struct { Secure bool SameSite http.SameSite + Domain string +} + +type ExerciseDBConfig struct { + BaseURL string + Timeout time.Duration + RetryCount int + Enabled bool } func Load() (*Config, error) { @@ -146,6 +155,13 @@ func Load() (*Config, error) { Cookie: CookieConfig{ Secure: getEnv("COOKIE_SECURE", "true") == trueStr, SameSite: parseSameSite(getEnv("COOKIE_SAMESITE", "Strict")), + Domain: getEnv("COOKIE_DOMAIN", ""), + }, + ExerciseDB: ExerciseDBConfig{ + BaseURL: getEnv("EXERCISEDB_BASE_URL", "https://exercise.hellogym.io"), + Timeout: getEnvDuration("EXERCISEDB_TIMEOUT", 30*time.Second), + RetryCount: getEnvInt("EXERCISEDB_RETRY_COUNT", 3), + Enabled: getEnv("EXERCISEDB_ENABLED", trueStr) == trueStr, }, } diff --git a/internal/http/auth_handlers.go b/internal/http/auth_handlers.go index 17f6dc6..a80ca99 100644 --- a/internal/http/auth_handlers.go +++ b/internal/http/auth_handlers.go @@ -27,17 +27,18 @@ func NewAuthHandlers(authService services.AuthService, logger *logger.Logger, cf } } -func (h *AuthHandlers) getCookieSettings() (secure bool, sameSite http.SameSite) { - return h.config.Cookie.Secure, h.config.Cookie.SameSite +func (h *AuthHandlers) getCookieSettings() (secure bool, sameSite http.SameSite, domain string) { + return h.config.Cookie.Secure, h.config.Cookie.SameSite, h.config.Cookie.Domain } func (h *AuthHandlers) setSecureCookie(w http.ResponseWriter, name, value string, maxAge int) { - secure, sameSite := h.getCookieSettings() + secure, sameSite, domain := h.getCookieSettings() cookie := &http.Cookie{ Name: name, Value: value, Path: "/", + Domain: domain, Secure: secure, HttpOnly: true, SameSite: sameSite, diff --git a/internal/http/exercise_handlers.go b/internal/http/exercise_handlers.go new file mode 100644 index 0000000..3711006 --- /dev/null +++ b/internal/http/exercise_handlers.go @@ -0,0 +1,320 @@ +package http + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/aleksandr/strive-api/internal/logger" + "github.com/aleksandr/strive-api/internal/models" + "github.com/aleksandr/strive-api/internal/services" + "github.com/aleksandr/strive-api/internal/validation" + "github.com/google/uuid" +) + +type ExerciseHandlers struct { + exerciseService services.ExerciseService + logger *logger.Logger + validator *validation.Validator +} + +func NewExerciseHandlers(exerciseService services.ExerciseService, logger *logger.Logger) *ExerciseHandlers { + return &ExerciseHandlers{ + exerciseService: exerciseService, + logger: logger, + validator: &validation.Validator{}, + } +} + +// GetExercises returns a list of exercises with optional filtering +// @Summary Get exercises list +// @Description Returns a paginated list of exercises with optional filtering by muscle group, equipment, and category +// @Tags exercises +// @Accept json +// @Produce json +// @Param muscle_group_id query string false "Filter by muscle group ID" +// @Param equipment_id query string false "Filter by equipment ID" +// @Param category query int false "Filter by category (9=strength, 10=bodyweight, 11=weighted)" +// @Param search query string false "Search by exercise name" +// @Param page query int false "Page number" default(1) +// @Param limit query int false "Items per page" default(20) +// @Success 200 {object} models.ExerciseListResponse "List of exercises" +// @Failure 400 {object} ErrorResponse "Invalid request parameters" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /api/v1/exercises [get] +func (h *ExerciseHandlers) GetExercises(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + filters, err := h.parseExerciseFilters(r) + if err != nil { + http.Error(w, `{"error":{"code":"INVALID_PARAMETER","message":"Invalid request parameters"}}`, http.StatusBadRequest) + return + } + + response, err := h.exerciseService.GetExercises(r.Context(), filters) + if err != nil { + h.logger.Error("Failed to get exercises", "error", err) + http.Error(w, `{"error":{"code":"INTERNAL_ERROR","message":"Failed to get exercises"}}`, http.StatusInternalServerError) + return + } + + h.writeJSONResponse(w, response) +} + +func (h *ExerciseHandlers) parseExerciseFilters(r *http.Request) (*models.ExerciseFilters, error) { + filters := &models.ExerciseFilters{ + Page: 1, + Limit: 20, + } + + if err := h.parseMuscleGroupFilter(r, filters); err != nil { + return nil, err + } + + if err := h.parseEquipmentFilter(r, filters); err != nil { + return nil, err + } + + if err := h.parseCategoryFilter(r, filters); err != nil { + return nil, err + } + + if err := h.parsePaginationFilters(r, filters); err != nil { + return nil, err + } + + if search := r.URL.Query().Get("search"); search != "" { + filters.Search = &search + } + + return filters, nil +} + +func (h *ExerciseHandlers) parseMuscleGroupFilter(r *http.Request, filters *models.ExerciseFilters) error { + if muscleGroupIDStr := r.URL.Query().Get("muscle_group_id"); muscleGroupIDStr != "" { + muscleGroupID, err := uuid.Parse(muscleGroupIDStr) + if err != nil { + h.logger.Error("Invalid muscle group ID", "error", err, "muscle_group_id", muscleGroupIDStr) + return err + } + filters.MuscleGroupID = &muscleGroupID + } + return nil +} + +func (h *ExerciseHandlers) parseEquipmentFilter(r *http.Request, filters *models.ExerciseFilters) error { + if equipmentIDStr := r.URL.Query().Get("equipment_id"); equipmentIDStr != "" { + equipmentID, err := uuid.Parse(equipmentIDStr) + if err != nil { + h.logger.Error("Invalid equipment ID", "error", err, "equipment_id", equipmentIDStr) + return err + } + filters.EquipmentID = &equipmentID + } + return nil +} + +func (h *ExerciseHandlers) parseCategoryFilter(r *http.Request, filters *models.ExerciseFilters) error { + if categoryStr := r.URL.Query().Get("category"); categoryStr != "" { + category, err := strconv.Atoi(categoryStr) + if err != nil { + h.logger.Error("Invalid category parameter", "error", err, "category", categoryStr) + return err + } + filters.Category = &category + } + return nil +} + +func (h *ExerciseHandlers) parsePaginationFilters(r *http.Request, filters *models.ExerciseFilters) error { + if pageStr := r.URL.Query().Get("page"); pageStr != "" { + page, err := strconv.Atoi(pageStr) + if err != nil || page < 1 { + h.logger.Error("Invalid page parameter", "error", err, "page", pageStr) + return err + } + filters.Page = page + } + + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + limit, err := strconv.Atoi(limitStr) + if err != nil || limit < 1 || limit > 100 { + h.logger.Error("Invalid limit parameter", "error", err, "limit", limitStr) + return err + } + filters.Limit = limit + } + + return nil +} + +func (h *ExerciseHandlers) writeJSONResponse(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(data); err != nil { + h.logger.Error("Failed to encode response", "error", err) + } +} + +// GetExerciseByID returns detailed information about a specific exercise +// @Summary Get exercise by ID +// @Description Returns detailed information about a specific exercise including muscle groups, equipment, and alternatives +// @Tags exercises +// @Accept json +// @Produce json +// @Param id path string true "Exercise ID" +// @Success 200 {object} models.Exercise "Exercise details" +// @Failure 400 {object} ErrorResponse "Invalid exercise ID" +// @Failure 404 {object} ErrorResponse "Exercise not found" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /api/v1/exercises/{id} [get] +func (h *ExerciseHandlers) GetExerciseByID(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + exerciseIDStr := r.URL.Path[len("/api/v1/exercises/"):] + exerciseID, err := uuid.Parse(exerciseIDStr) + if err != nil { + h.logger.Error("Invalid exercise ID", "error", err, "exercise_id", exerciseIDStr) + http.Error(w, `{"error":{"code":"INVALID_PARAMETER","message":"Invalid exercise ID"}}`, http.StatusBadRequest) + return + } + + exercise, err := h.exerciseService.GetExerciseByID(r.Context(), exerciseID) + if err != nil { + h.logger.Error("Failed to get exercise", "error", err, "exercise_id", exerciseID) + http.Error(w, `{"error":{"code":"EXERCISE_NOT_FOUND","message":"Exercise not found"}}`, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(exercise); err != nil { + h.logger.Error("Failed to encode response", "error", err) + } +} + +// GetMuscleGroups returns a list of all muscle groups +// @Summary Get muscle groups +// @Description Returns a list of all available muscle groups +// @Tags exercises +// @Accept json +// @Produce json +// @Success 200 {array} models.MuscleGroup "List of muscle groups" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /api/v1/exercises/muscle-groups [get] +func (h *ExerciseHandlers) GetMuscleGroups(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + muscleGroups, err := h.exerciseService.GetMuscleGroups(r.Context()) + if err != nil { + h.logger.Error("Failed to get muscle groups", "error", err) + http.Error(w, `{"error":{"code":"INTERNAL_ERROR","message":"Failed to get muscle groups"}}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(muscleGroups); err != nil { + h.logger.Error("Failed to encode response", "error", err) + } +} + +// GetEquipment returns a list of all equipment +// @Summary Get equipment +// @Description Returns a list of all available equipment +// @Tags exercises +// @Accept json +// @Produce json +// @Success 200 {array} models.Equipment "List of equipment" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /api/v1/exercises/equipment [get] +func (h *ExerciseHandlers) GetEquipment(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + equipment, err := h.exerciseService.GetEquipment(r.Context()) + if err != nil { + h.logger.Error("Failed to get equipment", "error", err) + http.Error(w, `{"error":{"code":"INTERNAL_ERROR","message":"Failed to get equipment"}}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(equipment); err != nil { + h.logger.Error("Failed to encode response", "error", err) + } +} + +// GetCacheStatus returns the current cache status +// @Summary Get cache status +// @Description Returns information about the exercise cache including last update time and validity +// @Tags exercises +// @Accept json +// @Produce json +// @Success 200 {object} models.CacheStatus "Cache status information" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /api/v1/exercises/cache/status [get] +func (h *ExerciseHandlers) GetCacheStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + status, err := h.exerciseService.GetCacheStatus(r.Context()) + if err != nil { + h.logger.Error("Failed to get cache status", "error", err) + http.Error(w, `{"error":{"code":"INTERNAL_ERROR","message":"Failed to get cache status"}}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(status); err != nil { + h.logger.Error("Failed to encode response", "error", err) + } +} + +// RefreshCache manually refreshes the exercise cache +// @Summary Refresh cache +// @Description Manually refreshes the exercise cache from ExerciseDB API +// @Tags exercises +// @Accept json +// @Produce json +// @Success 200 {object} map[string]string "Success message" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /api/v1/exercises/cache/refresh [post] +func (h *ExerciseHandlers) RefreshCache(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + err := h.exerciseService.RefreshCache(r.Context()) + if err != nil { + h.logger.Error("Failed to refresh cache", "error", err) + http.Error(w, `{"error":{"code":"INTERNAL_ERROR","message":"Failed to refresh cache"}}`, http.StatusInternalServerError) + return + } + + response := map[string]string{ + "message": "Cache refreshed successfully", + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(response); err != nil { + h.logger.Error("Failed to encode response", "error", err) + } +} diff --git a/internal/http/exercise_handlers_test.go b/internal/http/exercise_handlers_test.go new file mode 100644 index 0000000..f3a0ca4 --- /dev/null +++ b/internal/http/exercise_handlers_test.go @@ -0,0 +1,247 @@ +package http + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aleksandr/strive-api/internal/logger" + "github.com/aleksandr/strive-api/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockExerciseService struct { + mock.Mock +} + +func (m *MockExerciseService) GetExercises(ctx context.Context, filters *models.ExerciseFilters) (*models.ExerciseListResponse, error) { + args := m.Called(ctx, filters) + return args.Get(0).(*models.ExerciseListResponse), args.Error(1) +} + +func (m *MockExerciseService) GetExerciseByID(ctx context.Context, id uuid.UUID) (*models.Exercise, error) { + args := m.Called(ctx, id) + return args.Get(0).(*models.Exercise), args.Error(1) +} + +func (m *MockExerciseService) GetMuscleGroups(ctx context.Context) ([]models.MuscleGroup, error) { + args := m.Called(ctx) + return args.Get(0).([]models.MuscleGroup), args.Error(1) +} + +func (m *MockExerciseService) GetEquipment(ctx context.Context) ([]models.Equipment, error) { + args := m.Called(ctx) + return args.Get(0).([]models.Equipment), args.Error(1) +} + +func (m *MockExerciseService) GetCacheStatus(ctx context.Context) (*models.CacheStatus, error) { + args := m.Called(ctx) + return args.Get(0).(*models.CacheStatus), args.Error(1) +} + +func (m *MockExerciseService) RefreshCache(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func TestExerciseHandlers_GetExercises(t *testing.T) { + mockService := &MockExerciseService{} + logger := logger.New("DEBUG", "text") + + handlers := NewExerciseHandlers(mockService, logger) + + ctx := context.Background() + expectedResponse := &models.ExerciseListResponse{ + Exercises: []models.Exercise{ + { + ID: uuid.New(), + Name: "Push-up", + }, + }, + Total: 1, + Page: 1, + Limit: 20, + } + + mockService.On("GetExercises", ctx, mock.AnythingOfType("*models.ExerciseFilters")).Return(expectedResponse, nil) + + req := httptest.NewRequest("GET", "/api/v1/exercises", http.NoBody) + w := httptest.NewRecorder() + + handlers.GetExercises(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + mockService.AssertExpectations(t) +} + +func TestExerciseHandlers_GetExercises_WithFilters(t *testing.T) { + mockService := &MockExerciseService{} + logger := logger.New("DEBUG", "text") + + handlers := NewExerciseHandlers(mockService, logger) + + ctx := context.Background() + expectedResponse := &models.ExerciseListResponse{ + Exercises: []models.Exercise{ + { + ID: uuid.New(), + Name: "Bench Press", + }, + }, + Total: 1, + Page: 1, + Limit: 10, + } + + mockService.On("GetExercises", ctx, mock.AnythingOfType("*models.ExerciseFilters")).Return(expectedResponse, nil) + + req := httptest.NewRequest("GET", "/api/v1/exercises?muscle_group_id=123e4567-e89b-12d3-a456-426614174000&limit=10&page=1", http.NoBody) + w := httptest.NewRecorder() + + handlers.GetExercises(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + mockService.AssertExpectations(t) +} + +func TestExerciseHandlers_GetExerciseByID(t *testing.T) { + mockService := &MockExerciseService{} + logger := logger.New("DEBUG", "text") + + handlers := NewExerciseHandlers(mockService, logger) + + ctx := context.Background() + exerciseID := uuid.New() + expectedExercise := &models.Exercise{ + ID: exerciseID, + Name: "Push-up", + } + + mockService.On("GetExerciseByID", ctx, exerciseID).Return(expectedExercise, nil) + + req := httptest.NewRequest("GET", "/api/v1/exercises/"+exerciseID.String(), http.NoBody) + w := httptest.NewRecorder() + + handlers.GetExerciseByID(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + mockService.AssertExpectations(t) +} + +func TestExerciseHandlers_GetMuscleGroups(t *testing.T) { + mockService := &MockExerciseService{} + logger := logger.New("DEBUG", "text") + + handlers := NewExerciseHandlers(mockService, logger) + + ctx := context.Background() + expectedMuscleGroups := []models.MuscleGroup{ + { + ID: uuid.New(), + Name: "Chest", + }, + } + + mockService.On("GetMuscleGroups", ctx).Return(expectedMuscleGroups, nil) + + req := httptest.NewRequest("GET", "/api/v1/exercises/muscle-groups", http.NoBody) + w := httptest.NewRecorder() + + handlers.GetMuscleGroups(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + mockService.AssertExpectations(t) +} + +func TestExerciseHandlers_GetEquipment(t *testing.T) { + mockService := &MockExerciseService{} + logger := logger.New("DEBUG", "text") + + handlers := NewExerciseHandlers(mockService, logger) + + ctx := context.Background() + expectedEquipment := []models.Equipment{ + { + ID: uuid.New(), + Name: "Dumbbells", + }, + } + + mockService.On("GetEquipment", ctx).Return(expectedEquipment, nil) + + req := httptest.NewRequest("GET", "/api/v1/exercises/equipment", http.NoBody) + w := httptest.NewRecorder() + + handlers.GetEquipment(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + mockService.AssertExpectations(t) +} + +func TestExerciseHandlers_GetCacheStatus(t *testing.T) { + mockService := &MockExerciseService{} + logger := logger.New("DEBUG", "text") + + handlers := NewExerciseHandlers(mockService, logger) + + ctx := context.Background() + expectedStatus := &models.CacheStatus{ + TotalExercises: 100, + TotalMuscles: 14, + TotalEquipment: 8, + IsValid: true, + } + + mockService.On("GetCacheStatus", ctx).Return(expectedStatus, nil) + + req := httptest.NewRequest("GET", "/api/v1/exercises/cache/status", http.NoBody) + w := httptest.NewRecorder() + + handlers.GetCacheStatus(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + mockService.AssertExpectations(t) +} + +func TestExerciseHandlers_RefreshCache(t *testing.T) { + mockService := &MockExerciseService{} + logger := logger.New("DEBUG", "text") + + handlers := NewExerciseHandlers(mockService, logger) + + ctx := context.Background() + + mockService.On("RefreshCache", ctx).Return(nil) + + req := httptest.NewRequest("POST", "/api/v1/exercises/cache/refresh", http.NoBody) + w := httptest.NewRecorder() + + handlers.RefreshCache(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + mockService.AssertExpectations(t) +} + +func TestExerciseHandlers_InvalidMethod(t *testing.T) { + mockService := &MockExerciseService{} + logger := logger.New("DEBUG", "text") + + handlers := NewExerciseHandlers(mockService, logger) + + req := httptest.NewRequest("POST", "/api/v1/exercises", http.NoBody) + w := httptest.NewRecorder() + + handlers.GetExercises(w, req) + + assert.Equal(t, http.StatusMethodNotAllowed, w.Code) +} diff --git a/internal/models/exercise.go b/internal/models/exercise.go new file mode 100644 index 0000000..32946a1 --- /dev/null +++ b/internal/models/exercise.go @@ -0,0 +1,72 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type MuscleGroup struct { + ID uuid.UUID `json:"id" db:"id"` + ExerciseDBID int `json:"-" db:"exercise_db_id"` + Name string `json:"name" db:"name"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type Equipment struct { + ID uuid.UUID `json:"id" db:"id"` + ExerciseDBID int `json:"-" db:"exercise_db_id"` + Name string `json:"name" db:"name"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type Exercise struct { + ID uuid.UUID `json:"id" db:"id"` + ExerciseDBID int `json:"-" db:"exercise_db_id"` + Name string `json:"name" db:"name"` + Description *string `json:"description,omitempty" db:"description"` + Instructions *string `json:"instructions,omitempty" db:"instructions"` + Tips *string `json:"tips,omitempty" db:"tips"` + Category *int `json:"category,omitempty" db:"category"` + Language *int `json:"language,omitempty" db:"language"` + License *int `json:"license,omitempty" db:"license"` + LicenseAuthor *string `json:"license_author,omitempty" db:"license_author"` + Status *string `json:"status,omitempty" db:"status"` + NameOriginal *string `json:"name_original,omitempty" db:"name_original"` + CreationDate *time.Time `json:"creation_date,omitempty" db:"creation_date"` + UUID *string `json:"uuid,omitempty" db:"uuid"` + CachedAt time.Time `json:"cached_at" db:"cached_at"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + MuscleGroups []MuscleGroup `json:"muscle_groups,omitempty"` + Equipment []Equipment `json:"equipment,omitempty"` + Alternatives []Exercise `json:"alternatives,omitempty"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type ExerciseListResponse struct { + Exercises []Exercise `json:"exercises"` + Total int `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +type ExerciseFilters struct { + MuscleGroupID *uuid.UUID `json:"muscle_group_id,omitempty"` + EquipmentID *uuid.UUID `json:"equipment_id,omitempty"` + Category *int `json:"category,omitempty"` + Search *string `json:"search,omitempty"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +type CacheStatus struct { + LastUpdated time.Time `json:"last_updated"` + TotalExercises int `json:"total_exercises"` + TotalMuscles int `json:"total_muscles"` + TotalEquipment int `json:"total_equipment"` + IsValid bool `json:"is_valid"` + ExpiresAt time.Time `json:"expires_at"` +} diff --git a/internal/repositories/exercise_repository.go b/internal/repositories/exercise_repository.go new file mode 100644 index 0000000..9a5885f --- /dev/null +++ b/internal/repositories/exercise_repository.go @@ -0,0 +1,632 @@ +package repositories + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/aleksandr/strive-api/internal/models" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type ExerciseRepository interface { + GetAll(ctx context.Context, filters *models.ExerciseFilters) (*models.ExerciseListResponse, error) + GetByID(ctx context.Context, id uuid.UUID) (*models.Exercise, error) + GetByExerciseDBID(ctx context.Context, exerciseDBID int) (*models.Exercise, error) + GetMuscleGroups(ctx context.Context) ([]models.MuscleGroup, error) + GetEquipment(ctx context.Context) ([]models.Equipment, error) + GetCacheStatus(ctx context.Context) (*models.CacheStatus, error) + IsCacheValid(ctx context.Context) (bool, error) + ClearCache(ctx context.Context) error + SaveExercise(ctx context.Context, exercise *models.Exercise) error + SaveMuscleGroup(ctx context.Context, muscleGroup *models.MuscleGroup) error + SaveEquipment(ctx context.Context, equipment *models.Equipment) error + SaveExerciseMuscleGroup(ctx context.Context, exerciseID, muscleGroupID uuid.UUID, isPrimary bool) error + SaveExerciseEquipment(ctx context.Context, exerciseID, equipmentID uuid.UUID) error + SaveExerciseAlternative(ctx context.Context, exerciseID, alternativeID uuid.UUID) error +} + +type exerciseRepository struct { + pool *pgxpool.Pool +} + +func NewExerciseRepository(pool *pgxpool.Pool) ExerciseRepository { + return &exerciseRepository{ + pool: pool, + } +} + +func (r *exerciseRepository) GetAll(ctx context.Context, filters *models.ExerciseFilters) (*models.ExerciseListResponse, error) { + whereConditions := []string{} + args := []interface{}{} + argIndex := 1 + + if filters.MuscleGroupID != nil { + whereConditions = append(whereConditions, + fmt.Sprintf("e.id IN (SELECT exercise_id FROM exercise_muscle_groups WHERE muscle_group_id = $%d)", argIndex)) + args = append(args, *filters.MuscleGroupID) + argIndex++ + } + + if filters.EquipmentID != nil { + whereConditions = append(whereConditions, + fmt.Sprintf("e.id IN (SELECT exercise_id FROM exercise_equipment WHERE equipment_id = $%d)", argIndex)) + args = append(args, *filters.EquipmentID) + argIndex++ + } + + if filters.Category != nil { + whereConditions = append(whereConditions, fmt.Sprintf("e.category = $%d", argIndex)) + args = append(args, *filters.Category) + argIndex++ + } + + if filters.Search != nil && *filters.Search != "" { + whereConditions = append(whereConditions, fmt.Sprintf("e.name ILIKE $%d", argIndex)) + args = append(args, "%"+*filters.Search+"%") + argIndex++ + } + + whereClause := "" + if len(whereConditions) > 0 { + whereClause = "WHERE " + strings.Join(whereConditions, " AND ") + } + + countQuery := fmt.Sprintf(` + SELECT COUNT(DISTINCT e.id) + FROM exercises e + %s + `, whereClause) + + var total int + err := r.pool.QueryRow(ctx, countQuery, args...).Scan(&total) + if err != nil { + return nil, fmt.Errorf("failed to count exercises: %w", err) + } + + limit := filters.Limit + if limit <= 0 { + limit = 20 + } + offset := (filters.Page - 1) * limit + + query := fmt.Sprintf(` + SELECT DISTINCT e.id, e.exercise_db_id, e.name, e.description, e.instructions, e.tips, + e.category, e.language, e.license, e.license_author, e.status, e.name_original, + e.creation_date, e.uuid, e.cached_at, e.expires_at, e.created_at, e.updated_at + FROM exercises e + %s + ORDER BY e.name + LIMIT $%d OFFSET $%d + `, whereClause, argIndex, argIndex+1) + + args = append(args, limit, offset) + + rows, err := r.pool.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to get exercises: %w", err) + } + defer rows.Close() + + exercises := []models.Exercise{} + for rows.Next() { + exercise := models.Exercise{} + err := rows.Scan( + &exercise.ID, + &exercise.ExerciseDBID, + &exercise.Name, + &exercise.Description, + &exercise.Instructions, + &exercise.Tips, + &exercise.Category, + &exercise.Language, + &exercise.License, + &exercise.LicenseAuthor, + &exercise.Status, + &exercise.NameOriginal, + &exercise.CreationDate, + &exercise.UUID, + &exercise.CachedAt, + &exercise.ExpiresAt, + &exercise.CreatedAt, + &exercise.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan exercise: %w", err) + } + + muscleGroups, err := r.getMuscleGroupsForExercise(ctx, exercise.ID) + if err != nil { + return nil, fmt.Errorf("failed to get muscle groups for exercise: %w", err) + } + exercise.MuscleGroups = muscleGroups + + equipment, err := r.getEquipmentForExercise(ctx, exercise.ID) + if err != nil { + return nil, fmt.Errorf("failed to get equipment for exercise: %w", err) + } + exercise.Equipment = equipment + + alternatives, err := r.getAlternativesForExercise(ctx, exercise.ID) + if err != nil { + return nil, fmt.Errorf("failed to get alternatives for exercise: %w", err) + } + exercise.Alternatives = alternatives + + exercises = append(exercises, exercise) + } + + return &models.ExerciseListResponse{ + Exercises: exercises, + Total: total, + Page: filters.Page, + Limit: limit, + }, nil +} + +func (r *exerciseRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Exercise, error) { + query := ` + SELECT id, exercise_db_id, name, description, instructions, tips, + category, language, license, license_author, status, name_original, + creation_date, uuid, cached_at, expires_at, created_at, updated_at + FROM exercises + WHERE id = $1 + ` + + row := r.pool.QueryRow(ctx, query, id) + exercise := &models.Exercise{} + err := row.Scan( + &exercise.ID, + &exercise.ExerciseDBID, + &exercise.Name, + &exercise.Description, + &exercise.Instructions, + &exercise.Tips, + &exercise.Category, + &exercise.Language, + &exercise.License, + &exercise.LicenseAuthor, + &exercise.Status, + &exercise.NameOriginal, + &exercise.CreationDate, + &exercise.UUID, + &exercise.CachedAt, + &exercise.ExpiresAt, + &exercise.CreatedAt, + &exercise.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to get exercise by id: %w", err) + } + + muscleGroups, err := r.getMuscleGroupsForExercise(ctx, exercise.ID) + if err != nil { + return nil, fmt.Errorf("failed to get muscle groups for exercise: %w", err) + } + exercise.MuscleGroups = muscleGroups + + equipment, err := r.getEquipmentForExercise(ctx, exercise.ID) + if err != nil { + return nil, fmt.Errorf("failed to get equipment for exercise: %w", err) + } + exercise.Equipment = equipment + + alternatives, err := r.getAlternativesForExercise(ctx, exercise.ID) + if err != nil { + return nil, fmt.Errorf("failed to get alternatives for exercise: %w", err) + } + exercise.Alternatives = alternatives + + return exercise, nil +} + +func (r *exerciseRepository) GetByExerciseDBID(ctx context.Context, exerciseDBID int) (*models.Exercise, error) { + query := ` + SELECT id, exercise_db_id, name, description, instructions, tips, + category, language, license, license_author, status, name_original, + creation_date, uuid, cached_at, expires_at, created_at, updated_at + FROM exercises + WHERE exercise_db_id = $1 + ` + + row := r.pool.QueryRow(ctx, query, exerciseDBID) + exercise := &models.Exercise{} + err := row.Scan( + &exercise.ID, + &exercise.ExerciseDBID, + &exercise.Name, + &exercise.Description, + &exercise.Instructions, + &exercise.Tips, + &exercise.Category, + &exercise.Language, + &exercise.License, + &exercise.LicenseAuthor, + &exercise.Status, + &exercise.NameOriginal, + &exercise.CreationDate, + &exercise.UUID, + &exercise.CachedAt, + &exercise.ExpiresAt, + &exercise.CreatedAt, + &exercise.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to get exercise by exercise_db_id: %w", err) + } + + return exercise, nil +} + +func (r *exerciseRepository) GetMuscleGroups(ctx context.Context) ([]models.MuscleGroup, error) { + query := ` + SELECT id, exercise_db_id, name, created_at, updated_at + FROM muscle_groups + ORDER BY name + ` + + rows, err := r.pool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to get muscle groups: %w", err) + } + defer rows.Close() + + muscleGroups := []models.MuscleGroup{} + for rows.Next() { + mg := models.MuscleGroup{} + err := rows.Scan(&mg.ID, &mg.ExerciseDBID, &mg.Name, &mg.CreatedAt, &mg.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("failed to scan muscle group: %w", err) + } + muscleGroups = append(muscleGroups, mg) + } + + return muscleGroups, nil +} + +func (r *exerciseRepository) GetEquipment(ctx context.Context) ([]models.Equipment, error) { + query := ` + SELECT id, exercise_db_id, name, created_at, updated_at + FROM equipment + ORDER BY name + ` + + rows, err := r.pool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to get equipment: %w", err) + } + defer rows.Close() + + equipment := []models.Equipment{} + for rows.Next() { + eq := models.Equipment{} + err := rows.Scan(&eq.ID, &eq.ExerciseDBID, &eq.Name, &eq.CreatedAt, &eq.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("failed to scan equipment: %w", err) + } + equipment = append(equipment, eq) + } + + return equipment, nil +} + +func (r *exerciseRepository) GetCacheStatus(ctx context.Context) (*models.CacheStatus, error) { + query := ` + SELECT + MAX(e.cached_at) as last_updated, + COUNT(DISTINCT e.id) as total_exercises, + COUNT(DISTINCT mg.id) as total_muscles, + COUNT(DISTINCT eq.id) as total_equipment, + MIN(e.expires_at) as expires_at + FROM exercises e + LEFT JOIN muscle_groups mg ON true + LEFT JOIN equipment eq ON true + ` + + row := r.pool.QueryRow(ctx, query) + status := &models.CacheStatus{} + var lastUpdated *time.Time + var expiresAt *time.Time + + err := row.Scan(&lastUpdated, &status.TotalExercises, &status.TotalMuscles, &status.TotalEquipment, &expiresAt) + if err != nil { + return nil, fmt.Errorf("failed to get cache status: %w", err) + } + + if lastUpdated != nil { + status.LastUpdated = *lastUpdated + } + if expiresAt != nil { + status.ExpiresAt = *expiresAt + status.IsValid = time.Now().Before(*expiresAt) + } + + return status, nil +} + +func (r *exerciseRepository) IsCacheValid(ctx context.Context) (bool, error) { + query := ` + SELECT COUNT(*) > 0 AND MIN(expires_at) > NOW() + FROM exercises + ` + + var isValid bool + err := r.pool.QueryRow(ctx, query).Scan(&isValid) + if err != nil { + return false, fmt.Errorf("failed to check cache validity: %w", err) + } + + return isValid, nil +} + +func (r *exerciseRepository) ClearCache(ctx context.Context) error { + queries := []string{ + "DELETE FROM exercise_alternatives", + "DELETE FROM exercise_equipment", + "DELETE FROM exercise_muscle_groups", + "DELETE FROM exercises", + "DELETE FROM equipment", + "DELETE FROM muscle_groups", + } + + for _, query := range queries { + _, err := r.pool.Exec(ctx, query) + if err != nil { + return fmt.Errorf("failed to clear cache: %w", err) + } + } + + return nil +} + +func (r *exerciseRepository) SaveExercise(ctx context.Context, exercise *models.Exercise) error { + query := ` + INSERT INTO exercises ( + id, exercise_db_id, name, description, instructions, tips, + category, language, license, license_author, status, name_original, + creation_date, uuid, cached_at, expires_at, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18 + ) + ON CONFLICT (exercise_db_id) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + instructions = EXCLUDED.instructions, + tips = EXCLUDED.tips, + category = EXCLUDED.category, + language = EXCLUDED.language, + license = EXCLUDED.license, + license_author = EXCLUDED.license_author, + status = EXCLUDED.status, + name_original = EXCLUDED.name_original, + creation_date = EXCLUDED.creation_date, + uuid = EXCLUDED.uuid, + cached_at = EXCLUDED.cached_at, + expires_at = EXCLUDED.expires_at, + updated_at = EXCLUDED.updated_at + ` + + _, err := r.pool.Exec(ctx, query, + exercise.ID, + exercise.ExerciseDBID, + exercise.Name, + exercise.Description, + exercise.Instructions, + exercise.Tips, + exercise.Category, + exercise.Language, + exercise.License, + exercise.LicenseAuthor, + exercise.Status, + exercise.NameOriginal, + exercise.CreationDate, + exercise.UUID, + exercise.CachedAt, + exercise.ExpiresAt, + exercise.CreatedAt, + exercise.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("failed to save exercise: %w", err) + } + + return nil +} + +func (r *exerciseRepository) SaveMuscleGroup(ctx context.Context, muscleGroup *models.MuscleGroup) error { + query := ` + INSERT INTO muscle_groups (id, exercise_db_id, name, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (exercise_db_id) DO UPDATE SET + name = EXCLUDED.name, + updated_at = EXCLUDED.updated_at + ` + + _, err := r.pool.Exec(ctx, query, + muscleGroup.ID, + muscleGroup.ExerciseDBID, + muscleGroup.Name, + muscleGroup.CreatedAt, + muscleGroup.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("failed to save muscle group: %w", err) + } + + return nil +} + +func (r *exerciseRepository) SaveEquipment(ctx context.Context, equipment *models.Equipment) error { + query := ` + INSERT INTO equipment (id, exercise_db_id, name, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (exercise_db_id) DO UPDATE SET + name = EXCLUDED.name, + updated_at = EXCLUDED.updated_at + ` + + _, err := r.pool.Exec(ctx, query, + equipment.ID, + equipment.ExerciseDBID, + equipment.Name, + equipment.CreatedAt, + equipment.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("failed to save equipment: %w", err) + } + + return nil +} + +func (r *exerciseRepository) SaveExerciseMuscleGroup(ctx context.Context, exerciseID, muscleGroupID uuid.UUID, isPrimary bool) error { + query := ` + INSERT INTO exercise_muscle_groups (exercise_id, muscle_group_id, is_primary, created_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (exercise_id, muscle_group_id, is_primary) DO NOTHING + ` + + _, err := r.pool.Exec(ctx, query, exerciseID, muscleGroupID, isPrimary, time.Now()) + if err != nil { + return fmt.Errorf("failed to save exercise muscle group: %w", err) + } + + return nil +} + +func (r *exerciseRepository) SaveExerciseEquipment(ctx context.Context, exerciseID, equipmentID uuid.UUID) error { + query := ` + INSERT INTO exercise_equipment (exercise_id, equipment_id, created_at) + VALUES ($1, $2, $3) + ON CONFLICT (exercise_id, equipment_id) DO NOTHING + ` + + _, err := r.pool.Exec(ctx, query, exerciseID, equipmentID, time.Now()) + if err != nil { + return fmt.Errorf("failed to save exercise equipment: %w", err) + } + + return nil +} + +func (r *exerciseRepository) SaveExerciseAlternative(ctx context.Context, exerciseID, alternativeID uuid.UUID) error { + query := ` + INSERT INTO exercise_alternatives (exercise_id, alternative_exercise_id, created_at) + VALUES ($1, $2, $3) + ON CONFLICT (exercise_id, alternative_exercise_id) DO NOTHING + ` + + _, err := r.pool.Exec(ctx, query, exerciseID, alternativeID, time.Now()) + if err != nil { + return fmt.Errorf("failed to save exercise alternative: %w", err) + } + + return nil +} + +func (r *exerciseRepository) getMuscleGroupsForExercise(ctx context.Context, exerciseID uuid.UUID) ([]models.MuscleGroup, error) { + query := ` + SELECT mg.id, mg.exercise_db_id, mg.name, mg.created_at, mg.updated_at + FROM muscle_groups mg + JOIN exercise_muscle_groups emg ON mg.id = emg.muscle_group_id + WHERE emg.exercise_id = $1 + ORDER BY emg.is_primary DESC, mg.name + ` + + rows, err := r.pool.Query(ctx, query, exerciseID) + if err != nil { + return nil, fmt.Errorf("failed to get muscle groups for exercise: %w", err) + } + defer rows.Close() + + muscleGroups := []models.MuscleGroup{} + for rows.Next() { + mg := models.MuscleGroup{} + err := rows.Scan(&mg.ID, &mg.ExerciseDBID, &mg.Name, &mg.CreatedAt, &mg.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("failed to scan muscle group: %w", err) + } + muscleGroups = append(muscleGroups, mg) + } + + return muscleGroups, nil +} + +func (r *exerciseRepository) getEquipmentForExercise(ctx context.Context, exerciseID uuid.UUID) ([]models.Equipment, error) { + query := ` + SELECT e.id, e.exercise_db_id, e.name, e.created_at, e.updated_at + FROM equipment e + JOIN exercise_equipment ee ON e.id = ee.equipment_id + WHERE ee.exercise_id = $1 + ORDER BY e.name + ` + + rows, err := r.pool.Query(ctx, query, exerciseID) + if err != nil { + return nil, fmt.Errorf("failed to get equipment for exercise: %w", err) + } + defer rows.Close() + + equipment := []models.Equipment{} + for rows.Next() { + eq := models.Equipment{} + err := rows.Scan(&eq.ID, &eq.ExerciseDBID, &eq.Name, &eq.CreatedAt, &eq.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("failed to scan equipment: %w", err) + } + equipment = append(equipment, eq) + } + + return equipment, nil +} + +func (r *exerciseRepository) getAlternativesForExercise(ctx context.Context, exerciseID uuid.UUID) ([]models.Exercise, error) { + query := ` + SELECT e.id, e.exercise_db_id, e.name, e.description, e.instructions, e.tips, + e.category, e.language, e.license, e.license_author, e.status, e.name_original, + e.creation_date, e.uuid, e.cached_at, e.expires_at, e.created_at, e.updated_at + FROM exercises e + JOIN exercise_alternatives ea ON e.id = ea.alternative_exercise_id + WHERE ea.exercise_id = $1 + ORDER BY e.name + ` + + rows, err := r.pool.Query(ctx, query, exerciseID) + if err != nil { + return nil, fmt.Errorf("failed to get alternatives for exercise: %w", err) + } + defer rows.Close() + + alternatives := []models.Exercise{} + for rows.Next() { + alt := models.Exercise{} + err := rows.Scan( + &alt.ID, + &alt.ExerciseDBID, + &alt.Name, + &alt.Description, + &alt.Instructions, + &alt.Tips, + &alt.Category, + &alt.Language, + &alt.License, + &alt.LicenseAuthor, + &alt.Status, + &alt.NameOriginal, + &alt.CreationDate, + &alt.UUID, + &alt.CachedAt, + &alt.ExpiresAt, + &alt.CreatedAt, + &alt.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan alternative exercise: %w", err) + } + alternatives = append(alternatives, alt) + } + + return alternatives, nil +} diff --git a/internal/repositories/exercise_repository_test.go b/internal/repositories/exercise_repository_test.go new file mode 100644 index 0000000..f5d0cb3 --- /dev/null +++ b/internal/repositories/exercise_repository_test.go @@ -0,0 +1,291 @@ +package repositories + +import ( + "context" + "testing" + "time" + + "github.com/aleksandr/strive-api/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type mockExerciseRepository struct { + mock.Mock +} + +func (m *mockExerciseRepository) GetExercises(ctx context.Context, filters *models.ExerciseFilters) (*models.ExerciseListResponse, error) { + args := m.Called(ctx, filters) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.ExerciseListResponse), args.Error(1) +} + +func (m *mockExerciseRepository) GetExerciseByID(ctx context.Context, id uuid.UUID) (*models.Exercise, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.Exercise), args.Error(1) +} + +func (m *mockExerciseRepository) GetMuscleGroups(ctx context.Context) ([]models.MuscleGroup, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]models.MuscleGroup), args.Error(1) +} + +func (m *mockExerciseRepository) GetEquipment(ctx context.Context) ([]models.Equipment, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]models.Equipment), args.Error(1) +} + +func (m *mockExerciseRepository) GetCacheStatus(ctx context.Context) (*models.CacheStatus, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.CacheStatus), args.Error(1) +} + +func (m *mockExerciseRepository) IsCacheValid(ctx context.Context) (bool, error) { + args := m.Called(ctx) + return args.Bool(0), args.Error(1) +} + +func (m *mockExerciseRepository) ClearCache(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func (m *mockExerciseRepository) SaveExercise(ctx context.Context, exercise *models.Exercise) error { + args := m.Called(ctx, exercise) + return args.Error(0) +} + +func (m *mockExerciseRepository) SaveMuscleGroup(ctx context.Context, muscleGroup *models.MuscleGroup) error { + args := m.Called(ctx, muscleGroup) + return args.Error(0) +} + +func (m *mockExerciseRepository) SaveEquipment(ctx context.Context, equipment *models.Equipment) error { + args := m.Called(ctx, equipment) + return args.Error(0) +} + +func (m *mockExerciseRepository) SaveExerciseMuscleGroup(ctx context.Context, exerciseID, muscleGroupID uuid.UUID, isPrimary bool) error { + args := m.Called(ctx, exerciseID, muscleGroupID, isPrimary) + return args.Error(0) +} + +func (m *mockExerciseRepository) SaveExerciseEquipment(ctx context.Context, exerciseID, equipmentID uuid.UUID) error { + args := m.Called(ctx, exerciseID, equipmentID) + return args.Error(0) +} + +func (m *mockExerciseRepository) SaveExerciseAlternative(ctx context.Context, exerciseID, alternativeID uuid.UUID) error { + args := m.Called(ctx, exerciseID, alternativeID) + return args.Error(0) +} + +func TestExerciseRepository_GetAll(t *testing.T) { + repo := &mockExerciseRepository{} + ctx := context.Background() + filters := &models.ExerciseFilters{ + Page: 1, + Limit: 10, + } + + expectedResponse := &models.ExerciseListResponse{ + Exercises: []models.Exercise{}, + Total: 0, + Page: 1, + Limit: 10, + } + + repo.On("GetExercises", ctx, filters).Return(expectedResponse, nil) + + response, err := repo.GetExercises(ctx, filters) + assert.NoError(t, err) + assert.NotNil(t, response) + assert.Equal(t, 1, response.Page) + assert.Equal(t, 10, response.Limit) + assert.Equal(t, 0, response.Total) + + repo.AssertExpectations(t) +} + +func TestExerciseRepository_GetMuscleGroups(t *testing.T) { + repo := &mockExerciseRepository{} + ctx := context.Background() + + expectedMuscleGroups := []models.MuscleGroup{ + { + ID: uuid.New(), + Name: "Chest", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + + repo.On("GetMuscleGroups", ctx).Return(expectedMuscleGroups, nil) + + muscleGroups, err := repo.GetMuscleGroups(ctx) + assert.NoError(t, err) + assert.NotNil(t, muscleGroups) + assert.Len(t, muscleGroups, 1) + assert.Equal(t, "Chest", muscleGroups[0].Name) + + repo.AssertExpectations(t) +} + +func TestExerciseRepository_GetEquipment(t *testing.T) { + repo := &mockExerciseRepository{} + ctx := context.Background() + + expectedEquipment := []models.Equipment{ + { + ID: uuid.New(), + Name: "Dumbbell", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + + repo.On("GetEquipment", ctx).Return(expectedEquipment, nil) + + equipment, err := repo.GetEquipment(ctx) + assert.NoError(t, err) + assert.NotNil(t, equipment) + assert.Len(t, equipment, 1) + assert.Equal(t, "Dumbbell", equipment[0].Name) + + repo.AssertExpectations(t) +} + +func TestExerciseRepository_GetCacheStatus(t *testing.T) { + repo := &mockExerciseRepository{} + ctx := context.Background() + + expectedStatus := &models.CacheStatus{ + LastUpdated: time.Now(), + TotalExercises: 100, + TotalMuscles: 20, + TotalEquipment: 15, + IsValid: true, + ExpiresAt: time.Now().Add(24 * time.Hour), + } + + repo.On("GetCacheStatus", ctx).Return(expectedStatus, nil) + + status, err := repo.GetCacheStatus(ctx) + assert.NoError(t, err) + assert.NotNil(t, status) + assert.Equal(t, 100, status.TotalExercises) + assert.Equal(t, 20, status.TotalMuscles) + assert.Equal(t, 15, status.TotalEquipment) + assert.True(t, status.IsValid) + + repo.AssertExpectations(t) +} + +func TestExerciseRepository_IsCacheValid(t *testing.T) { + repo := &mockExerciseRepository{} + ctx := context.Background() + + repo.On("IsCacheValid", ctx).Return(true, nil) + + isValid, err := repo.IsCacheValid(ctx) + assert.NoError(t, err) + assert.True(t, isValid) + + repo.AssertExpectations(t) +} + +func TestExerciseRepository_SaveExercise(t *testing.T) { + repo := &mockExerciseRepository{} + ctx := context.Background() + + exercise := &models.Exercise{ + ID: uuid.New(), + ExerciseDBID: 1, + Name: "Push-up", + Description: stringPtr("Basic push-up exercise"), + Instructions: stringPtr("Start in plank position"), + Tips: stringPtr("Keep your body straight"), + CachedAt: time.Now(), + ExpiresAt: time.Now().Add(24 * time.Hour), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + repo.On("SaveExercise", ctx, exercise).Return(nil) + + err := repo.SaveExercise(ctx, exercise) + assert.NoError(t, err) + + repo.AssertExpectations(t) +} + +func TestExerciseRepository_SaveMuscleGroup(t *testing.T) { + repo := &mockExerciseRepository{} + ctx := context.Background() + + muscleGroup := &models.MuscleGroup{ + ID: uuid.New(), + ExerciseDBID: 1, + Name: "Chest", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + repo.On("SaveMuscleGroup", ctx, muscleGroup).Return(nil) + + err := repo.SaveMuscleGroup(ctx, muscleGroup) + assert.NoError(t, err) + + repo.AssertExpectations(t) +} + +func TestExerciseRepository_SaveEquipment(t *testing.T) { + repo := &mockExerciseRepository{} + ctx := context.Background() + + equipment := &models.Equipment{ + ID: uuid.New(), + ExerciseDBID: 1, + Name: "Dumbbell", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + repo.On("SaveEquipment", ctx, equipment).Return(nil) + + err := repo.SaveEquipment(ctx, equipment) + assert.NoError(t, err) + + repo.AssertExpectations(t) +} + +func TestExerciseRepository_ClearCache(t *testing.T) { + repo := &mockExerciseRepository{} + ctx := context.Background() + + repo.On("ClearCache", ctx).Return(nil) + + err := repo.ClearCache(ctx) + assert.NoError(t, err) + + repo.AssertExpectations(t) +} + +func stringPtr(s string) *string { + return &s +} diff --git a/internal/services/exercise_cache_service.go b/internal/services/exercise_cache_service.go new file mode 100644 index 0000000..83b2ad8 --- /dev/null +++ b/internal/services/exercise_cache_service.go @@ -0,0 +1,266 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/aleksandr/strive-api/internal/clients" + "github.com/aleksandr/strive-api/internal/logger" + "github.com/aleksandr/strive-api/internal/models" + "github.com/aleksandr/strive-api/internal/repositories" + "github.com/google/uuid" +) + +type ExerciseCacheService interface { + RefreshCache(ctx context.Context) error + IsCacheValid(ctx context.Context) (bool, error) + GetCacheStatus(ctx context.Context) (*models.CacheStatus, error) + ClearCache(ctx context.Context) error +} + +type exerciseCacheService struct { + exerciseRepo repositories.ExerciseRepository + exerciseDBClient *clients.ExerciseDBClient + logger *logger.Logger + cacheTTL time.Duration +} + +func NewExerciseCacheService( + exerciseRepo repositories.ExerciseRepository, + exerciseDBClient *clients.ExerciseDBClient, + logger *logger.Logger, + cacheTTL time.Duration, +) ExerciseCacheService { + return &exerciseCacheService{ + exerciseRepo: exerciseRepo, + exerciseDBClient: exerciseDBClient, + logger: logger, + cacheTTL: cacheTTL, + } +} + +func (s *exerciseCacheService) RefreshCache(ctx context.Context) error { + s.logger.Info("Starting cache refresh from ExerciseDB") + + if err := s.exerciseRepo.ClearCache(ctx); err != nil { + return fmt.Errorf("failed to clear existing cache: %w", err) + } + + if err := s.cacheMuscleGroups(ctx); err != nil { + return fmt.Errorf("failed to cache muscle groups: %w", err) + } + + if err := s.cacheEquipment(ctx); err != nil { + return fmt.Errorf("failed to cache equipment: %w", err) + } + + if err := s.cacheExercises(ctx); err != nil { + return fmt.Errorf("failed to cache exercises: %w", err) + } + + s.logger.Info("Cache refresh completed successfully") + return nil +} + +func (s *exerciseCacheService) IsCacheValid(ctx context.Context) (bool, error) { + return s.exerciseRepo.IsCacheValid(ctx) +} + +func (s *exerciseCacheService) GetCacheStatus(ctx context.Context) (*models.CacheStatus, error) { + return s.exerciseRepo.GetCacheStatus(ctx) +} + +func (s *exerciseCacheService) ClearCache(ctx context.Context) error { + s.logger.Info("Clearing exercise cache") + return s.exerciseRepo.ClearCache(ctx) +} + +func (s *exerciseCacheService) cacheMuscleGroups(ctx context.Context) error { + s.logger.Debug("Caching muscle groups from ExerciseDB") + + muscleGroups, err := s.exerciseDBClient.GetMuscleGroups(ctx) + if err != nil { + return fmt.Errorf("failed to get muscle groups from ExerciseDB: %w", err) + } + + for _, mg := range muscleGroups { + muscleGroup := &models.MuscleGroup{ + ID: uuid.New(), + ExerciseDBID: mg.ID, + Name: mg.Name, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := s.exerciseRepo.SaveMuscleGroup(ctx, muscleGroup); err != nil { + s.logger.Error("Failed to save muscle group", "error", err, "muscle_group", mg.Name) + continue + } + } + + s.logger.Info("Successfully cached muscle groups", "count", len(muscleGroups)) + return nil +} + +func (s *exerciseCacheService) cacheEquipment(ctx context.Context) error { + s.logger.Debug("Caching equipment from ExerciseDB") + + equipment, err := s.exerciseDBClient.GetEquipment(ctx) + if err != nil { + return fmt.Errorf("failed to get equipment from ExerciseDB: %w", err) + } + + for _, eq := range equipment { + equipmentModel := &models.Equipment{ + ID: uuid.New(), + ExerciseDBID: eq.ID, + Name: eq.Name, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := s.exerciseRepo.SaveEquipment(ctx, equipmentModel); err != nil { + s.logger.Error("Failed to save equipment", "error", err, "equipment", eq.Name) + continue + } + } + + s.logger.Info("Successfully cached equipment", "count", len(equipment)) + return nil +} + +func (s *exerciseCacheService) cacheExercises(ctx context.Context) error { + s.logger.Debug("Caching exercises from ExerciseDB") + + exercises, err := s.exerciseDBClient.GetAllExercises(ctx) + if err != nil { + return fmt.Errorf("failed to get exercises from ExerciseDB: %w", err) + } + + now := time.Now() + expiresAt := now.Add(s.cacheTTL) + + for i := range exercises { + ex := &exercises[i] + exercise := &models.Exercise{ + ID: uuid.New(), + ExerciseDBID: ex.ID, + Name: ex.Name, + Description: stringPtr(ex.Description), + Instructions: stringPtr(ex.Description), + Tips: stringPtr(ex.Description), + Category: intPtr(ex.Category), + Language: intPtr(ex.Language), + License: intPtr(ex.License), + LicenseAuthor: stringPtr(ex.LicenseAuthor), + Status: stringPtr(ex.Status), + NameOriginal: stringPtr(ex.NameOriginal), + CreationDate: parseDate(ex.CreationDate), + UUID: stringPtr(ex.UUID), + CachedAt: now, + ExpiresAt: expiresAt, + CreatedAt: now, + UpdatedAt: now, + } + + if err := s.exerciseRepo.SaveExercise(ctx, exercise); err != nil { + s.logger.Error("Failed to save exercise", "error", err, "exercise", ex.Name) + continue + } + + if err := s.cacheExerciseMuscleGroups(ctx, exercise.ID, ex.Muscles, ex.MusclesSecondary); err != nil { + s.logger.Error("Failed to cache exercise muscle groups", "error", err, "exercise", ex.Name) + } + + if err := s.cacheExerciseEquipment(ctx, exercise.ID, ex.Equipment); err != nil { + s.logger.Error("Failed to cache exercise equipment", "error", err, "exercise", ex.Name) + } + } + + s.logger.Info("Successfully cached exercises", "count", len(exercises)) + return nil +} + +func (s *exerciseCacheService) cacheExerciseMuscleGroups( + ctx context.Context, + exerciseID uuid.UUID, + primaryMuscles, secondaryMuscles []int, +) error { + muscleGroups, err := s.exerciseRepo.GetMuscleGroups(ctx) + if err != nil { + return fmt.Errorf("failed to get muscle groups: %w", err) + } + + muscleGroupMap := make(map[int]uuid.UUID) + for _, mg := range muscleGroups { + muscleGroupMap[mg.ExerciseDBID] = mg.ID + } + + for _, muscleID := range primaryMuscles { + if muscleGroupID, exists := muscleGroupMap[muscleID]; exists { + if err := s.exerciseRepo.SaveExerciseMuscleGroup(ctx, exerciseID, muscleGroupID, true); err != nil { + s.logger.Warn("Failed to save primary muscle group", "error", err, "muscle_id", muscleID) + } + } + } + + for _, muscleID := range secondaryMuscles { + if muscleGroupID, exists := muscleGroupMap[muscleID]; exists { + if err := s.exerciseRepo.SaveExerciseMuscleGroup(ctx, exerciseID, muscleGroupID, false); err != nil { + s.logger.Warn("Failed to save secondary muscle group", "error", err, "muscle_id", muscleID) + } + } + } + + return nil +} + +func (s *exerciseCacheService) cacheExerciseEquipment(ctx context.Context, exerciseID uuid.UUID, equipmentIDs []int) error { + equipment, err := s.exerciseRepo.GetEquipment(ctx) + if err != nil { + return fmt.Errorf("failed to get equipment: %w", err) + } + + equipmentMap := make(map[int]uuid.UUID) + for _, eq := range equipment { + equipmentMap[eq.ExerciseDBID] = eq.ID + } + + for _, equipmentID := range equipmentIDs { + if eqID, exists := equipmentMap[equipmentID]; exists { + if err := s.exerciseRepo.SaveExerciseEquipment(ctx, exerciseID, eqID); err != nil { + s.logger.Warn("Failed to save exercise equipment", "error", err, "equipment_id", equipmentID) + } + } + } + + return nil +} + +func stringPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + +func intPtr(i int) *int { + if i == 0 { + return nil + } + return &i +} + +func parseDate(dateStr string) *time.Time { + if dateStr == "" { + return nil + } + + parsed, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return nil + } + + return &parsed +} diff --git a/internal/services/exercise_service.go b/internal/services/exercise_service.go new file mode 100644 index 0000000..26982f5 --- /dev/null +++ b/internal/services/exercise_service.go @@ -0,0 +1,176 @@ +package services + +import ( + "context" + "errors" + "fmt" + + "github.com/aleksandr/strive-api/internal/logger" + "github.com/aleksandr/strive-api/internal/models" + "github.com/aleksandr/strive-api/internal/repositories" + "github.com/google/uuid" +) + +var ( + ErrExerciseNotFound = errors.New("exercise not found") + ErrCacheNotValid = errors.New("exercise cache is not valid") +) + +type ExerciseService interface { + GetExercises(ctx context.Context, filters *models.ExerciseFilters) (*models.ExerciseListResponse, error) + GetExerciseByID(ctx context.Context, id uuid.UUID) (*models.Exercise, error) + GetMuscleGroups(ctx context.Context) ([]models.MuscleGroup, error) + GetEquipment(ctx context.Context) ([]models.Equipment, error) + GetCacheStatus(ctx context.Context) (*models.CacheStatus, error) + RefreshCache(ctx context.Context) error +} + +type exerciseService struct { + exerciseRepo repositories.ExerciseRepository + cacheService ExerciseCacheService + logger *logger.Logger +} + +func NewExerciseService( + exerciseRepo repositories.ExerciseRepository, + cacheService ExerciseCacheService, + logger *logger.Logger, +) ExerciseService { + return &exerciseService{ + exerciseRepo: exerciseRepo, + cacheService: cacheService, + logger: logger, + } +} + +func (s *exerciseService) GetExercises(ctx context.Context, filters *models.ExerciseFilters) (*models.ExerciseListResponse, error) { + if filters.Page <= 0 { + filters.Page = 1 + } + if filters.Limit <= 0 { + filters.Limit = 20 + } + if filters.Limit > 100 { + filters.Limit = 100 + } + + isValid, err := s.cacheService.IsCacheValid(ctx) + if err != nil { + s.logger.Error("Failed to check cache validity", "error", err) + return nil, fmt.Errorf("failed to check cache validity: %w", err) + } + + if !isValid { + s.logger.Warn("Cache is not valid, attempting to refresh") + if err := s.cacheService.RefreshCache(ctx); err != nil { + s.logger.Error("Failed to refresh cache", "error", err) + return nil, fmt.Errorf("failed to refresh cache: %w", err) + } + } + + response, err := s.exerciseRepo.GetAll(ctx, filters) + if err != nil { + s.logger.Error("Failed to get exercises", "error", err) + return nil, fmt.Errorf("failed to get exercises: %w", err) + } + + s.logger.Debug("Successfully retrieved exercises", "count", len(response.Exercises), "total", response.Total) + return response, nil +} + +func (s *exerciseService) GetExerciseByID(ctx context.Context, id uuid.UUID) (*models.Exercise, error) { + isValid, err := s.cacheService.IsCacheValid(ctx) + if err != nil { + s.logger.Error("Failed to check cache validity", "error", err) + return nil, fmt.Errorf("failed to check cache validity: %w", err) + } + + if !isValid { + s.logger.Warn("Cache is not valid, attempting to refresh") + if err := s.cacheService.RefreshCache(ctx); err != nil { + s.logger.Error("Failed to refresh cache", "error", err) + return nil, fmt.Errorf("failed to refresh cache: %w", err) + } + } + + exercise, err := s.exerciseRepo.GetByID(ctx, id) + if err != nil { + s.logger.Error("Failed to get exercise by ID", "error", err, "exercise_id", id) + return nil, fmt.Errorf("failed to get exercise: %w", err) + } + + s.logger.Debug("Successfully retrieved exercise", "exercise_id", id, "name", exercise.Name) + return exercise, nil +} + +func (s *exerciseService) GetMuscleGroups(ctx context.Context) ([]models.MuscleGroup, error) { + isValid, err := s.cacheService.IsCacheValid(ctx) + if err != nil { + s.logger.Error("Failed to check cache validity", "error", err) + return nil, fmt.Errorf("failed to check cache validity: %w", err) + } + + if !isValid { + s.logger.Warn("Cache is not valid, attempting to refresh") + if err := s.cacheService.RefreshCache(ctx); err != nil { + s.logger.Error("Failed to refresh cache", "error", err) + return nil, fmt.Errorf("failed to refresh cache: %w", err) + } + } + + muscleGroups, err := s.exerciseRepo.GetMuscleGroups(ctx) + if err != nil { + s.logger.Error("Failed to get muscle groups", "error", err) + return nil, fmt.Errorf("failed to get muscle groups: %w", err) + } + + s.logger.Debug("Successfully retrieved muscle groups", "count", len(muscleGroups)) + return muscleGroups, nil +} + +func (s *exerciseService) GetEquipment(ctx context.Context) ([]models.Equipment, error) { + isValid, err := s.cacheService.IsCacheValid(ctx) + if err != nil { + s.logger.Error("Failed to check cache validity", "error", err) + return nil, fmt.Errorf("failed to check cache validity: %w", err) + } + + if !isValid { + s.logger.Warn("Cache is not valid, attempting to refresh") + if err := s.cacheService.RefreshCache(ctx); err != nil { + s.logger.Error("Failed to refresh cache", "error", err) + return nil, fmt.Errorf("failed to refresh cache: %w", err) + } + } + + equipment, err := s.exerciseRepo.GetEquipment(ctx) + if err != nil { + s.logger.Error("Failed to get equipment", "error", err) + return nil, fmt.Errorf("failed to get equipment: %w", err) + } + + s.logger.Debug("Successfully retrieved equipment", "count", len(equipment)) + return equipment, nil +} + +func (s *exerciseService) GetCacheStatus(ctx context.Context) (*models.CacheStatus, error) { + status, err := s.cacheService.GetCacheStatus(ctx) + if err != nil { + s.logger.Error("Failed to get cache status", "error", err) + return nil, fmt.Errorf("failed to get cache status: %w", err) + } + + return status, nil +} + +func (s *exerciseService) RefreshCache(ctx context.Context) error { + s.logger.Info("Manual cache refresh requested") + + if err := s.cacheService.RefreshCache(ctx); err != nil { + s.logger.Error("Failed to refresh cache", "error", err) + return fmt.Errorf("failed to refresh cache: %w", err) + } + + s.logger.Info("Cache refresh completed successfully") + return nil +} diff --git a/internal/services/exercise_service_test.go b/internal/services/exercise_service_test.go new file mode 100644 index 0000000..4832274 --- /dev/null +++ b/internal/services/exercise_service_test.go @@ -0,0 +1,308 @@ +package services + +import ( + "context" + "testing" + "time" + + "github.com/aleksandr/strive-api/internal/logger" + "github.com/aleksandr/strive-api/internal/models" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockExerciseRepository struct { + mock.Mock +} + +func (m *MockExerciseRepository) GetAll(ctx context.Context, filters *models.ExerciseFilters) (*models.ExerciseListResponse, error) { + args := m.Called(ctx, filters) + return args.Get(0).(*models.ExerciseListResponse), args.Error(1) +} + +func (m *MockExerciseRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Exercise, error) { + args := m.Called(ctx, id) + return args.Get(0).(*models.Exercise), args.Error(1) +} + +func (m *MockExerciseRepository) GetByExerciseDBID(ctx context.Context, exerciseDBID int) (*models.Exercise, error) { + args := m.Called(ctx, exerciseDBID) + return args.Get(0).(*models.Exercise), args.Error(1) +} + +func (m *MockExerciseRepository) GetMuscleGroups(ctx context.Context) ([]models.MuscleGroup, error) { + args := m.Called(ctx) + return args.Get(0).([]models.MuscleGroup), args.Error(1) +} + +func (m *MockExerciseRepository) GetEquipment(ctx context.Context) ([]models.Equipment, error) { + args := m.Called(ctx) + return args.Get(0).([]models.Equipment), args.Error(1) +} + +func (m *MockExerciseRepository) GetCacheStatus(ctx context.Context) (*models.CacheStatus, error) { + args := m.Called(ctx) + return args.Get(0).(*models.CacheStatus), args.Error(1) +} + +func (m *MockExerciseRepository) IsCacheValid(ctx context.Context) (bool, error) { + args := m.Called(ctx) + return args.Bool(0), args.Error(1) +} + +func (m *MockExerciseRepository) ClearCache(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func (m *MockExerciseRepository) SaveExercise(ctx context.Context, exercise *models.Exercise) error { + args := m.Called(ctx, exercise) + return args.Error(0) +} + +func (m *MockExerciseRepository) SaveMuscleGroup(ctx context.Context, muscleGroup *models.MuscleGroup) error { + args := m.Called(ctx, muscleGroup) + return args.Error(0) +} + +func (m *MockExerciseRepository) SaveEquipment(ctx context.Context, equipment *models.Equipment) error { + args := m.Called(ctx, equipment) + return args.Error(0) +} + +func (m *MockExerciseRepository) SaveExerciseMuscleGroup(ctx context.Context, exerciseID, muscleGroupID uuid.UUID, isPrimary bool) error { + args := m.Called(ctx, exerciseID, muscleGroupID, isPrimary) + return args.Error(0) +} + +func (m *MockExerciseRepository) SaveExerciseEquipment(ctx context.Context, exerciseID, equipmentID uuid.UUID) error { + args := m.Called(ctx, exerciseID, equipmentID) + return args.Error(0) +} + +func (m *MockExerciseRepository) SaveExerciseAlternative(ctx context.Context, exerciseID, alternativeID uuid.UUID) error { + args := m.Called(ctx, exerciseID, alternativeID) + return args.Error(0) +} + +type MockExerciseCacheService struct { + mock.Mock +} + +func (m *MockExerciseCacheService) RefreshCache(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func (m *MockExerciseCacheService) IsCacheValid(ctx context.Context) (bool, error) { + args := m.Called(ctx) + return args.Bool(0), args.Error(1) +} + +func (m *MockExerciseCacheService) GetCacheStatus(ctx context.Context) (*models.CacheStatus, error) { + args := m.Called(ctx) + return args.Get(0).(*models.CacheStatus), args.Error(1) +} + +func (m *MockExerciseCacheService) ClearCache(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func TestExerciseService_GetExercises(t *testing.T) { + mockRepo := &MockExerciseRepository{} + mockCache := &MockExerciseCacheService{} + logger := logger.New("DEBUG", "text") + + service := NewExerciseService(mockRepo, mockCache, logger) + + ctx := context.Background() + filters := &models.ExerciseFilters{ + Page: 1, + Limit: 10, + } + + expectedResponse := &models.ExerciseListResponse{ + Exercises: []models.Exercise{ + { + ID: uuid.New(), + Name: "Push-up", + }, + }, + Total: 1, + Page: 1, + Limit: 10, + } + + mockCache.On("IsCacheValid", ctx).Return(true, nil) + mockRepo.On("GetAll", ctx, filters).Return(expectedResponse, nil) + + response, err := service.GetExercises(ctx, filters) + + assert.NoError(t, err) + assert.Equal(t, expectedResponse, response) + mockCache.AssertExpectations(t) + mockRepo.AssertExpectations(t) +} + +func TestExerciseService_GetExercises_InvalidCache(t *testing.T) { + mockRepo := &MockExerciseRepository{} + mockCache := &MockExerciseCacheService{} + logger := logger.New("DEBUG", "text") + + service := NewExerciseService(mockRepo, mockCache, logger) + + ctx := context.Background() + filters := &models.ExerciseFilters{ + Page: 1, + Limit: 10, + } + + expectedResponse := &models.ExerciseListResponse{ + Exercises: []models.Exercise{ + { + ID: uuid.New(), + Name: "Push-up", + }, + }, + Total: 1, + Page: 1, + Limit: 10, + } + + mockCache.On("IsCacheValid", ctx).Return(false, nil) + mockCache.On("RefreshCache", ctx).Return(nil) + mockRepo.On("GetAll", ctx, filters).Return(expectedResponse, nil) + + response, err := service.GetExercises(ctx, filters) + + assert.NoError(t, err) + assert.Equal(t, expectedResponse, response) + mockCache.AssertExpectations(t) + mockRepo.AssertExpectations(t) +} + +func TestExerciseService_GetExerciseByID(t *testing.T) { + mockRepo := &MockExerciseRepository{} + mockCache := &MockExerciseCacheService{} + logger := logger.New("DEBUG", "text") + + service := NewExerciseService(mockRepo, mockCache, logger) + + ctx := context.Background() + exerciseID := uuid.New() + + expectedExercise := &models.Exercise{ + ID: exerciseID, + Name: "Push-up", + } + + mockCache.On("IsCacheValid", ctx).Return(true, nil) + mockRepo.On("GetByID", ctx, exerciseID).Return(expectedExercise, nil) + + exercise, err := service.GetExerciseByID(ctx, exerciseID) + + assert.NoError(t, err) + assert.Equal(t, expectedExercise, exercise) + mockCache.AssertExpectations(t) + mockRepo.AssertExpectations(t) +} + +func TestExerciseService_GetMuscleGroups(t *testing.T) { + mockRepo := &MockExerciseRepository{} + mockCache := &MockExerciseCacheService{} + logger := logger.New("DEBUG", "text") + + service := NewExerciseService(mockRepo, mockCache, logger) + + ctx := context.Background() + + expectedMuscleGroups := []models.MuscleGroup{ + { + ID: uuid.New(), + Name: "Chest", + }, + } + + mockCache.On("IsCacheValid", ctx).Return(true, nil) + mockRepo.On("GetMuscleGroups", ctx).Return(expectedMuscleGroups, nil) + + muscleGroups, err := service.GetMuscleGroups(ctx) + + assert.NoError(t, err) + assert.Equal(t, expectedMuscleGroups, muscleGroups) + mockCache.AssertExpectations(t) + mockRepo.AssertExpectations(t) +} + +func TestExerciseService_GetEquipment(t *testing.T) { + mockRepo := &MockExerciseRepository{} + mockCache := &MockExerciseCacheService{} + logger := logger.New("DEBUG", "text") + + service := NewExerciseService(mockRepo, mockCache, logger) + + ctx := context.Background() + + expectedEquipment := []models.Equipment{ + { + ID: uuid.New(), + Name: "Dumbbells", + }, + } + + mockCache.On("IsCacheValid", ctx).Return(true, nil) + mockRepo.On("GetEquipment", ctx).Return(expectedEquipment, nil) + + equipment, err := service.GetEquipment(ctx) + + assert.NoError(t, err) + assert.Equal(t, expectedEquipment, equipment) + mockCache.AssertExpectations(t) + mockRepo.AssertExpectations(t) +} + +func TestExerciseService_GetCacheStatus(t *testing.T) { + mockRepo := &MockExerciseRepository{} + mockCache := &MockExerciseCacheService{} + logger := logger.New("DEBUG", "text") + + service := NewExerciseService(mockRepo, mockCache, logger) + + ctx := context.Background() + + expectedStatus := &models.CacheStatus{ + LastUpdated: time.Now(), + TotalExercises: 100, + TotalMuscles: 14, + TotalEquipment: 8, + IsValid: true, + ExpiresAt: time.Now().Add(24 * time.Hour), + } + + mockCache.On("GetCacheStatus", ctx).Return(expectedStatus, nil) + + status, err := service.GetCacheStatus(ctx) + + assert.NoError(t, err) + assert.Equal(t, expectedStatus, status) + mockCache.AssertExpectations(t) +} + +func TestExerciseService_RefreshCache(t *testing.T) { + mockRepo := &MockExerciseRepository{} + mockCache := &MockExerciseCacheService{} + logger := logger.New("DEBUG", "text") + + service := NewExerciseService(mockRepo, mockCache, logger) + + ctx := context.Background() + + mockCache.On("RefreshCache", ctx).Return(nil) + + err := service.RefreshCache(ctx) + + assert.NoError(t, err) + mockCache.AssertExpectations(t) +} diff --git a/migrations/000005_exercises.down.sql b/migrations/000005_exercises.down.sql new file mode 100644 index 0000000..4ff0102 --- /dev/null +++ b/migrations/000005_exercises.down.sql @@ -0,0 +1,12 @@ +-- Drop triggers +DROP TRIGGER IF EXISTS update_exercises_updated_at ON exercises; +DROP TRIGGER IF EXISTS update_equipment_updated_at ON equipment; +DROP TRIGGER IF EXISTS update_muscle_groups_updated_at ON muscle_groups; + +-- Drop tables in reverse order +DROP TABLE IF EXISTS exercise_alternatives; +DROP TABLE IF EXISTS exercise_equipment; +DROP TABLE IF EXISTS exercise_muscle_groups; +DROP TABLE IF EXISTS exercises; +DROP TABLE IF EXISTS equipment; +DROP TABLE IF EXISTS muscle_groups; diff --git a/migrations/000005_exercises.up.sql b/migrations/000005_exercises.up.sql new file mode 100644 index 0000000..e28a281 --- /dev/null +++ b/migrations/000005_exercises.up.sql @@ -0,0 +1,98 @@ +-- Create muscle_groups table for caching ExerciseDB muscle groups +CREATE TABLE muscle_groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + exercise_db_id INTEGER NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create equipment table for caching ExerciseDB equipment +CREATE TABLE equipment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + exercise_db_id INTEGER NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create exercises table for caching ExerciseDB exercises +CREATE TABLE exercises ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + exercise_db_id INTEGER NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT, + instructions TEXT, + tips TEXT, + category INTEGER, + language INTEGER, + license INTEGER, + license_author VARCHAR(255), + status VARCHAR(10), + name_original VARCHAR(255), + creation_date DATE, + uuid VARCHAR(36), + cached_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create exercise_muscle_groups junction table +CREATE TABLE exercise_muscle_groups ( + exercise_id UUID NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, + muscle_group_id UUID NOT NULL REFERENCES muscle_groups(id) ON DELETE CASCADE, + is_primary BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (exercise_id, muscle_group_id, is_primary) +); + +-- Create exercise_equipment junction table +CREATE TABLE exercise_equipment ( + exercise_id UUID NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, + equipment_id UUID NOT NULL REFERENCES equipment(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (exercise_id, equipment_id) +); + +-- Create exercise_alternatives table for exercise alternatives +CREATE TABLE exercise_alternatives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + exercise_id UUID NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, + alternative_exercise_id UUID NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(exercise_id, alternative_exercise_id) +); + +-- Create indexes for performance +CREATE INDEX idx_muscle_groups_exercise_db_id ON muscle_groups(exercise_db_id); +CREATE INDEX idx_muscle_groups_name ON muscle_groups(name); + +CREATE INDEX idx_equipment_exercise_db_id ON equipment(exercise_db_id); +CREATE INDEX idx_equipment_name ON equipment(name); + +CREATE INDEX idx_exercises_exercise_db_id ON exercises(exercise_db_id); +CREATE INDEX idx_exercises_name ON exercises(name); +CREATE INDEX idx_exercises_category ON exercises(category); +CREATE INDEX idx_exercises_cached_at ON exercises(cached_at); +CREATE INDEX idx_exercises_expires_at ON exercises(expires_at); + +CREATE INDEX idx_exercise_muscle_groups_exercise_id ON exercise_muscle_groups(exercise_id); +CREATE INDEX idx_exercise_muscle_groups_muscle_group_id ON exercise_muscle_groups(muscle_group_id); +CREATE INDEX idx_exercise_muscle_groups_primary ON exercise_muscle_groups(is_primary); + +CREATE INDEX idx_exercise_equipment_exercise_id ON exercise_equipment(exercise_id); +CREATE INDEX idx_exercise_equipment_equipment_id ON exercise_equipment(equipment_id); + +CREATE INDEX idx_exercise_alternatives_exercise_id ON exercise_alternatives(exercise_id); +CREATE INDEX idx_exercise_alternatives_alternative_id ON exercise_alternatives(alternative_exercise_id); + +-- Create triggers for updated_at +CREATE TRIGGER update_muscle_groups_updated_at BEFORE UPDATE ON muscle_groups + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_equipment_updated_at BEFORE UPDATE ON equipment + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_exercises_updated_at BEFORE UPDATE ON exercises + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/server b/server index 4b9dfe8..ef11d46 100755 Binary files a/server and b/server differ