Skip to main content

max / makenotwork

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