//go:build integration // +build integration package integration import ( "bytes" "context" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "veza-backend-api/internal/core/track" "veza-backend-api/internal/models" "veza-backend-api/internal/services" "veza-backend-api/internal/testutils" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" "gorm.io/driver/postgres" "gorm.io/gorm" ) // TestUploadAsyncPollingStatus teste le flux complet upload async avec polling status // P1: Test d'intégration pour valider upload async + polling status // Utilise testcontainers pour PostgreSQL et Redis (environnement reproductible) func TestUploadAsyncPollingStatus(t *testing.T) { ctx := context.Background() gin.SetMode(gin.TestMode) // Setup PostgreSQL via testcontainers dsn, err := testutils.GetTestContainerDB(ctx) if err != nil { t.Skipf("Skipping test: PostgreSQL testcontainer not available: %v", err) return } db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) require.NoError(t, err) // Les migrations sont déjà appliquées par testcontainers // Vérifier que les tables existent var count int64 err = db.Table("users").Count(&count).Error if err != nil { t.Fatalf("Database not properly initialized: %v", err) } // Créer utilisateur de test userID := uuid.New() user := &models.User{ ID: userID, Email: "test@example.com", Username: "testuser", IsActive: true, } require.NoError(t, db.Create(user).Error) // Setup logger logger := zaptest.NewLogger(t) // Setup services uploadDir := t.TempDir() trackService := track.NewTrackService(db, logger, uploadDir) // UploadValidator nécessite UploadConfig uploadConfig := &services.UploadConfig{ ClamAVEnabled: false, // Désactivé pour test } uploadValidator, err := services.NewUploadValidator(uploadConfig, logger) require.NoError(t, err) // TrackHandler nécessite plusieurs services - créer avec signatures correctes trackUploadService := services.NewTrackUploadService(db, logger) // Setup Redis via testcontainers redisClient, err := testutils.GetTestRedisClient(ctx) if err != nil { t.Skipf("Skipping test: Redis testcontainer not available: %v", err) return } // TrackChunkService nécessite uploadDir, redis, logger chunkService := services.NewTrackChunkService(uploadDir, redisClient, logger) likeService := services.NewTrackLikeService(db, logger) streamService := services.NewStreamService("", logger) // empty string pour test trackHandler := track.NewTrackHandler(trackService, trackUploadService, chunkService, likeService, streamService) trackHandler.SetUploadValidator(uploadValidator) // Setup router router := gin.New() router.Use(func(c *gin.Context) { c.Set("user_id", userID) c.Next() }) api := router.Group("/api/v1") { api.POST("/tracks", trackHandler.UploadTrack) api.GET("/tracks/:id/status", trackHandler.GetUploadStatus) } // Étape 1: Créer un fichier de test audio minimal valide (WAV) // WAV est plus simple à créer qu'un MP3 valide et http.DetectContentType le détecte comme "audio/wave" testFile := filepath.Join(t.TempDir(), "test_audio.wav") testFileName := "test_audio.wav" // Créer un fichier WAV minimal valide (44 bytes header + quelques samples) // RIFF header (12 bytes) wavHeader := []byte("RIFF") wavHeader = append(wavHeader, []byte{0x24, 0x00, 0x00, 0x00}...) // File size - 8 wavHeader = append(wavHeader, []byte("WAVE")...) // fmt chunk (24 bytes) wavHeader = append(wavHeader, []byte("fmt ")...) wavHeader = append(wavHeader, []byte{0x10, 0x00, 0x00, 0x00}...) // fmt chunk size wavHeader = append(wavHeader, []byte{0x01, 0x00}...) // Audio format (PCM) wavHeader = append(wavHeader, []byte{0x01, 0x00}...) // Num channels wavHeader = append(wavHeader, []byte{0x44, 0xAC, 0x00, 0x00}...) // Sample rate (44100) wavHeader = append(wavHeader, []byte{0x88, 0x58, 0x01, 0x00}...) // Byte rate wavHeader = append(wavHeader, []byte{0x02, 0x00}...) // Block align wavHeader = append(wavHeader, []byte{0x10, 0x00}...) // Bits per sample // data chunk (8 bytes header + data) wavHeader = append(wavHeader, []byte("data")...) wavHeader = append(wavHeader, []byte{0x04, 0x00, 0x00, 0x00}...) // Data size wavHeader = append(wavHeader, []byte{0x00, 0x00, 0x00, 0x00}...) // Sample data (silence) require.NoError(t, os.WriteFile(testFile, wavHeader, 0644)) // Étape 2: Upload fichier (POST /api/v1/tracks) body := &bytes.Buffer{} writer := multipart.NewWriter(body) // Ajouter champs writer.WriteField("title", "Test Track") writer.WriteField("artist", "Test Artist") writer.WriteField("file_type", "audio") writer.WriteField("duration", "180") // Duration > 0 requis par contrainte DB // Ajouter fichier fileWriter, err := writer.CreateFormFile("file", testFileName) require.NoError(t, err) file, err := os.Open(testFile) require.NoError(t, err) _, err = io.Copy(fileWriter, file) require.NoError(t, err) file.Close() writer.Close() req := httptest.NewRequest("POST", "/api/v1/tracks", body) req.Header.Set("Content-Type", writer.FormDataContentType()) w := httptest.NewRecorder() router.ServeHTTP(w, req) // Vérifier réponse 202 Accepted if w.Code != http.StatusAccepted { t.Logf("Unexpected status code: %d", w.Code) t.Logf("Response body: %s", w.Body.String()) } assert.Equal(t, http.StatusAccepted, w.Code, "Should return 202 Accepted - Response: %s", w.Body.String()) var uploadResp map[string]interface{} err = json.Unmarshal(w.Body.Bytes(), &uploadResp) require.NoError(t, err, "Response should be valid JSON: %s", w.Body.String()) assert.True(t, uploadResp["success"].(bool), "Response should have success=true") data, ok := uploadResp["data"].(map[string]interface{}) require.True(t, ok, "Response should have data object: %v", uploadResp) trackIDInterface, ok := data["track_id"] require.True(t, ok, "Data should have track_id: %v", data) trackIDStr, ok := trackIDInterface.(string) require.True(t, ok, "track_id should be a string: %v (type: %T)", trackIDInterface, trackIDInterface) trackID, err := uuid.Parse(trackIDStr) require.NoError(t, err) // Vérifier Location header location := w.Header().Get("Location") assert.Contains(t, location, "/api/v1/tracks/") assert.Contains(t, location, "/status") // Vérifier status initial (dans la réponse 202, c'est directement dans data) initialStatusInterface, ok := data["status"] require.True(t, ok, "Data should have status: %v", data) initialStatus, ok := initialStatusInterface.(string) require.True(t, ok, "Status should be a string: %v", initialStatusInterface) assert.Contains(t, []string{"uploading", "processing"}, initialStatus, "Initial status should be uploading or processing") // Étape 3: Polling status (GET /api/v1/tracks/:id/status) // Le fichier est copié en arrière-plan, on doit attendre la transition maxAttempts := 30 // Augmenter pour laisser le temps à la copie async interval := 200 * time.Millisecond // 200ms pour laisser le temps à la goroutine finalStatus := "" for i := 0; i < maxAttempts; i++ { time.Sleep(interval) req := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/tracks/%s/status", trackID.String()), nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code, "Status endpoint should return 200") var statusResp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &statusResp) require.NoError(t, err) assert.True(t, statusResp["success"].(bool)) statusData, ok := statusResp["data"].(map[string]interface{}) require.True(t, ok, "Response should have data object: %v", statusResp) // GetUploadStatus retourne data.progress avec status, progress, message, etc. progressData, ok := statusData["progress"].(map[string]interface{}) require.True(t, ok, "Data should have progress object: %v", statusData) statusInterface, ok := progressData["status"] require.True(t, ok, "Progress should have status: %v", progressData) finalStatus, ok = statusInterface.(string) require.True(t, ok, "Status should be a string: %v (type: %T)", statusInterface, statusInterface) t.Logf("Poll attempt %d: status=%s", i+1, finalStatus) // Si status est completed ou failed, arrêter if finalStatus == "completed" || finalStatus == "failed" { break } } // Vérifier status final assert.Contains(t, []string{"completed", "processing", "failed"}, finalStatus, "Final status should be completed, processing, or failed") // Si completed, vérifier que le fichier existe if finalStatus == "completed" { // Vérifier en DB que le track est créé var track models.Track err := db.Where("id = ?", trackID).First(&track).Error require.NoError(t, err) assert.Equal(t, models.TrackStatusCompleted, track.Status) // Vérifier que le fichier existe (si path stocké) if track.FilePath != "" { _, err := os.Stat(track.FilePath) assert.NoError(t, err, "Track file should exist") } } } // TestUploadAsyncPollingStatus_Transitions teste les transitions de status // P1: Vérifie que les transitions sont cohérentes func TestUploadAsyncPollingStatus_Transitions(t *testing.T) { ctx := context.Background() gin.SetMode(gin.TestMode) // Setup PostgreSQL via testcontainers dsn, err := testutils.GetTestContainerDB(ctx) if err != nil { t.Skipf("Skipping test: PostgreSQL testcontainer not available: %v", err) return } db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) require.NoError(t, err) // Utiliser un email unique pour éviter les conflits entre tests // Username doit respecter chk_users_username_format (pas de tirets, seulement alphanum + underscore) userID := uuid.New() userIDShort := userID.String()[:8] // Remplacer les tirets par des underscores pour respecter la contrainte usernameSafe := fmt.Sprintf("test_%s", userIDShort) user := &models.User{ ID: userID, Email: fmt.Sprintf("test_transitions_%s@example.com", userIDShort), Username: usernameSafe, IsActive: true, } require.NoError(t, db.Create(user).Error) logger := zaptest.NewLogger(t) uploadDir := t.TempDir() trackService := track.NewTrackService(db, logger, uploadDir) uploadConfig := &services.UploadConfig{ClamAVEnabled: false} uploadValidator, err := services.NewUploadValidator(uploadConfig, logger) require.NoError(t, err) // Setup Redis via testcontainers redisClient, err := testutils.GetTestRedisClient(ctx) if err != nil { t.Skipf("Skipping test: Redis testcontainer not available: %v", err) return } trackUploadService := services.NewTrackUploadService(db, logger) chunkService := services.NewTrackChunkService(uploadDir, redisClient, logger) likeService := services.NewTrackLikeService(db, logger) streamService := services.NewStreamService("", logger) trackHandler := track.NewTrackHandler(trackService, trackUploadService, chunkService, likeService, streamService) trackHandler.SetUploadValidator(uploadValidator) router := gin.New() router.Use(func(c *gin.Context) { c.Set("user_id", userID) c.Next() }) router.POST("/api/v1/tracks", trackHandler.UploadTrack) router.GET("/api/v1/tracks/:id/status", trackHandler.GetUploadStatus) // Créer fichier de test testFile := filepath.Join(t.TempDir(), "test.mp3") require.NoError(t, os.WriteFile(testFile, []byte("test content"), 0644)) // Upload body := &bytes.Buffer{} writer := multipart.NewWriter(body) writer.WriteField("title", "Test") writer.WriteField("artist", "Test") writer.WriteField("file_type", "audio") fileWriter, _ := writer.CreateFormFile("file", "test.mp3") file, _ := os.Open(testFile) io.Copy(fileWriter, file) file.Close() writer.Close() req := httptest.NewRequest("POST", "/api/v1/tracks", body) req.Header.Set("Content-Type", writer.FormDataContentType()) w := httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusAccepted, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) trackID := resp["data"].(map[string]interface{})["track_id"].(string) // Polling avec vérification transitions seenStatuses := make(map[string]bool) maxAttempts := 30 // Augmenter pour laisser le temps aux transitions for i := 0; i < maxAttempts; i++ { time.Sleep(200 * time.Millisecond) req := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/tracks/%s/status", trackID), nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) var statusResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &statusResp) status := statusResp["data"].(map[string]interface{})["status"].(string) seenStatuses[status] = true if status == "completed" || status == "failed" { break } } // Vérifier que les transitions sont logiques // uploading -> processing -> completed (ou failed) t.Logf("Status vus: %v", seenStatuses) assert.True(t, len(seenStatuses) > 0, "Au moins un status doit être vu") }