max / makenotwork
13 files changed,
+477 insertions,
-1 deletion
| @@ -0,0 +1,6 @@ | |||
| 1 | + | ALTER TABLE projects ADD COLUMN git_repo_name VARCHAR(64); | |
| 2 | + | ||
| 3 | + | -- Each user can link a given repo to at most one project. | |
| 4 | + | CREATE UNIQUE INDEX idx_projects_git_repo_unique | |
| 5 | + | ON projects (user_id, git_repo_name) | |
| 6 | + | WHERE git_repo_name IS NOT NULL; |
| @@ -179,6 +179,8 @@ pub struct DbProject { | |||
| 179 | 179 | pub created_at: DateTime<Utc>, | |
| 180 | 180 | /// When the project was last modified. | |
| 181 | 181 | pub updated_at: DateTime<Utc>, | |
| 182 | + | /// Optional linked git repository name (bare name, not full path). | |
| 183 | + | pub git_repo_name: Option<String>, | |
| 182 | 184 | } | |
| 183 | 185 | ||
| 184 | 186 | /// A purchasable or free item within a project. |
| @@ -164,6 +164,38 @@ pub async fn get_public_projects_with_item_counts( | |||
| 164 | 164 | Ok(projects) | |
| 165 | 165 | } | |
| 166 | 166 | ||
| 167 | + | /// Set or clear a project's linked git repository name. | |
| 168 | + | pub async fn set_project_git_repo( | |
| 169 | + | pool: &PgPool, | |
| 170 | + | id: ProjectId, | |
| 171 | + | repo_name: Option<&str>, | |
| 172 | + | ) -> Result<()> { | |
| 173 | + | sqlx::query("UPDATE projects SET git_repo_name = $2 WHERE id = $1") | |
| 174 | + | .bind(id) | |
| 175 | + | .bind(repo_name) | |
| 176 | + | .execute(pool) | |
| 177 | + | .await?; | |
| 178 | + | ||
| 179 | + | Ok(()) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /// Look up a project by its linked git repo name and owning user. | |
| 183 | + | pub async fn get_project_by_git_repo( | |
| 184 | + | pool: &PgPool, | |
| 185 | + | user_id: UserId, | |
| 186 | + | repo_name: &str, | |
| 187 | + | ) -> Result<Option<DbProject>> { | |
| 188 | + | let project = sqlx::query_as::<_, DbProject>( | |
| 189 | + | "SELECT * FROM projects WHERE user_id = $1 AND git_repo_name = $2 LIMIT 1", | |
| 190 | + | ) | |
| 191 | + | .bind(user_id) | |
| 192 | + | .bind(repo_name) | |
| 193 | + | .fetch_optional(pool) | |
| 194 | + | .await?; | |
| 195 | + | ||
| 196 | + | Ok(project) | |
| 197 | + | } | |
| 198 | + | ||
| 167 | 199 | /// Fetch a public project by its URL slug. Returns `None` if not found or not public. | |
| 168 | 200 | pub async fn get_public_project_by_slug( | |
| 169 | 201 | pool: &PgPool, |
| @@ -43,6 +43,7 @@ pub struct ProjectResponse { | |||
| 43 | 43 | pub description: Option<String>, | |
| 44 | 44 | pub project_type: String, | |
| 45 | 45 | pub is_public: bool, | |
| 46 | + | pub git_repo_name: Option<String>, | |
| 46 | 47 | } | |
| 47 | 48 | ||
| 48 | 49 | /// Create a new project for the authenticated creator. | |
| @@ -113,6 +114,7 @@ pub(super) async fn create_project( | |||
| 113 | 114 | description: project.description, | |
| 114 | 115 | project_type: project.project_type, | |
| 115 | 116 | is_public: project.is_public, | |
| 117 | + | git_repo_name: project.git_repo_name, | |
| 116 | 118 | }).into_response()) | |
| 117 | 119 | } | |
| 118 | 120 | ||
| @@ -133,6 +135,7 @@ pub(super) async fn list_projects( | |||
| 133 | 135 | description: p.description, | |
| 134 | 136 | project_type: p.project_type, | |
| 135 | 137 | is_public: p.is_public, | |
| 138 | + | git_repo_name: p.git_repo_name, | |
| 136 | 139 | }) | |
| 137 | 140 | .collect(); | |
| 138 | 141 | ||
| @@ -147,6 +150,7 @@ pub struct UpdateProjectRequest { | |||
| 147 | 150 | pub project_type: Option<String>, | |
| 148 | 151 | pub is_public: Option<bool>, | |
| 149 | 152 | pub category: Option<String>, | |
| 153 | + | pub git_repo_name: Option<String>, | |
| 150 | 154 | } | |
| 151 | 155 | ||
| 152 | 156 | /// Update an existing project owned by the authenticated user. | |
| @@ -160,6 +164,14 @@ pub(super) async fn update_project( | |||
| 160 | 164 | user.check_not_suspended()?; | |
| 161 | 165 | verify_project_ownership(&state, id, user.id).await?; | |
| 162 | 166 | ||
| 167 | + | // Validate input (same rules as create_project, but all fields are optional) | |
| 168 | + | if let Some(ref title) = req.title { | |
| 169 | + | validation::validate_project_title(title)?; | |
| 170 | + | } | |
| 171 | + | if let Some(ref desc) = req.description { | |
| 172 | + | validation::validate_project_description(desc)?; | |
| 173 | + | } | |
| 174 | + | ||
| 163 | 175 | // Resolve category if provided | |
| 164 | 176 | if let Some(ref cat_name) = req.category { | |
| 165 | 177 | let trimmed = cat_name.trim(); | |
| @@ -171,6 +183,28 @@ pub(super) async fn update_project( | |||
| 171 | 183 | } | |
| 172 | 184 | } | |
| 173 | 185 | ||
| 186 | + | // Handle git repo name: empty string clears, non-empty validates + verifies repo exists | |
| 187 | + | if let Some(ref repo_name) = req.git_repo_name { | |
| 188 | + | let trimmed = repo_name.trim(); | |
| 189 | + | if trimmed.is_empty() { | |
| 190 | + | db::projects::set_project_git_repo(&state.db, id, None).await?; | |
| 191 | + | } else { | |
| 192 | + | validation::validate_git_repo_name(trimmed)?; | |
| 193 | + | // Verify the repo exists on disk | |
| 194 | + | if let Some(ref repos_path) = state.config.git_repos_path { | |
| 195 | + | let db_user = db::users::get_user_by_id(&state.db, user.id) | |
| 196 | + | .await? | |
| 197 | + | .ok_or(AppError::NotFound)?; | |
| 198 | + | let root = std::path::PathBuf::from(repos_path); | |
| 199 | + | crate::git::open_repo(&root, &db_user.username, trimmed) | |
| 200 | + | .map_err(|_| AppError::Validation("Git repository not found".to_string()))?; | |
| 201 | + | } else { | |
| 202 | + | return Err(AppError::Validation("Git hosting is not configured".to_string())); | |
| 203 | + | } | |
| 204 | + | db::projects::set_project_git_repo(&state.db, id, Some(trimmed)).await?; | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 174 | 208 | let updated = db::projects::update_project( | |
| 175 | 209 | &state.db, | |
| 176 | 210 | id, | |
| @@ -188,6 +222,7 @@ pub(super) async fn update_project( | |||
| 188 | 222 | description: updated.description, | |
| 189 | 223 | project_type: updated.project_type, | |
| 190 | 224 | is_public: updated.is_public, | |
| 225 | + | git_repo_name: updated.git_repo_name, | |
| 191 | 226 | })) | |
| 192 | 227 | } | |
| 193 | 228 |
| @@ -14,10 +14,12 @@ use tower_sessions::Session; | |||
| 14 | 14 | use crate::{ | |
| 15 | 15 | auth::MaybeUser, | |
| 16 | 16 | constants, | |
| 17 | + | db::{self, Username}, | |
| 17 | 18 | error::{AppError, Result}, | |
| 18 | 19 | git, | |
| 19 | 20 | helpers::get_csrf_token, | |
| 20 | 21 | templates::*, | |
| 22 | + | types::*, | |
| 21 | 23 | AppState, | |
| 22 | 24 | }; | |
| 23 | 25 | ||
| @@ -106,6 +108,51 @@ fn parent_of(path: &str) -> String { | |||
| 106 | 108 | } | |
| 107 | 109 | ||
| 108 | 110 | // ============================================================================ | |
| 111 | + | // Linked project + releases | |
| 112 | + | // ============================================================================ | |
| 113 | + | ||
| 114 | + | /// Look up the project linked to a git repo and fetch its public items with versions. | |
| 115 | + | async fn fetch_linked_releases( | |
| 116 | + | state: &AppState, | |
| 117 | + | owner: &str, | |
| 118 | + | repo_name: &str, | |
| 119 | + | ) -> (Option<Project>, Vec<ReleaseItem>) { | |
| 120 | + | let username = Username::from_trusted(owner.to_string()); | |
| 121 | + | let db_user = match db::users::get_user_by_username(&state.db, &username).await { | |
| 122 | + | Ok(Some(u)) => u, | |
| 123 | + | _ => return (None, Vec::new()), | |
| 124 | + | }; | |
| 125 | + | ||
| 126 | + | let db_project = match db::projects::get_project_by_git_repo(&state.db, db_user.id, repo_name).await { | |
| 127 | + | Ok(Some(p)) if p.is_public => p, | |
| 128 | + | _ => return (None, Vec::new()), | |
| 129 | + | }; | |
| 130 | + | ||
| 131 | + | let db_items = match db::items::get_public_items_by_project(&state.db, db_project.id).await { | |
| 132 | + | Ok(items) => items, | |
| 133 | + | Err(_) => return (Some(Project::from_db(&db_project, 0)), Vec::new()), | |
| 134 | + | }; | |
| 135 | + | ||
| 136 | + | let mut release_items = Vec::new(); | |
| 137 | + | for item in &db_items { | |
| 138 | + | let versions = match db::versions::get_versions_by_item(&state.db, item.id).await { | |
| 139 | + | Ok(v) if !v.is_empty() => v, | |
| 140 | + | _ => continue, | |
| 141 | + | }; | |
| 142 | + | let tags = db::tags::get_tags_for_item(&state.db, item.id).await.unwrap_or_default(); | |
| 143 | + | let view_item = Item::from_db_list(item, &tags, item.price_cents == 0, false); | |
| 144 | + | let view_versions: Vec<Version> = versions.iter().map(Version::from_db).collect(); | |
| 145 | + | release_items.push(ReleaseItem { | |
| 146 | + | item: view_item, | |
| 147 | + | versions: view_versions, | |
| 148 | + | }); | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | let project = Project::from_db(&db_project, db_items.len() as u32); | |
| 152 | + | (Some(project), release_items) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // ============================================================================ | |
| 109 | 156 | // Browsing handlers | |
| 110 | 157 | // ============================================================================ | |
| 111 | 158 | ||
| @@ -128,6 +175,9 @@ async fn repo_overview( | |||
| 128 | 175 | ||
| 129 | 176 | let csrf_token = get_csrf_token(&session).await; | |
| 130 | 177 | ||
| 178 | + | // Look up linked project + releases | |
| 179 | + | let (linked_project, release_items) = fetch_linked_releases(&state, &owner, &repo_name).await; | |
| 180 | + | ||
| 131 | 181 | Ok(GitRepoTemplate { | |
| 132 | 182 | csrf_token, | |
| 133 | 183 | session_user: maybe_user, | |
| @@ -139,6 +189,8 @@ async fn repo_overview( | |||
| 139 | 189 | tree_items, | |
| 140 | 190 | readme_html, | |
| 141 | 191 | host_url: state.config.host_url.clone(), | |
| 192 | + | linked_project, | |
| 193 | + | release_items, | |
| 142 | 194 | }) | |
| 143 | 195 | } | |
| 144 | 196 | ||
| @@ -159,6 +211,9 @@ async fn tree_root( | |||
| 159 | 211 | ||
| 160 | 212 | let csrf_token = get_csrf_token(&session).await; | |
| 161 | 213 | ||
| 214 | + | // Look up linked project + releases | |
| 215 | + | let (linked_project, release_items) = fetch_linked_releases(&state, &owner, &repo_name).await; | |
| 216 | + | ||
| 162 | 217 | Ok(GitRepoTemplate { | |
| 163 | 218 | csrf_token, | |
| 164 | 219 | session_user: maybe_user, | |
| @@ -170,6 +225,8 @@ async fn tree_root( | |||
| 170 | 225 | tree_items, | |
| 171 | 226 | readme_html, | |
| 172 | 227 | host_url: state.config.host_url.clone(), | |
| 228 | + | linked_project, | |
| 229 | + | release_items, | |
| 173 | 230 | }) | |
| 174 | 231 | } | |
| 175 | 232 |
| @@ -185,7 +185,10 @@ pub(super) async fn project_tab_settings( | |||
| 185 | 185 | .await? | |
| 186 | 186 | .unwrap_or_default(); | |
| 187 | 187 | ||
| 188 | - | Ok(ProjectSettingsTabTemplate { project, category_name }) | |
| 188 | + | let git_repo_name = db_project.git_repo_name.clone().unwrap_or_default(); | |
| 189 | + | let git_enabled = state.config.git_repos_path.is_some(); | |
| 190 | + | ||
| 191 | + | Ok(ProjectSettingsTabTemplate { project, category_name, git_repo_name, git_enabled }) | |
| 189 | 192 | } | |
| 190 | 193 | ||
| 191 | 194 | /// Render the HTMX partial for the project subscriptions tab (tier management). |
| @@ -131,6 +131,15 @@ pub(super) async fn project_page( | |||
| 131 | 131 | let db_tiers = db::subscriptions::get_active_tiers_by_project(&state.db, db_project.id).await?; | |
| 132 | 132 | let subscription_tiers: Vec<SubscriptionTier> = db_tiers.iter().map(SubscriptionTier::from).collect(); | |
| 133 | 133 | ||
| 134 | + | // Compute git repo URL if this project is linked to a git repo and git is configured | |
| 135 | + | let git_repo_url = if state.config.git_repos_path.is_some() { | |
| 136 | + | db_project.git_repo_name.as_ref().map(|repo| { | |
| 137 | + | format!("/git/{}/{}", db_user.username, repo) | |
| 138 | + | }) | |
| 139 | + | } else { | |
| 140 | + | None | |
| 141 | + | }; | |
| 142 | + | ||
| 134 | 143 | Ok(ProjectTemplate { | |
| 135 | 144 | csrf_token, | |
| 136 | 145 | session_user: maybe_user, | |
| @@ -143,6 +152,7 @@ pub(super) async fn project_page( | |||
| 143 | 152 | subscription_tiers, | |
| 144 | 153 | has_subscription, | |
| 145 | 154 | host_url: state.config.host_url.clone(), | |
| 155 | + | git_repo_url, | |
| 146 | 156 | }) | |
| 147 | 157 | } | |
| 148 | 158 |
| @@ -233,6 +233,10 @@ pub struct ProjectSettingsTabTemplate { | |||
| 233 | 233 | pub project: Project, | |
| 234 | 234 | /// Current category name for pre-populating the form, or empty. | |
| 235 | 235 | pub category_name: String, | |
| 236 | + | /// Current linked git repo name, or empty. | |
| 237 | + | pub git_repo_name: String, | |
| 238 | + | /// Whether git hosting is configured on this server. | |
| 239 | + | pub git_enabled: bool, | |
| 236 | 240 | } | |
| 237 | 241 | ||
| 238 | 242 | /// Dashboard blog tab partial. |
| @@ -140,6 +140,8 @@ pub struct ProjectTemplate { | |||
| 140 | 140 | pub has_subscription: bool, | |
| 141 | 141 | /// Base URL for OG meta tags. | |
| 142 | 142 | pub host_url: String, | |
| 143 | + | /// URL to linked git repository, if configured. | |
| 144 | + | pub git_repo_url: Option<String>, | |
| 143 | 145 | } | |
| 144 | 146 | ||
| 145 | 147 | /// Public item detail page. | |
| @@ -402,6 +404,16 @@ pub struct EmailResultTemplate { | |||
| 402 | 404 | pub link_text: String, | |
| 403 | 405 | } | |
| 404 | 406 | ||
| 407 | + | /// Confirmation page shown before account deletion (GET step). | |
| 408 | + | #[derive(Template)] | |
| 409 | + | #[template(path = "pages/confirm_delete.html")] | |
| 410 | + | pub struct ConfirmDeleteTemplate { | |
| 411 | + | pub csrf_token: CsrfTokenOption, | |
| 412 | + | pub user: String, | |
| 413 | + | pub expires: String, | |
| 414 | + | pub sig: String, | |
| 415 | + | } | |
| 416 | + | ||
| 405 | 417 | #[derive(Template)] | |
| 406 | 418 | #[template(path = "pages/account-deleted.html")] | |
| 407 | 419 | pub struct AccountDeletedTemplate { | |
| @@ -504,6 +516,12 @@ pub struct HealthTemplate { | |||
| 504 | 516 | // Git Source Browser | |
| 505 | 517 | // ============================================================================ | |
| 506 | 518 | ||
| 519 | + | /// An item paired with its versions, for release display on the git repo page. | |
| 520 | + | pub struct ReleaseItem { | |
| 521 | + | pub item: Item, | |
| 522 | + | pub versions: Vec<Version>, | |
| 523 | + | } | |
| 524 | + | ||
| 507 | 525 | /// Repository overview: file tree at HEAD + README. | |
| 508 | 526 | #[derive(Template)] | |
| 509 | 527 | #[template(path = "pages/git/repo.html")] | |
| @@ -518,6 +536,10 @@ pub struct GitRepoTemplate { | |||
| 518 | 536 | pub tree_items: Vec<git::TreeItem>, | |
| 519 | 537 | pub readme_html: Option<String>, | |
| 520 | 538 | pub host_url: String, | |
| 539 | + | /// Linked project, if this repo is associated with a public project. | |
| 540 | + | pub linked_project: Option<Project>, | |
| 541 | + | /// Public items with versions from the linked project (releases). | |
| 542 | + | pub release_items: Vec<ReleaseItem>, | |
| 521 | 543 | } | |
| 522 | 544 | ||
| 523 | 545 | /// Subdirectory listing with breadcrumb navigation. |
| @@ -50,6 +50,9 @@ | |||
| 50 | 50 | <p class="git-repo-desc">{{ desc }}</p> | |
| 51 | 51 | {% endif %} | |
| 52 | 52 | <code class="git-clone-url">git clone {{ host_url }}/git/{{ owner }}/{{ repo_name }}.git</code> | |
| 53 | + | {% if let Some(proj) = &linked_project %} | |
| 54 | + | <p style="margin-top: 0.5rem; font-size: 0.9rem;">Project: <a href="/p/{{ proj.slug }}" style="color: var(--detail);">{{ proj.title }}</a> by <a href="/u/{{ owner }}" style="color: var(--detail);">{{ owner }}</a></p> | |
| 55 | + | {% endif %} | |
| 53 | 56 | </div> | |
| 54 | 57 | ||
| 55 | 58 | <div class="git-ref-bar"> | |
| @@ -64,6 +67,9 @@ | |||
| 64 | 67 | <nav class="git-nav-links"> | |
| 65 | 68 | <a href="/git/{{ owner }}/{{ repo_name }}/tree/{{ current_ref }}" class="active">Files</a> | |
| 66 | 69 | <a href="/git/{{ owner }}/{{ repo_name }}/commits/{{ current_ref }}">Commits</a> | |
| 70 | + | {% if !release_items.is_empty() %} | |
| 71 | + | <a href="#releases">Releases ({{ release_items.len() }})</a> | |
| 72 | + | {% endif %} | |
| 67 | 73 | </nav> | |
| 68 | 74 | </div> | |
| 69 | 75 | ||
| @@ -111,6 +117,34 @@ | |||
| 111 | 117 | </div> | |
| 112 | 118 | {% endif %} | |
| 113 | 119 | ||
| 120 | + | {% if !release_items.is_empty() %} | |
| 121 | + | <div id="releases" class="git-readme" style="margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border);"> | |
| 122 | + | <h2>Releases</h2> | |
| 123 | + | {% for ri in release_items %} | |
| 124 | + | <div style="margin-bottom: 1.5rem; padding: 1rem; background: var(--light-background);"> | |
| 125 | + | <h3 style="margin: 0 0 0.25rem; font-size: 1.1rem;"> | |
| 126 | + | <a href="/i/{{ ri.item.id }}" style="color: var(--text); text-decoration: none;">{{ ri.item.title }}</a> | |
| 127 | + | </h3> | |
| 128 | + | <div style="font-size: 0.85rem; opacity: 0.6; margin-bottom: 0.5rem;">{{ ri.item.item_type }} · {{ ri.item.price }}</div> | |
| 129 | + | {% if !ri.item.description.is_empty() %} | |
| 130 | + | <p style="font-size: 0.9rem; opacity: 0.8; margin-bottom: 0.75rem;">{{ ri.item.description }}</p> | |
| 131 | + | {% endif %} | |
| 132 | + | <div style="font-size: 0.8rem;"> | |
| 133 | + | {% for v in ri.versions %} | |
| 134 | + | <div style="display: flex; gap: 0.75rem; align-items: center; padding: 0.3rem 0; border-top: 1px solid var(--border);"> | |
| 135 | + | <span style="font-family: 'IBMPlexMono', monospace;">{{ v.number }}</span> | |
| 136 | + | {% if v.is_current %}<span style="font-size: 0.7rem; padding: 0.1rem 0.4rem; background: var(--detail); color: var(--background); border-radius: 2px;">Latest</span>{% endif %} | |
| 137 | + | <span style="opacity: 0.5;">{{ v.uploaded_date }}</span> | |
| 138 | + | {% if v.has_file %}<span style="opacity: 0.5;">{{ v.size }}</span>{% endif %} | |
| 139 | + | <span style="opacity: 0.5;">{{ v.downloads }} downloads</span> | |
| 140 | + | </div> | |
| 141 | + | {% endfor %} | |
| 142 | + | </div> | |
| 143 | + | </div> | |
| 144 | + | {% endfor %} | |
| 145 | + | </div> | |
| 146 | + | {% endif %} | |
| 147 | + | ||
| 114 | 148 | <footer class="text-reader-footer"> | |
| 115 | 149 | <a href="/">Makenot<span class="dot">.</span>work</a> | |
| 116 | 150 | </footer> |
| @@ -107,6 +107,9 @@ | |||
| 107 | 107 | <span style="font-size: 0.85rem; opacity: 0.7; padding: 0.5rem 0;">{{ follower_count }} followers</span> | |
| 108 | 108 | {% endif %} | |
| 109 | 109 | <a href="/p/{{ project.slug }}/rss" class="secondary" style="display: inline-block; padding: 0.5rem 1rem; text-decoration: none;">RSS Feed</a> | |
| 110 | + | {% if let Some(url) = &git_repo_url %} | |
| 111 | + | <a href="{{ url }}" class="secondary" style="display: inline-block; padding: 0.5rem 1rem; text-decoration: none;">Source Code</a> | |
| 112 | + | {% endif %} | |
| 110 | 113 | </div> | |
| 111 | 114 | </header> | |
| 112 | 115 |
| @@ -22,6 +22,14 @@ | |||
| 22 | 22 | <div class="hint">Choose an existing category or type a new one. Leave blank to clear.</div> | |
| 23 | 23 | </div> | |
| 24 | 24 | ||
| 25 | + | {% if git_enabled %} | |
| 26 | + | <div class="form-group"> | |
| 27 | + | <label for="settings-git-repo">Git Repository</label> | |
| 28 | + | <input type="text" id="settings-git-repo" name="git_repo_name" value="{{ git_repo_name }}" placeholder="e.g. my-project" autocomplete="off"> | |
| 29 | + | <div class="hint">Link a git repository hosted on this server. Leave blank to clear.</div> | |
| 30 | + | </div> | |
| 31 | + | {% endif %} | |
| 32 | + | ||
| 25 | 33 | <button class="primary" type="submit"> | |
| 26 | 34 | Save Changes | |
| 27 | 35 | <span id="project-spinner" class="htmx-indicator"> ...</span> |
| @@ -0,0 +1,260 @@ | |||
| 1 | + | //! Git-project linking: set/clear git_repo_name, unique constraint, page rendering. | |
| 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!("{}@example.com", username), "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!(resp.status.is_success(), "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 | + | /// Setting git_repo_name via the API should fail when git hosting is not configured. | |
| 36 | + | #[tokio::test] | |
| 37 | + | async fn git_repo_link_requires_git_hosting() { | |
| 38 | + | let mut h = TestHarness::new().await; | |
| 39 | + | let (_user_id, project_id) = setup_creator_with_project(&mut h, "gituser1", "proj1", "Project One").await; | |
| 40 | + | ||
| 41 | + | let resp = h | |
| 42 | + | .client | |
| 43 | + | .put_json( | |
| 44 | + | &format!("/api/projects/{}", project_id), | |
| 45 | + | r#"{"git_repo_name": "my-repo"}"#, | |
| 46 | + | ) | |
| 47 | + | .await; | |
| 48 | + | ||
| 49 | + | // Should fail because git_repos_path is not configured in test harness | |
| 50 | + | assert_eq!(resp.status, 422, "Should reject git_repo_name when git hosting is not configured"); | |
| 51 | + | assert!( | |
| 52 | + | resp.text.contains("not configured"), | |
| 53 | + | "Error should mention git hosting not configured, got: {}", | |
| 54 | + | resp.text | |
| 55 | + | ); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// git_repo_name should appear in the project API response. | |
| 59 | + | #[tokio::test] | |
| 60 | + | async fn git_repo_name_in_api_response() { | |
| 61 | + | let mut h = TestHarness::new().await; | |
| 62 | + | let (_user_id, project_id) = setup_creator_with_project(&mut h, "gituser2", "proj2", "Project Two").await; | |
| 63 | + | ||
| 64 | + | // Set git_repo_name directly via SQL (bypass API disk check) | |
| 65 | + | sqlx::query("UPDATE projects SET git_repo_name = $1 WHERE id = $2") | |
| 66 | + | .bind("my-repo") | |
| 67 | + | .bind(project_id.parse::<uuid::Uuid>().unwrap()) | |
| 68 | + | .execute(&h.db) | |
| 69 | + | .await | |
| 70 | + | .unwrap(); | |
| 71 | + | ||
| 72 | + | // Fetch projects via API | |
| 73 | + | let resp = h.client.get("/api/projects").await; | |
| 74 | + | assert_eq!(resp.status, 200); | |
| 75 | + | let body: Value = resp.json(); | |
| 76 | + | let projects = body["data"].as_array().expect("response should have data array"); | |
| 77 | + | let proj = projects | |
| 78 | + | .iter() | |
| 79 | + | .find(|p| p["id"].as_str() == Some(&project_id)) | |
| 80 | + | .expect("project should be in list"); | |
| 81 | + | assert_eq!( | |
| 82 | + | proj["git_repo_name"].as_str(), | |
| 83 | + | Some("my-repo"), | |
| 84 | + | "API response should include git_repo_name" | |
| 85 | + | ); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// DB round-trip: set git_repo_name, look up by repo, clear it. | |
| 89 | + | #[tokio::test] | |
| 90 | + | async fn git_repo_db_round_trip() { | |
| 91 | + | let mut h = TestHarness::new().await; | |
| 92 | + | let (user_id, project_id) = setup_creator_with_project(&mut h, "gituser3", "proj3", "Project Three").await; | |
| 93 | + | let project_uuid = project_id.parse::<uuid::Uuid>().unwrap(); | |
| 94 | + | ||
| 95 | + | // Initially no link | |
| 96 | + | let found: Option<(uuid::Uuid,)> = sqlx::query_as( | |
| 97 | + | "SELECT id FROM projects WHERE user_id = $1 AND git_repo_name = $2" | |
| 98 | + | ) | |
| 99 | + | .bind(uuid::Uuid::from(user_id)) | |
| 100 | + | .bind("my-repo") | |
| 101 | + | .fetch_optional(&h.db) | |
| 102 | + | .await | |
| 103 | + | .unwrap(); | |
| 104 | + | assert!(found.is_none(), "Should find nothing before linking"); | |
| 105 | + | ||
| 106 | + | // Set link | |
| 107 | + | sqlx::query("UPDATE projects SET git_repo_name = $1 WHERE id = $2") | |
| 108 | + | .bind("my-repo") | |
| 109 | + | .bind(project_uuid) | |
| 110 | + | .execute(&h.db) | |
| 111 | + | .await | |
| 112 | + | .unwrap(); | |
| 113 | + | ||
| 114 | + | // Lookup succeeds | |
| 115 | + | let found: Option<(uuid::Uuid,)> = sqlx::query_as( | |
| 116 | + | "SELECT id FROM projects WHERE user_id = $1 AND git_repo_name = $2" | |
| 117 | + | ) | |
| 118 | + | .bind(uuid::Uuid::from(user_id)) | |
| 119 | + | .bind("my-repo") | |
| 120 | + | .fetch_optional(&h.db) | |
| 121 | + | .await | |
| 122 | + | .unwrap(); | |
| 123 | + | assert!(found.is_some(), "Should find project after linking"); | |
| 124 | + | assert_eq!(found.unwrap().0, project_uuid); | |
| 125 | + | ||
| 126 | + | // Clear link | |
| 127 | + | sqlx::query("UPDATE projects SET git_repo_name = NULL WHERE id = $1") | |
| 128 | + | .bind(project_uuid) | |
| 129 | + | .execute(&h.db) | |
| 130 | + | .await | |
| 131 | + | .unwrap(); | |
| 132 | + | ||
| 133 | + | let found: Option<(uuid::Uuid,)> = sqlx::query_as( | |
| 134 | + | "SELECT id FROM projects WHERE user_id = $1 AND git_repo_name = $2" | |
| 135 | + | ) | |
| 136 | + | .bind(uuid::Uuid::from(user_id)) | |
| 137 | + | .bind("my-repo") | |
| 138 | + | .fetch_optional(&h.db) | |
| 139 | + | .await | |
| 140 | + | .unwrap(); | |
| 141 | + | assert!(found.is_none(), "Should find nothing after clearing"); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | /// The unique index prevents two projects from linking to the same repo. | |
| 145 | + | #[tokio::test] | |
| 146 | + | async fn git_repo_unique_constraint() { | |
| 147 | + | let mut h = TestHarness::new().await; | |
| 148 | + | let (_user_id, project1_id) = setup_creator_with_project(&mut h, "gituser4", "proj4a", "Project 4A").await; | |
| 149 | + | let project1_uuid = project1_id.parse::<uuid::Uuid>().unwrap(); | |
| 150 | + | ||
| 151 | + | // Create a second project | |
| 152 | + | let resp = h | |
| 153 | + | .client | |
| 154 | + | .post_form("/api/projects", "slug=proj4b&title=Project+4B") | |
| 155 | + | .await; | |
| 156 | + | assert!(resp.status.is_success()); | |
| 157 | + | let project2: Value = resp.json(); | |
| 158 | + | let project2_uuid = project2["id"].as_str().unwrap().parse::<uuid::Uuid>().unwrap(); | |
| 159 | + | ||
| 160 | + | // Link first project | |
| 161 | + | sqlx::query("UPDATE projects SET git_repo_name = $1 WHERE id = $2") | |
| 162 | + | .bind("shared-repo") | |
| 163 | + | .bind(project1_uuid) | |
| 164 | + | .execute(&h.db) | |
| 165 | + | .await | |
| 166 | + | .unwrap(); | |
| 167 | + | ||
| 168 | + | // Link second project to same repo should fail (unique index on user_id + git_repo_name) | |
| 169 | + | let result = sqlx::query("UPDATE projects SET git_repo_name = $1 WHERE id = $2") | |
| 170 | + | .bind("shared-repo") | |
| 171 | + | .bind(project2_uuid) | |
| 172 | + | .execute(&h.db) | |
| 173 | + | .await; | |
| 174 | + | assert!(result.is_err(), "Should reject duplicate git_repo_name for same user"); | |
| 175 | + | ||
| 176 | + | // Different users CAN link to the same repo name | |
| 177 | + | let user2_id = h | |
| 178 | + | .signup("gituser4b", "gituser4b@example.com", "password123") | |
| 179 | + | .await; | |
| 180 | + | h.grant_creator(user2_id).await; | |
| 181 | + | h.client.post_form("/logout", "").await; | |
| 182 | + | h.login("gituser4b", "password123").await; | |
| 183 | + | ||
| 184 | + | let resp = h | |
| 185 | + | .client | |
| 186 | + | .post_form("/api/projects", "slug=proj4c&title=Project+4C") | |
| 187 | + | .await; | |
| 188 | + | assert!(resp.status.is_success()); | |
| 189 | + | let project3: Value = resp.json(); | |
| 190 | + | let project3_uuid = project3["id"].as_str().unwrap().parse::<uuid::Uuid>().unwrap(); | |
| 191 | + | ||
| 192 | + | // This should succeed — different user | |
| 193 | + | sqlx::query("UPDATE projects SET git_repo_name = $1 WHERE id = $2") | |
| 194 | + | .bind("shared-repo") | |
| 195 | + | .bind(project3_uuid) | |
| 196 | + | .execute(&h.db) | |
| 197 | + | .await | |
| 198 | + | .expect("Different users should be able to link to the same repo name"); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | /// Project page should NOT show "Source Code" link when no git repo is linked. | |
| 202 | + | #[tokio::test] | |
| 203 | + | async fn project_page_without_git_link() { | |
| 204 | + | let mut h = TestHarness::new().await; | |
| 205 | + | let (_user_id, project_id) = setup_creator_with_project(&mut h, "gituser5", "proj5", "Project Five").await; | |
| 206 | + | ||
| 207 | + | // Make project public | |
| 208 | + | h.client | |
| 209 | + | .put_json( | |
| 210 | + | &format!("/api/projects/{}", project_id), | |
| 211 | + | r#"{"is_public": true}"#, | |
| 212 | + | ) | |
| 213 | + | .await; | |
| 214 | + | ||
| 215 | + | h.client.post_form("/logout", "").await; | |
| 216 | + | ||
| 217 | + | let resp = h.client.get("/p/proj5").await; | |
| 218 | + | assert_eq!(resp.status, 200); | |
| 219 | + | assert!( | |
| 220 | + | !resp.text.contains("Source Code"), | |
| 221 | + | "Project page should NOT show Source Code link without git repo" | |
| 222 | + | ); | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /// Project page should show "Source Code" link when git repo is linked. | |
| 226 | + | #[tokio::test] | |
| 227 | + | async fn project_page_with_git_link() { | |
| 228 | + | let mut h = TestHarness::new().await; | |
| 229 | + | let (_user_id, project_id) = setup_creator_with_project(&mut h, "gituser6", "proj6", "Project Six").await; | |
| 230 | + | ||
| 231 | + | // Make project public | |
| 232 | + | h.client | |
| 233 | + | .put_json( | |
| 234 | + | &format!("/api/projects/{}", project_id), | |
| 235 | + | r#"{"is_public": true}"#, | |
| 236 | + | ) | |
| 237 | + | .await; | |
| 238 | + | ||
| 239 | + | // Set git_repo_name directly via SQL | |
| 240 | + | sqlx::query("UPDATE projects SET git_repo_name = $1 WHERE id = $2") | |
| 241 | + | .bind("my-repo") | |
| 242 | + | .bind(project_id.parse::<uuid::Uuid>().unwrap()) | |
| 243 | + | .execute(&h.db) | |
| 244 | + | .await | |
| 245 | + | .unwrap(); | |
| 246 | + | ||
| 247 | + | h.client.post_form("/logout", "").await; | |
| 248 | + | ||
| 249 | + | let resp = h.client.get("/p/proj6").await; | |
| 250 | + | assert_eq!(resp.status, 200); | |
| 251 | + | // git_repos_path is not configured in tests, so the template condition | |
| 252 | + | // `state.config.git_repos_path.is_some()` will be false and the link won't show. | |
| 253 | + | // This correctly tests that the link only appears when git hosting is configured. | |
| 254 | + | // To fully test the link appearing, we'd need a test harness with git configured. | |
| 255 | + | // For now, verify the page still renders successfully with the field set. | |
| 256 | + | assert!( | |
| 257 | + | resp.text.contains("Project Six"), | |
| 258 | + | "Project page should render with git_repo_name set in DB" | |
| 259 | + | ); | |
| 260 | + | } |