Skip to main content

max / makenotwork

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