//! Git-project linking via the git_repos table. use crate::harness::TestHarness; use makenotwork::db::UserId; use serde_json::Value; /// Helper: create a creator with a project, return (user_id, project_id). async fn setup_creator_with_project( h: &mut TestHarness, username: &str, slug: &str, title: &str, ) -> (UserId, String) { let user_id = h .signup(username, &format!("{username}@example.com"), "password123") .await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login(username, "password123").await; let resp = h .client .post_form( "/api/projects", &format!("slug={}&title={}", slug, title.replace(' ', "+")), ) .await; assert!( resp.status.is_success(), "Create project failed: {}", resp.text ); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); (user_id, project_id) } /// DB round-trip: create a git_repos row, look up by user+name, link/unlink project. #[tokio::test] async fn git_repo_db_round_trip() { let mut h = TestHarness::new().await; let (user_id, project_id) = setup_creator_with_project(&mut h, "gituser3", "proj3", "Project Three").await; let project_uuid = project_id.parse::().unwrap(); // Initially no repo let found: Option<(uuid::Uuid,)> = sqlx::query_as("SELECT id FROM git_repos WHERE user_id = $1 AND name = $2") .bind(uuid::Uuid::from(user_id)) .bind("my-repo") .fetch_optional(&h.db) .await .unwrap(); assert!(found.is_none(), "Should find nothing before creating"); // Create repo sqlx::query("INSERT INTO git_repos (user_id, name) VALUES ($1, $2)") .bind(uuid::Uuid::from(user_id)) .bind("my-repo") .execute(&h.db) .await .unwrap(); // Lookup succeeds let found: Option<(uuid::Uuid,)> = sqlx::query_as("SELECT id FROM git_repos WHERE user_id = $1 AND name = $2") .bind(uuid::Uuid::from(user_id)) .bind("my-repo") .fetch_optional(&h.db) .await .unwrap(); assert!(found.is_some(), "Should find repo after creating"); let repo_id = found.unwrap().0; // Link to project sqlx::query("UPDATE git_repos SET project_id = $2 WHERE id = $1") .bind(repo_id) .bind(project_uuid) .execute(&h.db) .await .unwrap(); // Verify link let linked: Option<(uuid::Uuid,)> = sqlx::query_as("SELECT project_id FROM git_repos WHERE id = $1") .bind(repo_id) .fetch_optional(&h.db) .await .unwrap(); assert_eq!(linked.unwrap().0, project_uuid); // Unlink sqlx::query("UPDATE git_repos SET project_id = NULL WHERE id = $1") .bind(repo_id) .execute(&h.db) .await .unwrap(); let unlinked: Option<(Option,)> = sqlx::query_as("SELECT project_id FROM git_repos WHERE id = $1") .bind(repo_id) .fetch_optional(&h.db) .await .unwrap(); assert!(unlinked.unwrap().0.is_none(), "Should be unlinked"); } /// The unique constraint prevents duplicate (user_id, name) in git_repos. #[tokio::test] async fn git_repo_unique_constraint() { let mut h = TestHarness::new().await; let (user_id, _project_id) = setup_creator_with_project(&mut h, "gituser4", "proj4a", "Project 4A").await; // Create first repo sqlx::query("INSERT INTO git_repos (user_id, name) VALUES ($1, $2)") .bind(uuid::Uuid::from(user_id)) .bind("shared-repo") .execute(&h.db) .await .unwrap(); // Duplicate should fail let result = sqlx::query("INSERT INTO git_repos (user_id, name) VALUES ($1, $2)") .bind(uuid::Uuid::from(user_id)) .bind("shared-repo") .execute(&h.db) .await; assert!(result.is_err(), "Should reject duplicate (user_id, name)"); // Different user CAN have the same repo name let user2_id = h .signup("gituser4b", "gituser4b@example.com", "password123") .await; sqlx::query("INSERT INTO git_repos (user_id, name) VALUES ($1, $2)") .bind(uuid::Uuid::from(user2_id)) .bind("shared-repo") .execute(&h.db) .await .expect("Different users should be able to have the same repo name"); } /// Multiple repos can link to the same project. #[tokio::test] async fn multiple_repos_link_to_same_project() { let mut h = TestHarness::new().await; let (user_id, project_id) = setup_creator_with_project(&mut h, "gituser7", "proj7", "Project Seven").await; let project_uuid = project_id.parse::().unwrap(); // Create two repos sqlx::query("INSERT INTO git_repos (user_id, name, project_id) VALUES ($1, $2, $3)") .bind(uuid::Uuid::from(user_id)) .bind("repo-a") .bind(project_uuid) .execute(&h.db) .await .unwrap(); sqlx::query("INSERT INTO git_repos (user_id, name, project_id) VALUES ($1, $2, $3)") .bind(uuid::Uuid::from(user_id)) .bind("repo-b") .bind(project_uuid) .execute(&h.db) .await .unwrap(); // Both should be linked let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM git_repos WHERE project_id = $1") .bind(project_uuid) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count.0, 2, "Both repos should be linked to the project"); } /// Link/unlink repos via the API endpoints. #[tokio::test] async fn link_unlink_repo_via_api() { let mut h = TestHarness::new().await; let (user_id, project_id) = setup_creator_with_project(&mut h, "gituser8", "proj8", "Project Eight").await; // Create a repo in the DB first (simulates auto-registration) sqlx::query("INSERT INTO git_repos (user_id, name) VALUES ($1, $2)") .bind(uuid::Uuid::from(user_id)) .bind("api-repo") .execute(&h.db) .await .unwrap(); // Link via API let resp = h .client .post_json( &format!("/api/projects/{project_id}/repos"), r#"{"name": "api-repo"}"#, ) .await; assert!( resp.status.is_success(), "Link should succeed: {}", resp.text ); // Verify link in DB let linked: Option<(uuid::Uuid,)> = sqlx::query_as("SELECT project_id FROM git_repos WHERE user_id = $1 AND name = $2") .bind(uuid::Uuid::from(user_id)) .bind("api-repo") .fetch_optional(&h.db) .await .unwrap(); assert_eq!( linked.unwrap().0, project_id.parse::().unwrap(), "Repo should be linked to project" ); // Unlink via API let resp = h .client .delete(&format!("/api/projects/{project_id}/repos/api-repo")) .await; assert!( resp.status.is_success(), "Unlink should succeed: {}", resp.text ); // Verify unlinked let unlinked: Option<(Option,)> = sqlx::query_as("SELECT project_id FROM git_repos WHERE user_id = $1 AND name = $2") .bind(uuid::Uuid::from(user_id)) .bind("api-repo") .fetch_optional(&h.db) .await .unwrap(); assert!(unlinked.unwrap().0.is_none(), "Repo should be unlinked"); } /// Linking a repo that doesn't exist should fail. #[tokio::test] async fn link_nonexistent_repo_fails() { let mut h = TestHarness::new().await; let (_user_id, project_id) = setup_creator_with_project(&mut h, "gituser9", "proj9", "Project Nine").await; let resp = h .client .post_json( &format!("/api/projects/{project_id}/repos"), r#"{"name": "no-such-repo"}"#, ) .await; assert_eq!(resp.status, 422, "Should reject linking nonexistent repo"); } /// Project page should NOT show repo links when none are linked. #[tokio::test] async fn project_page_without_git_link() { let mut h = TestHarness::new().await; let (_user_id, project_id) = setup_creator_with_project(&mut h, "gituser5", "proj5", "Project Five").await; // Make project public h.client .put_json( &format!("/api/projects/{project_id}"), r#"{"is_public": true}"#, ) .await; h.client.post_form("/logout", "").await; let resp = h.client.get("/p/proj5").await; assert_eq!(resp.status, 200); assert!( resp.text.contains("Project Five"), "Project page should render" ); } /// Project page should show repo links when repos are linked. #[tokio::test] async fn project_page_with_git_link() { let mut h = TestHarness::new().await; let (user_id, project_id) = setup_creator_with_project(&mut h, "gituser6", "proj6", "Project Six").await; let project_uuid = project_id.parse::().unwrap(); // Make project public h.client .put_json( &format!("/api/projects/{project_id}"), r#"{"is_public": true}"#, ) .await; // Create and link a repo sqlx::query("INSERT INTO git_repos (user_id, name, project_id) VALUES ($1, $2, $3)") .bind(uuid::Uuid::from(user_id)) .bind("my-repo") .bind(project_uuid) .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; let resp = h.client.get("/p/proj6").await; assert_eq!(resp.status, 200); // git_repos_path is not configured in tests, so no repo links will appear. // This correctly tests that the page renders with repos in the DB. assert!( resp.text.contains("Project Six"), "Project page should render with git repos in DB" ); } // PERF-3: the project code tab batches all linked repos' collaborators into one // query (db::repo_collaborators::list_collaborators_for_repos) instead of a // per-repo N+1. Pin that the batched query returns every collaborator tagged // with the right repo_id, and that an empty input short-circuits. #[tokio::test] async fn list_collaborators_for_repos_groups_by_repo() { use makenotwork::db::GitRepoId; use makenotwork::db::repo_collaborators::list_collaborators_for_repos; let mut h = TestHarness::new().await; let owner = h .signup("repoowner", "repoowner@test.com", "password123") .await; let alice = h.signup("collaba", "collaba@test.com", "password123").await; let bob = h.signup("collabb", "collabb@test.com", "password123").await; let repo1: uuid::Uuid = sqlx::query_scalar( "INSERT INTO git_repos (user_id, name) VALUES ($1, 'repo-one') RETURNING id", ) .bind(owner) .fetch_one(&h.db) .await .unwrap(); let repo2: uuid::Uuid = sqlx::query_scalar( "INSERT INTO git_repos (user_id, name) VALUES ($1, 'repo-two') RETURNING id", ) .bind(owner) .fetch_one(&h.db) .await .unwrap(); // repo1: alice (push) + bob (no push); repo2: bob (push). sqlx::query( "INSERT INTO repo_collaborators (repo_id, user_id, can_push) VALUES ($1, $2, true)", ) .bind(repo1) .bind(alice) .execute(&h.db) .await .unwrap(); sqlx::query( "INSERT INTO repo_collaborators (repo_id, user_id, can_push) VALUES ($1, $2, false)", ) .bind(repo1) .bind(bob) .execute(&h.db) .await .unwrap(); sqlx::query( "INSERT INTO repo_collaborators (repo_id, user_id, can_push) VALUES ($1, $2, true)", ) .bind(repo2) .bind(bob) .execute(&h.db) .await .unwrap(); let r1 = GitRepoId::from(repo1); let r2 = GitRepoId::from(repo2); let rows = list_collaborators_for_repos(&h.db, &[r1, r2]) .await .unwrap(); assert_eq!( rows.len(), 3, "all collaborators across both repos in one query" ); assert_eq!( rows.iter().filter(|c| c.repo_id == r1).count(), 2, "repo1 has two collaborators" ); let repo2_collabs: Vec<_> = rows.iter().filter(|c| c.repo_id == r2).collect(); assert_eq!(repo2_collabs.len(), 1, "repo2 has one collaborator"); assert_eq!(repo2_collabs[0].username, "collabb"); assert!(repo2_collabs[0].can_push, "bob has push on repo2"); // Empty input short-circuits without a query. assert!( list_collaborators_for_repos(&h.db, &[]) .await .unwrap() .is_empty() ); }