Skip to main content

max / makenotwork

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