Skip to main content

max / makenotwork

9.1 KB · 261 lines History Blame Raw
1 //! Gallery workflow tests — item/project image galleries (launchplan S.1).
2 //!
3 //! Covers the add-only confirm (storage increment + row insert), list ordering,
4 //! delete (storage decrement + row removal), the per-entity cap, reorder, the
5 //! project-target path, and cross-user access control. Mirrors the presign→PUT
6 //! →confirm shape of `storage.rs`.
7
8 use crate::harness::TestHarness;
9 use serde_json::{json, Value};
10
11 async fn setup_creator_with_item(h: &mut TestHarness) -> (String, String, String) {
12 let setup = h.create_creator_with_item("creator", "audio", 0).await;
13 h.trust_user(setup.user_id).await;
14 h.grant_tier(setup.user_id, "small_files").await;
15 (setup.user_id.to_string(), setup.project_id, setup.item_id)
16 }
17
18 async fn storage_used(h: &TestHarness, user_id: &str) -> i64 {
19 sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid")
20 .bind(user_id)
21 .fetch_one(&h.db)
22 .await
23 .unwrap()
24 }
25
26 /// Run the full presign → PUT → confirm for one gallery image. Returns (id, s3_key).
27 async fn add_gallery_image(
28 h: &mut TestHarness,
29 target_type: &str,
30 target_id: &str,
31 file_name: &str,
32 bytes: Vec<u8>,
33 ) -> (String, String) {
34 let resp = h
35 .client
36 .post_json(
37 "/api/gallery/presign",
38 &json!({
39 "target_type": target_type,
40 "target_id": target_id,
41 "file_name": file_name,
42 "content_type": "image/png",
43 })
44 .to_string(),
45 )
46 .await;
47 assert!(resp.status.is_success(), "gallery presign failed: {}", resp.text);
48 let s3_key = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string();
49
50 h.storage.as_ref().unwrap().put(&s3_key, bytes);
51
52 let resp = h
53 .client
54 .post_json(
55 "/api/gallery/confirm",
56 &json!({
57 "target_type": target_type,
58 "target_id": target_id,
59 "s3_key": s3_key,
60 "alt": "a descriptive caption",
61 })
62 .to_string(),
63 )
64 .await;
65 assert!(resp.status.is_success(), "gallery confirm failed: {}", resp.text);
66 let data: Value = resp.json();
67 assert_eq!(data["success"], true);
68 (data["id"].as_str().unwrap().to_string(), s3_key)
69 }
70
71 #[tokio::test]
72 async fn gallery_confirm_inserts_row_and_charges_storage() {
73 let mut h = TestHarness::with_storage().await;
74 let (user_id, _, item_id) = setup_creator_with_item(&mut h).await;
75
76 let (id, s3_key) = add_gallery_image(&mut h, "item", &item_id, "shot.png", vec![0u8; 1234]).await;
77 assert!(!id.is_empty());
78
79 let (count, db_key, db_size): (i64, String, i64) = sqlx::query_as(
80 "SELECT COUNT(*)::bigint, MAX(s3_key), MAX(file_size_bytes) FROM item_images WHERE item_id = $1::uuid",
81 )
82 .bind(&item_id)
83 .fetch_one(&h.db)
84 .await
85 .unwrap();
86 assert_eq!(count, 1, "one gallery row inserted");
87 assert_eq!(db_key, s3_key);
88 assert_eq!(db_size, 1234);
89 assert_eq!(storage_used(&h, &user_id).await, 1234, "confirm charges the full size");
90 }
91
92 #[tokio::test]
93 async fn gallery_list_returns_in_insertion_order() {
94 let mut h = TestHarness::with_storage().await;
95 let (_, _, item_id) = setup_creator_with_item(&mut h).await;
96
97 let (id_a, _) = add_gallery_image(&mut h, "item", &item_id, "a.png", vec![1u8; 100]).await;
98 let (id_b, _) = add_gallery_image(&mut h, "item", &item_id, "b.png", vec![2u8; 100]).await;
99
100 let resp = h.client.get(&format!("/api/gallery/list/item/{}", item_id)).await;
101 assert!(resp.status.is_success(), "list failed: {}", resp.text);
102 let data: Value = resp.json();
103 let arr = data.as_array().unwrap();
104 assert_eq!(arr.len(), 2);
105 assert_eq!(arr[0]["id"], id_a);
106 assert_eq!(arr[1]["id"], id_b);
107 assert_eq!(arr[0]["alt"], "a descriptive caption");
108 }
109
110 #[tokio::test]
111 async fn gallery_delete_removes_row_and_decrements_storage() {
112 let mut h = TestHarness::with_storage().await;
113 let (user_id, _, item_id) = setup_creator_with_item(&mut h).await;
114
115 let (id, _) = add_gallery_image(&mut h, "item", &item_id, "shot.png", vec![0u8; 1000]).await;
116 assert_eq!(storage_used(&h, &user_id).await, 1000);
117
118 let resp = h.client.delete(&format!("/api/gallery/image/item/{}", id)).await;
119 assert!(resp.status.is_success(), "delete failed: {}", resp.text);
120
121 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM item_images WHERE item_id = $1::uuid")
122 .bind(&item_id)
123 .fetch_one(&h.db)
124 .await
125 .unwrap();
126 assert_eq!(count, 0, "row removed");
127 assert_eq!(storage_used(&h, &user_id).await, 0, "delete decrements storage");
128 }
129
130 #[tokio::test]
131 async fn gallery_cap_enforced_at_presign() {
132 let mut h = TestHarness::with_storage().await;
133 let (_, _, item_id) = setup_creator_with_item(&mut h).await;
134
135 // Seed the gallery to its cap (8) directly, then a 9th presign must 400.
136 for i in 0..8 {
137 sqlx::query(
138 "INSERT INTO item_images (item_id, s3_key, image_url, position) VALUES ($1::uuid, $2, $3, $4)",
139 )
140 .bind(&item_id)
141 .bind(format!("key-{i}"))
142 .bind(format!("http://test-storage/key-{i}"))
143 .bind(i)
144 .execute(&h.db)
145 .await
146 .unwrap();
147 }
148
149 let resp = h
150 .client
151 .post_json(
152 "/api/gallery/presign",
153 &json!({
154 "target_type": "item",
155 "target_id": item_id,
156 "file_name": "ninth.png",
157 "content_type": "image/png",
158 })
159 .to_string(),
160 )
161 .await;
162 assert_eq!(resp.status.as_u16(), 400, "9th presign over cap must be rejected: {}", resp.text);
163 }
164
165 #[tokio::test]
166 async fn gallery_reorder_updates_positions() {
167 let mut h = TestHarness::with_storage().await;
168 let (_, _, item_id) = setup_creator_with_item(&mut h).await;
169
170 let (id_a, _) = add_gallery_image(&mut h, "item", &item_id, "a.png", vec![1u8; 100]).await;
171 let (id_b, _) = add_gallery_image(&mut h, "item", &item_id, "b.png", vec![2u8; 100]).await;
172
173 let resp = h
174 .client
175 .post_json(
176 "/api/gallery/reorder",
177 &json!({
178 "target_type": "item",
179 "target_id": item_id,
180 "ordered_ids": [id_b, id_a],
181 })
182 .to_string(),
183 )
184 .await;
185 assert!(resp.status.is_success(), "reorder failed: {}", resp.text);
186
187 let resp = h.client.get(&format!("/api/gallery/list/item/{}", item_id)).await;
188 let data: Value = resp.json();
189 let arr = data.as_array().unwrap();
190 assert_eq!(arr[0]["id"], id_b, "b is now first");
191 assert_eq!(arr[1]["id"], id_a, "a is now second");
192 }
193
194 #[tokio::test]
195 async fn gallery_project_target_inserts_row() {
196 let mut h = TestHarness::with_storage().await;
197 let (user_id, project_id, _) = setup_creator_with_item(&mut h).await;
198
199 let (_, s3_key) = add_gallery_image(&mut h, "project", &project_id, "banner.png", vec![0u8; 500]).await;
200
201 let (count, db_key): (i64, String) = sqlx::query_as(
202 "SELECT COUNT(*)::bigint, MAX(s3_key) FROM project_images WHERE project_id = $1::uuid",
203 )
204 .bind(&project_id)
205 .fetch_one(&h.db)
206 .await
207 .unwrap();
208 assert_eq!(count, 1);
209 assert_eq!(db_key, s3_key);
210 assert!(s3_key.starts_with(&format!("projects/{}/gallery/", project_id)));
211 assert_eq!(storage_used(&h, &user_id).await, 500);
212 }
213
214 #[tokio::test]
215 async fn gallery_presign_non_owner_forbidden() {
216 let mut h = TestHarness::with_storage().await;
217 let (_, _, item_id) = setup_creator_with_item(&mut h).await;
218
219 h.client.post_form("/logout", "").await;
220 h.signup("intruder", "intruder@test.com", "password123").await;
221 h.login("intruder", "password123").await;
222
223 let resp = h
224 .client
225 .post_json(
226 "/api/gallery/presign",
227 &json!({
228 "target_type": "item",
229 "target_id": item_id,
230 "file_name": "evil.png",
231 "content_type": "image/png",
232 })
233 .to_string(),
234 )
235 .await;
236 assert_eq!(resp.status.as_u16(), 403, "non-owner presign must be 403: {}", resp.text);
237 }
238
239 #[tokio::test]
240 async fn gallery_delete_non_owner_does_not_remove() {
241 let mut h = TestHarness::with_storage().await;
242 let (user_id, _, item_id) = setup_creator_with_item(&mut h).await;
243 let (img_id, _) = add_gallery_image(&mut h, "item", &item_id, "shot.png", vec![0u8; 1000]).await;
244
245 h.client.post_form("/logout", "").await;
246 h.signup("intruder", "intruder@test.com", "password123").await;
247 h.login("intruder", "password123").await;
248
249 let resp = h.client.delete(&format!("/api/gallery/image/item/{}", img_id)).await;
250 assert_eq!(resp.status.as_u16(), 404, "non-owner delete must be 404: {}", resp.text);
251
252 // Row + the owner's storage are untouched.
253 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM item_images WHERE item_id = $1::uuid")
254 .bind(&item_id)
255 .fetch_one(&h.db)
256 .await
257 .unwrap();
258 assert_eq!(count, 1, "image survives a non-owner delete");
259 assert_eq!(storage_used(&h, &user_id).await, 1000, "owner storage unchanged");
260 }
261