Skip to main content

max / makenotwork

7.2 KB · 256 lines History Blame Raw
1 //! Project management workflow tests, CRUD, update, delete cascade.
2
3 use crate::harness::TestHarness;
4 use serde_json::Value;
5
6 /// Helper: create a creator and return user_id.
7 async fn setup_creator(h: &mut TestHarness, username: &str) -> String {
8 h.create_creator(username).await.to_string()
9 }
10
11 #[tokio::test]
12 async fn create_project_returns_slug() {
13 let mut h = TestHarness::new().await;
14 setup_creator(&mut h, "projcreate").await;
15
16 let resp = h
17 .client
18 .post_form("/api/projects", "slug=my-cool-project&title=Cool+Project")
19 .await;
20 assert!(
21 resp.status.is_success(),
22 "Create project failed: {}",
23 resp.text
24 );
25 let project: Value = resp.json();
26 assert_eq!(project["slug"].as_str().unwrap(), "my-cool-project");
27 assert_eq!(project["title"].as_str().unwrap(), "Cool Project");
28 }
29
30 #[tokio::test]
31 async fn create_project_requires_creator() {
32 let mut h = TestHarness::new().await;
33 let _user_id = h
34 .signup("projnoauth", "projnoauth@test.com", "password123")
35 .await;
36
37 let resp = h
38 .client
39 .post_form("/api/projects", "slug=blocked&title=Blocked")
40 .await;
41 assert!(
42 resp.status.is_client_error(),
43 "Non-creator should be rejected: {} {}",
44 resp.status,
45 resp.text
46 );
47 }
48
49 #[tokio::test]
50 async fn update_project_title_and_description() {
51 let mut h = TestHarness::new().await;
52 setup_creator(&mut h, "projupdate").await;
53
54 let resp = h
55 .client
56 .post_form("/api/projects", "slug=updatable&title=Original")
57 .await;
58 let project: Value = resp.json();
59 let project_id = project["id"].as_str().unwrap();
60
61 let resp = h
62 .client
63 .put_json(
64 &format!("/api/projects/{project_id}"),
65 r#"{"title": "Updated Title", "description": "A new description"}"#,
66 )
67 .await;
68 assert!(
69 resp.status.is_success(),
70 "Update project failed: {} {}",
71 resp.status,
72 resp.text
73 );
74
75 // Verify in DB
76 let (title, desc): (String, Option<String>) =
77 sqlx::query_as("SELECT title, description FROM projects WHERE id = $1::uuid")
78 .bind(project_id)
79 .fetch_one(&h.db)
80 .await
81 .unwrap();
82 assert_eq!(title, "Updated Title");
83 assert_eq!(desc.as_deref(), Some("A new description"));
84 }
85
86 #[tokio::test]
87 async fn update_project_non_owner_rejected() {
88 let mut h = TestHarness::new().await;
89 setup_creator(&mut h, "projown").await;
90
91 let resp = h
92 .client
93 .post_form("/api/projects", "slug=owned-proj&title=Owned")
94 .await;
95 let project: Value = resp.json();
96 let project_id = project["id"].as_str().unwrap().to_string();
97
98 // Switch to different creator
99 h.client.post_form("/logout", "").await;
100 setup_creator(&mut h, "projintruder").await;
101
102 let resp = h
103 .client
104 .put_json(
105 &format!("/api/projects/{project_id}"),
106 r#"{"title": "Hacked"}"#,
107 )
108 .await;
109 assert_eq!(
110 resp.status, 403,
111 "Non-owner update should be 403: {}",
112 resp.text
113 );
114 }
115
116 #[tokio::test]
117 async fn delete_project_cascades_to_items() {
118 let mut h = TestHarness::new().await;
119 setup_creator(&mut h, "projdel").await;
120
121 let resp = h
122 .client
123 .post_form("/api/projects", "slug=deleteme&title=Delete+Me")
124 .await;
125 let project: Value = resp.json();
126 let project_id = project["id"].as_str().unwrap().to_string();
127
128 // Add items
129 for i in 0..3 {
130 let resp = h
131 .client
132 .post_form(
133 &format!("/api/projects/{project_id}/items"),
134 &format!("title=Item+{i}"),
135 )
136 .await;
137 assert!(resp.status.is_success());
138 }
139
140 // Delete project
141 let resp = h
142 .client
143 .delete(&format!("/api/projects/{project_id}"))
144 .await;
145 assert!(
146 resp.status.is_success(),
147 "Delete project failed: {} {}",
148 resp.status,
149 resp.text
150 );
151
152 // Verify project and items are gone
153 let proj_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM projects WHERE id = $1::uuid")
154 .bind(&project_id)
155 .fetch_one(&h.db)
156 .await
157 .unwrap();
158 assert_eq!(proj_count, 0, "Project should be deleted");
159
160 let item_count: i64 =
161 sqlx::query_scalar("SELECT COUNT(*) FROM items WHERE project_id = $1::uuid")
162 .bind(&project_id)
163 .fetch_one(&h.db)
164 .await
165 .unwrap();
166 assert_eq!(item_count, 0, "Items should be cascade-deleted");
167 }
168
169 #[tokio::test]
170 async fn delete_project_non_owner_rejected() {
171 let mut h = TestHarness::new().await;
172 setup_creator(&mut h, "projdelown").await;
173
174 let resp = h
175 .client
176 .post_form("/api/projects", "slug=nodelete&title=No+Delete")
177 .await;
178 let project: Value = resp.json();
179 let project_id = project["id"].as_str().unwrap().to_string();
180
181 // Switch user
182 h.client.post_form("/logout", "").await;
183 setup_creator(&mut h, "projdelother").await;
184
185 let resp = h
186 .client
187 .delete(&format!("/api/projects/{project_id}"))
188 .await;
189 assert_eq!(
190 resp.status, 403,
191 "Non-owner delete should be 403: {}",
192 resp.text
193 );
194 }
195
196 #[tokio::test]
197 async fn duplicate_slug_auto_suffixed() {
198 let mut h = TestHarness::new().await;
199 setup_creator(&mut h, "projslug").await;
200
201 let resp = h
202 .client
203 .post_form("/api/projects", "slug=unique-slug&title=First")
204 .await;
205 assert!(resp.status.is_success());
206 let first: serde_json::Value = resp.json();
207 assert_eq!(first["slug"], "unique-slug");
208
209 // A second project with the same slug does NOT 500 or overwrite: the
210 // create path routes through `insert_with_unique_slug`, which auto-suffixes
211 // the collision (`unique-slug` -> `unique-slug-2`) and retries. This is the
212 // deliberate Run 2 UX seal against slug-dedup drift.
213 let resp = h
214 .client
215 .post_form("/api/projects", "slug=unique-slug&title=Second")
216 .await;
217 assert!(
218 resp.status.is_success(),
219 "Duplicate slug should auto-suffix, not fail: {} {}",
220 resp.status,
221 resp.text
222 );
223 let second: serde_json::Value = resp.json();
224 assert_eq!(
225 second["slug"], "unique-slug-2",
226 "collision must auto-suffix to unique-slug-2: {second}"
227 );
228 }
229
230 #[tokio::test]
231 async fn project_with_category() {
232 let mut h = TestHarness::new().await;
233 setup_creator(&mut h, "projcat").await;
234
235 let resp = h
236 .client
237 .post_form(
238 "/api/projects",
239 "slug=music-proj&title=Music+Project&category=Music",
240 )
241 .await;
242 assert!(
243 resp.status.is_success(),
244 "Create project with category failed: {}",
245 resp.text
246 );
247
248 // Verify category was set
249 let category: Option<String> =
250 sqlx::query_scalar("SELECT c.name FROM projects p JOIN project_categories c ON p.category_id = c.id WHERE p.slug = 'music-proj'")
251 .fetch_optional(&h.db)
252 .await
253 .unwrap();
254 assert_eq!(category.as_deref(), Some("Music"), "Category should be set");
255 }
256