//! Build pipeline integration tests — config CRUD, triggers, cancellation. use crate::harness::{BuildOptions, TestHarness}; use makenotwork::db::{BuildConfigId, BuildId, GitRepoId, SyncAppId, UserId}; use serde::Deserialize; use serde_json::json; use sqlx::PgPool; // ── Response types ── #[derive(Deserialize)] struct AuthResponse { token: String, #[serde(rename = "user_id")] _user_id: UserId, #[serde(rename = "app_id")] _app_id: SyncAppId, } #[derive(Deserialize)] struct ConfigResponse { id: BuildConfigId, app_id: SyncAppId, repo_id: GitRepoId, build_command: String, artifact_path: String, targets: Vec, enabled: bool, } #[derive(Deserialize)] struct BuildResponse { id: BuildId, version: String, tag: String, status: String, triggered_by: String, } // ── Helpers ── /// Insert a sync app directly via SQL. async fn create_sync_app(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) { let api_key = format!("test-build-key-{}", uuid::Uuid::new_v4()); let key_hash = crate::harness::hash_api_key(&api_key); let key_prefix = &api_key[..8]; let app_id: SyncAppId = sqlx::query_scalar( "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) VALUES ($1, 'Build App', $2, $3) RETURNING id", ) .bind(user_id) .bind(&key_hash) .bind(key_prefix) .fetch_one(pool) .await .expect("Failed to create sync app"); (app_id, api_key) } /// Insert a git repo directly via SQL. async fn create_git_repo(pool: &PgPool, user_id: UserId, name: &str) -> GitRepoId { let repo_id: GitRepoId = sqlx::query_scalar( "INSERT INTO git_repos (user_id, name) VALUES ($1, $2) RETURNING id", ) .bind(user_id) .bind(name) .fetch_one(pool) .await .expect("Failed to create git repo"); repo_id } /// Sign up, create an app + repo, get a JWT token. async fn setup_authenticated(h: &mut TestHarness) -> (SyncAppId, GitRepoId) { let user_id = h.signup("builduser", "build@example.com", "Password1!").await; let (app_id, api_key) = create_sync_app(&h.db, user_id).await; let repo_id = create_git_repo(&h.db, user_id, "test-repo").await; let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "build@example.com", "password": "Password1!", "api_key": api_key, "key": "test-sdk-key", }) .to_string(), ) .await; assert_eq!(resp.status, 200, "Auth failed: {}", resp.text); let auth: AuthResponse = resp.json(); h.client.set_bearer_token(&auth.token); (app_id, repo_id) } /// Build a harness with a build trigger token set. #[allow(dead_code)] async fn harness_with_trigger_token() -> TestHarness { TestHarness::build(BuildOptions { build_trigger_token: Some("test-trigger-token".to_string()), ..Default::default() }) .await } // ── Tests ── #[tokio::test] async fn create_build_config_and_retrieve() { let mut h = TestHarness::new().await; let (app_id, repo_id) = setup_authenticated(&mut h).await; // Create config let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/config", app_id), &json!({ "repo_id": repo_id, "build_command": "cargo build --release --target {target}", "artifact_path": "target/{target}/release/myapp", "targets": ["linux/x86_64", "linux/aarch64"] }) .to_string(), ) .await; assert_eq!(resp.status, 201, "Create config failed: {}", resp.text); let config: ConfigResponse = resp.json(); assert_eq!(config.app_id, app_id); assert_eq!(config.repo_id, repo_id); assert_eq!(config.targets, vec!["linux/x86_64", "linux/aarch64"]); assert!(config.enabled); // Retrieve let resp = h .client .get(&format!("/api/sync/builds/apps/{}/config", app_id)) .await; assert_eq!(resp.status, 200); let retrieved: ConfigResponse = resp.json(); assert_eq!(retrieved.id, config.id); assert_eq!(retrieved.build_command, "cargo build --release --target {target}"); } #[tokio::test] async fn update_build_config() { let mut h = TestHarness::new().await; let (app_id, repo_id) = setup_authenticated(&mut h).await; // Create let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/config", app_id), &json!({ "repo_id": repo_id, "build_command": "make build", "artifact_path": "dist/app", "targets": ["linux/x86_64"] }) .to_string(), ) .await; assert_eq!(resp.status, 201); // Update let resp = h .client .put_json( &format!("/api/sync/builds/apps/{}/config", app_id), &json!({ "build_command": "make release", "artifact_path": "dist/app-v2", "targets": ["linux/x86_64", "darwin/aarch64"], "enabled": false }) .to_string(), ) .await; assert_eq!(resp.status, 200, "Update failed: {}", resp.text); let config: ConfigResponse = resp.json(); assert_eq!(config.build_command, "make release"); assert_eq!(config.artifact_path, "dist/app-v2"); assert!(!config.enabled); assert_eq!(config.targets.len(), 2); } #[tokio::test] async fn delete_build_config() { let mut h = TestHarness::new().await; let (app_id, repo_id) = setup_authenticated(&mut h).await; // Create let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/config", app_id), &json!({ "repo_id": repo_id, "build_command": "make", "artifact_path": "out/app", "targets": ["linux/x86_64"] }) .to_string(), ) .await; assert_eq!(resp.status, 201); // Delete let resp = h .client .delete(&format!("/api/sync/builds/apps/{}/config", app_id)) .await; assert_eq!(resp.status, 204, "Delete failed: {}", resp.text); // Verify gone let resp = h .client .get(&format!("/api/sync/builds/apps/{}/config", app_id)) .await; assert_eq!(resp.status, 404); } #[tokio::test] async fn invalid_target_rejected() { let mut h = TestHarness::new().await; let (app_id, repo_id) = setup_authenticated(&mut h).await; let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/config", app_id), &json!({ "repo_id": repo_id, "build_command": "make", "artifact_path": "out/app", "targets": ["windows/x86_64"] }) .to_string(), ) .await; assert_eq!(resp.status, 400, "Should reject invalid target: {}", resp.text); } #[tokio::test] async fn build_config_ownership_check() { let mut h = TestHarness::new().await; // User A creates app + repo let user_a = h.signup("usera", "a@example.com", "Password1!").await; let (app_a_id, _) = create_sync_app(&h.db, user_a).await; // User B signs up, gets JWT let user_b = h.signup("userb", "b@example.com", "Password1!").await; let (_, api_key_b) = create_sync_app(&h.db, user_b).await; let repo_b_id = create_git_repo(&h.db, user_b, "b-repo").await; let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "b@example.com", "password": "Password1!", "api_key": api_key_b, "key": "test-sdk-key", }) .to_string(), ) .await; let auth_b: AuthResponse = resp.json(); h.client.set_bearer_token(&auth_b.token); // User B tries to create config on User A's app let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/config", app_a_id), &json!({ "repo_id": repo_b_id, "build_command": "make", "artifact_path": "out/app", "targets": ["linux/x86_64"] }) .to_string(), ) .await; assert_eq!(resp.status, 403, "Should deny cross-user access"); } #[tokio::test] async fn hook_trigger_unconfigured() { // No build_trigger_token set → 503 let mut h = TestHarness::new().await; let resp = h .client .request_with_headers( "POST", "/api/internal/builds/trigger", Some(&json!({ "repo_owner": "someone", "repo_name": "something", "tag": "v1.0.0" }) .to_string()), &[("authorization", "Bearer whatever"), ("content-type", "application/json")], ) .await; assert_eq!(resp.status, 503, "Should return 503 when BUILD_TRIGGER_TOKEN not set: {}", resp.text); } #[tokio::test] async fn manual_trigger_creates_build() { let mut h = TestHarness::new().await; let (app_id, repo_id) = setup_authenticated(&mut h).await; // Create config let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/config", app_id), &json!({ "repo_id": repo_id, "build_command": "make", "artifact_path": "out/app", "targets": ["linux/x86_64"] }) .to_string(), ) .await; assert_eq!(resp.status, 201); // Trigger build let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/trigger", app_id), &json!({ "tag": "v0.2.2" }).to_string(), ) .await; assert_eq!(resp.status, 201, "Manual trigger failed: {}", resp.text); let build: BuildResponse = resp.json(); assert_eq!(build.version, "0.2.2"); assert_eq!(build.tag, "v0.2.2"); assert_eq!(build.status, "pending"); assert_eq!(build.triggered_by, "manual"); // List builds let resp = h .client .get(&format!("/api/sync/builds/apps/{}/builds", app_id)) .await; assert_eq!(resp.status, 200); let builds: Vec = resp.json(); assert_eq!(builds.len(), 1); assert_eq!(builds[0].id, build.id); } #[tokio::test] async fn cancel_pending_build() { let mut h = TestHarness::new().await; let (app_id, repo_id) = setup_authenticated(&mut h).await; // Create config + trigger let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/config", app_id), &json!({ "repo_id": repo_id, "build_command": "make", "artifact_path": "out/app", "targets": ["linux/x86_64"] }) .to_string(), ) .await; assert_eq!(resp.status, 201); let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/trigger", app_id), &json!({ "tag": "v1.0.0" }).to_string(), ) .await; assert_eq!(resp.status, 201); let build: BuildResponse = resp.json(); // Cancel let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/builds/{}/cancel", app_id, build.id), "{}", ) .await; assert_eq!(resp.status, 204, "Cancel failed: {}", resp.text); // Verify status let resp = h .client .get(&format!("/api/sync/builds/apps/{}/builds/{}", app_id, build.id)) .await; assert_eq!(resp.status, 200); let cancelled: BuildResponse = resp.json(); assert_eq!(cancelled.status, "cancelled"); } #[tokio::test] async fn duplicate_active_build_rejected() { let mut h = TestHarness::new().await; let (app_id, repo_id) = setup_authenticated(&mut h).await; // Create config + trigger first build let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/config", app_id), &json!({ "repo_id": repo_id, "build_command": "make", "artifact_path": "out/app", "targets": ["linux/x86_64"] }) .to_string(), ) .await; assert_eq!(resp.status, 201); let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/trigger", app_id), &json!({ "tag": "v1.0.0" }).to_string(), ) .await; assert_eq!(resp.status, 201); // Second trigger should fail let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/trigger", app_id), &json!({ "tag": "v1.0.1" }).to_string(), ) .await; assert_eq!(resp.status, 400, "Should reject duplicate active build: {}", resp.text); } #[tokio::test] async fn invalid_tag_version_rejected() { let mut h = TestHarness::new().await; let (app_id, repo_id) = setup_authenticated(&mut h).await; // Create config let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/config", app_id), &json!({ "repo_id": repo_id, "build_command": "make", "artifact_path": "out/app", "targets": ["linux/x86_64"] }) .to_string(), ) .await; assert_eq!(resp.status, 201); // Invalid version tag let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/trigger", app_id), &json!({ "tag": "not-a-version" }).to_string(), ) .await; assert_eq!(resp.status, 400, "Should reject non-semver tag: {}", resp.text); // Partial semver let resp = h .client .post_json( &format!("/api/sync/builds/apps/{}/trigger", app_id), &json!({ "tag": "v1.2" }).to_string(), ) .await; assert_eq!(resp.status, 400, "Should reject incomplete semver tag"); }