Skip to main content

max / makenotwork

10.3 KB · 349 lines History Blame Raw
1 //! Versions: create, is_current toggling, ordering, ownership, validation, draft visibility.
2
3 use crate::harness::TestHarness;
4 use serde_json::Value;
5
6 /// Helper: create a creator with a project and a digital item, return (project_id, item_id).
7 async fn setup_creator_with_digital_item(
8 h: &mut TestHarness,
9 username: &str,
10 _email: &str,
11 ) -> (String, String) {
12 let setup = h.create_creator_with_item(username, "digital", 0).await;
13 (setup.project_id, setup.item_id)
14 }
15
16 #[tokio::test]
17 async fn version_create_and_is_current() {
18 let mut h = TestHarness::new().await;
19 let (_project_id, item_id) =
20 setup_creator_with_digital_item(&mut h, "vcreator", "vcreator@test.com").await;
21
22 // Create v1
23 let resp = h
24 .client
25 .post_json(
26 &format!("/api/items/{item_id}/versions"),
27 r#"{"version_number": "1.0.0", "changelog": "Initial release"}"#,
28 )
29 .await;
30 assert!(
31 resp.status.is_success(),
32 "Create v1 failed: {} {}",
33 resp.status,
34 resp.text
35 );
36 let v1: Value = resp.json();
37 let v1_id = v1["id"].as_str().unwrap().to_string();
38 assert!(
39 v1["is_current"].as_bool().unwrap(),
40 "v1 should be current on creation"
41 );
42
43 // Create v2
44 let resp = h
45 .client
46 .post_json(
47 &format!("/api/items/{item_id}/versions"),
48 r#"{"version_number": "2.0.0", "changelog": "Major update"}"#,
49 )
50 .await;
51 assert!(
52 resp.status.is_success(),
53 "Create v2 failed: {} {}",
54 resp.status,
55 resp.text
56 );
57 let v2: Value = resp.json();
58 assert!(v2["is_current"].as_bool().unwrap(), "v2 should be current");
59
60 // Verify v1 is no longer current
61 let v1_current: bool = sqlx::query_scalar("SELECT is_current FROM versions WHERE id = $1")
62 .bind(v1_id.parse::<uuid::Uuid>().unwrap())
63 .fetch_one(&h.db)
64 .await
65 .unwrap();
66 assert!(
67 !v1_current,
68 "v1 should no longer be current after v2 was created"
69 );
70 }
71
72 #[tokio::test]
73 async fn version_list_newest_first() {
74 let mut h = TestHarness::new().await;
75 let (project_id, item_id) =
76 setup_creator_with_digital_item(&mut h, "vlist", "vlist@test.com").await;
77
78 // Create 3 versions
79 for ver in ["1.0.0", "1.1.0", "2.0.0"] {
80 let body = format!(r#"{{"version_number": "{ver}"}}"#);
81 let resp = h
82 .client
83 .post_json(&format!("/api/items/{item_id}/versions"), &body)
84 .await;
85 assert!(
86 resp.status.is_success(),
87 "Create version {ver} failed: {} {}",
88 resp.status,
89 resp.text
90 );
91 }
92
93 // Make item public so list endpoint works
94 h.client
95 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
96 .await;
97 h.client
98 .put_json(
99 &format!("/api/projects/{project_id}"),
100 r#"{"is_public": true}"#,
101 )
102 .await;
103
104 // List versions, newest first (ORDER BY created_at DESC)
105 let resp = h
106 .client
107 .get(&format!("/api/items/{item_id}/versions"))
108 .await;
109 assert!(
110 resp.status.is_success(),
111 "List versions failed: {} {}",
112 resp.status,
113 resp.text
114 );
115 let list: Value = resp.json();
116 let data = list["data"].as_array().unwrap();
117 assert_eq!(data.len(), 3);
118 assert_eq!(data[0]["version_number"].as_str().unwrap(), "2.0.0");
119 assert_eq!(data[1]["version_number"].as_str().unwrap(), "1.1.0");
120 assert_eq!(data[2]["version_number"].as_str().unwrap(), "1.0.0");
121 }
122
123 #[tokio::test]
124 async fn version_ownership_enforced() {
125 let mut h = TestHarness::new().await;
126 let (_project_id, item_id) =
127 setup_creator_with_digital_item(&mut h, "vowner", "vowner@test.com").await;
128
129 // Switch to creator B
130 h.client.post_form("/logout", "").await;
131 let b_id = h
132 .signup("vintruder", "vintruder@test.com", "password123")
133 .await;
134 h.grant_creator(b_id).await;
135 h.client.post_form("/logout", "").await;
136 h.login("vintruder", "password123").await;
137
138 // Creator B tries to create a version on A's item
139 let resp = h
140 .client
141 .post_json(
142 &format!("/api/items/{item_id}/versions"),
143 r#"{"version_number": "9.9.9"}"#,
144 )
145 .await;
146 assert_eq!(
147 resp.status, 403,
148 "Non-owner should get 403 on POST version, got {} {}",
149 resp.status, resp.text
150 );
151 }
152
153 #[tokio::test]
154 async fn version_validation() {
155 let mut h = TestHarness::new().await;
156 let (_project_id, item_id) =
157 setup_creator_with_digital_item(&mut h, "vvalid", "vvalid@test.com").await;
158
159 // Empty version_number
160 let resp = h
161 .client
162 .post_json(
163 &format!("/api/items/{item_id}/versions"),
164 r#"{"version_number": ""}"#,
165 )
166 .await;
167 assert!(
168 resp.status == 400 || resp.status == 422,
169 "Empty version_number should be rejected, got {} {}",
170 resp.status,
171 resp.text
172 );
173 }
174
175 #[tokio::test]
176 async fn list_versions_requires_public_item() {
177 let mut h = TestHarness::new().await;
178 let (_project_id, item_id) =
179 setup_creator_with_digital_item(&mut h, "vdraft", "vdraft@test.com").await;
180
181 // Create a version, then make item non-public
182 let resp = h
183 .client
184 .post_json(
185 &format!("/api/items/{item_id}/versions"),
186 r#"{"version_number": "0.1.0"}"#,
187 )
188 .await;
189 assert!(resp.status.is_success());
190
191 // Mark item as draft (not public)
192 h.client
193 .put_form(&format!("/api/items/{item_id}"), "is_public=false")
194 .await;
195
196 // Logout, unauthenticated user tries to list versions of draft item
197 h.client.post_form("/logout", "").await;
198 h.client.fetch_csrf_token().await;
199
200 let resp = h
201 .client
202 .get(&format!("/api/items/{item_id}/versions"))
203 .await;
204 assert_eq!(
205 resp.status, 404,
206 "Draft item versions should return 404, got {} {}",
207 resp.status, resp.text
208 );
209 }
210
211 #[tokio::test]
212 async fn version_changelog_too_long() {
213 let mut h = TestHarness::new().await;
214 let (_project_id, item_id) =
215 setup_creator_with_digital_item(&mut h, "vchangelog", "vchangelog@test.com").await;
216
217 // changelog > 10,000 chars
218 let long_changelog = "x".repeat(10_001);
219 let body = serde_json::json!({
220 "version_number": "1.0.0",
221 "changelog": long_changelog,
222 });
223 let resp = h
224 .client
225 .post_json(&format!("/api/items/{item_id}/versions"), &body.to_string())
226 .await;
227 assert!(
228 resp.status == 400 || resp.status == 422,
229 "Changelog >10000 chars should be rejected, got {} {}",
230 resp.status,
231 resp.text
232 );
233 }
234
235 #[tokio::test]
236 async fn version_number_too_long() {
237 let mut h = TestHarness::new().await;
238 let (_project_id, item_id) =
239 setup_creator_with_digital_item(&mut h, "vlong", "vlong@test.com").await;
240
241 // version_number > 50 chars
242 let long_ver = "v".repeat(51);
243 let body = serde_json::json!({
244 "version_number": long_ver,
245 });
246 let resp = h
247 .client
248 .post_json(&format!("/api/items/{item_id}/versions"), &body.to_string())
249 .await;
250 assert!(
251 resp.status == 400 || resp.status == 422,
252 "Version number >50 chars should be rejected, got {} {}",
253 resp.status,
254 resp.text
255 );
256 }
257
258 #[tokio::test]
259 async fn version_number_boundary_succeeds() {
260 let mut h = TestHarness::new().await;
261 let (_project_id, item_id) =
262 setup_creator_with_digital_item(&mut h, "vbound", "vbound@test.com").await;
263
264 // Exactly 50 chars, should succeed
265 let ver_50 = "v".repeat(50);
266 let body = serde_json::json!({
267 "version_number": ver_50,
268 });
269 let resp = h
270 .client
271 .post_json(&format!("/api/items/{item_id}/versions"), &body.to_string())
272 .await;
273 assert!(
274 resp.status.is_success(),
275 "50-char version number should be accepted, got {} {}",
276 resp.status,
277 resp.text
278 );
279 let version: Value = resp.json();
280 assert_eq!(version["version_number"].as_str().unwrap(), ver_50);
281 }
282
283 #[tokio::test]
284 async fn version_optional_fields_preserved() {
285 let mut h = TestHarness::new().await;
286 let (_project_id, item_id) =
287 setup_creator_with_digital_item(&mut h, "vfields", "vfields@test.com").await;
288
289 let body = serde_json::json!({
290 "version_number": "3.0.0",
291 "changelog": "Added widgets",
292 "file_url": "https://example.com/app-3.0.0.zip",
293 "file_size_bytes": 1_048_576_i64,
294 "file_name": "app-3.0.0.zip",
295 });
296 let resp = h
297 .client
298 .post_json(&format!("/api/items/{item_id}/versions"), &body.to_string())
299 .await;
300 assert!(
301 resp.status.is_success(),
302 "Create version with optional fields failed: {} {}",
303 resp.status,
304 resp.text
305 );
306 let version: Value = resp.json();
307 let version_id = version["id"].as_str().unwrap();
308
309 // Verify fields in response
310 assert_eq!(version["changelog"].as_str().unwrap(), "Added widgets");
311 assert_eq!(
312 version["file_url"].as_str().unwrap(),
313 "https://example.com/app-3.0.0.zip"
314 );
315
316 // Verify file_size_bytes and file_name via direct SQL (not in VersionResponse)
317 let (file_size, file_name): (Option<i64>, Option<String>) =
318 sqlx::query_as("SELECT file_size_bytes, file_name FROM versions WHERE id = $1")
319 .bind(version_id.parse::<uuid::Uuid>().unwrap())
320 .fetch_one(&h.db)
321 .await
322 .unwrap();
323 assert_eq!(file_size, Some(1_048_576));
324 assert_eq!(file_name.as_deref(), Some("app-3.0.0.zip"));
325 }
326
327 #[tokio::test]
328 async fn version_unauthenticated_rejected() {
329 let mut h = TestHarness::new().await;
330 let (_project_id, item_id) =
331 setup_creator_with_digital_item(&mut h, "vunauth", "vunauth@test.com").await;
332
333 h.client.post_form("/logout", "").await;
334 h.client.fetch_csrf_token().await;
335
336 let resp = h
337 .client
338 .post_json(
339 &format!("/api/items/{item_id}/versions"),
340 r#"{"version_number": "1.0.0"}"#,
341 )
342 .await;
343 assert_eq!(
344 resp.status, 401,
345 "Unauthenticated POST version should be 401, got {} {}",
346 resp.status, resp.text
347 );
348 }
349