diff --git a/VEZA_COMPLETE_MVP_TODOLIST.json b/VEZA_COMPLETE_MVP_TODOLIST.json index c47f3b9e7..2af37e3d2 100644 --- a/VEZA_COMPLETE_MVP_TODOLIST.json +++ b/VEZA_COMPLETE_MVP_TODOLIST.json @@ -2276,7 +2276,7 @@ "description": "Verify GET /api/v1/tracks/resume/:uploadId works for chunked uploads", "owner": "backend", "estimated_hours": 2, - "status": "todo", + "status": "completed", "files_involved": [], "implementation_steps": [ { @@ -2297,7 +2297,9 @@ "Unit tests", "Integration tests" ], - "notes": "" + "notes": "", + "completed_at": "2025-12-24T14:42:51.466308", + "implementation_notes": "Created comprehensive integration tests for GET /api/v1/tracks/resume/:uploadId endpoint. Tests verify: 1) Resume works correctly with partial chunked uploads, 2) Returns 404 for non-existent uploads, 3) Returns 403 for unauthorized access. Endpoint was already implemented and registered in router." }, { "id": "BE-API-026", diff --git a/veza-backend-api/tests/integration/resume_upload_test.go b/veza-backend-api/tests/integration/resume_upload_test.go new file mode 100644 index 000000000..c3d61ee85 --- /dev/null +++ b/veza-backend-api/tests/integration/resume_upload_test.go @@ -0,0 +1,361 @@ +//go:build integration +// +build integration + +package integration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gorm.io/driver/postgres" + "gorm.io/gorm" + + "veza-backend-api/internal/core/track" + "veza-backend-api/internal/models" + "veza-backend-api/internal/services" + "veza-backend-api/tests/testutils" +) + +// TestResumeUploadEndpoint_ChunkedUploads tests the GET /api/v1/tracks/resume/:uploadId endpoint +// BE-API-025: Verify GET /api/v1/tracks/resume/:uploadId works for chunked uploads +func TestResumeUploadEndpoint_ChunkedUploads(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) + + // Create test user + userID := uuid.New() + userIDShort := userID.String()[:8] + usernameSafe := fmt.Sprintf("test_%s", userIDShort) + user := &models.User{ + ID: userID, + Email: fmt.Sprintf("test_resume_%s@example.com", userIDShort), + Username: usernameSafe, + IsActive: true, + } + require.NoError(t, db.Create(user).Error) + + // Setup Redis + redisAddr := os.Getenv("REDIS_ADDR") + if redisAddr == "" { + redisAddr = "localhost:6379" + } + rdb := redis.NewClient(&redis.Options{ + Addr: redisAddr, + }) + defer rdb.Close() + + if err := rdb.Ping(ctx).Err(); err != nil { + t.Skipf("Skipping test: Redis not available at %s: %v", redisAddr, err) + return + } + + logger := zap.NewNop() + uploadDir := t.TempDir() + chunksDir := uploadDir + "/chunks" + + trackService := track.NewTrackService(db, logger, uploadDir) + trackUploadService := services.NewTrackUploadService(db, logger) + chunkService := services.NewTrackChunkService(chunksDir, rdb, logger) + likeService := services.NewTrackLikeService(db, logger) + streamService := services.NewStreamService("", logger) + + trackHandler := track.NewTrackHandler(trackService, trackUploadService, chunkService, likeService, streamService) + + // 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/initiate", trackHandler.InitiateChunkedUpload) + api.POST("/tracks/chunk", trackHandler.UploadChunk) + api.GET("/tracks/resume/:uploadId", trackHandler.ResumeUpload) + } + + // Step 1: Initiate chunked upload + initReqBody := bytes.NewBufferString(`{ + "total_chunks": 5, + "total_size": 5242880, + "filename": "test_audio.mp3" + }`) + initReq := httptest.NewRequest("POST", "/api/v1/tracks/initiate", initReqBody) + initReq.Header.Set("Content-Type", "application/json") + initW := httptest.NewRecorder() + router.ServeHTTP(initW, initReq) + + require.Equal(t, http.StatusOK, initW.Code, "Initiate should succeed: %s", initW.Body.String()) + + var initResp map[string]interface{} + err = json.Unmarshal(initW.Body.Bytes(), &initResp) + require.NoError(t, err) + require.True(t, initResp["success"].(bool)) + + uploadID := initResp["data"].(map[string]interface{})["upload_id"].(string) + require.NotEmpty(t, uploadID) + + // Step 2: Upload some chunks (not all) + chunksToUpload := []int{1, 2, 4} // Upload chunks 1, 2, and 4 (missing 3 and 5) + + for _, chunkNum := range chunksToUpload { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Add form fields + writer.WriteField("upload_id", uploadID) + writer.WriteField("chunk_number", fmt.Sprintf("%d", chunkNum)) + writer.WriteField("total_chunks", "5") + writer.WriteField("total_size", "5242880") + writer.WriteField("filename", "test_audio.mp3") + + // Add chunk file (dummy content) + chunkContent := bytes.NewBuffer(make([]byte, 1024*1024)) // 1MB dummy chunk + fileWriter, err := writer.CreateFormFile("chunk", fmt.Sprintf("chunk_%d.bin", chunkNum)) + require.NoError(t, err) + _, err = io.Copy(fileWriter, chunkContent) + require.NoError(t, err) + writer.Close() + + chunkReq := httptest.NewRequest("POST", "/api/v1/tracks/chunk", body) + chunkReq.Header.Set("Content-Type", writer.FormDataContentType()) + chunkW := httptest.NewRecorder() + router.ServeHTTP(chunkW, chunkReq) + + assert.Equal(t, http.StatusOK, chunkW.Code, "Chunk %d upload should succeed: %s", chunkNum, chunkW.Body.String()) + } + + // Step 3: Test resume endpoint + resumeReq := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/tracks/resume/%s", uploadID), nil) + resumeW := httptest.NewRecorder() + router.ServeHTTP(resumeW, resumeReq) + + // Verify response + require.Equal(t, http.StatusOK, resumeW.Code, "Resume should succeed: %s", resumeW.Body.String()) + + var resumeResp map[string]interface{} + err = json.Unmarshal(resumeW.Body.Bytes(), &resumeResp) + require.NoError(t, err) + require.True(t, resumeResp["success"].(bool)) + + data := resumeResp["data"].(map[string]interface{}) + assert.Equal(t, uploadID, data["upload_id"]) + assert.Equal(t, userID.String(), data["user_id"]) + assert.Equal(t, float64(5), data["total_chunks"]) + assert.Equal(t, float64(5242880), data["total_size"]) + assert.Equal(t, "test_audio.mp3", data["filename"]) + + // Verify chunks received + receivedCount := int(data["received_count"].(float64)) + assert.Equal(t, 3, receivedCount, "Should have received 3 chunks") + + chunksReceived := data["chunks_received"].([]interface{}) + assert.Len(t, chunksReceived, 3, "Should have 3 chunks in chunks_received array") + + // Verify progress + progress := int(data["progress"].(float64)) + assert.Equal(t, 60, progress, "Progress should be 60% (3/5 chunks)") + + // Verify last chunk + lastChunk := int(data["last_chunk"].(float64)) + assert.Equal(t, 4, lastChunk, "Last chunk received should be 4") +} + +// TestResumeUploadEndpoint_NotFound tests that resume returns 404 for non-existent upload +func TestResumeUploadEndpoint_NotFound(t *testing.T) { + ctx := context.Background() + gin.SetMode(gin.TestMode) + + 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) + + userID := uuid.New() + userIDShort := userID.String()[:8] + usernameSafe := fmt.Sprintf("test_%s", userIDShort) + user := &models.User{ + ID: userID, + Email: fmt.Sprintf("test_resume_404_%s@example.com", userIDShort), + Username: usernameSafe, + IsActive: true, + } + require.NoError(t, db.Create(user).Error) + + redisAddr := os.Getenv("REDIS_ADDR") + if redisAddr == "" { + redisAddr = "localhost:6379" + } + rdb := redis.NewClient(&redis.Options{ + Addr: redisAddr, + }) + defer rdb.Close() + + if err := rdb.Ping(ctx).Err(); err != nil { + t.Skipf("Skipping test: Redis not available: %v", err) + return + } + + logger := zap.NewNop() + uploadDir := t.TempDir() + chunksDir := uploadDir + "/chunks" + + trackService := track.NewTrackService(db, logger, uploadDir) + trackUploadService := services.NewTrackUploadService(db, logger) + chunkService := services.NewTrackChunkService(chunksDir, rdb, logger) + likeService := services.NewTrackLikeService(db, logger) + streamService := services.NewStreamService("", logger) + + trackHandler := track.NewTrackHandler(trackService, trackUploadService, chunkService, likeService, streamService) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("user_id", userID) + c.Next() + }) + + api := router.Group("/api/v1") + api.GET("/tracks/resume/:uploadId", trackHandler.ResumeUpload) + + // Try to resume non-existent upload + nonExistentID := uuid.New().String() + resumeReq := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/tracks/resume/%s", nonExistentID), nil) + resumeW := httptest.NewRecorder() + router.ServeHTTP(resumeW, resumeReq) + + assert.Equal(t, http.StatusNotFound, resumeW.Code, "Should return 404 for non-existent upload") +} + +// TestResumeUploadEndpoint_Unauthorized tests that resume returns 403 for other user's upload +func TestResumeUploadEndpoint_Unauthorized(t *testing.T) { + ctx := context.Background() + gin.SetMode(gin.TestMode) + + 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) + + // Create two users + user1ID := uuid.New() + user2ID := uuid.New() + + user1 := &models.User{ + ID: user1ID, + Email: fmt.Sprintf("test_user1_%s@example.com", user1ID.String()[:8]), + Username: fmt.Sprintf("test_user1_%s", user1ID.String()[:8]), + IsActive: true, + } + user2 := &models.User{ + ID: user2ID, + Email: fmt.Sprintf("test_user2_%s@example.com", user2ID.String()[:8]), + Username: fmt.Sprintf("test_user2_%s", user2ID.String()[:8]), + IsActive: true, + } + require.NoError(t, db.Create(user1).Error) + require.NoError(t, db.Create(user2).Error) + + redisAddr := os.Getenv("REDIS_ADDR") + if redisAddr == "" { + redisAddr = "localhost:6379" + } + rdb := redis.NewClient(&redis.Options{ + Addr: redisAddr, + }) + defer rdb.Close() + + if err := rdb.Ping(ctx).Err(); err != nil { + t.Skipf("Skipping test: Redis not available: %v", err) + return + } + + logger := zap.NewNop() + uploadDir := t.TempDir() + chunksDir := uploadDir + "/chunks" + + trackService := track.NewTrackService(db, logger, uploadDir) + trackUploadService := services.NewTrackUploadService(db, logger) + chunkService := services.NewTrackChunkService(chunksDir, rdb, logger) + likeService := services.NewTrackLikeService(db, logger) + streamService := services.NewStreamService("", logger) + + trackHandler := track.NewTrackHandler(trackService, trackUploadService, chunkService, likeService, streamService) + + router := gin.New() + + api := router.Group("/api/v1") + { + api.POST("/tracks/initiate", trackHandler.InitiateChunkedUpload) + api.GET("/tracks/resume/:uploadId", trackHandler.ResumeUpload) + } + + // User1 initiates upload + router.Use(func(c *gin.Context) { + c.Set("user_id", user1ID) + c.Next() + }) + + initReqBody := bytes.NewBufferString(`{ + "total_chunks": 3, + "total_size": 3145728, + "filename": "user1_file.mp3" + }`) + initReq := httptest.NewRequest("POST", "/api/v1/tracks/initiate", initReqBody) + initReq.Header.Set("Content-Type", "application/json") + initW := httptest.NewRecorder() + router.ServeHTTP(initW, initReq) + + require.Equal(t, http.StatusOK, initW.Code) + var initResp map[string]interface{} + json.Unmarshal(initW.Body.Bytes(), &initResp) + uploadID := initResp["data"].(map[string]interface{})["upload_id"].(string) + + // User2 tries to resume user1's upload + router2 := gin.New() + router2.Use(func(c *gin.Context) { + c.Set("user_id", user2ID) + c.Next() + }) + api2 := router2.Group("/api/v1") + api2.GET("/tracks/resume/:uploadId", trackHandler.ResumeUpload) + + resumeReq := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/tracks/resume/%s", uploadID), nil) + resumeW := httptest.NewRecorder() + router2.ServeHTTP(resumeW, resumeReq) + + assert.Equal(t, http.StatusForbidden, resumeW.Code, "Should return 403 for other user's upload") +} +