374 lines
13 KiB
Go
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")
|
|
}
|