You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1539 lines
50 KiB
Rust

//! 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<Body> {
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<serde_json::Value> = (0..5)
.map(|i| {
serde_json::json!({
"title": format!("Bulk {}", i),
"url": format!("https://bulk{}.example.com", i)
})
})
.collect();
let body = serde_json::json!({ "sources": sources, "theme_id": theme_id });
let (status, resp) = app
.post_with_session("/api/v1/sources/bulk", &body, &session)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
resp["imported"], 2,
"Only 2 should be imported (remaining capacity)"
);
let errors = resp["errors"].as_array().unwrap();
assert!(
!errors.is_empty(),
"Should report that limit was reached"
);
// Verify total is exactly 100
let (_, list_body) = app
.get_with_session(&format!("/api/v1/sources?theme_id={}", theme_id), &session)
.await;
let all_sources = list_body.as_array().unwrap();
assert_eq!(all_sources.len(), 100, "Should have exactly 100 sources");
}
// ═══════════════════════════════════════════════════════════════════════════
// Boundary / Edge Cases
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn create_source_with_boundary_values_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("sources-boundary@example.com")
.await;
let theme_id = create_theme(&app, &session).await;
// Title exactly 200 chars
let title_200 = "a".repeat(200);
let body = serde_json::json!({
"title": title_200,
"url": "https://boundary-title.example.com",
"theme_id": theme_id
});
let (status, _) = app
.post_with_session("/api/v1/sources", &body, &session)
.await;
assert_eq!(
status,
StatusCode::CREATED,
"Title of exactly 200 chars should be accepted"
);
// URL exactly 1000 chars
let url_1000 = format!("https://example.com/{}", "b".repeat(980));
assert_eq!(url_1000.len(), 1000);
let body2 = serde_json::json!({
"title": "Boundary URL",
"url": url_1000,
"theme_id": theme_id
});
let (status2, _) = app
.post_with_session("/api/v1/sources", &body2, &session)
.await;
assert_eq!(
status2,
StatusCode::CREATED,
"URL of exactly 1000 chars should be accepted"
);
// Minimal valid source
let body3 = serde_json::json!({
"title": "A",
"url": "http://x.co",
"theme_id": theme_id
});
let (status3, _) = app
.post_with_session("/api/v1/sources", &body3, &session)
.await;
assert_eq!(
status3,
StatusCode::CREATED,
"Minimal valid source should be accepted"
);
}
#[tokio::test]
async fn create_source_with_http_url_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("sources-http@example.com")
.await;
let theme_id = create_theme(&app, &session).await;
let body = serde_json::json!({
"title": "HTTP Source",
"url": "http://insecure.example.com",
"theme_id": theme_id
});
let (status, resp) = app
.post_with_session("/api/v1/sources", &body, &session)
.await;
assert_eq!(
status,
StatusCode::CREATED,
"http:// URLs should be accepted"
);
assert_eq!(resp["url"], "http://insecure.example.com");
}
#[tokio::test]
async fn bulk_import_all_duplicates_within_batch() {
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-inner-dup@example.com")
.await;
let theme_id = create_theme(&app, &session).await;
// Same URL appearing twice in one batch
let body = serde_json::json!({
"sources": [
{ "title": "First", "url": "https://same.example.com" },
{ "title": "Second", "url": "https://same.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);
// The ON CONFLICT DO NOTHING means the second insert is a no-op
assert_eq!(resp["imported"], 1, "Only one should be imported");
assert_eq!(resp["skipped"], 1, "The duplicate should be counted as skipped");
// Verify only 1 source exists
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);
}