veza/veza-backend-api/tests/integration/upload_async_polling_test.go
2025-12-16 11:23:49 -05:00

374 lines
13 KiB
Go

//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")
}