//! Health and API integration tests //! //! These tests verify that the server endpoints are functioning correctly. //! Run with: cargo test --test health use reqwest::StatusCode; use sqlx::PgPool; use std::time::Duration; /// Base URL for the test server const BASE_URL: &str = "http://localhost:3000"; /// Test helper to make HTTP requests struct TestClient { client: reqwest::Client, } impl TestClient { fn new() -> Self { TestClient { client: reqwest::Client::builder() .timeout(Duration::from_secs(10)) .cookie_store(true) .build() .expect("Failed to create HTTP client"), } } async fn get(&self, path: &str) -> reqwest::Result { self.client.get(format!("{}{}", BASE_URL, path)).send().await } async fn post_form( &self, path: &str, form: &[(&str, &str)], ) -> reqwest::Result { self.client .post(format!("{}{}", BASE_URL, path)) .form(form) .send() .await } } // ============================================================================= // Public Endpoint Tests // ============================================================================= #[tokio::test] async fn test_health_endpoint() { let client = TestClient::new(); // Simple health check should return OK let resp = client.get("/health").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK, "Health endpoint should return 200"); let body = r.text().await.unwrap_or_default(); assert!(body.contains("System Health"), "Health page should contain title"); } Err(e) => { // Server might not be running - skip test eprintln!("Server not available: {}. Skipping test.", e); } } } #[tokio::test] async fn test_index_page() { let client = TestClient::new(); let resp = client.get("/").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK, "Index page should return 200"); let body = r.text().await.unwrap_or_default(); assert!(body.contains("Makenot"), "Index page should contain site name"); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } #[tokio::test] async fn test_login_page() { let client = TestClient::new(); let resp = client.get("/login").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK, "Login page should return 200"); let body = r.text().await.unwrap_or_default(); assert!(body.contains("Log in"), "Login page should contain login form"); assert!(body.contains("csrf-token"), "Login page should have CSRF token"); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } #[tokio::test] async fn test_join_page() { let client = TestClient::new(); let resp = client.get("/join").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK, "Join page should return 200"); let body = r.text().await.unwrap_or_default(); assert!(body.contains("Create"), "Join page should contain create form"); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } #[tokio::test] async fn test_discover_page() { let client = TestClient::new(); let resp = client.get("/discover").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK, "Discover page should return 200"); let body = r.text().await.unwrap_or_default(); assert!(body.contains("Discover") || body.contains("discover"), "Discover page should contain discover content"); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } #[tokio::test] async fn test_nonexistent_page_returns_404() { let client = TestClient::new(); let resp = client.get("/this-page-does-not-exist-12345").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::NOT_FOUND, "Nonexistent page should return 404"); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } // ============================================================================= // Auth Endpoint Tests // ============================================================================= #[tokio::test] async fn test_login_with_invalid_credentials() { let client = TestClient::new(); let resp = client .post_form("/login", &[ ("login", "nonexistent@example.com"), ("password", "wrongpassword"), ]) .await; match resp { Ok(r) => { // Login is exempt from CSRF, so should get through // Should return 200 with error message (HTMX), or 400 (API) let status = r.status(); // Login now exempt from CSRF, so we should get actual response assert!( status == StatusCode::OK || status == StatusCode::BAD_REQUEST || status == StatusCode::UNAUTHORIZED, "Invalid login should return error, got: {}", status ); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } #[tokio::test] async fn test_protected_route_requires_auth() { let client = TestClient::new(); let resp = client.get("/dashboard").await; match resp { Ok(r) => { // Should redirect to login or return unauthorized let status = r.status(); assert!( status == StatusCode::UNAUTHORIZED || status == StatusCode::SEE_OTHER || status == StatusCode::FOUND || status == StatusCode::TEMPORARY_REDIRECT, "Dashboard should require authentication, got: {}", status ); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } // ============================================================================= // API Endpoint Tests // ============================================================================= #[tokio::test] async fn test_username_validation_endpoint_exists() { let client = TestClient::new(); // Username validation - may require CSRF for authenticated users // Just verify endpoint responds (not 404 or 500) let resp = client .post_form("/api/validate/username", &[("username", "testuser")]) .await; match resp { Ok(r) => { let status = r.status(); // Should not be 404 or 500 - may be 200 (valid), 400 (invalid), or 403 (CSRF) assert!( status != StatusCode::NOT_FOUND && status != StatusCode::INTERNAL_SERVER_ERROR, "Username validation endpoint should exist and not error, got: {}", status ); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } // ============================================================================= // Static File Tests // ============================================================================= #[tokio::test] async fn test_static_css_served() { let client = TestClient::new(); let resp = client.get("/static/style.css").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK, "Static CSS should be served"); let content_type = r.headers().get("content-type").map(|v| v.to_str().unwrap_or("")); assert!( content_type.map(|ct| ct.contains("css")).unwrap_or(false), "CSS file should have CSS content type" ); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } // ============================================================================= // Database Integration Tests (requires DATABASE_URL) // ============================================================================= #[tokio::test] async fn test_database_connection() { // Only run if DATABASE_URL is set let database_url = match std::env::var("DATABASE_URL") { Ok(url) => url, Err(_) => { eprintln!("DATABASE_URL not set, skipping database test"); return; } }; let pool = PgPool::connect(&database_url).await; match pool { Ok(p) => { // Test a simple query let result: Result<(i64,), _> = sqlx::query_as("SELECT COUNT(*) FROM users") .fetch_one(&p) .await; assert!(result.is_ok(), "Should be able to query users table"); } Err(e) => { panic!("Failed to connect to database: {}", e); } } } #[tokio::test] async fn test_database_tables_exist() { let database_url = match std::env::var("DATABASE_URL") { Ok(url) => url, Err(_) => { eprintln!("DATABASE_URL not set, skipping database test"); return; } }; let pool = PgPool::connect(&database_url).await.expect("Failed to connect"); // Check that all expected tables exist (must match migrations 001-025). // Note: the session table was renamed from `sessions` to `user_sessions` // when tower-sessions-sqlx-store config was updated; the old name was left // in this list and broke the assertion in fresh DBs. let tables = vec![ "users", "projects", "items", "versions", "transactions", "custom_links", "user_sessions", "blog_posts", "chapters", "creator_waitlist", "creator_waves", "login_tokens", "license_keys", "license_activations", "sync_apps", "sync_devices", "sync_log", "sync_keys", "oauth_authorization_codes", ]; for table in tables { let result: Result<(bool,), _> = sqlx::query_as( "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = $1)" ) .bind(table) .fetch_one(&pool) .await; match result { Ok((exists,)) => { assert!(exists, "Table '{}' should exist", table); } Err(e) => { panic!("Failed to check table '{}': {}", table, e); } } } } // ============================================================================= // Public Pages (additional) // ============================================================================= #[tokio::test] async fn test_policy_page() { let client = TestClient::new(); let resp = client.get("/policy").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK, "Policy page should return 200"); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } #[tokio::test] async fn test_creators_page() { let client = TestClient::new(); let resp = client.get("/creators").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK, "Creators page should return 200"); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } // ============================================================================= // 404 for nonexistent content // ============================================================================= #[tokio::test] async fn test_nonexistent_user_returns_404() { let client = TestClient::new(); let resp = client.get("/u/nonexistent_user_99999").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::NOT_FOUND, "Nonexistent user should return 404"); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } #[tokio::test] async fn test_nonexistent_project_returns_404() { let client = TestClient::new(); let resp = client.get("/p/nonexistent-project-slug-99999").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::NOT_FOUND, "Nonexistent project should return 404"); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } // ============================================================================= // Auth-required API endpoints reject unauthenticated // ============================================================================= #[tokio::test] async fn test_api_projects_requires_auth() { let client = TestClient::new(); let resp = client.post_form("/api/projects", &[("title", "Test")]).await; match resp { Ok(r) => { let status = r.status(); assert!( status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN || status == StatusCode::SEE_OTHER || status == StatusCode::FOUND, "POST /api/projects should require auth, got: {}", status ); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } #[tokio::test] async fn test_api_items_requires_auth() { let client = TestClient::new(); let resp = client .post_form( "/api/projects/00000000-0000-0000-0000-000000000000/items", &[("title", "Test")], ) .await; match resp { Ok(r) => { let status = r.status(); assert!( status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN || status == StatusCode::SEE_OTHER || status == StatusCode::FOUND, "POST /api/projects/:id/items should require auth, got: {}", status ); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } #[tokio::test] async fn test_api_export_projects_requires_auth() { let client = TestClient::new(); let resp = client.post_form("/api/export/projects", &[]).await; match resp { Ok(r) => { let status = r.status(); assert!( status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN || status == StatusCode::SEE_OTHER || status == StatusCode::FOUND, "POST /api/export/projects should require auth, got: {}", status ); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } // ============================================================================= // Discover variants // ============================================================================= #[tokio::test] async fn test_discover_projects_mode() { let client = TestClient::new(); let resp = client.get("/discover?mode=projects").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK, "Discover projects mode should return 200"); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } #[tokio::test] async fn test_discover_results_partial() { let client = TestClient::new(); let resp = client.get("/discover/results").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK, "Discover results partial should return 200"); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } // ============================================================================= // RSS 404s for nonexistent content // ============================================================================= #[tokio::test] async fn test_nonexistent_user_rss_returns_404() { let client = TestClient::new(); let resp = client.get("/u/nonexistent_user_99999/rss").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::NOT_FOUND, "Nonexistent user RSS should return 404"); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } #[tokio::test] async fn test_nonexistent_project_rss_returns_404() { let client = TestClient::new(); let resp = client.get("/p/nonexistent-project-slug-99999/rss").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::NOT_FOUND, "Nonexistent project RSS should return 404"); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } // ============================================================================= // Username validation // ============================================================================= #[tokio::test] async fn test_username_validation_short_input() { let client = TestClient::new(); let resp = client .post_form("/api/validate/username", &[("username", "ab")]) .await; match resp { Ok(r) => { let status = r.status(); assert!( status != StatusCode::NOT_FOUND && status != StatusCode::INTERNAL_SERVER_ERROR, "Short username validation should not be 404/500, got: {}", status ); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } #[tokio::test] async fn test_username_validation_invalid_chars() { let client = TestClient::new(); let resp = client .post_form("/api/validate/username", &[("username", "user@name!")]) .await; match resp { Ok(r) => { let status = r.status(); assert!( status != StatusCode::NOT_FOUND && status != StatusCode::INTERNAL_SERVER_ERROR, "Invalid-chars username validation should not be 404/500, got: {}", status ); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } // ============================================================================= // JSON Health Endpoint // ============================================================================= #[tokio::test] async fn test_api_health_json_endpoint() { let client = TestClient::new(); let resp = client.get("/api/health").await; match resp { Ok(r) => { let status = r.status(); assert!( status == StatusCode::OK || status == StatusCode::SERVICE_UNAVAILABLE, "JSON health endpoint should return 200 or 503, got: {}", status ); let content_type = r.headers().get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert!( content_type.contains("application/json"), "JSON health endpoint should return application/json, got: {}", content_type ); let body: serde_json::Value = r.json().await.expect("Should parse as JSON"); assert!( body.get("status").is_some(), "JSON response should have 'status' field" ); assert!( body.get("version").is_some(), "JSON response should have 'version' field" ); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } // ============================================================================= // Health self-check (verifies uptime field) // ============================================================================= #[tokio::test] async fn test_health_contains_uptime() { let client = TestClient::new(); let resp = client.get("/health").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK); let body = r.text().await.unwrap_or_default(); assert!( body.contains("Uptime:"), "Health page should contain uptime field" ); } Err(e) => { eprintln!("Server not available: {}. Skipping test.", e); } } } // ============================================================================= // Test Runner Summary // ============================================================================= /// Run this to see a summary of all tests /// cargo test --test health -- --nocapture #[tokio::test] async fn test_summary() { println!("\n=== Makenotwork Integration Tests ===\n"); println!("Tests check:"); println!(" - Public pages load correctly"); println!(" - Auth endpoints respond appropriately"); println!(" - Protected routes require authentication"); println!(" - Static files are served"); println!(" - Database connection works (if DATABASE_URL set)"); println!("\nNote: Tests that require the server will be skipped if not running."); println!("\nTo run all tests with server:"); println!(" 1. Start server: cargo run"); println!(" 2. Run tests: cargo test --test health"); }