//! Content insertion workflow tests: presign, confirm, rename, delete. //! //! These tests use the in-memory storage backend. use crate::harness::TestHarness; use serde_json::Value; /// Setup: creator user logged in with small_files tier. Returns user_id. async fn setup_creator(h: &mut TestHarness) -> String { let user_id = h.create_creator("insuser").await; h.grant_tier(user_id, "small_files").await; user_id.to_string() } /// Record a pending-upload row for a test-forged key. Under scan-then-promote the /// confirm proves ownership via `pending_uploads` (a `staging/{uuid}` key has no /// user in its path), so a test that fabricates a key + object must also register /// it as if presign had, otherwise the ownership gate correctly rejects it. async fn record_pending(h: &TestHarness, user_id: &str, s3_key: &str) { sqlx::query("INSERT INTO pending_uploads (user_id, s3_key, bucket) VALUES ($1::uuid, $2, 'main') ON CONFLICT DO NOTHING") .bind(user_id) .bind(s3_key) .execute(&h.db) .await .expect("record pending upload"); } #[tokio::test] async fn presign_requires_auth() { let mut h = TestHarness::with_storage().await; let resp = h .client .post_json( "/api/users/me/insertions/presign", r#"{"file_name": "test.mp3", "content_type": "audio/mpeg"}"#, ) .await; assert_eq!( resp.status, 403, "Unauthenticated presign should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn presign_invalid_content_type_rejected() { let mut h = TestHarness::with_storage().await; let _user_id = setup_creator(&mut h).await; let resp = h .client .post_json( "/api/users/me/insertions/presign", r#"{"file_name": "evil.exe", "content_type": "application/octet-stream"}"#, ) .await; assert_eq!( resp.status, 400, "Invalid content type should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn presign_valid_audio_succeeds() { let mut h = TestHarness::with_storage().await; let _user_id = setup_creator(&mut h).await; let resp = h .client .post_json( "/api/users/me/insertions/presign", r#"{"file_name": "intro.mp3", "content_type": "audio/mpeg"}"#, ) .await; assert_eq!( resp.status, 200, "Valid presign should succeed: {} {}", resp.status, resp.text ); let body: Value = resp.json(); assert!(body["upload_url"].is_string(), "Should return upload_url"); assert!(body["s3_key"].is_string(), "Should return s3_key"); } #[tokio::test] async fn confirm_nonexistent_object_rejected() { let mut h = TestHarness::with_storage().await; let _user_id = setup_creator(&mut h).await; let resp = h .client .post_json( "/api/users/me/insertions/confirm", r#"{"s3_key": "nonexistent/key.mp3", "title": "Test", "duration_ms": 5000, "file_size": 1024, "mime_type": "audio/mpeg"}"#, ) .await; assert_eq!( resp.status, 400, "Confirming nonexistent object should fail: {} {}", resp.status, resp.text ); } #[tokio::test] async fn confirm_with_object_succeeds() { let mut h = TestHarness::with_storage().await; let user_id = setup_creator(&mut h).await; // Pre-populate storage with a fake object and record it as a pending upload // (the confirm now proves ownership via pending_uploads, not a key prefix). let s3_key = format!("{user_id}/insertions/intro.mp3"); h.storage.as_ref().unwrap().put(&s3_key, vec![0u8; 1024]); record_pending(&h, &user_id, &s3_key).await; let resp = h .client .post_json( "/api/users/me/insertions/confirm", &format!( r#"{{"s3_key": "{s3_key}", "title": "Intro Music", "duration_ms": 5000, "file_size": 1024, "mime_type": "audio/mpeg"}}"# ), ) .await; assert_eq!( resp.status, 200, "Confirm with existing object should succeed: {} {}", resp.status, resp.text ); let body: Value = resp.json(); assert_eq!(body["title"].as_str().unwrap(), "Intro Music"); } #[tokio::test] async fn confirm_empty_title_rejected() { let mut h = TestHarness::with_storage().await; let user_id = setup_creator(&mut h).await; let s3_key = format!("{user_id}/insertions/empty.mp3"); h.storage.as_ref().unwrap().put(&s3_key, vec![0u8; 1024]); let resp = h .client .post_json( "/api/users/me/insertions/confirm", &format!( r#"{{"s3_key": "{s3_key}", "title": "", "duration_ms": 5000, "file_size": 1024, "mime_type": "audio/mpeg"}}"# ), ) .await; assert_eq!( resp.status, 400, "Empty title should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn delete_nonexistent_insertion_returns_404() { let mut h = TestHarness::with_storage().await; let _user_id = setup_creator(&mut h).await; let fake_id = uuid::Uuid::new_v4(); let resp = h.client.delete(&format!("/api/insertions/{fake_id}")).await; assert_eq!( resp.status, 404, "Deleting nonexistent insertion should return 404: {} {}", resp.status, resp.text ); } #[tokio::test] async fn rename_nonexistent_insertion_returns_404() { let mut h = TestHarness::with_storage().await; let _user_id = setup_creator(&mut h).await; let fake_id = uuid::Uuid::new_v4(); let resp = h .client .put_json( &format!("/api/insertions/{fake_id}"), r#"{"title": "New Name"}"#, ) .await; assert_eq!( resp.status, 404, "Renaming nonexistent insertion should return 404: {} {}", resp.status, resp.text ); } // ── Video clips ── #[tokio::test] async fn presign_valid_video_succeeds() { let mut h = TestHarness::with_storage().await; let _user_id = setup_creator(&mut h).await; let resp = h .client .post_json( "/api/users/me/insertions/presign", r#"{"file_name": "bumper.mp4", "content_type": "video/mp4"}"#, ) .await; assert_eq!( resp.status, 200, "Valid video presign should succeed: {} {}", resp.status, resp.text ); } /// Confirm an insertion clip of the given kind and return its JSON body. /// Uses placeholder bytes: confirm validates size/mime/title synchronously and /// only sniffs magic bytes later in the async scan worker. async fn confirm_clip(h: &mut TestHarness, user_id: &str, file: &str, mime: &str) -> Value { let s3_key = format!("{user_id}/insertions/{file}"); h.storage.as_ref().unwrap().put(&s3_key, vec![0u8; 1024]); record_pending(h, user_id, &s3_key).await; let resp = h .client .post_json( "/api/users/me/insertions/confirm", &format!( r#"{{"s3_key": "{s3_key}", "title": "Clip", "duration_ms": 3000, "file_size": 1024, "mime_type": "{mime}"}}"# ), ) .await; assert_eq!( resp.status, 200, "Confirm {} should succeed: {} {}", file, resp.status, resp.text ); resp.json() } #[tokio::test] async fn confirm_video_persists_video_media_type() { let mut h = TestHarness::with_storage().await; let user_id = setup_creator(&mut h).await; let body = confirm_clip(&mut h, &user_id, "bumper.mp4", "video/mp4").await; assert_eq!( body["media_type"].as_str().unwrap(), "video", "A video/mp4 clip must persist media_type=video, not the legacy audio default" ); } #[tokio::test] async fn confirm_audio_persists_audio_media_type() { let mut h = TestHarness::with_storage().await; let user_id = setup_creator(&mut h).await; let body = confirm_clip(&mut h, &user_id, "intro.mp3", "audio/mpeg").await; assert_eq!(body["media_type"].as_str().unwrap(), "audio"); } /// POST a placement of `insertion_id` on `item_id` as a pre-roll. async fn place_pre_roll(h: &mut TestHarness, item_id: &str, insertion_id: &str) -> u16 { let resp = h .client .post_json( &format!("/api/items/{item_id}/insertions"), &format!( r#"{{"insertion_id": "{insertion_id}", "position": "pre_roll", "sort_order": 0}}"# ), ) .await; resp.status.as_u16() } #[tokio::test] async fn video_clip_rejected_on_audio_item() { let mut h = TestHarness::with_storage().await; let setup = h.create_creator_with_item("vidonaud", "audio", 0).await; h.grant_tier(setup.user_id, "small_files").await; let user_id = setup.user_id.to_string(); let clip = confirm_clip(&mut h, &user_id, "bumper.mp4", "video/mp4").await; let insertion_id = clip["id"].as_str().unwrap().to_string(); let status = place_pre_roll(&mut h, &setup.item_id, &insertion_id).await; assert_eq!( status, 400, "A video clip must not be placeable on an audio item" ); } #[tokio::test] async fn video_clip_allowed_on_video_item() { let mut h = TestHarness::with_storage().await; let setup = h.create_creator_with_item("vidonvid", "video", 0).await; h.grant_tier(setup.user_id, "small_files").await; let user_id = setup.user_id.to_string(); let clip = confirm_clip(&mut h, &user_id, "bumper.mp4", "video/mp4").await; let insertion_id = clip["id"].as_str().unwrap().to_string(); let status = place_pre_roll(&mut h, &setup.item_id, &insertion_id).await; assert!( (200..300).contains(&status), "A video clip must be placeable on a video item, got {status}" ); } #[tokio::test] async fn audio_clip_allowed_on_video_item() { let mut h = TestHarness::with_storage().await; let setup = h.create_creator_with_item("audonvid", "video", 0).await; h.grant_tier(setup.user_id, "small_files").await; let user_id = setup.user_id.to_string(); let clip = confirm_clip(&mut h, &user_id, "intro.mp3", "audio/mpeg").await; let insertion_id = clip["id"].as_str().unwrap().to_string(); let status = place_pre_roll(&mut h, &setup.item_id, &insertion_id).await; assert!( (200..300).contains(&status), "An audio clip must be placeable on a video item, got {status}" ); } #[tokio::test] async fn clip_rejected_on_non_media_item() { let mut h = TestHarness::with_storage().await; let setup = h.create_creator_with_item("cliponwtext", "text", 0).await; h.grant_tier(setup.user_id, "small_files").await; let user_id = setup.user_id.to_string(); let clip = confirm_clip(&mut h, &user_id, "intro.mp3", "audio/mpeg").await; let insertion_id = clip["id"].as_str().unwrap().to_string(); let status = place_pre_roll(&mut h, &setup.item_id, &insertion_id).await; assert_eq!( status, 400, "Clips must not be placeable on a non-media (text) item" ); }