Skip to main content

max / makenotwork

12.5 KB · 408 lines History Blame Raw
1 //! Git-project linking via the git_repos table.
2
3 use crate::harness::TestHarness;
4 use makenotwork::db::UserId;
5 use serde_json::Value;
6
7 /// Helper: create a creator with a project, return (user_id, project_id).
8 async fn setup_creator_with_project(
9 h: &mut TestHarness,
10 username: &str,
11 slug: &str,
12 title: &str,
13 ) -> (UserId, String) {
14 let user_id = h
15 .signup(username, &format!("{username}@example.com"), "password123")
16 .await;
17 h.grant_creator(user_id).await;
18 h.client.post_form("/logout", "").await;
19 h.login(username, "password123").await;
20
21 let resp = h
22 .client
23 .post_form(
24 "/api/projects",
25 &format!("slug={}&title={}", slug, title.replace(' ', "+")),
26 )
27 .await;
28 assert!(
29 resp.status.is_success(),
30 "Create project failed: {}",
31 resp.text
32 );
33 let project: Value = resp.json();
34 let project_id = project["id"].as_str().unwrap().to_string();
35
36 (user_id, project_id)
37 }
38
39 /// DB round-trip: create a git_repos row, look up by user+name, link/unlink project.
40 #[tokio::test]
41 async fn git_repo_db_round_trip() {
42 let mut h = TestHarness::new().await;
43 let (user_id, project_id) =
44 setup_creator_with_project(&mut h, "gituser3", "proj3", "Project Three").await;
45 let project_uuid = project_id.parse::<uuid::Uuid>().unwrap();
46
47 // Initially no repo
48 let found: Option<(uuid::Uuid,)> =
49 sqlx::query_as("SELECT id FROM git_repos WHERE user_id = $1 AND name = $2")
50 .bind(uuid::Uuid::from(user_id))
51 .bind("my-repo")
52 .fetch_optional(&h.db)
53 .await
54 .unwrap();
55 assert!(found.is_none(), "Should find nothing before creating");
56
57 // Create repo
58 sqlx::query("INSERT INTO git_repos (user_id, name) VALUES ($1, $2)")
59 .bind(uuid::Uuid::from(user_id))
60 .bind("my-repo")
61 .execute(&h.db)
62 .await
63 .unwrap();
64
65 // Lookup succeeds
66 let found: Option<(uuid::Uuid,)> =
67 sqlx::query_as("SELECT id FROM git_repos WHERE user_id = $1 AND name = $2")
68 .bind(uuid::Uuid::from(user_id))
69 .bind("my-repo")
70 .fetch_optional(&h.db)
71 .await
72 .unwrap();
73 assert!(found.is_some(), "Should find repo after creating");
74 let repo_id = found.unwrap().0;
75
76 // Link to project
77 sqlx::query("UPDATE git_repos SET project_id = $2 WHERE id = $1")
78 .bind(repo_id)
79 .bind(project_uuid)
80 .execute(&h.db)
81 .await
82 .unwrap();
83
84 // Verify link
85 let linked: Option<(uuid::Uuid,)> =
86 sqlx::query_as("SELECT project_id FROM git_repos WHERE id = $1")
87 .bind(repo_id)
88 .fetch_optional(&h.db)
89 .await
90 .unwrap();
91 assert_eq!(linked.unwrap().0, project_uuid);
92
93 // Unlink
94 sqlx::query("UPDATE git_repos SET project_id = NULL WHERE id = $1")
95 .bind(repo_id)
96 .execute(&h.db)
97 .await
98 .unwrap();
99
100 let unlinked: Option<(Option<uuid::Uuid>,)> =
101 sqlx::query_as("SELECT project_id FROM git_repos WHERE id = $1")
102 .bind(repo_id)
103 .fetch_optional(&h.db)
104 .await
105 .unwrap();
106 assert!(unlinked.unwrap().0.is_none(), "Should be unlinked");
107 }
108
109 /// The unique constraint prevents duplicate (user_id, name) in git_repos.
110 #[tokio::test]
111 async fn git_repo_unique_constraint() {
112 let mut h = TestHarness::new().await;
113 let (user_id, _project_id) =
114 setup_creator_with_project(&mut h, "gituser4", "proj4a", "Project 4A").await;
115
116 // Create first repo
117 sqlx::query("INSERT INTO git_repos (user_id, name) VALUES ($1, $2)")
118 .bind(uuid::Uuid::from(user_id))
119 .bind("shared-repo")
120 .execute(&h.db)
121 .await
122 .unwrap();
123
124 // Duplicate should fail
125 let result = sqlx::query("INSERT INTO git_repos (user_id, name) VALUES ($1, $2)")
126 .bind(uuid::Uuid::from(user_id))
127 .bind("shared-repo")
128 .execute(&h.db)
129 .await;
130 assert!(result.is_err(), "Should reject duplicate (user_id, name)");
131
132 // Different user CAN have the same repo name
133 let user2_id = h
134 .signup("gituser4b", "gituser4b@example.com", "password123")
135 .await;
136
137 sqlx::query("INSERT INTO git_repos (user_id, name) VALUES ($1, $2)")
138 .bind(uuid::Uuid::from(user2_id))
139 .bind("shared-repo")
140 .execute(&h.db)
141 .await
142 .expect("Different users should be able to have the same repo name");
143 }
144
145 /// Multiple repos can link to the same project.
146 #[tokio::test]
147 async fn multiple_repos_link_to_same_project() {
148 let mut h = TestHarness::new().await;
149 let (user_id, project_id) =
150 setup_creator_with_project(&mut h, "gituser7", "proj7", "Project Seven").await;
151 let project_uuid = project_id.parse::<uuid::Uuid>().unwrap();
152
153 // Create two repos
154 sqlx::query("INSERT INTO git_repos (user_id, name, project_id) VALUES ($1, $2, $3)")
155 .bind(uuid::Uuid::from(user_id))
156 .bind("repo-a")
157 .bind(project_uuid)
158 .execute(&h.db)
159 .await
160 .unwrap();
161
162 sqlx::query("INSERT INTO git_repos (user_id, name, project_id) VALUES ($1, $2, $3)")
163 .bind(uuid::Uuid::from(user_id))
164 .bind("repo-b")
165 .bind(project_uuid)
166 .execute(&h.db)
167 .await
168 .unwrap();
169
170 // Both should be linked
171 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM git_repos WHERE project_id = $1")
172 .bind(project_uuid)
173 .fetch_one(&h.db)
174 .await
175 .unwrap();
176 assert_eq!(count.0, 2, "Both repos should be linked to the project");
177 }
178
179 /// Link/unlink repos via the API endpoints.
180 #[tokio::test]
181 async fn link_unlink_repo_via_api() {
182 let mut h = TestHarness::new().await;
183 let (user_id, project_id) =
184 setup_creator_with_project(&mut h, "gituser8", "proj8", "Project Eight").await;
185
186 // Create a repo in the DB first (simulates auto-registration)
187 sqlx::query("INSERT INTO git_repos (user_id, name) VALUES ($1, $2)")
188 .bind(uuid::Uuid::from(user_id))
189 .bind("api-repo")
190 .execute(&h.db)
191 .await
192 .unwrap();
193
194 // Link via API
195 let resp = h
196 .client
197 .post_json(
198 &format!("/api/projects/{project_id}/repos"),
199 r#"{"name": "api-repo"}"#,
200 )
201 .await;
202 assert!(
203 resp.status.is_success(),
204 "Link should succeed: {}",
205 resp.text
206 );
207
208 // Verify link in DB
209 let linked: Option<(uuid::Uuid,)> =
210 sqlx::query_as("SELECT project_id FROM git_repos WHERE user_id = $1 AND name = $2")
211 .bind(uuid::Uuid::from(user_id))
212 .bind("api-repo")
213 .fetch_optional(&h.db)
214 .await
215 .unwrap();
216 assert_eq!(
217 linked.unwrap().0,
218 project_id.parse::<uuid::Uuid>().unwrap(),
219 "Repo should be linked to project"
220 );
221
222 // Unlink via API
223 let resp = h
224 .client
225 .delete(&format!("/api/projects/{project_id}/repos/api-repo"))
226 .await;
227 assert!(
228 resp.status.is_success(),
229 "Unlink should succeed: {}",
230 resp.text
231 );
232
233 // Verify unlinked
234 let unlinked: Option<(Option<uuid::Uuid>,)> =
235 sqlx::query_as("SELECT project_id FROM git_repos WHERE user_id = $1 AND name = $2")
236 .bind(uuid::Uuid::from(user_id))
237 .bind("api-repo")
238 .fetch_optional(&h.db)
239 .await
240 .unwrap();
241 assert!(unlinked.unwrap().0.is_none(), "Repo should be unlinked");
242 }
243
244 /// Linking a repo that doesn't exist should fail.
245 #[tokio::test]
246 async fn link_nonexistent_repo_fails() {
247 let mut h = TestHarness::new().await;
248 let (_user_id, project_id) =
249 setup_creator_with_project(&mut h, "gituser9", "proj9", "Project Nine").await;
250
251 let resp = h
252 .client
253 .post_json(
254 &format!("/api/projects/{project_id}/repos"),
255 r#"{"name": "no-such-repo"}"#,
256 )
257 .await;
258 assert_eq!(resp.status, 422, "Should reject linking nonexistent repo");
259 }
260
261 /// Project page should NOT show repo links when none are linked.
262 #[tokio::test]
263 async fn project_page_without_git_link() {
264 let mut h = TestHarness::new().await;
265 let (_user_id, project_id) =
266 setup_creator_with_project(&mut h, "gituser5", "proj5", "Project Five").await;
267
268 // Make project public
269 h.client
270 .put_json(
271 &format!("/api/projects/{project_id}"),
272 r#"{"is_public": true}"#,
273 )
274 .await;
275
276 h.client.post_form("/logout", "").await;
277
278 let resp = h.client.get("/p/proj5").await;
279 assert_eq!(resp.status, 200);
280 assert!(
281 resp.text.contains("Project Five"),
282 "Project page should render"
283 );
284 }
285
286 /// Project page should show repo links when repos are linked.
287 #[tokio::test]
288 async fn project_page_with_git_link() {
289 let mut h = TestHarness::new().await;
290 let (user_id, project_id) =
291 setup_creator_with_project(&mut h, "gituser6", "proj6", "Project Six").await;
292 let project_uuid = project_id.parse::<uuid::Uuid>().unwrap();
293
294 // Make project public
295 h.client
296 .put_json(
297 &format!("/api/projects/{project_id}"),
298 r#"{"is_public": true}"#,
299 )
300 .await;
301
302 // Create and link a repo
303 sqlx::query("INSERT INTO git_repos (user_id, name, project_id) VALUES ($1, $2, $3)")
304 .bind(uuid::Uuid::from(user_id))
305 .bind("my-repo")
306 .bind(project_uuid)
307 .execute(&h.db)
308 .await
309 .unwrap();
310
311 h.client.post_form("/logout", "").await;
312
313 let resp = h.client.get("/p/proj6").await;
314 assert_eq!(resp.status, 200);
315 // git_repos_path is not configured in tests, so no repo links will appear.
316 // This correctly tests that the page renders with repos in the DB.
317 assert!(
318 resp.text.contains("Project Six"),
319 "Project page should render with git repos in DB"
320 );
321 }
322
323 // PERF-3: the project code tab batches all linked repos' collaborators into one
324 // query (db::repo_collaborators::list_collaborators_for_repos) instead of a
325 // per-repo N+1. Pin that the batched query returns every collaborator tagged
326 // with the right repo_id, and that an empty input short-circuits.
327 #[tokio::test]
328 async fn list_collaborators_for_repos_groups_by_repo() {
329 use makenotwork::db::GitRepoId;
330 use makenotwork::db::repo_collaborators::list_collaborators_for_repos;
331
332 let mut h = TestHarness::new().await;
333 let owner = h
334 .signup("repoowner", "repoowner@test.com", "password123")
335 .await;
336 let alice = h.signup("collaba", "collaba@test.com", "password123").await;
337 let bob = h.signup("collabb", "collabb@test.com", "password123").await;
338
339 let repo1: uuid::Uuid = sqlx::query_scalar(
340 "INSERT INTO git_repos (user_id, name) VALUES ($1, 'repo-one') RETURNING id",
341 )
342 .bind(owner)
343 .fetch_one(&h.db)
344 .await
345 .unwrap();
346 let repo2: uuid::Uuid = sqlx::query_scalar(
347 "INSERT INTO git_repos (user_id, name) VALUES ($1, 'repo-two') RETURNING id",
348 )
349 .bind(owner)
350 .fetch_one(&h.db)
351 .await
352 .unwrap();
353
354 // repo1: alice (push) + bob (no push); repo2: bob (push).
355 sqlx::query(
356 "INSERT INTO repo_collaborators (repo_id, user_id, can_push) VALUES ($1, $2, true)",
357 )
358 .bind(repo1)
359 .bind(alice)
360 .execute(&h.db)
361 .await
362 .unwrap();
363 sqlx::query(
364 "INSERT INTO repo_collaborators (repo_id, user_id, can_push) VALUES ($1, $2, false)",
365 )
366 .bind(repo1)
367 .bind(bob)
368 .execute(&h.db)
369 .await
370 .unwrap();
371 sqlx::query(
372 "INSERT INTO repo_collaborators (repo_id, user_id, can_push) VALUES ($1, $2, true)",
373 )
374 .bind(repo2)
375 .bind(bob)
376 .execute(&h.db)
377 .await
378 .unwrap();
379
380 let r1 = GitRepoId::from(repo1);
381 let r2 = GitRepoId::from(repo2);
382 let rows = list_collaborators_for_repos(&h.db, &[r1, r2])
383 .await
384 .unwrap();
385 assert_eq!(
386 rows.len(),
387 3,
388 "all collaborators across both repos in one query"
389 );
390 assert_eq!(
391 rows.iter().filter(|c| c.repo_id == r1).count(),
392 2,
393 "repo1 has two collaborators"
394 );
395 let repo2_collabs: Vec<_> = rows.iter().filter(|c| c.repo_id == r2).collect();
396 assert_eq!(repo2_collabs.len(), 1, "repo2 has one collaborator");
397 assert_eq!(repo2_collabs[0].username, "collabb");
398 assert!(repo2_collabs[0].can_push, "bob has push on repo2");
399
400 // Empty input short-circuits without a query.
401 assert!(
402 list_collaborators_for_repos(&h.db, &[])
403 .await
404 .unwrap()
405 .is_empty()
406 );
407 }
408