//! Integration tests for the sources CRUD endpoints (Phase 2). //! //! Tests: //! - GET /api/v1/sources — list user's sources //! - POST /api/v1/sources — create a single source //! - DELETE /api/v1/sources/:id — delete a source //! - POST /api/v1/sources/bulk — bulk import from JSON array //! - POST /api/v1/sources/import-csv — import from CSV file upload //! - GET /api/v1/sources/export-csv — download sources as CSV //! //! Covers authentication, validation, ownership isolation, max limit, //! duplicate handling, and CSV roundtrip. //! //! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run. mod common; use axum::body::Body; use axum::http::{Method, Request, StatusCode}; fn require_test_db() -> bool { std::env::var("TEST_DATABASE_URL").is_ok() } /// Helper: create a theme and return its id. async fn create_theme(app: &common::TestApp, session: &str) -> String { let body = serde_json::json!({ "name": "Test Theme", "theme": "Test", "categories": ["Cat"] }); let (status, resp) = app .post_with_session("/api/v1/themes", &body, session) .await; assert_eq!(status.as_u16(), 201, "Theme creation should succeed"); resp["id"].as_str().unwrap().to_string() } // ═══════════════════════════════════════════════════════════════════════════ // Authentication // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn get_sources_without_auth_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (status, body) = app.get_with_session("/api/v1/sources", "invalid-session-token").await; assert_eq!( status, StatusCode::UNAUTHORIZED, "GET /sources without auth should return 401" ); assert_eq!(body["error"], "unauthorized"); } #[tokio::test] async fn post_sources_without_auth_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let body = serde_json::json!({ "title": "My Blog", "url": "https://example.com" }); let (status, resp) = app.post("/api/v1/sources", &body).await; assert_eq!( status, StatusCode::UNAUTHORIZED, "POST /sources without auth should return 401" ); assert_eq!(resp["error"], "unauthorized"); } #[tokio::test] async fn delete_source_without_auth_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let fake_id = uuid::Uuid::new_v4(); // Build a DELETE request without session let req = Request::builder() .method(Method::DELETE) .uri(format!("/api/v1/sources/{}", fake_id)) .header("X-Requested-With", "XMLHttpRequest") .body(Body::empty()) .unwrap(); let response = app.raw_request(req).await; assert_eq!( response.status(), StatusCode::UNAUTHORIZED, "DELETE /sources/:id without auth should return 401" ); } // ═══════════════════════════════════════════════════════════════════════════ // CRUD — Basic operations // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn get_sources_empty_list() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("sources-empty@example.com") .await; let theme_id = create_theme(&app, &session).await; let (status, body) = app .get_with_session(&format!("/api/v1/sources?theme_id={}", theme_id), &session) .await; assert_eq!(status, StatusCode::OK, "GET /sources should return 200"); let sources = body.as_array().expect("Response should be an array"); assert!(sources.is_empty(), "New user should have no sources"); } #[tokio::test] async fn create_source_with_valid_data_returns_201() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("sources-create@example.com") .await; let theme_id = create_theme(&app, &session).await; let body = serde_json::json!({ "title": "My Blog", "url": "https://blog.example.com", "theme_id": theme_id }); let (status, resp) = app .post_with_session("/api/v1/sources", &body, &session) .await; assert_eq!( status, StatusCode::CREATED, "POST /sources with valid data should return 201" ); assert_eq!(resp["title"], "My Blog"); assert_eq!(resp["url"], "https://blog.example.com"); assert!( resp["id"].as_str().is_some(), "Response should contain an id" ); assert!( resp["created_at"].as_str().is_some(), "Response should contain created_at" ); } #[tokio::test] async fn create_source_then_list_shows_it() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("sources-create-list@example.com") .await; let theme_id = create_theme(&app, &session).await; // Create a source let body = serde_json::json!({ "title": "Tech News", "url": "https://technews.example.com", "theme_id": theme_id }); let (create_status, create_resp) = app .post_with_session("/api/v1/sources", &body, &session) .await; assert_eq!(create_status, StatusCode::CREATED); let created_id = create_resp["id"].as_str().unwrap(); // List sources let (list_status, list_body) = app .get_with_session(&format!("/api/v1/sources?theme_id={}", theme_id), &session) .await; assert_eq!(list_status, StatusCode::OK); let sources = list_body.as_array().expect("Should be an array"); assert_eq!(sources.len(), 1, "Should have exactly one source"); assert_eq!(sources[0]["id"], created_id); assert_eq!(sources[0]["title"], "Tech News"); assert_eq!(sources[0]["url"], "https://technews.example.com"); } #[tokio::test] async fn create_multiple_sources_list_returns_all() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("sources-multi@example.com") .await; let theme_id = create_theme(&app, &session).await; // Create three sources for i in 1..=3 { let body = serde_json::json!({ "title": format!("Source {}", i), "url": format!("https://source{}.example.com", i), "theme_id": theme_id }); let (status, _) = app .post_with_session("/api/v1/sources", &body, &session) .await; assert_eq!(status, StatusCode::CREATED); } // List should have 3 sources let (status, list_body) = app .get_with_session(&format!("/api/v1/sources?theme_id={}", theme_id), &session) .await; assert_eq!(status, StatusCode::OK); let sources = list_body.as_array().unwrap(); assert_eq!(sources.len(), 3, "Should have exactly 3 sources"); } // ═══════════════════════════════════════════════════════════════════════════ // CRUD — Validation errors // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn create_source_invalid_url_returns_422() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("sources-invalid-url@example.com") .await; let theme_id = create_theme(&app, &session).await; let body = serde_json::json!({ "title": "My Blog", "url": "not-a-valid-url", "theme_id": theme_id }); let (status, resp) = app .post_with_session("/api/v1/sources", &body, &session) .await; assert_eq!( status, StatusCode::UNPROCESSABLE_ENTITY, "Invalid URL should return 422" ); assert_eq!(resp["error"], "validation_error"); } #[tokio::test] async fn create_source_ftp_url_returns_422() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("sources-ftp-url@example.com") .await; let theme_id = create_theme(&app, &session).await; let body = serde_json::json!({ "title": "FTP Source", "url": "ftp://files.example.com", "theme_id": theme_id }); let (status, resp) = app .post_with_session("/api/v1/sources", &body, &session) .await; assert_eq!( status, StatusCode::UNPROCESSABLE_ENTITY, "FTP URL should return 422" ); assert_eq!(resp["error"], "validation_error"); } #[tokio::test] async fn create_source_empty_title_returns_422() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("sources-empty-title@example.com") .await; let theme_id = create_theme(&app, &session).await; let body = serde_json::json!({ "title": " ", "url": "https://example.com", "theme_id": theme_id }); let (status, resp) = app .post_with_session("/api/v1/sources", &body, &session) .await; assert_eq!( status, StatusCode::UNPROCESSABLE_ENTITY, "Empty title should return 422" ); assert_eq!(resp["error"], "validation_error"); } #[tokio::test] async fn create_source_title_too_long_returns_422() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("sources-title-long@example.com") .await; let theme_id = create_theme(&app, &session).await; let long_title = "a".repeat(201); let body = serde_json::json!({ "title": long_title, "url": "https://example.com", "theme_id": theme_id }); let (status, resp) = app .post_with_session("/api/v1/sources", &body, &session) .await; assert_eq!( status, StatusCode::UNPROCESSABLE_ENTITY, "Title >200 chars should return 422" ); assert_eq!(resp["error"], "validation_error"); } #[tokio::test] async fn create_source_url_too_long_returns_422() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("sources-url-long@example.com") .await; let theme_id = create_theme(&app, &session).await; let long_url = format!("https://example.com/{}", "a".repeat(990)); assert!(long_url.len() > 1000); let body = serde_json::json!({ "title": "My Blog", "url": long_url, "theme_id": theme_id }); let (status, resp) = app .post_with_session("/api/v1/sources", &body, &session) .await; assert_eq!( status, StatusCode::UNPROCESSABLE_ENTITY, "URL >1000 chars should return 422" ); assert_eq!(resp["error"], "validation_error"); } #[tokio::test] async fn create_source_empty_url_returns_422() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("sources-empty-url@example.com") .await; let theme_id = create_theme(&app, &session).await; let body = serde_json::json!({ "title": "My Blog", "url": "", "theme_id": theme_id }); let (status, resp) = app .post_with_session("/api/v1/sources", &body, &session) .await; assert_eq!( status, StatusCode::UNPROCESSABLE_ENTITY, "Empty URL should return 422" ); assert_eq!(resp["error"], "validation_error"); } // ═══════════════════════════════════════════════════════════════════════════ // CRUD — Duplicate URL handling // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn create_source_duplicate_url_returns_error() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("sources-dup@example.com") .await; let theme_id = create_theme(&app, &session).await; let body = serde_json::json!({ "title": "My Blog", "url": "https://duplicate.example.com", "theme_id": theme_id }); // First creation should succeed let (status1, _) = app .post_with_session("/api/v1/sources", &body, &session) .await; assert_eq!(status1, StatusCode::CREATED); // Second creation with same URL should fail (DB unique constraint) let body2 = serde_json::json!({ "title": "Different Title", "url": "https://duplicate.example.com", "theme_id": theme_id }); let (status2, _) = app .post_with_session("/api/v1/sources", &body2, &session) .await; // The DB has a unique constraint on (user_id, url), so this should return // an error (500 from DB constraint or could be handled as 409 Conflict). // Since the handler doesn't explicitly check for duplicates before insert, // it will hit the DB constraint, which maps to an internal error. assert!( status2 == StatusCode::INTERNAL_SERVER_ERROR || status2 == StatusCode::CONFLICT || status2 == StatusCode::UNPROCESSABLE_ENTITY, "Duplicate URL should return an error status, got {}", status2 ); } // ═══════════════════════════════════════════════════════════════════════════ // CRUD — Delete // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn delete_source_valid_id_returns_204() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("sources-delete@example.com") .await; let theme_id = create_theme(&app, &session).await; // Create a source let body = serde_json::json!({ "title": "To Delete", "url": "https://delete.example.com", "theme_id": theme_id }); let (create_status, create_resp) = app .post_with_session("/api/v1/sources", &body, &session) .await; assert_eq!(create_status, StatusCode::CREATED); let source_id = create_resp["id"].as_str().unwrap(); // Delete it let (del_status, _) = app .delete_with_session(&format!("/api/v1/sources/{}", source_id), &session) .await; assert_eq!( del_status, StatusCode::NO_CONTENT, "DELETE should return 204" ); } #[tokio::test] async fn delete_source_then_list_no_longer_shows_it() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("sources-delete-list@example.com") .await; let theme_id = create_theme(&app, &session).await; // Create two sources let body1 = serde_json::json!({ "title": "Keep This", "url": "https://keep.example.com", "theme_id": theme_id }); let (_, resp1) = app .post_with_session("/api/v1/sources", &body1, &session) .await; let keep_id = resp1["id"].as_str().unwrap().to_string(); let body2 = serde_json::json!({ "title": "Delete This", "url": "https://delete-me.example.com", "theme_id": theme_id }); let (_, resp2) = app .post_with_session("/api/v1/sources", &body2, &session) .await; let delete_id = resp2["id"].as_str().unwrap(); // Delete the second one let (del_status, _) = app .delete_with_session(&format!("/api/v1/sources/{}", delete_id), &session) .await; assert_eq!(del_status, StatusCode::NO_CONTENT); // List should only show the first source let (_, list_body) = app .get_with_session(&format!("/api/v1/sources?theme_id={}", theme_id), &session) .await; let sources = list_body.as_array().unwrap(); assert_eq!(sources.len(), 1, "Should have exactly 1 source after delete"); assert_eq!(sources[0]["id"], keep_id); } #[tokio::test] async fn delete_source_nonexistent_id_returns_404() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("sources-delete-404@example.com") .await; let fake_id = uuid::Uuid::new_v4(); let (status, body) = app .delete_with_session(&format!("/api/v1/sources/{}", fake_id), &session) .await; assert_eq!( status, StatusCode::NOT_FOUND, "DELETE with non-existent id should return 404" ); assert_eq!(body["error"], "not_found"); } // ═══════════════════════════════════════════════════════════════════════════ // Ownership Isolation // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn user_a_sources_not_visible_to_user_b() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_a_id, session_a) = app .create_authenticated_user("user-a-sources@example.com") .await; let (_user_b_id, session_b) = app .create_authenticated_user("user-b-sources@example.com") .await; let theme_id_a = create_theme(&app, &session_a).await; let theme_id_b = create_theme(&app, &session_b).await; // User A creates a source let body = serde_json::json!({ "title": "User A Blog", "url": "https://usera.example.com", "theme_id": theme_id_a }); let (status, _) = app .post_with_session("/api/v1/sources", &body, &session_a) .await; assert_eq!(status, StatusCode::CREATED); // User B lists sources -> should be empty let (list_status, list_body) = app .get_with_session(&format!("/api/v1/sources?theme_id={}", theme_id_b), &session_b) .await; assert_eq!(list_status, StatusCode::OK); let sources = list_body.as_array().unwrap(); assert!( sources.is_empty(), "User B should NOT see User A's sources" ); // User A lists sources -> should see their source let (_, list_body_a) = app .get_with_session(&format!("/api/v1/sources?theme_id={}", theme_id_a), &session_a) .await; let sources_a = list_body_a.as_array().unwrap(); assert_eq!(sources_a.len(), 1, "User A should see their own source"); } #[tokio::test] async fn user_b_cannot_delete_user_a_source_returns_404() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_a_id, session_a) = app .create_authenticated_user("owner-a@example.com") .await; let (_user_b_id, session_b) = app .create_authenticated_user("attacker-b@example.com") .await; let theme_id_a = create_theme(&app, &session_a).await; // User A creates a source let body = serde_json::json!({ "title": "User A Private", "url": "https://private-a.example.com", "theme_id": theme_id_a }); let (_, create_resp) = app .post_with_session("/api/v1/sources", &body, &session_a) .await; let source_id = create_resp["id"].as_str().unwrap(); // User B tries to delete it -> should get 404 (NOT 403, to avoid info leakage) let (del_status, del_body) = app .delete_with_session(&format!("/api/v1/sources/{}", source_id), &session_b) .await; assert_eq!( del_status, StatusCode::NOT_FOUND, "Deleting another user's source should return 404, not 403" ); assert_eq!(del_body["error"], "not_found"); // Verify User A's source is still there let (_, list_body) = app .get_with_session(&format!("/api/v1/sources?theme_id={}", theme_id_a), &session_a) .await; let sources = list_body.as_array().unwrap(); assert_eq!( sources.len(), 1, "User A's source should NOT have been deleted" ); } // ═══════════════════════════════════════════════════════════════════════════ // Bulk Import // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn bulk_import_valid_sources_succeeds() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("bulk-import@example.com") .await; let theme_id = create_theme(&app, &session).await; let body = serde_json::json!({ "sources": [ { "title": "Blog 1", "url": "https://blog1.example.com" }, { "title": "Blog 2", "url": "https://blog2.example.com" }, { "title": "Blog 3", "url": "https://blog3.example.com" } ], "theme_id": theme_id }); let (status, resp) = app .post_with_session("/api/v1/sources/bulk", &body, &session) .await; assert_eq!( status, StatusCode::OK, "Bulk import should return 200" ); assert_eq!(resp["imported"], 3, "Should have imported 3 sources"); assert_eq!(resp["skipped"], 0, "Should have skipped 0 sources"); // Verify they appear in the list let (_, list_body) = app .get_with_session(&format!("/api/v1/sources?theme_id={}", theme_id), &session) .await; let sources = list_body.as_array().unwrap(); assert_eq!(sources.len(), 3); } #[tokio::test] async fn bulk_import_with_duplicates_skips_them() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("bulk-dup@example.com") .await; let theme_id = create_theme(&app, &session).await; // First, create one source normally let body = serde_json::json!({ "title": "Existing", "url": "https://existing.example.com", "theme_id": theme_id }); let (status, _) = app .post_with_session("/api/v1/sources", &body, &session) .await; assert_eq!(status, StatusCode::CREATED); // Now bulk import with the same URL + a new one let bulk = serde_json::json!({ "sources": [ { "title": "Existing Dup", "url": "https://existing.example.com" }, { "title": "New One", "url": "https://new.example.com" } ], "theme_id": theme_id }); let (bulk_status, resp) = app .post_with_session("/api/v1/sources/bulk", &bulk, &session) .await; assert_eq!(bulk_status, StatusCode::OK); assert_eq!( resp["imported"], 1, "Only 1 new source should be imported" ); assert_eq!( resp["skipped"], 1, "1 duplicate should be skipped" ); // Total should be 2 (original + new) let (_, list_body) = app .get_with_session(&format!("/api/v1/sources?theme_id={}", theme_id), &session) .await; let sources = list_body.as_array().unwrap(); assert_eq!(sources.len(), 2); } #[tokio::test] async fn bulk_import_empty_array_returns_error() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("bulk-empty@example.com") .await; let theme_id = create_theme(&app, &session).await; let body = serde_json::json!({ "sources": [], "theme_id": theme_id }); let (status, resp) = app .post_with_session("/api/v1/sources/bulk", &body, &session) .await; assert_eq!( status, StatusCode::UNPROCESSABLE_ENTITY, "Empty bulk import should return 422" ); assert_eq!(resp["error"], "validation_error"); } #[tokio::test] async fn bulk_import_with_invalid_entries_reports_errors() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("bulk-invalid@example.com") .await; let theme_id = create_theme(&app, &session).await; let body = serde_json::json!({ "sources": [ { "title": "Valid", "url": "https://valid.example.com" }, { "title": "", "url": "https://empty-title.example.com" }, { "title": "Bad URL", "url": "not-a-url" }, { "title": "Also Valid", "url": "https://alsovalid.example.com" } ], "theme_id": theme_id }); let (status, resp) = app .post_with_session("/api/v1/sources/bulk", &body, &session) .await; assert_eq!(status, StatusCode::OK, "Bulk import should still return 200"); assert_eq!( resp["imported"], 2, "Only the 2 valid sources should be imported" ); let errors = resp["errors"].as_array().expect("errors should be an array"); assert_eq!( errors.len(), 2, "Should have 2 validation errors (empty title + invalid URL)" ); // Verify only 2 sources exist let (_, list_body) = app .get_with_session(&format!("/api/v1/sources?theme_id={}", theme_id), &session) .await; let sources = list_body.as_array().unwrap(); assert_eq!(sources.len(), 2); } #[tokio::test] async fn bulk_import_without_auth_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let body = serde_json::json!({ "sources": [ { "title": "Blog", "url": "https://blog.example.com" } ] }); let (status, resp) = app.post("/api/v1/sources/bulk", &body).await; assert_eq!( status, StatusCode::UNAUTHORIZED, "Bulk import without auth should return 401" ); assert_eq!(resp["error"], "unauthorized"); } // ═══════════════════════════════════════════════════════════════════════════ // CSV Export // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn export_csv_with_sources_returns_csv() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("csv-export@example.com") .await; let theme_id = create_theme(&app, &session).await; // Create some sources for i in 1..=2 { let body = serde_json::json!({ "title": format!("Source {}", i), "url": format!("https://source{}.example.com", i), "theme_id": theme_id }); let (s, _) = app .post_with_session("/api/v1/sources", &body, &session) .await; assert_eq!(s, StatusCode::CREATED); } // Export CSV let req = Request::builder() .method(Method::GET) .uri(format!("/api/v1/sources/export-csv?theme_id={}", theme_id)) .header( "Cookie", format!("ai_synth_session={}", session), ) .body(Body::empty()) .unwrap(); let (status, text, headers) = app.raw_request_text(req).await; assert_eq!(status, StatusCode::OK, "CSV export should return 200"); // Check Content-Type let content_type = headers .get("content-type") .map(|v| v.to_str().unwrap_or("")) .unwrap_or(""); assert!( content_type.contains("text/csv"), "Content-Type should be text/csv, got: {}", content_type ); // Check Content-Disposition let disposition = headers .get("content-disposition") .map(|v| v.to_str().unwrap_or("")) .unwrap_or(""); assert!( disposition.contains("attachment"), "Content-Disposition should indicate attachment, got: {}", disposition ); // Check CSV content let lines: Vec<&str> = text.lines().collect(); assert!(lines.len() >= 3, "CSV should have header + 2 data rows"); assert_eq!(lines[0], "title,url", "First line should be the header"); // Data rows — order is newest-first assert!( text.contains("Source 1") && text.contains("Source 2"), "CSV should contain both sources" ); assert!( text.contains("https://source1.example.com") && text.contains("https://source2.example.com"), "CSV should contain both URLs" ); } #[tokio::test] async fn export_csv_with_no_sources_returns_header_only() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("csv-export-empty@example.com") .await; let theme_id = create_theme(&app, &session).await; let req = Request::builder() .method(Method::GET) .uri(format!("/api/v1/sources/export-csv?theme_id={}", theme_id)) .header( "Cookie", format!("ai_synth_session={}", session), ) .body(Body::empty()) .unwrap(); let (status, text, _) = app.raw_request_text(req).await; assert_eq!(status, StatusCode::OK); let lines: Vec<&str> = text.lines().collect(); assert_eq!(lines.len(), 1, "Should have only the header row"); assert_eq!(lines[0], "title,url"); } #[tokio::test] async fn export_csv_without_auth_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let req = Request::builder() .method(Method::GET) .uri("/api/v1/sources/export-csv") .body(Body::empty()) .unwrap(); let response = app.raw_request(req).await; assert_eq!( response.status(), StatusCode::UNAUTHORIZED, "CSV export without auth should return 401" ); } // ═══════════════════════════════════════════════════════════════════════════ // CSV Import // ═══════════════════════════════════════════════════════════════════════════ /// Helper to build a multipart request body for CSV import. fn build_csv_multipart_request( csv_content: &str, session_cookie: &str, theme_id: &str, ) -> Request
{ let boundary = "----TestBoundary12345"; let body = format!( "--{boundary}\r\n\ Content-Disposition: form-data; name=\"file\"; filename=\"sources.csv\"\r\n\ Content-Type: text/csv\r\n\ \r\n\ {csv_content}\r\n\ --{boundary}--\r\n", boundary = boundary, csv_content = csv_content ); Request::builder() .method(Method::POST) .uri(format!("/api/v1/sources/import-csv?theme_id={}", theme_id)) .header( "Content-Type", format!("multipart/form-data; boundary={}", boundary), ) .header("X-Requested-With", "XMLHttpRequest") .header( "Cookie", format!("ai_synth_session={}", session_cookie), ) .body(Body::from(body)) .unwrap() } #[tokio::test] async fn import_csv_with_valid_data_succeeds() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("csv-import@example.com") .await; let theme_id = create_theme(&app, &session).await; let csv_content = "title,url\nBlog One,https://blog1.example.com\nBlog Two,https://blog2.example.com"; let req = build_csv_multipart_request(csv_content, &session, &theme_id); let response = app.raw_request(req).await; let status = response.status(); assert_eq!(status, StatusCode::OK, "CSV import should return 200"); // Parse the response body let bytes = http_body_util::BodyExt::collect(response.into_body()) .await .unwrap() .to_bytes(); let resp: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(resp["imported"], 2, "Should have imported 2 sources"); assert_eq!(resp["skipped"], 0); // Verify via list let (_, list_body) = app .get_with_session(&format!("/api/v1/sources?theme_id={}", theme_id), &session) .await; let sources = list_body.as_array().unwrap(); assert_eq!(sources.len(), 2); } #[tokio::test] async fn import_csv_semicolon_separated_succeeds() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("csv-semicolon@example.com") .await; let theme_id = create_theme(&app, &session).await; let csv_content = "titre;lien\nMon Blog;https://monblog.example.com\nActus;https://actus.example.com"; let req = build_csv_multipart_request(csv_content, &session, &theme_id); let response = app.raw_request(req).await; let status = response.status(); assert_eq!( status, StatusCode::OK, "CSV import with semicolons should return 200" ); let bytes = http_body_util::BodyExt::collect(response.into_body()) .await .unwrap() .to_bytes(); let resp: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!( resp["imported"], 2, "Should import 2 sources from semicolon-separated CSV" ); // Verify the actual titles let (_, list_body) = app .get_with_session(&format!("/api/v1/sources?theme_id={}", theme_id), &session) .await; let sources = list_body.as_array().unwrap(); assert_eq!(sources.len(), 2); let titles: Vec<&str> = sources .iter() .map(|s| s["title"].as_str().unwrap()) .collect(); assert!(titles.contains(&"Mon Blog")); assert!(titles.contains(&"Actus")); } #[tokio::test] async fn import_csv_without_auth_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let boundary = "----TestBoundary12345"; let body_str = format!( "--{boundary}\r\n\ Content-Disposition: form-data; name=\"file\"; filename=\"sources.csv\"\r\n\ Content-Type: text/csv\r\n\ \r\n\ title,url\nBlog,https://example.com\r\n\ --{boundary}--\r\n", boundary = boundary ); let req = Request::builder() .method(Method::POST) .uri("/api/v1/sources/import-csv") .header( "Content-Type", format!("multipart/form-data; boundary={}", boundary), ) .header("X-Requested-With", "XMLHttpRequest") .body(Body::from(body_str)) .unwrap(); let response = app.raw_request(req).await; assert_eq!( response.status(), StatusCode::UNAUTHORIZED, "CSV import without auth should return 401" ); } // ═══════════════════════════════════════════════════════════════════════════ // CSV Roundtrip: create sources -> export -> verify content // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn csv_export_roundtrip() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("csv-roundtrip@example.com") .await; let theme_id = create_theme(&app, &session).await; // Create sources let source_data = vec![ ("Rust Blog", "https://rust-blog.example.com"), ("AI News", "https://ainews.example.com"), ("Tech Crunch", "https://techcrunch.example.com"), ]; for (title, url) in &source_data { let body = serde_json::json!({ "title": title, "url": url, "theme_id": theme_id }); let (s, _) = app .post_with_session("/api/v1/sources", &body, &session) .await; assert_eq!(s, StatusCode::CREATED); } // Export CSV let req = Request::builder() .method(Method::GET) .uri(format!("/api/v1/sources/export-csv?theme_id={}", theme_id)) .header("Cookie", format!("ai_synth_session={}", session)) .body(Body::empty()) .unwrap(); let (status, csv_text, _) = app.raw_request_text(req).await; assert_eq!(status, StatusCode::OK); // Verify CSV has a header and 3 data rows let lines: Vec<&str> = csv_text.lines().collect(); assert_eq!(lines[0], "title,url"); // There should be 3 data rows (newest first, but we don't care about order here) assert_eq!( lines.len() - 1, // subtract header 3, "Should have 3 data rows" ); // Verify all sources are present for (title, url) in &source_data { assert!( csv_text.contains(title), "CSV should contain title '{}'", title ); assert!( csv_text.contains(url), "CSV should contain URL '{}'", url ); } } // ═══════════════════════════════════════════════════════════════════════════ // Max Sources Limit // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn max_sources_limit_enforced() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (user_id, session) = app .create_authenticated_user("max-sources@example.com") .await; let theme_id = create_theme(&app, &session).await; let theme_uuid: uuid::Uuid = theme_id.parse().unwrap(); // Insert 100 sources directly into the database (faster than 100 API calls) for i in 0..100 { sqlx::query( "INSERT INTO sources (user_id, title, url, theme_id) VALUES ($1, $2, $3, $4)", ) .bind(user_id) .bind(format!("Source {}", i)) .bind(format!("https://source{}.example.com", i)) .bind(theme_uuid) .execute(&app.pool) .await .expect("Failed to insert source"); } // Verify we have 100 let (_, list_body) = app .get_with_session(&format!("/api/v1/sources?theme_id={}", theme_id), &session) .await; let sources = list_body.as_array().unwrap(); assert_eq!(sources.len(), 100, "Should have 100 sources"); // Attempt to create the 101st source via API let body = serde_json::json!({ "title": "One Too Many", "url": "https://toomany.example.com", "theme_id": theme_id }); let (status, resp) = app .post_with_session("/api/v1/sources", &body, &session) .await; assert_eq!( status, StatusCode::UNPROCESSABLE_ENTITY, "Creating more than 100 sources should return 422" ); assert_eq!(resp["error"], "validation_error"); assert!( resp["message"] .as_str() .unwrap_or("") .contains("100"), "Error message should mention the limit" ); } #[tokio::test] async fn bulk_import_respects_max_sources_limit() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (user_id, session) = app .create_authenticated_user("bulk-max@example.com") .await; let theme_id = create_theme(&app, &session).await; let theme_uuid: uuid::Uuid = theme_id.parse().unwrap(); // Insert 98 sources directly for i in 0..98 { sqlx::query( "INSERT INTO sources (user_id, title, url, theme_id) VALUES ($1, $2, $3, $4)", ) .bind(user_id) .bind(format!("Source {}", i)) .bind(format!("https://source{}.example.com", i)) .bind(theme_uuid) .execute(&app.pool) .await .expect("Failed to insert source"); } // Bulk import 5 sources (but only 2 slots remaining) let sources: Vec