//! Creator media workflow tests — real audio files through the full upload pipeline. //! //! Uses ffmpeg-generated test fixtures from `tests/fixtures/`. use crate::harness::TestHarness; use serde_json::{json, Value}; const TEST_MP3: &[u8] = include_bytes!("../fixtures/test.mp3"); const TEST_FLAC: &[u8] = include_bytes!("../fixtures/test.flac"); const TEST_WAV: &[u8] = include_bytes!("../fixtures/test.wav"); const TEST_OGG: &[u8] = include_bytes!("../fixtures/test.ogg"); const TEST_M4A: &[u8] = include_bytes!("../fixtures/test.m4a"); /// Helper: set up a trusted creator with a project and audio item. async fn setup_creator(h: &mut TestHarness, username: &str) -> (String, String, String) { let setup = h.create_creator_with_item(username, "audio", 0).await; h.trust_user(setup.user_id).await; h.grant_tier(setup.user_id, "small_files").await; (setup.user_id.to_string(), setup.project_id, setup.item_id) } /// Helper: presign + upload real bytes + confirm for an audio file. async fn upload_audio( h: &mut TestHarness, item_id: &str, file_name: &str, content_type: &str, data: &[u8], ) -> String { let body = json!({ "item_id": item_id, "file_type": "audio", "file_name": file_name, "content_type": content_type, }); let resp = h.client.post_json("/api/upload/presign", &body.to_string()).await; assert!(resp.status.is_success(), "Presign {} failed: {}", file_name, resp.text); let data_resp: Value = resp.json(); let s3_key = data_resp["s3_key"].as_str().unwrap().to_string(); // Put real file bytes into in-memory storage h.storage.as_ref().unwrap().put(&s3_key, data.to_vec()); // Confirm let body = json!({ "item_id": item_id, "file_type": "audio", "s3_key": s3_key, }); let resp = h.client.post_json("/api/upload/confirm", &body.to_string()).await; assert!(resp.status.is_success(), "Confirm {} failed: {}", file_name, resp.text); // Async scan pipeline (Phase 1) — drive the worker so the caller can // assert final scan_status without sleeping. h.drain_scan_jobs().await; s3_key } // --------------------------------------------------------------------------- // Multi-format upload // --------------------------------------------------------------------------- #[tokio::test] async fn upload_real_mp3() { let mut h = TestHarness::with_storage().await; let (_, _, item_id) = setup_creator(&mut h, "mp3user").await; let s3_key = upload_audio(&mut h, &item_id, "track.mp3", "audio/mpeg", TEST_MP3).await; let db_key: Option = sqlx::query_scalar("SELECT audio_s3_key FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(db_key.as_deref(), Some(s3_key.as_str())); } #[tokio::test] async fn upload_real_flac() { let mut h = TestHarness::with_storage().await; let (_, _, item_id) = setup_creator(&mut h, "flacuser").await; upload_audio(&mut h, &item_id, "track.flac", "audio/flac", TEST_FLAC).await; } #[tokio::test] async fn upload_real_wav() { let mut h = TestHarness::with_storage().await; let (_, _, item_id) = setup_creator(&mut h, "wavuser").await; upload_audio(&mut h, &item_id, "track.wav", "audio/wav", TEST_WAV).await; } #[tokio::test] async fn upload_real_ogg() { let mut h = TestHarness::with_storage().await; let (_, _, item_id) = setup_creator(&mut h, "ogguser").await; upload_audio(&mut h, &item_id, "track.ogg", "audio/ogg", TEST_OGG).await; } #[tokio::test] async fn upload_real_m4a() { let mut h = TestHarness::with_storage().await; let (_, _, item_id) = setup_creator(&mut h, "m4auser").await; upload_audio(&mut h, &item_id, "track.m4a", "audio/mp4", TEST_M4A).await; } // --------------------------------------------------------------------------- // Scanning with real audio // --------------------------------------------------------------------------- #[tokio::test] async fn scan_real_mp3_passes() { let mut h = TestHarness::with_storage_and_scanner().await; let (_, _, item_id) = setup_creator(&mut h, "scanmp3").await; upload_audio(&mut h, &item_id, "clean.mp3", "audio/mpeg", TEST_MP3).await; let scan_status: String = sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(scan_status, "clean", "Real MP3 should pass scanning"); } #[tokio::test] async fn scan_real_flac_passes() { let mut h = TestHarness::with_storage_and_scanner().await; let (_, _, item_id) = setup_creator(&mut h, "scanflac").await; upload_audio(&mut h, &item_id, "clean.flac", "audio/flac", TEST_FLAC).await; let scan_status: String = sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(scan_status, "clean", "Real FLAC should pass scanning"); } #[tokio::test] async fn scan_real_wav_passes() { let mut h = TestHarness::with_storage_and_scanner().await; let (_, _, item_id) = setup_creator(&mut h, "scanwav").await; upload_audio(&mut h, &item_id, "clean.wav", "audio/wav", TEST_WAV).await; let scan_status: String = sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(scan_status, "clean", "Real WAV should pass scanning"); } // --------------------------------------------------------------------------- // Full lifecycle: upload → chapters → publish → stream // --------------------------------------------------------------------------- #[tokio::test] async fn full_audio_lifecycle() { let mut h = TestHarness::with_storage().await; let (_, project_id, item_id) = setup_creator(&mut h, "lifecycle").await; // Upload real MP3 let s3_key = upload_audio(&mut h, &item_id, "episode.mp3", "audio/mpeg", TEST_MP3).await; // Add chapters let resp = h .client .post_json( &format!("/api/items/{}/chapters", item_id), r#"{"title": "Intro", "start_seconds": 0.0, "sort_order": 0}"#, ) .await; assert!(resp.status.is_success(), "Create chapter failed: {}", resp.text); let resp = h .client .post_json( &format!("/api/items/{}/chapters", item_id), r#"{"title": "Main Content", "start_seconds": 15.0, "sort_order": 1}"#, ) .await; assert!(resp.status.is_success(), "Create chapter 2 failed: {}", resp.text); // Publish item + project let resp = h .client .put_form(&format!("/api/items/{}", item_id), "is_public=true") .await; assert!(resp.status.is_success(), "Publish item failed: {}", resp.text); let resp = h .client .put_json( &format!("/api/projects/{}", project_id), r#"{"is_public": true}"#, ) .await; assert!(resp.status.is_success(), "Publish project failed: {}", resp.text); // Get stream URL let resp = h.client.get(&format!("/api/stream/{}", item_id)).await; assert!(resp.status.is_success(), "Stream failed: {}", resp.text); let data: Value = resp.json(); assert!(data["stream_url"].is_string(), "Should return stream_url"); // Verify the audio bytes are actually in storage let stored = h.storage.as_ref().unwrap().get(&s3_key); assert_eq!(stored.len(), TEST_MP3.len(), "Stored bytes should match uploaded MP3"); // Verify chapters exist let resp = h .client .get(&format!("/api/items/{}/chapters", item_id)) .await; assert!(resp.status.is_success(), "List chapters failed: {}", resp.text); let chapters: Value = resp.json(); let data = chapters["data"].as_array().unwrap(); assert_eq!(data.len(), 2); } // --------------------------------------------------------------------------- // Version upload with real file // --------------------------------------------------------------------------- #[tokio::test] async fn version_upload_real_audio() { let mut h = TestHarness::with_storage().await; let (_, project_id, item_id) = setup_creator(&mut h, "versmedia").await; // Create a version let resp = h .client .post_json( &format!("/api/items/{}/versions", item_id), r#"{"version_number": "1.0.0", "changelog": "Initial release"}"#, ) .await; assert!(resp.status.is_success(), "Create version failed: {}", resp.text); let version: Value = resp.json(); let version_id = version["id"].as_str().unwrap().to_string(); // Presign version upload (downloads accept application/octet-stream) let resp = h .client .post_json( &format!("/api/versions/{}/upload/presign", version_id), r#"{"file_name": "track-v1.zip", "content_type": "application/zip"}"#, ) .await; assert!(resp.status.is_success(), "Version presign failed: {}", resp.text); let data: Value = resp.json(); let s3_key = data["s3_key"].as_str().unwrap().to_string(); // Upload real bytes (using MP3 data as a stand-in for zip — content doesn't matter for storage) h.storage.as_ref().unwrap().put(&s3_key, TEST_MP3.to_vec()); // Confirm let resp = h .client .post_json( &format!("/api/versions/{}/upload/confirm", version_id), &json!({"s3_key": s3_key}).to_string(), ) .await; assert!(resp.status.is_success(), "Version confirm failed: {}", resp.text); // Publish and download h.client .put_form(&format!("/api/items/{}", item_id), "is_public=true") .await; h.client .put_json(&format!("/api/projects/{}", project_id), r#"{"is_public": true}"#) .await; let resp = h .client .get(&format!("/api/versions/{}/download", version_id)) .await; assert!(resp.status.is_success(), "Download failed: {}", resp.text); let data: Value = resp.json(); assert!(data["download_url"].is_string(), "Should return download_url"); } // --------------------------------------------------------------------------- // Content insertion with real audio // --------------------------------------------------------------------------- #[tokio::test] async fn insertion_lifecycle_with_real_audio() { let mut h = TestHarness::with_storage().await; let (_, _, item_id) = setup_creator(&mut h, "inslife").await; // Presign insertion let resp = h .client .post_json( "/api/users/me/insertions/presign", r#"{"file_name": "intro.mp3", "content_type": "audio/mpeg"}"#, ) .await; assert!(resp.status.is_success(), "Presign insertion failed: {}", resp.text); let data: Value = resp.json(); let s3_key = data["s3_key"].as_str().unwrap().to_string(); // Upload real MP3 h.storage.as_ref().unwrap().put(&s3_key, TEST_MP3.to_vec()); // Confirm insertion let resp = h .client .post_json( "/api/users/me/insertions/confirm", &json!({ "s3_key": s3_key, "title": "Intro Jingle", "duration_ms": 1000, "file_size": TEST_MP3.len(), "mime_type": "audio/mpeg", }) .to_string(), ) .await; assert!(resp.status.is_success(), "Confirm insertion failed: {}", resp.text); let insertion: Value = resp.json(); let insertion_id = insertion["id"].as_str().unwrap().to_string(); assert_eq!(insertion["title"].as_str().unwrap(), "Intro Jingle"); // Attach as pre-roll to item let resp = h .client .post_json( &format!("/api/items/{}/insertions", item_id), &json!({ "insertion_id": insertion_id, "position": "pre_roll", "sort_order": 0, }) .to_string(), ) .await; assert!(resp.status.is_success(), "Create placement failed: {}", resp.text); // Rename insertion let resp = h .client .put_json( &format!("/api/insertions/{}", insertion_id), r#"{"title": "Updated Intro"}"#, ) .await; assert!(resp.status.is_success(), "Rename insertion failed: {}", resp.text); // Delete insertion (cascades to placements) let resp = h .client .delete(&format!("/api/insertions/{}", insertion_id)) .await; assert!(resp.status.is_success(), "Delete insertion failed: {}", resp.text); } // --------------------------------------------------------------------------- // Mid-roll placement requires offset // --------------------------------------------------------------------------- #[tokio::test] async fn midroll_placement_requires_offset() { let mut h = TestHarness::with_storage().await; let (_, _, item_id) = setup_creator(&mut h, "midroll").await; // Create a quick insertion via DB shortcut let s3_key = "midroll/insertions/clip.mp3"; h.storage.as_ref().unwrap().put(s3_key, TEST_OGG.to_vec()); let resp = h .client .post_json( "/api/users/me/insertions/presign", r#"{"file_name": "clip.ogg", "content_type": "audio/ogg"}"#, ) .await; assert!(resp.status.is_success()); let data: Value = resp.json(); let real_key = data["s3_key"].as_str().unwrap().to_string(); h.storage.as_ref().unwrap().put(&real_key, TEST_OGG.to_vec()); let resp = h .client .post_json( "/api/users/me/insertions/confirm", &json!({ "s3_key": real_key, "title": "Mid Ad", "duration_ms": 1000, "file_size": TEST_OGG.len(), "mime_type": "audio/ogg", }) .to_string(), ) .await; assert!(resp.status.is_success()); let insertion: Value = resp.json(); let insertion_id = insertion["id"].as_str().unwrap(); // Mid-roll without offset should fail let resp = h .client .post_json( &format!("/api/items/{}/insertions", item_id), &json!({ "insertion_id": insertion_id, "position": "mid_roll", "sort_order": 0, }) .to_string(), ) .await; assert!( resp.status.is_client_error(), "Mid-roll without offset should fail: {} {}", resp.status, resp.text ); // Mid-roll with offset should succeed let resp = h .client .post_json( &format!("/api/items/{}/insertions", item_id), &json!({ "insertion_id": insertion_id, "position": "mid_roll", "offset_ms": 30000, "sort_order": 0, }) .to_string(), ) .await; assert!( resp.status.is_success(), "Mid-roll with offset should succeed: {} {}", resp.status, resp.text ); }