//! Gallery workflow tests — item/project image galleries (launchplan S.1). //! //! Covers the add-only confirm (storage increment + row insert), list ordering, //! delete (storage decrement + row removal), the per-entity cap, reorder, the //! project-target path, and cross-user access control. Mirrors the presign→PUT //! →confirm shape of `storage.rs`. use crate::harness::TestHarness; use serde_json::{json, Value}; async fn setup_creator_with_item(h: &mut TestHarness) -> (String, String, String) { let setup = h.create_creator_with_item("creator", "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) } async fn storage_used(h: &TestHarness, user_id: &str) -> i64 { sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid") .bind(user_id) .fetch_one(&h.db) .await .unwrap() } /// Run the full presign → PUT → confirm for one gallery image. Returns (id, s3_key). async fn add_gallery_image( h: &mut TestHarness, target_type: &str, target_id: &str, file_name: &str, bytes: Vec, ) -> (String, String) { let resp = h .client .post_json( "/api/gallery/presign", &json!({ "target_type": target_type, "target_id": target_id, "file_name": file_name, "content_type": "image/png", }) .to_string(), ) .await; assert!(resp.status.is_success(), "gallery presign failed: {}", resp.text); let s3_key = resp.json::()["s3_key"].as_str().unwrap().to_string(); h.storage.as_ref().unwrap().put(&s3_key, bytes); let resp = h .client .post_json( "/api/gallery/confirm", &json!({ "target_type": target_type, "target_id": target_id, "s3_key": s3_key, "alt": "a descriptive caption", }) .to_string(), ) .await; assert!(resp.status.is_success(), "gallery confirm failed: {}", resp.text); let data: Value = resp.json(); assert_eq!(data["success"], true); (data["id"].as_str().unwrap().to_string(), s3_key) } #[tokio::test] async fn gallery_confirm_inserts_row_and_charges_storage() { let mut h = TestHarness::with_storage().await; let (user_id, _, item_id) = setup_creator_with_item(&mut h).await; let (id, s3_key) = add_gallery_image(&mut h, "item", &item_id, "shot.png", vec![0u8; 1234]).await; assert!(!id.is_empty()); let (count, db_key, db_size): (i64, String, i64) = sqlx::query_as( "SELECT COUNT(*)::bigint, MAX(s3_key), MAX(file_size_bytes) FROM item_images WHERE item_id = $1::uuid", ) .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1, "one gallery row inserted"); assert_eq!(db_key, s3_key); assert_eq!(db_size, 1234); assert_eq!(storage_used(&h, &user_id).await, 1234, "confirm charges the full size"); } #[tokio::test] async fn gallery_list_returns_in_insertion_order() { let mut h = TestHarness::with_storage().await; let (_, _, item_id) = setup_creator_with_item(&mut h).await; let (id_a, _) = add_gallery_image(&mut h, "item", &item_id, "a.png", vec![1u8; 100]).await; let (id_b, _) = add_gallery_image(&mut h, "item", &item_id, "b.png", vec![2u8; 100]).await; let resp = h.client.get(&format!("/api/gallery/list/item/{}", item_id)).await; assert!(resp.status.is_success(), "list failed: {}", resp.text); let data: Value = resp.json(); let arr = data.as_array().unwrap(); assert_eq!(arr.len(), 2); assert_eq!(arr[0]["id"], id_a); assert_eq!(arr[1]["id"], id_b); assert_eq!(arr[0]["alt"], "a descriptive caption"); } #[tokio::test] async fn gallery_delete_removes_row_and_decrements_storage() { let mut h = TestHarness::with_storage().await; let (user_id, _, item_id) = setup_creator_with_item(&mut h).await; let (id, _) = add_gallery_image(&mut h, "item", &item_id, "shot.png", vec![0u8; 1000]).await; assert_eq!(storage_used(&h, &user_id).await, 1000); let resp = h.client.delete(&format!("/api/gallery/image/item/{}", id)).await; assert!(resp.status.is_success(), "delete failed: {}", resp.text); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM item_images WHERE item_id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 0, "row removed"); assert_eq!(storage_used(&h, &user_id).await, 0, "delete decrements storage"); } #[tokio::test] async fn gallery_cap_enforced_at_presign() { let mut h = TestHarness::with_storage().await; let (_, _, item_id) = setup_creator_with_item(&mut h).await; // Seed the gallery to its cap (8) directly, then a 9th presign must 400. for i in 0..8 { sqlx::query( "INSERT INTO item_images (item_id, s3_key, image_url, position) VALUES ($1::uuid, $2, $3, $4)", ) .bind(&item_id) .bind(format!("key-{i}")) .bind(format!("http://test-storage/key-{i}")) .bind(i) .execute(&h.db) .await .unwrap(); } let resp = h .client .post_json( "/api/gallery/presign", &json!({ "target_type": "item", "target_id": item_id, "file_name": "ninth.png", "content_type": "image/png", }) .to_string(), ) .await; assert_eq!(resp.status.as_u16(), 400, "9th presign over cap must be rejected: {}", resp.text); } #[tokio::test] async fn gallery_reorder_updates_positions() { let mut h = TestHarness::with_storage().await; let (_, _, item_id) = setup_creator_with_item(&mut h).await; let (id_a, _) = add_gallery_image(&mut h, "item", &item_id, "a.png", vec![1u8; 100]).await; let (id_b, _) = add_gallery_image(&mut h, "item", &item_id, "b.png", vec![2u8; 100]).await; let resp = h .client .post_json( "/api/gallery/reorder", &json!({ "target_type": "item", "target_id": item_id, "ordered_ids": [id_b, id_a], }) .to_string(), ) .await; assert!(resp.status.is_success(), "reorder failed: {}", resp.text); let resp = h.client.get(&format!("/api/gallery/list/item/{}", item_id)).await; let data: Value = resp.json(); let arr = data.as_array().unwrap(); assert_eq!(arr[0]["id"], id_b, "b is now first"); assert_eq!(arr[1]["id"], id_a, "a is now second"); } #[tokio::test] async fn gallery_project_target_inserts_row() { let mut h = TestHarness::with_storage().await; let (user_id, project_id, _) = setup_creator_with_item(&mut h).await; let (_, s3_key) = add_gallery_image(&mut h, "project", &project_id, "banner.png", vec![0u8; 500]).await; let (count, db_key): (i64, String) = sqlx::query_as( "SELECT COUNT(*)::bigint, MAX(s3_key) FROM project_images WHERE project_id = $1::uuid", ) .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1); assert_eq!(db_key, s3_key); assert!(s3_key.starts_with(&format!("projects/{}/gallery/", project_id))); assert_eq!(storage_used(&h, &user_id).await, 500); } #[tokio::test] async fn gallery_presign_non_owner_forbidden() { let mut h = TestHarness::with_storage().await; let (_, _, item_id) = setup_creator_with_item(&mut h).await; h.client.post_form("/logout", "").await; h.signup("intruder", "intruder@test.com", "password123").await; h.login("intruder", "password123").await; let resp = h .client .post_json( "/api/gallery/presign", &json!({ "target_type": "item", "target_id": item_id, "file_name": "evil.png", "content_type": "image/png", }) .to_string(), ) .await; assert_eq!(resp.status.as_u16(), 403, "non-owner presign must be 403: {}", resp.text); } #[tokio::test] async fn gallery_delete_non_owner_does_not_remove() { let mut h = TestHarness::with_storage().await; let (user_id, _, item_id) = setup_creator_with_item(&mut h).await; let (img_id, _) = add_gallery_image(&mut h, "item", &item_id, "shot.png", vec![0u8; 1000]).await; h.client.post_form("/logout", "").await; h.signup("intruder", "intruder@test.com", "password123").await; h.login("intruder", "password123").await; let resp = h.client.delete(&format!("/api/gallery/image/item/{}", img_id)).await; assert_eq!(resp.status.as_u16(), 404, "non-owner delete must be 404: {}", resp.text); // Row + the owner's storage are untouched. let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM item_images WHERE item_id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1, "image survives a non-owner delete"); assert_eq!(storage_used(&h, &user_id).await, 1000, "owner storage unchanged"); }